【入門編】DartのNull安全とイミュータブルなデータクラスの設計 – Dart コア文法・オブジェクト指向・Null安全解析バイブル

こんにちは!FlutterやDartの開発現場でバリバリコードを書いていると、「データを安全に、かつ美しく保ちたい」という場面に何度も遭遇しますよね。

今回は、コードジェネレーター(Freezedなど)の魔法に頼らず、DartのSound Null Safetyとイミュータブル(不変)なデータクラスを自らの手でどう設計するか、その極意を一緒に紐解いていきましょう。

ここをクリアすれば、Dartの型システムの本質とメモリ上の振る舞いがグッと見えてきて、コードの信頼性が劇的に跳ね上がりますよ。ぜひ最後までついてきてくださいね!

—

なぜ「イミュータブル × Null安全」が最強なのか?

まず大前提として、DartのSound Null Safetyは、単なる「`null`によるクラッシュを防ぐためのガード」ではありません。「この変数は絶対に`null`にならない」という保証をコンパイラに与え、実行時エラーの可能性をビルド時にゼロにするための強力な最適化エンジンです。

そして、データクラスを「イミュータブル(一度作ったら中身を変えられない)」にすると、次のような圧倒的なメリットが生まれます。

1. 予期せぬ状態変化の消滅: アプリのどこかで勝手に値が書き換えられるバグが構造的に起きなくなります。
2. 参照比較の高速化: 中身が変わらないため、UIの再描画(Flutterなど)や状態管理において「前と同じデータか?」をメモリ上のアドレス(`==` や `identical`)で一瞬で判定できます。
3. スレッド(Isolate)間の安全なデータ共有: Dartはシングルスレッド(Isolate)ベースですが、別Isolateへデータを渡す際も、イミュータブルであれば安全かつ高速にメッセージングが行えます。

—

基礎から実践:手動イミュータブルクラスの設計レシピ

では早速、ライブラリを使わずに堅牢なイミュータブルクラスを書いてみましょう。
今回は、ユーザー情報を表す `User` クラスを題材にします。

class User {
// 1. すべてのフィールドを `final` にする
final String id;
final String name;
final String? email; // Null許容型(未登録かもしれない)

// 2. コンストラクタは `const` にする
const User({
required this.id,
required this.name,
this.email, // requiredではないので、指定しない場合はnullになる
});

// 3. 値の変更ではなく「コピーして新しいインスタンスを返す」copyWith メソッド
User copyWith({
String? id,
String? name,
// emailは「nullを明示的に代入したいケース」もあるため、少し工夫が必要(後述)
String? email,
}) {
return User(
id: id ?? this.id,
name: name ?? this.name,
email: email ?? this.email,
);
}

// 4. 値の等価性を比較するための operator == と hashCode のoverride
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;

return other is User &&
other.id == id &&
other.name == name &&
other.email == email;
}

@override
int get hashCode => id.hashCode ^ name.hashCode ^ email.hashCode;
}

コードのポイントを先輩目線で解説!

  • `final` と `const` のコンボ:

フィールドを `final` にすることで再代入を防ぎ、コンストラクタに `const` をつけることで、コンパイル時にメモリ上の同じインスタンスを使い回す(定数最適化)ことができます。無駄なオブジェクト生成が減り、GC(ガベージコレクション)の負担が軽くなります。

  • `copyWith` パターンの本質:

イミュータブルなオブジェクトのプロパティを変更したいときは、既存のものを書き換えるのではなく、「一部を書き換えた全く新しい別のインスタンス」を生成します。これが関数型プログラミングの基本であり、状態管理を予測可能にするキモです。

—

ここでハマる!陥りがちな罠とスマートな解決策

手動でイミュータブルクラスを設計する際、初学者が必ずと言っていいほどハマる「沼」があります。それが 「Null許容型と `copyWith` のジレンマ」 です。

罠:`email` を `null` に更新できない問題!

先ほど実装した `copyWith` をよく見てみてください。

User user = User(id: ‘1’, name: ‘Taro’, email: ‘taro@example.com’);

// emailを null に消去したい!と思ってこう書くと…?
User updatedUser = user.copyWith(email: null);

なんとこのコード、`email: null` と渡しているにもかかわらず、`email ?? this.email` の評価によって元のメールアドレス(`taro@example.com`)がそのまま維持されてしまいます。
「`null`を代入したいのか、引数を省略(変更なし)したいのか」を、通常の型システムだけで判別できないのが原因です。

解決策:Sentinel(哨戒値)パターンを用いる

Dartのプロたちがこっそり使っているスマートな解決策がこれです。
「未指定」を表す特別なダミーオブジェクト(Sentinel)を用意します。

// 内部的な判定用定数
const Object _undefined = Object();

class User {
final String id;
final String name;
final String? email;

const User({
required this.id,
required this.name,
this.email,
});

User copyWith({
String? id,
String? name,
// 型を Object? にして、デフォルト値を _undefined にする
Object? email = _undefined,
}) {
return User(
id: id ?? this.id,
name: name ?? this.name,
// _undefined のままであれば変更なし、nullが渡されたらちゃんとnullにする!
email: identical(email, _undefined) ? this.email : email as String?,
);
}
}

これで、以下のように「メールアドレスを空にする(`null`にする)」操作が完璧にできるようになります!

void main() {
var user1 = const User(id: ‘1’, name: ‘Taro’, email: ‘taro@example.com’);

// emailをnullに更新!
var user2 = user1.copyWith(email: null);

print(user2.email); // ちゃんと null が出力されます!
}

このイディオムをマスターすれば、コードジェネレーターなしでも現場で戦える確かな実力がつきますよ。

—

まとめ

今回は、Freezedなどのライブラリの裏側で何が行われているのかを知るために、あえて素のDartでイミュータブルなデータクラスとNull安全の制御を実装してみました。

  • フィールドはすべて `final`、コンストラクタは `const` にする。
  • 変更時は `copyWith` を使って新しいインスタンスを生み出す。
  • Null許容型の更新には Sentinel値(`_undefined`)を活用して、`null`の代入を正しくハンドリングする。

ここをクリアすれば、Dartの型システムやメモリモデルの動きが手に取るようにわかるようになります。基礎固めとしてはバッチリですね!

明日の開発から、ぜひこの知見をコードに落とし込んでみてください。それではまた!

タイトルとURLをコピーしました