Dart入門:型・finalとconst・null安全を実行結果で理解
Dart講座1。dart runで最初の5分、var/final/constの実エラー、~/の整数除算、文字列補間、? ! ?? lateとdart analyzeの診断コード、JITとAOTの起動時間の実測まで初心者向けに解説。
シリーズ:Dart 講座 1/6(初級)。 Dart を一度も書いたことがない人、あるいは Flutter のサンプルを動かしてみたものの null まわりの赤い波線で手が止まった人向けです。読み終えると、最初のプログラムを実行し、var・final・const を使い分け、コンパイラの null 安全エラーと実行時のクラッシュメッセージを読めるようになります。
Dart は Flutter アプリを書くための言語です。Android で Kotlin を、iOS で Swift を書いてきた人なら String? の ? には見覚えがあるはずですが、Dart には late や「型の昇格」など独特の部分があり、Flutter の現場で最初の1か月に読むクラッシュレポートの多くはこのページに出てくるエラー(Null check operator used on a null value、LateInitializationError、アナライザーがすでに警告していた型エラー)です。以下はすべて Apple Silicon Mac(M1 Pro)上の Dart SDK 3.11.5(stable) で実際に実行した結果で、出力中のファイルパスだけ ~/demo/ に置き換えています。
5分で最初のプログラム
Flutter をインストール済みなら Dart もすでに入っています。dart コマンドは Flutter SDK に同梱されています。
$ dart --version
Dart SDK version: 3.11.5 (stable) (Wed Apr 15 00:36:32 2026 -0700) on "macos_arm64"
hello.dart というファイルを作ります。
void main() {
print('Hello, Dart!');
}
実行します。
$ dart run hello.dart
Hello, Dart!
準備はこれだけです。今使った用語を整理しておきます。
main()はエントリーポイント、つまり Dart が最初に呼ぶ関数です。voidは「何も返さない」という意味です。print()は端末に1行出力します。- 文の終わりには
;が必要です。付け忘れは最初に出会うエラーの定番で、dart runはError: Expected ';' after this.とキャレット(^)で場所を示し、dart analyzeはExpected to find ';'. - expected_tokenと報告します。
この講座ではプロジェクトフォルダは要りません。dart create -t console my_app で pubspec.yaml やテスト付きのプロジェクトを作れますが、.dart ファイル1つでも単独で動くので、ここではその形で進めます。
var・final・const と、それぞれのエラー
変数は値に付ける名前です。Dart では3通りの宣言があります。
void main() {
var count = 3; // type inferred: int
final name = 'Mina'; // set once, at run time
const pi = 3.14159; // compile-time constant
final now = DateTime.now();
print('${count.runtimeType} ${name.runtimeType} ${pi.runtimeType} ${now.year > 2000}');
}
int String double true
int や String と書いていないのに、Dart は型を知っています。これが型推論で、右辺を見て変数の型を確定させます。var count = 3 は int count = 3 とまったく同じ意味です。JavaScript の var のような「何でも入る変数」ではありません。
わざと壊してみます。
void main() {
final name = 'Mina';
name = 'Jun';
const now = DateTime.now();
var count = 3;
count = 'three';
Object? b = 'hello';
print(b.length);
int half = 7 / 2;
}
dart analyze は実行せずにファイルを検査します。各行の末尾にあるのが診断コードで、メッセージの意味が分からないときはこのコードで検索します(未使用変数の警告は省略)。
$ dart analyze errors.dart
error - errors.dart:3:3 - The final variable 'name' can only be set once. Try making 'name' non-final. - assignment_to_final_local
error - errors.dart:5:15 - Const variables must be initialized with a constant value. Try changing the initializer to be a constant expression. - const_initialized_with_non_constant_value
error - errors.dart:5:15 - The constructor being called isn't a const constructor. Try removing 'const' from the constructor invocation. - const_with_non_const
error - errors.dart:8:11 - A value of type 'String' can't be assigned to a variable of type 'int'. Try changing the type of the variable, or casting the right-hand type to 'int'. - invalid_assignment
error - errors.dart:11:11 - The getter 'length' isn't defined for the type 'Object'. ... - undefined_getter
error - errors.dart:13:14 - A value of type 'double' can't be assigned to a variable of type 'int'. ... - invalid_assignment
同じファイルを dart run すると起動を拒否し、別の言い回しで問題箇所を指します。
errors.dart:3:3: Error: Can't assign to the final variable 'name'.
name = 'Jun';
^^^^
errors.dart:5:24: Error: Cannot invoke a non-'const' constructor where a const expression is expected.
Try using a constructor or factory that is 'const'.
const now = DateTime.now();
^^^
ここから分かること:
finalは「代入は1回」。値は実行時に計算してよい(DateTime.now()も可)。constは「コンパイル時に値が決まっている」。現在時刻はそうではないのでconst_initialized_with_non_constant_value。varは1行目で型をintに確定したので、count = 'three'はinvalid_assignment。
Flutter のチームでよく見る習慣は「基本は final、変わらないリテラルやウィジェットには const、本当に再代入するときだけ var」です(任意の lint prefer_final_locals で強制するチームもあります)。const のオブジェクトはコンパイル時に作られて同じインスタンスが使い回されるので、Flutter のコードには const Text(...) があふれています。
dynamic と Object?:「何でも入る」の2つの書き方
dynamic も Object? も、どんな型の値でも入ります。違いはコンパイラが何を許すかです。
void main() {
dynamic a = 'hello';
Object? b = 'hello';
print(a.length);
if (b is String) print(b.length); // promoted to String
a = 42;
print(a.length);
}
このファイルに dart analyze は No issues found! と答えますが、実行するとクラッシュします。
5
5
Unhandled exception:
NoSuchMethodError: Class 'int' has no instance getter 'length'.
Receiver: 42
Tried calling: length
dynamic にするとコンパイラはチェックをやめるので、間違いはその行が実行されたとき、つまりユーザーのスマホの上で初めて表に出ます。Object? なら b.length はコンパイル時に拒否され(前節の undefined_getter)、is で型を確かめるまで使えません。if (b is String) のブロック内では b が String に昇格します。これを型昇格(type promotion)といい、null 安全も同じ仕組みを使っています。
| やりたいこと | 使うもの | チェックする人 |
|---|---|---|
| 型が分かっている値 | その型(int、String)か final x = … |
コンパイラ |
何でも受け取り、is で確かめてから使う |
Object?(null にならないなら Object) |
コンパイラ |
| すぐ変換する生の JSON | dynamic を受けたら直ちに型付きクラスへ |
実行時まで誰も |
jsonDecode() の戻り値は dynamic です。API レスポンスを読む Flutter コードで NoSuchMethodError が多いのはこのためで、受け取った境界で型付きデータに変換するのが鉄則です。
数値:int・double と ~/
print(7 / 2); // 3.5
print(7 ~/ 2); // 3
print(-7 ~/ 2); // -3
print(7 % 3); // 1
print(-7 % 3); // 2
print((-7).remainder(3)); // -1
print(0.1 + 0.2); // 0.30000000000000004
print(1 / 0); // Infinity
print(9223372036854775807 + 1);
print(2.5.round()); // 3
print((-2.5).round()); // -3
3.5
3
-3
1
2
-1
0.30000000000000004
Infinity
-9223372036854775808
3
-3
覚えておくこと4つ:
/は常にdoubleを返します。8 / 2も4.0です。整数の割り算は~/で、ゼロ方向に切り捨てるので-7 ~/ 2は-4ではなく-3です。- 割る数が正なら
%は負になりません。-7 % 3は2。Java や C と同じ-1が欲しければ.remainder()を使います。 - Dart VM とネイティブアプリでは
intは黙ってオーバーフローします。64ビットの最大値に1を足すと最小値に戻り、例外は出ません。 round()は .5 をゼロから遠い側へ丸めます(2.5→3、-2.5→-3)。Python の偶数丸めとは違います。
int.parse('42') は文字列を数値に変換し、不正な入力では例外を投げます。int.tryParse('4x2') は代わりに null を返します。ここで null 安全の出番です。
文字列と補間
final name = 'Mina';
final apps = 3;
print('Hi $name, you have $apps apps.');
print('Next year: ${apps + 1} apps, ${name.toUpperCase()}');
print('Price: \$${(1234.5).toStringAsFixed(2)}');
print(r'raw \n $name');
print('😀'.length);
print('😀'.runes.length);
Hi Mina, you have 3 apps.
Next year: 4 apps, MINA
Price: $1234.50
raw \n $name
2
1
$name は変数を、${…} は任意の式を埋め込みます。ドル記号そのものは \$ と書きます。シングルとダブルのクォートは同じ意味で、''' は複数行文字列、r を前に付けるとエスケープも補間も無効になります。'Age: ' + apps は int が String ではないのでコンパイルエラーで、補間で書くのが Dart 流です。
絵文字の行は実務の罠です。.length は UTF-16 のコード単位を数えるので、絵文字1つが 2 になります。ニックネームを .length で「10文字まで」に制限すると、絵文字を使う人は5文字しか入りません。日本語の通常の文字は1単位なので 'こんにちは'.length は 5 でしたが、人名や店名で出てくる「𠮷」(つちよし)はサロゲートペアなので '𠮷野家'.length は 4、.runes.length は 3 でした。氏名入力欄の文字数チェックで実際に引っかかる組み合わせです。.runes.length はコードポイント数で、国旗や肌色付き絵文字のように人間が「1文字」と見るものを数えるには characters パッケージ(Flutter がすでに依存しているもの)を使います。
健全な null 安全:? ?. ?? ! late
Dart 2.12 から、そして Dart 3 からは無効化もできなくなった仕組みとして、String のような型には null を入れられません。値がないかもしれないなら ? を付けて String? にします。するとコンパイラは、使う前に null の場合の処理を書くよう求めます。
String? findNickname(String user) => user == 'mina' ? 'Mimi' : null;
void main() {
String? a = findNickname('mina');
String? b = findNickname('jun');
print(a?.length); // 4
print(b?.length); // null
print(b ?? 'no nickname'); // fallback
b ??= 'guest'; // assign only if null
print(b);
String? c = findNickname('jun');
if (c != null) {
print(c.length); // promoted to String inside the if
}
print(c!.length); // you promised it is not null... it is
}
4
null
no nickname
guest
Unhandled exception:
Null check operator used on a null value
#0 main (file:///~/demo/nulls.dart:18:10)
?.は値が null でないときだけメンバーを呼び、null なら式全体がnullになります。??は代わりの値を返し、??=は変数が null のときだけ代入します。if (c != null)のブロック内ではcがStringに昇格します。is Stringと同じ仕組みです。!(null アサーション演算子)は「null ではない、信じてくれ」とコンパイラに伝えます。コンパイラは黙りますが、チェックは実行時に移ります。外れたときに出るのが、Flutter アプリで最も多いクラッシュです。
? を付けずに書くと、先にコンパイラが止めてくれます。
error - errors.dart:16:14 - The property 'length' can't be unconditionally accessed because the receiver can be 'null'. Try making the access conditional (using '?.') or adding a null check to the target ('!'). - unchecked_use_of_nullable_value
error - errors.dart:18:13 - A value of type 'Null' can't be assigned to a variable of type 'int'. ... - invalid_assignment
アナライザーの提案文に「or adding a null check to the target ('!')」とあることに注意してください。技術的には正しいのですが、たいていは間違った直し方です。まず if、?.、?? を検討します。
late:「読む前に必ず入れます」という約束
late を付けると、null 非許容の変数を値なしで宣言できます。Flutter の State クラスでよく見る形です(initState で代入する late final AnimationController controller; など)。
class Settings {
late String token; // "I will set it before reading it"
late final String theme = loadTheme(); // lazy: runs on first read
late final int id; // set once, later
void login() => token = 'abc123';
}
String loadTheme() {
print(' loadTheme() runs now');
return 'dark';
}
void main() {
final s = Settings();
print('created Settings');
print(s.theme);
print(s.theme);
s.id = 1;
try {
s.id = 2;
} catch (e) {
print(e);
}
print(s.token); // forgot to call login()
}
created Settings
loadTheme() runs now
dark
dark
LateInitializationError: Field 'id' has already been initialized.
Unhandled exception:
LateInitializationError: Field 'token' has not been initialized.
#0 Settings.token (~/demo/late.dart)
#1 main (~/demo/late.dart:26:11)
1回の実行で3つの動きが見えます。late final … = loadTheme() は最初に読まれたときに1回だけ実行され、late final に2回代入すると例外、未代入の late フィールドを読むと例外です。ローカル変数ならアナライザーが見抜けます(late String msg; print(msg); は definitely_unassigned_late_local_variable)。しかしフィールドは見抜けないので、late も ! と同じく実行時にしか確かめられない約束です。
よくある間違い:! でアナライザーを黙らせる
状況。 画面にユーザーのニックネームを出す。モデルは String? nickname、ウィジェットで Text(user.nickname!) と書くと赤い波線が消え、テスト用アカウントは全員ニックネームを設定していたので動作確認も通った。
実際に起きること。 ニックネーム未設定の本物のユーザーが画面を開いた瞬間に Null check operator used on a null value。Flutter のデバッグビルドなら赤いエラー画面、リリースビルドなら灰色の領域かクラッシュレポートになります。アナライザーは警告していたのに、! がその警告を消したわけです。
直し方。 値がないときに画面に何を出すかを決めて、コードに書きます。
Text(user.nickname ?? 'Guest')
「なければ何も出さない」なら if (user.nickname != null) Text(user.nickname!) も可ですが、それはチェックが ! のすぐ隣にあるからです。引き継いだコードを点検するときは !. と !) で検索し、1つずつ「これを保証しているのは誰か」と問うのが手早い方法です。
実測:dart run とコンパイル済み exe の起動時間
dart run は起動のたびにコードをその場でコンパイル(JIT)します。dart compile exe は事前に(AOT)ネイティブ実行ファイルへコンパイルします。Flutter のリリースビルドと同じ種類のコンパイルです。
$ dart compile exe hello.dart -o hello
Generated: ~/demo/hello
コンパイルは 0.87 秒、実行ファイルは 5,587,776 バイト(約 5.3 MB、Dart ランタイムを内蔵するため)でした。hello.dart を各コマンド20回ずつ起動して中央値を取り、それを3回繰り返しました。
| コマンド | 起動時間の中央値(3回分) |
|---|---|
dart run hello.dart |
330〜340 ms |
dart hello.dart |
193〜210 ms |
./hello(AOT exe) |
16〜18 ms |
要約では出てこない発見が2つあります。1つ目に、同じファイルでも dart run file.dart は dart file.dart より毎回約 130 ms 遅いこと。dart run はパッケージ解決の経路を通る(プロジェクトの bin/ を実行するのと同じコマンド)一方、dart file.dart はファイルを読むだけです。それ以上は追っていませんが、ちょっとした実験なら dart file.dart で十分です。2つ目に、AOT ビルドのクラッシュメッセージも同じ Null check operator used on a null value でしたが、スタックトレースから列番号が消えていたこと(nulls.dart:18:10 ではなく nulls.dart:18)。終了コードはどちらも 255 でした。
正直な限界とバージョンメモ
- すべて Dart 3.11.5、Apple M1 Pro でのみ実行しました。時間は環境で変わるので、比率を見てください。
- null 安全は Dart 2.12 で導入されました。「もう無効化できない」点は実際に確かめました。先頭に
// @dart=2.9と書いたファイルは 3.11.5 でillegal_language_version_override("The language version must be >=2.12.0")になります。 - 整数オーバーフローの折り返しは Dart VM とネイティブ(Flutter のモバイル・デスクトップ)の話です。Web では
intが JavaScript の数値になり上限が違います。この記事では Web ビルドは実行していません。 dart analyzeはpubspec.yamlなしで実行したため、既定の診断だけが出ています。dart createやflutter createのプロジェクトでは推奨 lint パッケージが加わりますが、pub getが必要なので、ここではパッケージなしの単一ファイルに留めました。
練習問題
1. 実行せずに出力を予想してください。
print(17 ~/ 5);
print(17 % 5);
print(-17 ~/ 5);
print(-17 % 5);
print(17 / 5);
2. ! も if も使わずに、長さを返し name が null なら 0 を返す int nameLength(String? name) を書いてください。
3. 次のコードはクラッシュします。エラーメッセージの意味を説明し、2通りの方法で直してください。
String? title;
print(title!.toUpperCase());
解答
1. 実際の出力は 3、2、-3、3、3.4。~/ はゼロ方向への切り捨て(-4 ではなく -3)で、正の数で割る % は負にならないので -17 % 5 は 3 です(-17 = -4 × 5 + 3)。
2. int nameLength(String? name) => name?.length ?? 0; で、'Mina' には 4、null には 0 が出力されました。?. で結果が int? になり、?? で int に戻ります。
3. Null check operator used on a null value:! で null ではないと宣言したのに null だった、という意味です。代替値で直すなら print(title?.toUpperCase() ?? '(untitled)');、チェックで直すなら if (title == null) return '(untitled)'; のあとに title.toUpperCase()(昇格するので ! 不要)。おまけ:この短いコードのまま1つ目の直し方を書くと、アナライザーは dead_code 警告を出しました。フロー解析で title が常に null だと証明できたためです。実際のコードでは値は外から来るので、この警告は出ません。
次回
次回は関数(名前付き引数と required、クロージャー)とコレクションです。遅延評価される Iterable の罠や、ループ中にリストを変更したときのエラーも実際に出します。
よくある質問
Flutter で「Null check operator used on a null value」と出るのはなぜ?
どこかのコードが、その時点で null だった変数に ! 演算子を使ったからです。! は「null ではないと信じてくれ」とコンパイラに伝えるもので、チェックが実行時に先送りされ、外れるとプログラムが止まります。! を if (x != null) や ?.、?? による代替値に置き換えます。
Dart の final と const の違いは?
どちらも代入は1回だけです。const はコンパイル時に値が決まっている必要があり(リテラルや他の const)、final は実行時に計算した値でも構いません。const now = DateTime.now() は const_initialized_with_non_constant_value でエラーになり、final now = DateTime.now() は通ります。
int half = 7 / 2 がエラーになるのはなぜ?
Dart の / は常に double を返すため、int への代入は invalid_assignment というコンパイルエラーになります。整数の割り算は ~/ を使う(7 ~/ 2 は 3)か、(7 / 2).toInt() や .round() で明示的に変換します。