Dartの定数(const)で定義できるものとできないものの境界線:VMのメモリ最適化から紐解く真の「不変性」
フロントエンド開発、特にFlutterを用いたマルチプラットフォーム開発において、私たちは何気なく`const`キーワードを使っています。linterに「`const`を付けなさい」と促されるがままに付与しているメンバーも少なくないでしょう。
しかし、テクニカルリードとしてコードレビューをする際、私は必ずメンバーに問いかけます。
「なぜ、ここで`final`ではなく`const`にしなければならないのか? その判断によって、Dart VMとコンパイラ(AOT/JIT)の挙動はどう変わるのか?」
`const`は単なる「再代入不可のシンタックスシュガー」ではありません。Dartにおける`const`は、「コンパイル時定数(Compile-time Constant)」であり、実行時(Runtime)のオーバーヘッドを極限までゼロに近づけるための、コンパイラに対する強力な最適化の指示書です。
本書では、Dartコアコミッターの視点から、`const`として定義できるものとできないものの境界線を厳密に定義し、Dart VMの内部動作(カノニカル化やメモリ空間の最適化)を踏まえた、堅牢で高パフォーマンスな設計パターンを解説します。
—
1. 原理:`const`と`final`の決定的な違いとVMのメモリ戦略
まず、多くの開発者が混同しがちな`final`と`const`の違いを、Dart VMの視点から整理します。
| 特性 | `final` (実行時定数) | `const` (コンパイル時定数) |
| :— | :— | :— |
| 評価されるタイミング | 実行時(そのコードが通った瞬間) | コンパイル時(ビルド時) |
| 初期化値の条件 | 実行時の任意の計算結果、APIレスポンス等 | コンパイル時に完全に決定している値のみ |
| メモリ上の挙動 | 呼び出しのたびに通常のヒープ領域にアロケーションされ得る | カノニカル化(一意化)され、読み取り専用領域に一意に配置 |
| GC(ガベージコレクション) | 参照がなくなればGCの対象となる | GCの対象外(アプリ起動から終了まで生存) |
Dart VMにおける「カノニカル化(Canonicalization)」の深淵
Dartの`const`を理解する上で最も重要な概念がカノニカル化(Canonicalization:標準化)です。
コンパイラは、コード内に存在する同一の`const`オブジェクトを検出し、コンパイル時にメモリ上の全く同一のインスタンス(単一の番地)に集約します。
void main() {
// 異なる場所で定義された2つのconstリスト
const listA = [1, 2, 3];
const listB = [1, 2, 3];
// identical() はメモリのアドレス(ポインタ)が等しいかを判定する
print(identical(listA, listB)); // 結果は必ず「true」
}
通常、`final listA = [1, 2, 3];` と `final listB = [1, 2, 3];` を比較した場合、実行時にそれぞれヒープ領域にメモリが確保されるため、`identical(listA, listB)` は `false` になります。
しかし `const` の場合、Dart VMはメモリのアロケーション(割り当て)を1度しか行いません。どれだけ多くのウィジェットやクラスで `const [1, 2, 3]` を呼び出そうとも、消費されるメモリは1インスタンス分のみです。さらに、このインスタンスは「不変領域(Read-Only Memory)」に配置されるため、ガベージコレクタ(GC)がこのオブジェクトを走査・回収する必要は一切なくなります。これが、Flutterで`const`ウィジェットを使用するとレンダリングパフォーマンスが向上する本質的な理由です。
—
2. 境界線:何が`const`になれて、何になれないのか
では、コンパイル時定数として定義できるものの具体的な境界線はどこにあるのでしょうか。
`const` として定義できるもの(境界線の内側)
1. 基本データ型のリテラル
- `num`, `int`, `double`, `String`, `bool`, `Null`
2. コンパイル時定数同士の演算結果
- `const double pi = 3.1415926535;`
- `const double circle = 2 pi;`(定数同士の四則演算はコンパイル時に計算可能)
- `const String message = ‘Hello, ‘ + ‘World!’;`(文字列結合も可)
3. `const` コンストラクタを持つクラスのインスタンス
- すべてのフィールドが `final` であり、コンストラクタの前に `const` が付与されているクラス。
4. 定数からなるコレクション(List, Set, Map)
- 要素がすべてコンパイル時定数である必要があります。
5. 一部の標準APIおよび演算
- `identical(a, b)`(`a`, `b`が定数の場合、結果も定数)
- `String.length`(コンパイル時に文字列長が確定するため、 Dart 2.12以降はconstコンテキストで評価可能)
`const` として定義できないもの(境界線の外側:コンパイルエラー)
1. 実行時の情報に依存する処理・API
- `DateTime.now()` (実行時のシステム時刻に依存するため不可)
- `Platform.isAndroid` (実行環境に依存するため不可)
2. `final` 変数や通常の変数(非const)の参照
- `final x = 10; const y = x;` (`x` は実行時定数であるため、`const`には代入できない)
3. `const` コンストラクタを持たないクラスの生成
4. 動的な要素を含むコレクションの初期化
- `const list = [1, 2, myVar];` (`myVar` が非constの場合、リスト全体をconstにすることはできない)
—
3. アンチパターンとリファクタリング
実務のコードレビューでよく見かける、`const`の不適切な設計パターンと、それをスマートに解決するリファクタリングアプローチを示します。
【アンチパターン1】不変クラスの「偽の定数化」によるメモリリーク
// 良くない例:不変(Immutable)に見えて、コンパイル時定数になっていない
class UserProfile {
final String id;
final String nickname;
// constキーワードが欠落している
UserProfile({required this.id, required this.nickname});
}
void main() {
// 毎回新しいインスタンスがヒープにアロケーションされ、GCの負荷になる
final profile1 = UserProfile(id: ‘001’, nickname: ‘DevArchitect’);
final profile2 = UserProfile(id: ‘001’, nickname: ‘DevArchitect’);
print(identical(profile1, profile2)); // false
}
リファクタリング:`const`コンストラクタの導入とディープ・イミュータビリティ
すべてのフィールドを `final` にし、コンストラクタに `const` を付与することで、このクラスは「カノニカル化」の恩恵を受けることができます。
// 良い例:完全なコンパイル時定数クラス
class UserProfile {
final String id;
final String nickname;
// constコンストラクタを定義
const UserProfile({required this.id, required this.nickname});
}
void main() {
// コンパイル時に同一インスタンスに集約される
const profile1 = UserProfile(id: ‘001’, nickname: ‘DevArchitect’);
const profile2 = UserProfile(id: ‘001’, nickname: ‘DevArchitect’);
print(identical(profile1, profile2)); // true (メモリ消費は1つ分)
}
—
【アンチパターン2】環境変数の動的評価の失敗
APIのエンドポイントやデバッグフラグなどの環境変数を、実行時に条件分岐させて `const` に代入しようとすると、コンパイルエラーになります。
// コンパイルエラー:実行時の環境判定をconstに代入することはできない
// const String apiHost = isDebug ? ‘https://sandbox.api.com’ : ‘https://api.com’;
リファクタリング:`String.fromEnvironment` と `const` 条件演算子の最適化
Dartコンパイラは、`const`式内での三項演算子(条件演算子)の評価をコンパイル時に実行できます。 これを利用して、環境変数(`–dart-define`)からビルド時に値を決定させます。
// 良い例:コンパイル時に環境変数を評価し、完全に定数化する
const bool isDebug = bool.fromEnvironment(‘DEBUG’, defaultValue: false);
// コンパイラはビルド時にどちらか一方の文字列だけを残し、もう一方をデッドコードとして排除する
const String apiHost = isDebug
? ‘https://sandbox.api.com’
: ‘https://api.com’;
—
4. 実務で使える極限のプロダクションコード例
以下は、実務のフロントエンド開発や非同期API連携の基盤設計において、型安全・メモリ効率最大化・スレッドセーフ(Isolate間共有可能) を担保するために、`const`の制約を限界まで活用した「API設定(Configuration)&テーマエンジン」の設計パターンです。
/// システム全体のAPI設定を司るコンパイル時定数クラス
class ApiConfig {
final String baseUrl;
final Duration timeout;
final Map
// すべてのフィールドがコンパイル時定数として評価可能でなければならない
const ApiConfig({
required this.baseUrl,
required this.timeout,
required this.defaultHeaders,
});
// ネストされたオブジェクトやコレクションも、すべてconstで定義
static const ApiConfig production = ApiConfig(
baseUrl: ‘https://api.production.internal’,
timeout: Duration(seconds: 30), // Durationのコンストラクタはconst対応している
defaultHeaders: {
‘Content-Type’: ‘application/json’,
‘X-App-Client’: ‘Flutter-Core’,
},
);
static const ApiConfig staging = ApiConfig(
baseUrl: ‘https://api.staging.internal’,
timeout: Duration(seconds: 45),
defaultHeaders: {
‘Content-Type’: ‘application/json’,
‘X-App-Client’: ‘Flutter-Core-Staging’,
‘X-Debug-Mode’: ‘true’,
},
);
}
/// UIテーマを定義するメタデータクラス。
/// ネストされたオブジェクト構造を完全にconstで構築する。
class AppTheme {
final String themeName;
final int primaryColorValue; // Colorクラスの内部表現(ARGB)を模したもの
final List
const AppTheme({
required this.themeName,
required this.primaryColorValue,
required this.spacingScale,
});
// Dartのconstリストは「不変(Immutable)」であるため、実行時に要素を変更しようとすると
// UnsupportedError(ランタイムエラー)を投げる堅牢な読み取り専用オブジェクトとなる。
static const AppTheme darkTheme = AppTheme(
themeName: ‘Dark Dimension’,
primaryColorValue: 0xFF121212,
spacingScale: [4.0, 8.0, 16.0, 24.0, 32.0],
);
}
void main() {
// 1. 同一性の確認
const configA = ApiConfig.production;
const configB = ApiConfig.production;
// メモリ上の参照は完全に同一(ポインタ比較 O(1))
assert(identical(configA, configB));
// 2. イミュータビリティの検証
try {
// constのMapやListに対して、要素の変更操作を行うと実行時に即座にエラーとなる
// これにより、不慮の副作用による状態の汚染を100%防ぐことができる
(configA.defaultHeaders as Map
} catch (e) {
print(‘Safety standard met: $e’); // Unsupported operation: Cannot modify unmodifiable map
}
}
—
5. Dart VMとIsolateの観点から見た`const`の深淵
さらに高度なアーキテクチャ設計を行うために、Dartの並行処理モデルである Isolate(アイソレート) と `const` の関係について触れておきます。
Dartはシングルスレッドで動作するIsolateが基本単位であり、各Isolateは独立したメモリ(ヒープ)を持っています。そのため、Isolate間でオブジェクトを転送する際、通常はデータのディープコピー(シリアライズ/デシリアライズ)が発生し、これには大きなCPUオーバーヘッドが伴います。
しかし、`const` オブジェクトは例外です。
import ‘dart:isolate’;
void worker(SendPort sendPort) {
// Isolate間で定数を送信する
sendPort.send(ApiConfig.production);
}
`const`で定義されたオブジェクトは、すべてのIsolateが共有する「システム全体の読み取り専用ヒープ(Read-Only Heap)」に配置されます。そのため、異なるIsolate間で `const` オブジェクトを送信する際、Dart VMはメモリコピーを一切行わず、単なるポインタの受け渡し(O(1))で処理を完結させます。
この特性は、バックグラウンドでの大規模なJSONパース処理や暗号化処理、大量のログ送信といったマルチIsolate構成の設計において、メモリ消費量とスレッド間通信のレイテンシを極限まで削減するための強力な武器になります。
—
まとめ:テクニカルリードが示す開発指針
Dartにおける `const` の境界線を知ることは、単に「エラーを出さずにコードを書く」ための知識ではありません。「ハードウェアリソースをいかに効率的に使い、GCによる一瞬のカクつき(Jank)を排除するか」という、プロフェッショナルとしての品質へのこだわりそのものです。
私たちの開発プロジェクトにおいて、以下の設計基準を徹底してください。
1. すべての不変データクラス(Data Transfer Object, Value Object含む)は、可能な限り `const` コンストラクタを定義せよ。
2. UIを構成するウィジェットや不変の設定値は、静的に確定できる限りすべて `const` でインスタンス化せよ。
3. Isolate間でやり取りするメッセージオブジェクトや設定データは、可能な限り `const` の境界線の内側に収まるように設計せよ。
コードの一行一行が、実行時にどうコンパイルされ、VM上でどうメモリを消費するのか。そのレイヤーへの解像度を高めることが、世界に通用するプロダクトを創るための唯一の道です。