コードレビューをしていて、最も頭痛がする瞬間の一つがこれだ。
class UserProfile {
// ❌ 最悪のアンチパターン:外部から内部状態を破壊し放題
String name;
List
UserProfile(this.name, this.permissions);
}
このコードを書いた開発者を呼び出し、「なぜこれを `final` にして隠蔽しないのか?」と問うと、決まって「後から書き換える必要があるからです」という返事が返ってくる。
フロントエンドの状態管理であれ、非同期APIから取得したドメインモデルのハンドリングであれ、「変更可能な状態(Mutable State)を不用意に露出させること」は、アプリケーションに爆弾を抱えることと同義だ。特にFlutterやWeb(Dart)の複雑な非同期パイプラインにおいて、予期せぬタイミングで外部からオブジェクトの内部を書き換えられるバグほど、追跡が困難なものはない。
今回は、Dartの `final` と `getter` を極限まで巧みに組み合わせ、「堅牢性」と「パフォーマンス」を両立させたクラス設計の極意を叩き込む。
—
1. なぜ `var` や剥き出しのプロパティがプロダクションで御法度なのか
Dartの変数宣言には `var`、`final`、`const` がある。
フロントエンドのコンポーネント設計やAPI連携のレイヤーにおいて、モデルやステートホルダーのプロパティに `var` を使うことは、ドアの鍵を全開にして貴重品を置きっぱなしにするようなものだ。
しかし、単に `final` を付けるだけでは不十分なケースがある。それが「コレクション(List, Map, Set)」を扱う場合だ。
class BadSession {
final List
BadSession(this._roles);
// ❌ getterでそのまま生リストを返している
List
}
// 呼び出し側で何が起きるか?
void main() {
final session = BadSession([‘admin’]);
session.roles.add(‘super_user’); // 💥 内部のfinal変数の「参照先」は不変でも、リストの中身は破壊できる!
}
Dartの `final` は、あくまで「変数への再代入(Re-assignment)を禁止する」ものであり、オブジェクトのミュータビリティ(可変性)そのものを保証するものではない。この仕様の穴を突き、内部状態を完全に保護するための設計パターンを次章で解説する。
—
2. 実務で通用する「真にイミュータブル」なプロパティ設計パターン
内部の安全性を保ちつつ、外部へデータを安全に公開するプロダクションコードの模範解答を見てほしい。
以下のコードは、APIから取得したユーザーのセッション情報を保持し、UI層やビジネスロジック層へ安全に公開するためのコンポーネントだ。
import ‘package:meta/meta.dart’;
/// ユーザーの権限と状態を安全に管理するドメインモデル
class UserSession {
// 1. 内部保持するデータは必ず privateかつfinal にする
final String _userId;
final String _email;
final List
final DateTime _lastAccessedAt;
// コンストラクタ
UserSession({
required String userId,
required String email,
required List
required DateTime lastAccessedAt,
}) : _userId = userId,
_email = email,
// 2. 防御的コピー:外部から渡されたミュータブルなリストの参照を切る
_permissions = List.unmodifiable(permissions),
// DateTime自体はイミュータブルだが、安全のためコンストラクタでコピーするか、
// そのまま保持するかは設計による(今回はそのまま)
_lastAccessedAt = lastAccessedAt;
// 3. 外部公開用のゲッター(余計な加工をせず、安全に読み取り専用で返す)
String get userId => _userId;
String get email => _email;
// Listを返す場合は、必ず UnmodifiableListView を返すか、.unmodifiable でラップしたものを返す
// これにより、呼び出し側での .add() や .remove() をコンパイルエラー(または実行時例外)にする
List
DateTime get lastAccessedAt => _lastAccessedAt;
// 4. 状態を変更したい場合は、ミューテーションメソッドではなく
// 「新しいインスタンスを生成して返す(CopyWithパターン)」を採用する
UserSession copyWith({
List
DateTime? lastAccessedAt,
}) {
return UserSession(
userId: _userId,
email: _email,
permissions: permissions ?? _permissions,
lastAccessedAt: lastAccessedAt ?? _lastAccessedAt,
);
}
}
この設計が優れている理由
1. 防御的コピー(Defensive Copying)と `List.unmodifiable`
コンストラクタインジェクションの時点で外部リストの参照を切り離し、さらにDartのコアライブラリが提供するイミュータブルなビューに変換している。これにより、どれだけ悪意(あるいはうっかり)のあるコードが書かれても、クラス内部の `_permissions` が外部から書き換えられることは絶対にない。
2. Computed Getter(計算プロパティ)としての活用
getterは単にフィールドの値を返すだけでなく、必要に応じてロジックを挟むこともできる。例えば「管理者の権限を持っているか」を外部に公開する場合、フラグを持たせるのではなくgetterで評価する。
bool get isAdmin => _permissions.contains(‘admin’);
これにより、状態の矛盾(データとフラグの不一致)が構造的に起きなくなる。
—
3. パフォーマンスの罠:getterの多用とDart VMの最適化
ここで、シニアエンジニアなら一歩進んでパフォーマンスについて考慮しなければならない。
「すべてのプロパティに `final` を持ち、`getter` を経由させることで、オーバーヘッド(実行速度の低下やメモリ圧迫)は発生しないのか?」
結論から言えば、現代のDart AOT/JITコンパイラおよびDart VMにおいて、単純なgetterの呼び出しコストは極めて低い。
Dartのコンパイラは、このような単純なgetterをインライン展開(Inlining)する。つまり、実行時にはメソッド呼び出しのオーバーヘッドが消え、直接フィールドにアクセスする機械語とほぼ同等のコードに最適化される。
ただし、getterの中で重い処理や毎回新しいインスタンスを生成する処理を書くことは別の話だ。
❌ やってはいけないアンチパターン(パフォーマンス悪化)
class HeavyModel {
final List
HeavyModel(this._rawDatapoints);
// ❌ getterを呼ぶたびにO(N)の処理とメモリ割り当てが発生する!
List
_rawDatapoints.map((e) => ‘Value: $e’).toList();
}
もしUIのビルドフェーズや、高頻度で回るループの中でこの `formattedData` が呼ばれた場合、GC(ガベージコレクション)の圧迫を招き、フレームドロップ(カクつき)の原因になる。
⭕ 正しいアプローチ(キャッシュまたはコンストラクタ時計算)
データ変換が必要な場合は、コンストラクタで一度だけ計算してイミュータブルなリストとして保持するか、必要に応じて `late final` やキャッシュ機構を利用すべきだ。
class OptimizedModel {
// コンストラクタで一度だけフォーマット済みリストを生成して保持する
final List
OptimizedModel(List
: formattedData = List.unmodifiable(
rawDatapoints.map((e) => ‘Value: $e’),
);
}
—
4. コードレビューアーからの最終メッセージ
プログラミング言語Dartの美しさは、「厳格な型システムとイミュータビリティを、冗長なボイラープレートなしに記述できる点」にある。
`var` を封印し、`final` フィールドと `getter`、そして `copyWith` パターンを組み合わせる習慣をつけなさい。この規律を守るだけで、あなたの書くコードから「原因不明の状態バグ」の9割が駆逐される。
次にコードを書くとき、自問してほしい。
「このプロパティは、本当に外部から書き換えられる必要性があるか?」
答えがNoなら、迷わず `final` と `getter` で堅牢な要塞を築くことだ。