【実務・中級編】Dartのfinal変数とsetterの不在:不変性を強制するクラス設計の基本 – Dart コア文法・オブジェクト指向・Null安全解析バイブル

Dartのfinal変数とsetterの不在:不変性を強制するクラス設計の基本

フロントエンド開発やマルチスレッド(Isolate)を駆使した非同期API連携において、我々が日々直面するバグの温床――その大半は「意図しない状態の書き換え(副作用)」に起因します。

「なぜかUIの表示がデータと同期しない」
「非同期処理の合間に、別クラスからプロパティが書き換えられて整合性が崩れた」

これらの問題に対する最も強力で、かつエレガントな処方箋が「オブジェクトの不変性(Immutability)」です。

今回は、Dartのコンパイル機構やDart VMのメモリ管理といった低レイヤの視点を交え、なぜ `final` 変数とsetterの排除が堅牢なアーキテクチャの絶対条件なのか、そして実務で即戦力となる「完全不変(Immutable)クラス」の設計パターンを徹底的に解説します。

—

1. Dart VMとコンパイラから見た「final」の正体

多くの入門書は、`final` を単に「一度しか代入できない変数」と説明します。しかし、テクニカルリードの視点から見れば、その理解は表面的一歩手前で止まっています。我々が着目すべきは、「コンパイラによる最適化」と「メモリ空間での振る舞い」です。

暗黙的セッター(Implicit Setter)の排除とインライン化

Dartでは、クラスのパブリックフィールドに対して自動的にゲッター(Getter)とセッター(Setter)が生成されます。

class User {
String name; // ゲッター name と セッター name= が自動生成される
User(this.name);
}

フィールドを `final` に指定すると、セッター(`name=`)は最初から生成されません。
これは単に「代入を禁止する」という構文規則以上の意味を持ちます。Dart AOT(Ahead-Of-Time)コンパイラは、セッターが存在しない(=値が書き換わらない)ことを保証されたフィールドに対して、ドラスティックな最適化(脱仮想化やメンバーアクセスのインライン化)を行います。結果として、ランタイム時のメソッドルックアップ(Inline Cache)のオーバーヘッドが完全に消失し、最速のJIT/AOT実行パスが確保されます。

世代別ガベージコレクション(Generational GC)への恩恵

Dart VMは「世代別ガベージコレクション」を採用しています。新しく生成されたオブジェクトは「Young Generation(スカベンジ領域)」に割り当てられ、頻繁に回収されます。

不変オブジェクトは、生成後に他のオブジェクトへの参照を書き換えることがありません。GCの文脈において、古い世代(Old Generation)から新しい世代(Young Generation)への参照が発生した際、VMは「書き込みバリア(Write Barrier)」という処理を実行してこれを追跡する必要がありますが、不変オブジェクトであればこの書き込みバリア自体の発生頻度を劇的に減らすことができます。 つまり、不変設計はメモリ管理のCPUサイクルすら削減するのです。

—

2. アンチパターン:なぜ「可変クラス」はコードレビューで即却下されるのか

まずは、実務で絶対に書いてはならない「最悪のアンチパターン」を見てみましょう。APIから取得したユーザー情報を保持するモデルクラスを想定します。

【NG例】バグを誘発する可変(Mutable)クラス

// ❌ レビューで即却下される設計
class UserProfile {
String id;
String username;
List roles; // 可変なリスト

UserProfile({
required this.id,
required this.username,
required this.roles,
});
}

void badExample() {
final profile = UserProfile(
id: ‘usr_100’,
username: ‘alice’,
roles: [‘admin’, ‘developer’],
);

// 1. 外部から自由に書き換えられてしまう(カプセル化の崩壊)
profile.username = ‘bob’;

// 2. コレクションの内部が書き換えられても、変更を検知できない
//(FlutterのStateNotifierやCubit等で再描画が走らない原因)
profile.roles.add(‘guest’);
}

この設計がはらむ致命的な欠陥

1. スレッド(Isolate)間共有の危険性:
Dartはシングルスレッド(シングルIsolate)動作が基本ですが、重い処理をバックグラウンドIsolateに逃がす際、可変オブジェクトを渡すとデータの競合や不整合を招きます。
2. リアクティブプログラミングとの相性の悪さ:
Flutterの `ChangeNotifier` や `Bloc` などの状態管理フレームワークにおいて、「インスタンスの同一性(Reference Equality)」で変更を検知する場合、内部メンバが書き換えられてもインスタンスの参照が変わらないため、UIが再描画されないという古典的なバグを引き起こします。

—

3. 極限の不変設計パターン:実務で使える堅牢なプロダクションコード

それでは、実務の最前線で通用する「完全不変」なクラス設計を示します。
以下のコードは、APIレスポンスのパース、ディープコピー(`copyWith`)、値の等価性(Value Equality)、およびコレクションの不変性を完全に担保した、コピペでそのまま使える極上の設計パターンです。

【GOOD例】完全不変(Immutable)クラスの実装

import ‘package:meta/meta.dart’;
import ‘package:collection/collection.dart’;

/// `@immutable` アノテーションにより、すべてのフィールドが final であることを
/// 静的解析(Linter)レベルで強制する。
@immutable
class UserProfile {
final String id;
final String username;

// 外部からの要素追加・削除を防ぐため、不変コレクションとしてカプセル化する
final List _roles;

/// ゲッターを通じて、外部には「変更不可能なビュー」のみを公開する
List get roles => UnmodifiableListView(_roles);

/// 1. const コンストラクタによる「コンパイル時定数」化のサポート
/// これにより、同一値のインスタンスがメモリ上で正準化(Canonicalization)される
const UserProfile({
required this.id,
required this.username,
required List roles,
}) : _roles = roles;

/// 2. ファクトリコンストラクタによる安全な初期化
factory UserProfile.fromJson(Map json) {
return UserProfile(
id: json[‘id’] as String,
username: json[‘username’] as String,
// APIからの可変リストを、内部で不変リストへ変換して保持
roles: List.from(json[‘roles’] as Iterable),
);
}

/// 3. copyWith パターンによる「状態の安全な遷移」
/// 元のオブジェクトを一切汚さず、変更したいプロパティだけを書き換えた新しいインスタンスを生成する
UserProfile copyWith({
String? id,
String? username,
List? roles,
}) {
return UserProfile(
id: id ?? this.id,
username: username ?? this.username,
// 新しいリストインスタンスを生成して渡すことで、参照を切り離す
roles: roles ?? List.from(this._roles),
);
}

/// 4. 値の等価性(Value Equality)の定義
/// Dartのデフォルトは「参照の比較」だが、不変オブジェクトは「値の比較」であるべき
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is UserProfile &&
runtimeType == other.runtimeType &&
id == other.id &&
username == other.username &&
// collection パッケージの DeepCollectionEquality を使用して、
// リストの中身まで厳密に比較する
const DeepCollectionEquality().equals(_roles, other._roles);

@override
int get hashCode =>
id.hashCode ^
username.hashCode ^
const DeepCollectionEquality().hash(_roles);

@override
String toString() {
return ‘UserProfile(id: $id, username: $username, roles: $_roles)’;
}
}

—

4. なぜこの設計が美しいのか? テクニカルリードが解説する3つのポイント

① `UnmodifiableListView` によるコレクションの要塞化

単に `final List roles` と宣言しただけでは、`roles.add(‘guest’)` のような破壊的操作を防げません。`final` が保証するのは 「変数(参照)の再代入不可」 であり、「オブジェクト内部の不変性(Deep Immutability)」 ではないからです。
上記コードでは、内部メンバをプライベート(`_roles`)にし、公開ゲッターで `UnmodifiableListView` を噛ませることで、実行時の不正な書き換え操作に対して即座にエラー(`UnsupportedError`)を投げ、バグの芽を摘んでいます。

② `const` コンストラクタによるメモリ効率の極大化

`const` 修飾子を付与されたオブジェクトは、コンパイル時にメモリの「定数領域」に一元管理されます。

void memoryTest() {
// コンパイル時に同一のインスタンスとして扱われる(正準化)
const user1 = UserProfile(id: ‘1’, username: ‘a’, roles: []);
const user2 = UserProfile(id: ‘1’, username: ‘a’, roles: []);

print(identical(user1, user2)); // true 💻 メモリの節約と高速な比較
}

これにより、Widgetの再ビルドや、同一データを何度もパースする際のアロケーションオーバヘッドが完全にゼロになります。

③ 予測可能性(Predictability)の担保

`copyWith` の導入により、状態を変更したい場合は必ず「古い状態から新しい状態を作る」という単方向の流れが強制されます。

// 状態の安全な更新例
final originalAdmin = UserProfile(id: ‘1’, username: ‘Alice’, roles: [‘admin’]);
final updatedUser = originalAdmin.copyWith(username: ‘Alice (Archived)’);

// originalAdmin は完全に無傷のまま保たれる

これにより、非同期API連携のコールバック待ちの間に、UI側の操作によって元データが書き換わってしまうといった、並行処理系の怪奇現象が100%発生しなくなります。

—

5. テクニカルリードからの最終アドバイス

コードレビューにおいて、私はメンバーに常に問いかけます。
「そのクラスのフィールドは、本当に可変(Mutable)である必要がありますか?」

「後で変えるかもしれないから」という曖昧な理由で `var` や非 `final` フィールド、setterを放置することは、自ら時限爆弾をコードベースに埋め込むようなものです。

1. 基本はすべて `final` にせよ。
2. 可能なら `const` を狙え。
3. コレクションは必ず `UnmodifiableListView` または `IList` (fast_immutable_collections等) で包め。
4. 状態の変更は、setterではなく `copyWith` による「射影」で行え。

この4原則を徹底するだけで、あなたの開発するアプリケーションの堅牢性は次元が変わります。コンパイラを味方につけ、実行時エラーをコンパイル時エラーへと昇華させるスマートな設計を、今日から実践していきましょう。

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