伝説のアーキテクトが説く:`final` と `const` の二面性とランタイムの真実
Dartランタイムの深淵、そしてAOT(Ahead-Of-Time)コンパイルの冷徹な最適化メカニズムへようこそ。
日々の開発において、変数を定義する際になし崩し的に `var` を使い、イミュータブルにすべき箇所で迷いなく `final` を叩き、たまに `const` を添える——そんなコード書いていないか?
シニアエンジニアやシステムアーキテクトを自負するのであれば、その選択が「コンパイル時にどう解決されるか」「Dart VMのメモリ空間(Heap / Constant Pool)のどこに配置されるか」を完全に支配していなければならない。
今回は、`final` と `const` を分かつ唯一にして絶対の境界線——「評価タイミング(Evaluation Timing)」について、コンパイラの挙動とIsolateのメモリレイアウトの観点から徹底的に解剖する。
—
1. 評価タイミングの断絶:実行時 vs コンパイル時
`final` と `const` の本質的な違いを一枚の図表、いや、脳内に刻むべき決定木のフローとして整理しよう。
[ 変数宣言時の値の確定タイミングはいつか? ]
│
├─► 【実行時 (Runtime)】
│ └─► ユーザー入力、I/O、非同期処理の結果、現在時刻(DateTime.now())など
│ ⇒ 【 final 】 を選択せよ
│
└─► 【コンパイル時 (Compile-time)】
└─► ソースコードの字面だけで完全に値が確定し、外部依存がない
│
├─► オブジェクトやコレクションの構造も含めて、完全に不変の定数として埋め込みたい
│ ⇒ 【 const 】 を選択せよ
│
└─► (※ Dart 3以降では構造的イミュータビリティが強制されるため、
実質的にconstが許容するプリミティブおよび不変式に限られる)
このフローチャートの美しさは、単なる構文規則ではなく、「CPUサイクルとメモリ割り当てがどのフェーズで発生するか」を完全に反映している点にある。
—
2. コンパイラとDart VMの内部挙動:深層アプローチ
`final` の正体:シングル・アサインメントの実行時変数
`final` は、ランタイムにおける「一度だけ代入可能な変数(Single-assignment variable)」だ。
Dart VMのスタックフレーム、あるいはヒープ上のオブジェクトへの参照として、プログラムの実行フェーズ(Runtime)で値がバインドされる。
final currentTime = DateTime.now(); // 実行時に評価され、VMがOSからシステム時刻を取得する
この時、`currentTime` 自体の再代入はコンパイル時(analyzer)および実行時に厳格に禁止されるが、指し示す先のオブジェクトがミュータブルであれば、その内部状態を変えることは可能だ(例:`final list = []; list.add(1);` は合法)。
`const` の正体:定数プール(Constant Pool)への完全埋め込み
一方、`const` は「コンパイル時定数(Compile-time constant)」である。
DartのAOTコンパイラ(あるいはJITのコンパイルフェーズ)は、ソースコードを解析した時点でその値を完全に計算し、生成されるバイナリの定数プール(Constant Pool)に直接焼き付ける。
const int timeoutSeconds = 30 60; // コンパイル時に ‘1800’ というリテラルとして評価完了
ここで重要なのは、「`const` オブジェクトの正統性(Canonicalization)」だ。
同じコンパイル時定数式から生成された `const` オブジェクトは、メモリ上で完全に同一のインスタンス(同一のアドレス)を指すように、Dart VMによって重畳排除(Canonicalize)される。これにより、ガベージコレクタ(GC)の負荷が劇的に軽減され、メモリのフットプリントが最小化される。
—
3. 実践:境界線を踏み外したアンチパターンと最適解
百聞は一見に如かず。現場でよく見られる「誤った不変性(Immutable)の設計」と、それをアーキテクチャレベルで矯正するコードを見ていこう。
危険なコード例:意図せぬ実行時オーバーヘッドとメモリ肥大化
class NetworkConfig {
// 悪い例: 毎回ランタイムにリストがインスタンス化され、ヒープを汚染する
final List
// 悪い例: DateTime.now() はコンパイルできないため const にできないが、
// もし誤って const にしようとするとコンパイルエラーになる。
// では、インスタンスごとに無駄に生成される final は適切か?
final DateTime initializedAt = DateTime.now();
}
改善された極限最適化コード
class NetworkConfig {
// 良い例: const を使い、コンパイル時に定数プールへ完全固定。
// Isolate間で共有されてもアロケーションコストはゼロ。
static const List
// 良い例: 実行時に一意に決まる値は final で保持。
final DateTime initializedAt;
NetworkConfig() : initializedAt = DateTime.now();
}
さらに、Dart 3のレコードやパターンマッチング、そして `const` コンストラクタを組み合わせることで、オブジェクトのグラフ全体をコンパイル時定数に昇格させることが可能だ。
class Point {
final int x;
final int y;
const Point(this.x, this.y);
}
// アプリケーション起動時の初期配置座標として、定数プールに一発で焼き付けられる
const Point origin = Point(0, 0);
この `const Point(0, 0)` は、アプリが起動した瞬間からメモリ上の固定領域に存在し、新たなインスタンス生成のオーバーヘッドを完全にバイパスする。数百万回のループ内でこのようなオブジェクトを参照する場合、そのパフォーマンス差は歴然である。
—
4. チーフアーキテクトからの最終提言
変数を宣言するとき、指先を止めてこう自問せよ。
1. 「この値は、コードを書いている静的な瞬間に、完全に決定できるか?」
- YESであれば、迷わず `const` を使え。定数プールへ焼き込み、ランタイムの自由度を奪うことで、極限のパフォーマンスとメモリ効率を手に入れろ。
2. 「いや、外部環境(I/O、時刻、乱数、ユーザー入力)に依存するため、実行時にしか確定しないか?」
- YESであれば、`final` を選べ。再代入のバグを防ぐ防壁としつつ、ランタイムの動的な評価を正確に受け止めろ。
DartのコンパイラとVMは、お前が記述したその修飾子の意味を寸分たがわず読み取り、マシーン語へと昇華させる。その挙動を完全に掌握した者だけが、真に堅牢で爆速なFlutter / Dartアプリケーションを構築できる。
妥協のないコードを書き続けろ。