コードレビュー:なぜその「定数」はコンパイル時エラーになるのか?
プルリクエストのレビュー中、次のようなコードを見かけて思わず手が止まったことはないでしょうか。
// 良くあるアンチパターン
const defaultTimeout = Duration(seconds: 30);
// ❌ Error: Constructor invocation must be const.
「あれ? `Duration` のコンストラクタは `const` にできるはずなのに、なぜ変数宣言に付けると怒られるんだ?」
そう首をかしげたあなたは、Dartのコンパイルモデルの核心——「定数式(Constant Expressions)」とDart VMのメモリレイアウトの境界線——に足を踏み入れようとしています。
ネットの適当なリファレンスには「`const` を付け忘れているからです」などと書いてありますが、それは表層的な言い訳に過ぎません。チーフアーキテクトとして、このエラーの背後にあるDart VMのコンパイルパイプラインと、AOT(Ahead-Of-Time)コンパilationにおける最適化のメカニズムを剥き出しにして解説しましょう。
—
1. `var`、`final`、`const` の本質的乖離
まず、変数宣言の3つのキーワードが、Dartのランタイムにおいて何を意味するのかを正確に定義します。
- `var` (Mutable Variable):
型推論を伴う再代入可能な変数。値はヒープ(またはローカル変数であればスタック/レジスタ)に確保され、実行時に書き換え可能です。
- `final` (Runtime Constant):
「初期化は一度だけ許可する」という制約。値が決定するのは実行時(Runtime)です。そのため、ランタイムの関数呼び出し結果や、I/Oの結果をバインドできます。
- `const` (Compile-Time / Canonicalized Constant):
これは単なる「定数」ではありません。「コンパイル時に完全に評価され、バイナリのイミュータブルデータセクション(ROData)に埋め込まれる実体」です。
問題の核心は、`const` が要求する「コンパイル時評価」と「関数呼び出し(Function Invocation)」の相性の悪さにあります。
—
2. なぜDartの定数式で「通常の関数呼び出し」が制限されるのか?
Dartのコンパイラ(frontend / CFE: Common Frontend)は、ソースコードをAST(抽象構文木)に変換し、`const` 式に遭遇すると、その場で式を評価(Constant Folding)しようと試みます。
ここで技術的な壁にぶつかります。「任意の関数呼び出し」をコンパイル時に評価することは、理論上の停止問題(Halting Problem)に直結するからです。
Dart VMの仕様と制約
Dart VMやAOTコンパイラ(`dart AOT`)が、コンパイル時に実行できるのは、「あらかじめ定められた純粋な演算(プリミティブ演算子、文字列結合、`const` コンストラクタ呼び出し等)」に限られます。
もし、次のようなコードが許容されるとしたらどうでしょう。
int computeMagicNumber() {
// 外部APIを叩くかもしれないし、無限ループするかもしれない
return 42;
}
const int magic = computeMagicNumber(); // ❌ 不可能
コンパイラはビルド時にこの関数を実行するために、サンドボックス化された仮想マシンをビルドプロセス内に内包しなければならなくなります。これはコンパイル速度を劇的に低下させ、ビルドパイプラインを不安定にします。
したがって、Dartの仕様では、コンパイル時定数として評価できる関数は「`const` コンストラクタ(付随する副作用のない初期化子)」と「一部のビルトイン演算子」に厳格に制限されているのです。
—
3. 実務で遭遇する罠と正しい設計パターン
フロントエンド(Flutter)やAPI連携を伴うWeb/Mobileアプリケーション開発において、この仕様を知らないと、無駄なオブジェクト生成や、不必要な `final` からの書き換え、あるいは起動時のパフォーマンス劣化を招きます。
以下のプロダクションコードを見てください。保守性が高く、Dart VMのメモリ効率を極限まで高めた設計パターンです。
示唆に富むプロダクションコード例
import ‘package:flutter/foundation.dart’;
/// 【チーフアーキテクトの推奨設計】
/// APIクライアントやUIコンポーネントの設定を司るイミュータブルなコンフィグクラス。
/// Dartの「Canonicalization(正準化)」の恩恵を最大化する設計。
class ApiConfig {
const ApiConfig({
required this.endpoint,
required this.timeout,
required this.retryCount,
});
final String endpoint;
final Duration timeout;
final int retryCount;
// 定数オブジェクトの定義
// ※ Duration(seconds: …) は const コンストラクタなので const 評価が可能
static const ApiConfig production = ApiConfig(
endpoint: ‘https://api.enterprise.io/v1’,
timeout: Duration(seconds: 30), // ✅ OK: const コンストラクタ呼び出し
retryCount: 3,
);
static const ApiConfig staging = ApiConfig(
endpoint: ‘https://staging-api.enterprise.io/v1’,
timeout: Duration(seconds: 10), // ✅ OK
retryCount: 1,
);
}
/// ❌ 誤った設計(アンチパターン)
/// ランタイム関数や動的計算をコンパイル時定数に混ぜようとする試み
/
const globalInvalidConfig = ApiConfig(
endpoint: ‘https://’ + String.fromEnvironment(‘ENV’) + ‘.io’, // 極端な動的結合
timeout: Duration(seconds: int.parse(’30’)), // ❌ Error: int.parse は const ではない
retryCount: 3,
);
/
/// ✅ 正しい代替アプローチ:実行時初期化が必要な場合
/// 環境変数や動的な計算が必要なケースでは `final` を使用し、
/// アプリケーション起動時に一度だけインスタンスを生成する(Lazy Initialization)。
class RuntimeConfigHolder {
RuntimeConfigHolder._();
static late final ApiConfig current = _initializeConfig();
static ApiConfig _initializeConfig() {
// 実行時(Runtime)に環境変数を安全にパースする
final env = const String.fromEnvironment(‘ENV’, defaultValue: ‘prod’);
if (env == ‘staging’) {
return ApiConfig.staging;
}
return ApiConfig.production;
}
}
このコードが優れている理由
1. メモリの正準化(Canonicalization)の活用:
`ApiConfig.production` はコンパイル時定数として評価されるため、アプリのライフサイクル全体を通じてメモリ上にただ一つのインスタンスしか存在しません(イミュータブルなデータセクションに焼き込まれます)。不要なGC(ガベージコレクション)の圧力をゼロに抑えられます。
2. 責務の明確な分離:
静的に決定する定数(`const`)と、環境変数などの実行時パラメータに依存する設定(`late final` + ファクトリ関数)の境界線が明確になっています。
—
4. パフォーマンスとメモリレイアウトの深層
最後に、なぜここまで `const` と定数式にこだわるのか、Dart VMのメモリ視点からトドメを刺しておきましょう。
DartのAOTコンパイルされたバイナリには、Snapshotと呼ばれるデータ構造が含まれています。
- App JIT / AOT Snapshot:
`const` で宣言されたオブジェクト群は、Snapshotのロード時にパース処理を一切経ることなく、そのままメモリ上にマップ(Memory-mapped)されます。
- つまり、起動コストが「0(ゼロ)」です。
もしこれを誤って `final` や通常の関数呼び出しで初期化していると、アプリが起動するたびにヒープ上でオブジェクトの割り当て(Allocation)が発生し、コンストラクタが実行されます。ミリ秒単位の起動速度を争うモダンなWebフロントエンド(Flutter Web)やモバイルアプリにおいて、この差は致命傷になり得ます。
まとめ
- 関数呼び出しは原則としてコンパイル時定数にできない(停止問題とサンドボックス化のコストのため)。
- `const` にできるのは、`const` コンストラクタと、コンパイラが静的に評価できるプリミティブな演算のみ。
- 動的な値やランタイムの関数を通す必要がある場合は、あきらめて `final` または `late final` を使い、実行時初期化に倒す。
- 静的なデータは徹底的に `const` で固め、Dart VMの正準化(Canonicalization)とメモリマップの恩恵を最大化せよ。
コードレビューで「なぜそこが `const` にできないのか」を問われたら、単に「エラーになるから」ではなく、「Dart VMのメモリレイアウトとAOTのコンパイルモデルにおいて、その式は実行時評価を強制されるからだ」と涼しい顔して答えてやってください。それこそが、Dartを真に掌握したエンジニアの姿です。