Dartにおけるconstの深淵:正規化(Canonicalization)とコンパイル時評価の境界線
Dartという言語を真に理解しようとする時、避けては通れない、そして最も誤解されやすい概念が `const` である。
多くの開発者は `final` と `const` の違いを「実行時に決まるか、コンパイル時に決まるか」という単純な二元論で片付けてしまう。しかし、Dart VMの内部構造やAOT(Ahead-of-Time)コンパイラの最適化戦略という視点に立てば、その本質は「正規化(Canonicalization)」と「メモリの不変領域へのマッピング」にあることがわかる。
本稿では、シニアエンジニアに向けて、Dartの `const` がコンパイル時にどのように評価され、実行時のメモリ効率にどう寄与するのか、その極限の境界線を解剖する。
—
1. `const` の本質:値の同一性と正規化(Canonicalization)
Dartにおいて `const` は単なる「定数」ではない。それは「正規化されたオブジェクト」である。
コンパイラはコード内のすべての `const` 式をスキャンし、同一の構造を持つ定数を一つのメモリ空間に集約する。これが「正規化」だ。
void main() {
// これらは別々に宣言されているが、コンパイル時に全く同じメモリアドレスを指すように最適化される
const a = [1, 2, 3];
const b = [1, 2, 3];
print(identical(a, b)); // 結果: true (VMレベルで完全に同一のポインタ)
final x = [1, 2, 3];
final y = [1, 2, 3];
print(identical(x, y)); // 結果: false (ヒープ上の異なるインスタンス)
}
この挙動が意味するのは、`const` オブジェクトは実行時にインスタンス化されるのではなく、バイナリのデータセクションに埋め込まれ、プログラムのロード時に一度だけメモリに配置されるということだ。これは、メモリの局所性を高め、ガベージコレクション(GC)の負荷をゼロにする。
—
2. コンパイル時評価の境界線:何が `const` になれるのか
`const` の境界線は、「コンパイラがフロントエンド(CFE: Common Front End)でその値を完全に決定できるか」にある。
許容されるもの:
- リテラル(数値、文字列、ブール値)
- 他の `const` 定数を用いた算術演算
- `const` コンストラクタによって生成されたオブジェクト
- `Symbol` や `Type` オブジェクト
拒絶されるもの:
- 実行時の状態に依存する計算(`DateTime.now()` など)
- インスタンスメソッドの呼び出し
- `final` 変数の参照
- ループや条件分岐(三項演算子は可能だが、条件自体が `const` である必要がある)
境界線の極致:`const` コンストラクタの制約
`const` オブジェクトを自作するには、コンストラクタに `const` 修飾子を付与する必要がある。ここには厳格なルールが存在する。
1. すべてのフィールドが `final` であること。
2. コンストラクタ本体(`{}`)を持ってはならない。(初期化リストのみ許可)
3. 初期化リストで渡される引数もすべて `const` 評価可能であること。
class SecurityToken {
final String id;
final int level;
// constコンストラクタ:ボディを持てないのがポイント
const SecurityToken(this.id, this.level);
// 計算を含む場合は初期化リストで行う必要があるが、
// そこに使える関数はDart VMが認めた「定数評価可能なもの」に限定される
const SecurityToken.root() : id = ‘ROOT’, level = 999;
}
void main() {
// コンパイル時にこのオブジェクトは「正規化」され、バイナリに焼き込まれる
const root = SecurityToken.root();
}
—
3. メモリ最適化とIsolate間の挙動
Dartのマルチスレッドモデルである `Isolate` において、`const` はさらに強力な武器となる。
通常のオブジェクトを別のIsolateに送る場合、Dart VMはオブジェクトをコピー(シリアライズ/デシリアライズ)する。しかし、`const` オブジェクトは不変であることが保証されているため、Isolate間を跨いで参照を共有することが理論上可能であり、実際に最適化の対象となる。
コピーコストがゼロになるこの特性は、大規模な静的データ(暗号化テーブル、プロトコル定義など)を扱うシステムにおいて、スループットに劇的な差を生む。
—
4. 複雑なオブジェクトを定数化するための工夫:`extension type` の活用
Dart 3で導入された `extension type` は、`const` の境界線を押し広げる強力な道具だ。既存の型に対してゼロコストのラッパーを提供しつつ、`const` 評価を維持できる。
// メモリレイアウトは int と同一だが、コンパイル時に特定の意味を持たせる
extension type const NetworkPort(int value) {
bool get isPrivileged => value < 1024;
}
void main() {
// intリテラルと同様のコストで、型安全な定数を定義可能
const SSH_PORT = NetworkPort(22);
// コンパイル時に評価されるため、実行時のオーバーヘッドはない
const isSshPrivileged = SSH_PORT.isPrivileged;
}
ここで重要なのは、`extension type` のコンストラクタも `const` にできるという点だ。これにより、プリミティブな値をラップしつつ、不変性と正規化の恩恵をフルに享受できる。
---
5. アンチパターン:`const` の濫用とバイナリサイズ
`const` は魔法ではない。
すべてのオブジェクトを `const` にしようと躍起になると、バイナリサイズ(AOTコンパイル後の実行ファイル)が肥大化する可能性がある。
正規化されるとはいえ、巨大なデータ構造をすべて `const` で定義すると、それらは実行ファイルのデータセクションを占有し続ける。実行時に一度しか使わない巨大なリストなどは、あえて `final`(Lazyな生成)に留めるほうが、初期ロード時間やメモリフットプリントの観点から有利な場合もある。
—
結論:アーキテクトが取るべき戦略
1. Identityの保証: `const` を使う最大の理由は、値が等しいことではなく、インスタンスが同一であること(Canonicalization)を保証するためである。
2. 不変性の強制: セキュリティ的に重要なパラメータ(APIエンドポイント、権限レベル、暗号化フラグ)は、必ず `const` を用い、実行時の改ざん可能性をコンパイルレベルで封殺せよ。
3. 計算のシフト左: 実行時に行っていた計算を、`const` コンストラクタや初期化リストに追い込むことで、ランタイムのCPUサイクルを節約し、電力効率とレスポンス速度を極限まで高めよ。
`const` の境界線を理解することは、Dart VMのメモリモデルを支配することと同義である。コードの背後に透けて見えるバイナリの姿を常に意識し、最適な定数設計を行ってほしい。