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

Dartを掌握する極限の知見:`final` とセッター不在がもたらすゼロコスト不変性の哲学

Dart VMのコア・アーキテクチャおよびAOTコンパイラの設計に携わる者として、幾度となく目にしてきたアンチパターンがある。それは、「変更されたくない」という素朴な動機から、不確実にカプセル化されたセッターや、無駄なミュータブル・ステートを保持したクラス群を生み出す設計だ。

外部からの意図せぬ状態書き換えを防ぎ、堅牢なドメインモデルを構築するためには、言語仕様としての `final` キーワードの正確な意味と、セッターを排除したクラス設計を完全に同期させなければならない。

本稿では、Dartにおける `final` がコンパイラ、メモリモデル、そしてIsolateの並行性コンテキストにおいて何を意味するのか、その深層を解き明かす。

—

1. `final` の本質:コンパイル時セーフティとランタイムの不変性

多くのプログラマは、`final` を「一度だけ代入できる変数」と誤解している。しかし、Dartのコアセマンティクスにおいて、`final` は「代入の禁止」と「メモリ上の不変性の保証(Transitive Immutabilityの前提)」をコンパイラに強制するための契約である。

AOTコンパイラとメモリ最適化の観点

DartのAOT(Ahead-Of-Time)コンパイラ(Dart SDK 2.12以降のNull安全導入後)において、フィールドが `final` で宣言されている場合、SSA(静的単一代入)形式の中間表現(IR)において、その値は「不変(Immutable)」として扱われる。

これにより、コンパイラは以下の最適化を行える。

1. レジスタ割り当ての効率化:
値が変化しないことが保証されるため、VMは値をロードする命令(`LoadField`)をループ外や関数呼び出しの前にホイスティング(移動)し、レジスタに常駐させることが可能になる。
2. インライン展開の最適化:
ゲッター呼び出しが実質的に定数参照やダイレクトなメモリアクセスへと最適化される。

class ImmutableVector {
final double x;
final double y;

const ImmutableVector(this.x, this.y);

// 計算結果も常に新しいインスタンスを返す
ImmutableVector add(ImmutableVector other) {
return ImmutableVector(x + other.x, y + other.y);
}
}

上記のコードにおいて、`x` と `y` にセッターが存在せず、かつ `final` であるため、`ImmutableVector` のインスタンスは生成された瞬間からメモリ上でビット単位の不変性が保証される。

—

2. なぜ「セッター」を排除すべきなのか?

オブジェクト指向プログラミングの初期における悪習の一つに、「すべてのフィールドにプライベート変数(`_foo`)と、ゲッター・セッター(`get foo`, `set foo`)を定義する」というものがある。

これをDartでやってはならない。

// 【アンチパターン】セッターを持つ「見せかけだけの」不変クラス
class LeakyConfiguration {
String _host;
int _port;

LeakyConfiguration(this._host, this._port);

String get host => _host;
int get port => _port;

// このセッターが存在するだけで、このクラスの信頼性は崩壊する
set host(String value) {
if (value.isEmpty) throw ArgumentError();
_host = value;
}
}

セッターの不在がもたらす防壁

セッターを排除し、すべてのフィールドを `final` にすることは、単なる「スタイルの統一」ではない。プログラムの実行トレースを予測可能にするための防壁である。

  • 参照の共有と予期せぬ副作用(Side Effects)の防止:

マルチスレッド(Dartの場合はIsolate、あるいはAsyncイベントループ)において、あるオブジェクトが渡された先で勝手に状態を書き換えられるリスクを完全に断絶する。

  • デバッグ時のメンタルモデルの単純化:

「このオブジェクトの状態は、コンストラクタが実行された瞬間に確定し、二度と変化しない」という不変条件(Invariant)が成立するため、バグ調査時の認知負荷が劇的に低下する。

—

3. 実践:完全なる不変性とビルダーパターンの融合

「不変であれ。しかし、状態を変更したい場合はどうするのか?」という当然の疑問が生じる。答えは明快だ。「変更したい部分だけを置き換えた、新しいインスタンスを生成する(破壊的変更を行わない:Persistent Data Structures)」。

Dartでは、これをエレガントに行うための `copyWith` パターンが定着している。

class NetworkConfig {
final String endpoint;
final Duration timeout;
final int maxRetries;

// コンストラクタは常に const 化の視野に入れる
const NetworkConfig({
required this.endpoint,
required this.timeout,
required this.maxRetries,
});

// セッターの代わりに copyWith を提供する
NetworkConfig copyWith({
String? endpoint,
Duration? timeout,
int? maxRetries,
}) {
return NetworkConfig(
endpoint: endpoint ?? this.endpoint,
timeout: timeout ?? this.timeout,
maxRetries: maxRetries ?? this.maxRetries,
);
}
}

void main() {
// 初期設定
const config = NetworkConfig(
endpoint: ‘https://api.internal.net/v1’,
timeout: Duration(seconds: 5),
maxRetries: 3,
);

// タイムアウトだけを変更した「新しい」設定インスタンスを取得
// 元の config は一切変化しない(スレッドセーフティの担保)
final updatedConfig = config.copyWith(timeout: const Duration(seconds: 10));

// 実行時アサーション
print(‘Original Timeout: ${config.inSeconds}’); // 5
print(‘Updated Timeout: ${updatedConfig.timeout.inSeconds}’); // 10
}

—

4. イベントループと非同期処理における `final` の優位性

Dartのランタイムは、シングルスレッドのイベントループ(Event Loop)上で動作し、IOやタイマーなどの非同期イベントをマイクロタスクキューおよびイベントキューから順次処理していく。

ここで重要になるのが、非同期処理の合間にオブジェクトの状態が書き換わるリスクの排除である。

class SessionContext {
final String token;
final DateTime expiresAt;

const SessionContext({required this.token, required this.expiresAt});

bool get isExpired => DateTime.now().isAfter(expiresAt);
}

Future processSecureRequest(SessionContext session) async {
// 非同期のI/O待ちが発生する
await Future.delayed(const Duration(milliseconds: 500));

// もし session がミュータブルでセッターを持っていた場合、
// この瞬間に別のアクションによって token が書き換えられているリスクがある。
// しかし final であれば、スコープに入った時点の状態が完全に保全される。
if (session.isExpired) {
throw SecurityException(‘Session expired during execution.’);
}

// 安全にネットワークリクエストを続行
executeApiCall(session.token);
}

セッターを廃した `final` フィールドを持つクラス設計は、非同期境界(`await` ポイント)を跨ぐ際のレースコンディションや、コンテキストの改ざん攻撃に対する最も強力な防御壁となる。

—

結語:コードは「書くもの」ではなく「構造化するもの」である

シニアエンジニアに求められるのは、ただ動くコードを書くことではない。「誤った使い方ができない構造(Poka-Yoke)」をコードベースに刻み込むことだ。

`final` キーワードとセッターの完全な排除は、そのための最も基礎的でありながら、最も強力な武器である。今日からあなたの書くクラスからすべてのセッターを消去し、真の不変性(Immutability)という名の防壁を構築せよ。

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