こんにちは!FlutterやDartでの開発を楽しんでいますか?
他のプログラミング言語、例えばJavaやTypeScript、Swiftなどを触ってきた方ほど、Dartの「Null安全(Sound Null Safety)」の世界に入ったときに、こんな疑問を抱くことがありますよね。
「変数を宣言するとき、`const`にすべき? それとも `late final`? いったい何がどう違うの?」
どちらも「値が決まったら簡単には変えたくない(イミュータブルに扱いたい)」という意図で使われがちなキーワードですが、Dartのコンパイラや実行時(Dart VM)の裏側では、全く異なる宇宙の出来事のように扱われています。
ここをクリアすれば、あなたの書くコードのメモリ効率も、アーキテクチャの美しさも一段と跳ね上がりますよ。今日は、世界最高峰のDartを知る私と一緒に、この2つの使い分けの本質を紐解いていきましょう!
—
1. ざっくりイメージ:コンパイル時定数 vs 遅延初期化
まずは、この2つのキーワードが「いつ」「どこで」メモリに固定されるのかを、直感的な図解でイメージしてみましょう。
【 constの世界:コンパイル時定数 】
ソースコード記述 ──> 【 Dart AOT / JIT コンパイラ 】 ──> バイナリに埋め込み (即座に固定!)
※アプリが起動した瞬間、すでにメモリ上に存在します。
【 late finalの世界:遅延イミュータブル 】
ソースコード記述 ──> コンパイル通過 ──> アプリ実行 ──> 【 初めてアクセスされた瞬間 】に1度だけ初期化
※使うまではメモリ領域の枠組みだけを用意し、中身は空っぽ(Unassigned)です。
このように、時間の軸がまったく違います。それぞれの基本と、なぜその違いが存在するのかを詳しく見ていきましょう。
—
2. `const` ―― アプリの起動を爆速にする「究極の定数」
基本的な使い方とコードの意味
`const` は、「コンパイル時定数(Compile-time constant)」を定義します。つまり、人間がコードを書いている「コンパイルの時点」で、その値が完全に確定していなければなりません。
// 正しい例:コンパイル時に計算できる
const int maxRetryCount = 3;
const String appName = ‘Dart Master’;
const Duration timeout = Duration(seconds: 30);
なぜ `const` は特別なのか?(アーキテクチャの視点)
`const` を使って宣言された値やオブジェクトは、Dart VM(またはAOTコンパイラ)によって、バイナリファイルのデータセクションに直接埋め込まれます。
アプリが起動したとき、Dartのランタイムは「あらかじめ用意されたメモリ上の定数領域」をそのまま参照するだけなので、実行時の計算コストやメモリ割り当てのオーバーヘッドが完全にゼロになります。さらに、同じ `const` オブジェクトはメモリ上で常に同一のインスタンス(Canonicalized instance)として共有されるため、メモリ効率も極限まで高まります。
陥りやすいエラー
よくあるのが、実行時にならないと決まらない値を `const` にしようとしてコンパイルエラーになるケースです。
void main() {
// ❌ 実行時エラー(コンパイルエラー)になります!
// 現在時刻は「アプリが実行された瞬間」に決まるため、コンパイル時には分からないからです。
const DateTime now = DateTime.now();
}
> 先輩からのアドバイス:
> 「コンパイル時に値が絶対に決まるもの」は、迷わず `const` を選びましょう。FlutterのUIツリー(Widget)の構築でも、`const` を適切に置くことで不要な再描画を防ぎ、パフォーマンスが劇的に向上します。
—
3. `late final` ―― 「今はまだ作れないけれど、一度決めたら変えない」
基本的な使い方とコードの意味
一方で `late final` は、「初期化は後回しにするけれど、一度値を代入したら二度と変更させない」という、実用主義のための強力な組み合わせです。
class UserProfile {
// コンストラクタ時点では分からないが、
// 最初にこのプロパティにアクセスされた時に一度だけ初期化される
late final String deviceId = _fetchDeviceIdSync();
String _fetchDeviceIdSync() {
print(‘重い処理:デバイスIDを取得中…’);
return ‘device_12345’;
}
}
void main() {
var profile = UserProfile();
print(‘インスタンス生成完了!’); // この時点ではまだ _fetchDeviceIdSync は呼ばれていない
print(profile.deviceId); // ←ここで初めて初期化が走り、値が確定する(finalなので以降は変更不可)
// profile.deviceId = ‘new_id’; ❌ 2回目の代入はコンパイルエラー!
}
実行結果のイメージ
インスタンス生成完了!
重い処理:デバイスIDを取得中…
device_12345
なぜ `late final` が必要なのか?(Null安全との関係)
DartのNull安全では、「非Null型(例: `String`)」の変数は、宣言時またはコンストラクタ内で必ず初期化しなければならないという鉄則があります。
しかし、次のようなシチュエーションでは困ってしまいますよね。
1. 依存性の注入(DI)やライフサイクルの都合上、コンストラクタの時点では値を渡せない。
2. 初期化コストが非常に高いため、実際に必要になるまで遅延させたい(Lazy Initialization)。
ここで `late` をつけると、Dartのコンパイラに対して「初期化チェックをコンパイル時から実行時へ委譲する」ことができます。さらに `final` を組み合わせることで、「遅延初期化は許すけれど、変数がフラフラ書き換わるバグは絶対に許さない」という堅牢な設計が完成します。
陥りやすい罠:実行時例外(LateInitializationError)
`late` は強力ですが、Dart VMに「私が責任を持って後で初期化しますから!」と約束するいわば諸刃の剣です。初期化する前にアクセスしてしまうと、コンパイルは通るものの、実行時にクラッシュします。
class Config {
late final String apiKey; // 初期化していない!
}
void main() {
var config = Config();
print(config.apiKey); // 💥 実行時エラー:LateInitializationError: Field ‘apiKey’ has not been initialized.
}
—
4. 比較まとめ:どう使い分けるべきか?
ここまでの内容を、アーキテクチャの観点からすっきりと整理しておきましょう。
| 評価軸 | `const` | `late final` |
| :— | :— | :— |
| 初期化のタイミング | コンパイル時(コードを書いている時) | 実行時(最初にアクセスされた時) |
| メモリの効率 | 究極に高い(バイナリ埋め込み・共有) | 通常(アクセス時にメモリ確保) |
| 値の変更 | 不変(Immutable) | 一度だけ代入可能(Write-once) |
| 主な用途 | 定数、設定値、UIの不変パーツ | 依存性注入、ライフサイクル依存の値、遅延ロード |
迷ったときの判断フローチャート
1. その値は、アプリをビルドする前に完全に分かっていますか?
- 👉 YES: `const` にしましょう(最高パフォーマンス)。
- 👉 NO: 次へ進む。
2. その値は、インスタンス生成時には無いが、一度決まったら二度と変えたくないですか?
- 👉 YES: `late final` が最適解です。
- 👉 NO: 通常の `final` や `var` を検討しましょう。
—
さいごに
DartのNull安全は、単に「エラーを防ぐための足かせ」ではありません。コンパイラと開発者が「データのライフサイクル」について共通の厳格な契約を結び、実行時の安全性とパフォーマンスを極限まで引き出すための最高の発明です。
`const` と `late final` の違いを深く理解し、コードの意図に合わせて適切に使い分けられるようになると、あなたの書くDartコードは一気にプロフェッショナルな輝きを放ちます。
ここをクリアしたあなたなら、もうDartの型システムと言語仕様の核心をしっかりと掴んでいますよ。自信を持って、素晴らしいアプリケーションを作り上げていきましょう!