Dart のレコード・パターン・sealed クラスを実測で理解する
レコード、分解代入、switch 式、ガード、sealed クラスを Dart 3.11 で実行。網羅性エラーの実物、jsonDecode とマップパターンの JSON パース、AOT でのメモリと速度の実測付き。
シリーズ:Dart 講座 4/6(中級)。 ヘルパー関数から Map<String, dynamic> を返したり、if (x is ...) を何段も重ねたりしている Dart/Flutter 開発者向けです。読み終えると、レコードで複数の値を返し、パターンでデータを分解し、sealed クラスで画面の状態を表して抜けている case をコンパイラに見つけさせ、as キャストの壁なしで JSON をパースできるようになります。
レビューする Flutter のコードには、Dart 3 以前の癖が2つよく残っています。「2つの値を返す」ためだけの小さなクラスや Map と、isLoading・error・data を nullable で並べてありえない組み合わせまで許してしまう画面状態です。レコード・パターン・sealed クラスはこの両方を解決し、3つとも Dart 3.0 で一緒に入りました。この記事では Dart 3.11 で実際の動き、出会うエラーの実物、そして AOT ビルドで計ったコストを見ていきます。
実行環境: Dart SDK 3.11.5(stable、macos_arm64)、Apple Silicon Mac(M1 Pro)。例は dart run(JIT)で実行し、時間とメモリは dart compile exe(AOT)のバイナリで計りました。乱数は Random(42) で固定。アナライザのメッセージは dart analyze の出力です。
レコード:位置フィールド・名前付きフィールド・等価性
レコードは (int, String) や ({double lat, double lng}) のような型を持つ、名前のない不変の値の組です。座標の例は東京駅あたりにしました。
(int, String) parse(String line) {
final i = line.indexOf('=');
return (int.parse(line.substring(i + 1)), line.substring(0, i));
}
({double lat, double lng}) tokyo() => (lat: 35.68, lng: 139.76);
void main() {
final r = parse('port=8080');
print(r);
print('${r.$1} ${r.$2}');
final (value, key) = parse('timeout=30'); // 分解代入
print('$key -> $value');
final p = tokyo();
print(p);
final (:lat, :lng) = p;
print('lat=$lat lng=$lng');
print((1, 'a') == (1, 'a'));
print((x: 1, y: 2) == (y: 2, x: 1));
print((1, 2) == (2, 1));
print({(1, 'a'), (1, 'a')}.length);
print((1, 'a').runtimeType);
print(identical((1, 2), (1, 2)));
var (a, b) = (1, 2);
(a, b) = (b, a); // 入れ替え
print('a=$a b=$b');
final pos = (1, 2, label: 'p');
print('${pos.$1} ${pos.$2} ${pos.label}');
}
(8080, port)
8080 port
timeout -> 30
(lat: 35.68, lng: 139.76)
lat=35.68 lng=139.76
true
true
false
1
(int, String)
false
a=2 b=1
1 2 p
位置フィールドは $1・$2 で読みます(0 ではなく 1 から)。名前付きフィールドは名前で読み、並び順は関係ありません。(x: 1, y: 2) == (y: 2, x: 1) は true です。== と hashCode は中身で比べる構造的等価なので、レコードの Set は重複を正しく取り除きます。第 3 回の「== だけ override して hashCode を忘れる」バグは、レコードでは起こりえません。
レコードは const でない限り正規化されません。identical((1, 2), (1, 2)) は false、identical(const (1, 2), const (1, 2)) は true で、JIT でも AOT ビルドでも同じでした。レコードの比較は identical ではなく == で行います。
パターン:分解代入・switch 式・if-case
パターンは形を確かめながら変数に取り出します。final (value, key) = ... はもう見ました。本領は switch 式です。
String bucket(int code) => switch (code) {
>= 200 && < 300 => 'ok',
301 || 302 || 307 || 308 => 'redirect',
>= 400 && < 500 => 'client error',
_ => 'other',
};
void main() {
print([200, 302, 404, 500].map(bucket).toList());
final Object v = [1, 2, 3];
if (v case [int first, ...final rest] when rest.isNotEmpty) {
print('first=$first rest=$rest');
}
}
[ok, redirect, client error, other]
first=1 rest=[2, 3]
>= 200 && < 300は関係パターンを論理積でつないだもの。301 || 302は定数パターンの論理和。if (v case ...)は switch 全体を書かずに1つのパターンだけ照合します。vはObject型ですが、リストパターンが「先頭が int のリストか」を確かめつつfirstとrestを取り出すので、キャストは1つも出てきません。whenはガードで、追加で true でなければならない普通の条件式です。
(リダイレクトのコードは switch の題材にちょうどよく、Dart の http が POST の 301 を追わない件はまさにこの4つの話です。)
sealed クラスと網羅的な switch
sealed クラスは直接のサブタイプが同じライブラリ内に固定されます。コンパイラが一覧を全部知っているので、switch が全部をカバーしているか検査できます。Flutter の画面状態でよくある形です。
sealed class LoadState {}
class Loading extends LoadState {}
class Loaded extends LoadState {
final List<String> items;
Loaded(this.items);
}
class Failed extends LoadState {
final Object error;
final int retries;
Failed(this.error, {this.retries = 0});
}
String label(LoadState s) => switch (s) {
Loading() => 'spinner',
Loaded(items: []) => 'empty list',
Loaded(:final items) when items.length > 100 => 'big list (${items.length})',
Loaded(:final items) => '${items.length} items',
Failed(retries: >= 3) => 'give up',
Failed(:final error) => 'retry: $error',
};
void main() {
for (final s in [Loading(), Loaded([]), Loaded(List.filled(150, 'x')), Loaded(['a', 'b']),
Failed('timeout'), Failed('timeout', retries: 3)]) {
print('${s.runtimeType.toString().padRight(8)} -> ${label(s)}');
}
}
Loading -> spinner
Loaded -> empty list
Loaded -> big list (150)
Loaded -> 2 items
Failed -> retry: timeout
Failed -> give up
Loaded(:final items) はオブジェクトパターンで、型を確かめつつ items ゲッターを同名の変数に取り出します。Loaded(items: []) は空リストのときだけ一致します。ウィジェットの build() では、同じ switch から CircularProgressIndicator・ListView・エラー表示を返すことになります。
ここからが本題です。チームの誰かが class Refreshing extends LoadState {} を追加し、UI の対応を忘れたとします。
error - nonex.dart:7:30 - The type 'LoadState' isn't exhaustively matched by the switch cases since it doesn't match the pattern 'Refreshing()'. Try adding a wildcard pattern or cases that match 'Refreshing()'. - non_exhaustive_switch_expression
dart run も同じく Error: The type 'LoadState' is not exhaustively matched by the switch cases since it doesn't match 'Refreshing()'. で止まります。抜けている case を名指ししてくれる。状態を bool の組み合わせではなく sealed クラスで表す理由はここにあります。
網羅性でよくある間違い
間違い 1:sealed ではなく abstract にする。 abstract class LoadState で同じ2つの case だけ書くと、switch 式のエラーは doesn't match the pattern 'LoadState()' に変わります。コンパイラはサブクラスの一覧を知らないので、ワイルドカードを求めるしかありません。さらに悪いことに、abstract クラスに対する switch 文は何の警告もなくコンパイルされ、扱っていないサブタイプは黙って素通りします。直し方: sealed にし、sealed 型には _ => を書かないことで、新しいサブタイプがビルドを止めるようにします。
間違い 2:ガードも網羅性に数えられると思う。
sealed class R {}
class Ok extends R { final int v; Ok(this.v); }
class Err extends R {}
String a(R r) => switch (r) {
Ok(:final v) when v > 0 => 'positive',
Ok(:final v) when v <= 0 => 'not positive',
Err() => 'err',
};
error - The type 'R' isn't exhaustively matched by the switch cases since it doesn't match the pattern 'Ok()'. … - non_exhaustive_switch_expression
人間には v > 0 と v <= 0 で全部だと分かりますが、コンパイラはガードの中身を推論しません。直し方: Ok の最後の case をガードなしの Ok() => 'not positive' にします。
間違い 3:絶対に実行されない case。 Ok() => 'ok' を Ok(v: 0) => 'zero' より前に置くと warning - This case is covered by the previous cases. … - unreachable_switch_case が出ます。具体的な case から先に並べます。
マップパターンと jsonDecode で JSON をパースする
Flutter アプリでパターンが一番効くのはここです。中身を完全には管理できない「アップデート確認 API」の JSON を読みます。
import 'dart:convert';
final class AppVersion {
final String platform;
final String latest;
final int minBuild;
final String? notes;
const AppVersion(this.platform, this.latest, this.minBuild, this.notes);
@override
String toString() => 'AppVersion($platform $latest, min build $minBuild, notes: $notes)';
}
AppVersion? parseVersion(Object? json) {
if (json case {
'platform': final String platform && ('ios' || 'android'),
'latest': final String latest,
'min_build': final int minBuild,
}) {
final notes = switch (json['notes']) { final String s => s, _ => null };
return AppVersion(platform, latest, minBuild, notes);
}
return null;
}
AppVersion parseVersionManual(Map<String, dynamic> json) => AppVersion(
json['platform'] as String,
json['latest'] as String,
json['min_build'] as int,
json['notes'] as String?,
);
void main() {
const bodies = [
'{"platform":"ios","latest":"2.4.0","min_build":120,"notes":"bug fixes"}',
'{"platform":"android","latest":"2.4.0","min_build":118}',
'{"platform":"web","latest":"2.4.0","min_build":1}',
'{"platform":"ios","latest":"2.4.0","min_build":"120"}',
'{"platform":"ios","latest":"2.4.0","min_build":120.0}',
'[1,2,3]',
];
for (final body in bodies) {
print(parseVersion(jsonDecode(body)));
}
print('--- manual casts on the 4th body:');
try {
parseVersionManual(jsonDecode(bodies[3]) as Map<String, dynamic>);
} catch (e) {
print('${e.runtimeType}: $e');
}
print('--- extra keys are fine for map patterns:');
print(parseVersion(jsonDecode('{"platform":"ios","latest":"3.0.0","min_build":200,"force":true}')));
final decoded = jsonDecode('{"a":1}');
print('jsonDecode map type: ${decoded.runtimeType}');
}
AppVersion(ios 2.4.0, min build 120, notes: bug fixes)
AppVersion(android 2.4.0, min build 118, notes: null)
null
null
null
null
--- manual casts on the 4th body:
_TypeError: type 'String' is not a subtype of type 'int' in type cast
--- extra keys are fine for map patterns:
AppVersion(ios 3.0.0, min build 200, notes: null)
jsonDecode map type: _Map<String, dynamic>
1つのパターンが、例外を1つも投げずに4種類の不正な入力を弾きました。未知のプラットフォーム、文字列の "120"、double の 120.0、オブジェクトでなく配列。意外なのは 120.0 で、jsonDecode は小数点付きの数値を double で返すため final int に一致しません。キャスト版は _TypeError で落ちました。Flutter アプリならデバッグでは赤い画面、リリースでは壊れた画面です。サーバー側の数値の型は実際に変わります。プリペアドステートメントで JSON の型が変わった PHP の記事では、"7" が 7 になって古い Flutter ビルドが落ちた実例を書きました。
マップパターンは余分なキーを無視します("force": true があっても通った)。サーバーがフィールドを増やしても古いアプリは壊れません。
最初にやった間違い。 最初の版ではプラットフォームの確認を 'platform': 'ios' || 'android' && final String platform と書き、コンパイルが通りませんでした。
error - json.dart:16:21 - Variable pattern 'platform' is missing in this branch of the logical-or pattern. Try declaring this variable pattern in the branch. - missing_variable_pattern
&& は || より強く結合するので、これは 'ios' || ('android' && final String platform) と読まれ、'ios' 側の分岐では変数が束縛されません。直し方: 先に束縛してから確かめ、括弧を付けます。final String platform && ('ios' || 'android')。
実測:レコード対小さなクラス、パターン対キャスト(AOT)
すべて AOT ビルド(dart compile exe)で、ウォームアップ3回のあと9回計った中央値、データは Random(42) です。
1. int を2つ返す関数を 1,000 万回。 mmRecord は (int, int) を、mmClass は final class MinMax を返します。@pragma('vm:never-inline') 付き(本物の関数呼び出し)と、なし(インライン化されうる)の両方で計りました。
record, never-inline (10M) 23.1 ms
class, never-inline (10M) 43.1 ms
record, inlinable (10M) 13.2 ms
class, inlinable (10M) 13.2 ms
json: map pattern (200k x5) 45.6 ms
json: as casts (200k x5) 41.2 ms
2回目は 23.4 / 47.4 / 13.3 / 13.4 / 47.2 / 42.6ms でした。インライン化されると両者は同じで、どちらも割り当てが消えています。本物の呼び出しを挟むと、AOT ではレコードが約2倍速くなりました。JIT(dart run)ではその差が消えました(69.1 対 66.7ms)。AOT コンパイラがレコードを割り当てずにフィールドを返していると考えると辻褄が合いますが、機械語は確認していないので「おそらく」の説明として読んでください。
2. レコードを保持したときのメモリ。 100 万個を List に入れ、新しいプロセスで RSS の増加を見ました。基準は int だけのリストです(プログラム全体は「自分で動かす」に)。
baseline 1M: +7.6 MiB (1000000)
record 1M: +40.9 MiB (1000000)
named 1M: +41.0 MiB (1000000)
class 1M: +31.0 MiB (1000000)
record-d 1M: +77.5 MiB (1000000)
class-d 1M: +31.0 MiB (1000000)
record は (i, i + 1)、named は (min: i, max: i + 1)、class は final class MinMax { final int min, max; }、record-d は (i * 0.5, i + 0.5)、class-d は final class Pt { final double x, y; } です。2回目も 0.7MiB 以内で一致しました。保持するとレコードは同等のクラスより大きく、double では倍以上になりました。クラスは final double フィールドをボックス化せずに持ちますが、レコードのフィールドは参照を持つので、double が1つずつヒープ上のオブジェクトになります。100 万個の座標を持つならクラスにします。
3. マップパターン対 as キャスト。 デコード済みの JSON オブジェクト 20 万個を5回パース(計 100 万回)。パターンは 45.6ms、キャストは 41.2ms で約 10% 遅く、JIT では 42.7 対 41.0ms でした。マップパターンはキーの存在を確かめてから読むので、その分の仕事が増えていると考えられます。1オブジェクトあたり約 45ns の差です。API のレスポンスは普通1回しかパースしないので、不正な入力に強いほうを選びましょう。
自分で動かす
どちらも SDK だけで動きます。保存して dart compile exe bench.dart -o bench && ./bench、dart compile exe mem.dart -o mem && ./mem record を実行します(モードは baseline・record・named・class・record-d・class-d)。
import 'dart:convert';
import 'dart:math';
final class MinMax {
final int min, max;
const MinMax(this.min, this.max);
}
@pragma('vm:never-inline')
(int, int) mmRecord(List<int> xs, int i) => (xs[i], xs[i + 1]);
@pragma('vm:never-inline')
MinMax mmClass(List<int> xs, int i) => MinMax(xs[i], xs[i + 1]);
(int, int) mmRecordInl(List<int> xs, int i) => (xs[i], xs[i + 1]);
MinMax mmClassInl(List<int> xs, int i) => MinMax(xs[i], xs[i + 1]);
final class Item {
final String id;
final int qty;
final String? note;
Item(this.id, this.qty, this.note);
}
Item? parsePattern(Object? j) {
if (j case {'id': final String id, 'qty': final int qty}) {
return Item(id, qty, j['note'] as String?);
}
return null;
}
Item parseCast(Map<String, dynamic> j) =>
Item(j['id'] as String, j['qty'] as int, j['note'] as String?);
int sink = 0;
double median(void Function() f, {int warm = 3, int runs = 9}) {
for (var i = 0; i < warm; i++) f();
final t = <double>[];
for (var i = 0; i < runs; i++) {
final sw = Stopwatch()..start();
f();
t.add(sw.elapsedMicroseconds / 1000);
}
t.sort();
return t[runs ~/ 2];
}
void main() {
final rnd = Random(42);
const n = 10000000;
final xs = List<int>.generate(n + 1, (_) => rnd.nextInt(1000));
void report(String name, double ms) => print('${name.padRight(34)} ${ms.toStringAsFixed(1)} ms');
report('record, never-inline (10M)', median(() {
var s = 0;
for (var i = 0; i < n; i++) { final (a, b) = mmRecord(xs, i); s += a - b; }
sink += s;
}));
report('class, never-inline (10M)', median(() {
var s = 0;
for (var i = 0; i < n; i++) { final m = mmClass(xs, i); s += m.min - m.max; }
sink += s;
}));
report('record, inlinable (10M)', median(() {
var s = 0;
for (var i = 0; i < n; i++) { final (a, b) = mmRecordInl(xs, i); s += a - b; }
sink += s;
}));
report('class, inlinable (10M)', median(() {
var s = 0;
for (var i = 0; i < n; i++) { final m = mmClassInl(xs, i); s += m.min - m.max; }
sink += s;
}));
final rows = List.generate(200000, (i) => {'id': 'sku-$i', 'qty': rnd.nextInt(50), if (i.isEven) 'note': 'n$i'});
final decoded = (jsonDecode(jsonEncode(rows)) as List).cast<Map<String, dynamic>>();
report('json: map pattern (200k x5)', median(() {
var s = 0;
for (var k = 0; k < 5; k++) for (final r in decoded) { s += parsePattern(r)!.qty; }
sink += s;
}));
report('json: as casts (200k x5)', median(() {
var s = 0;
for (var k = 0; k < 5; k++) for (final r in decoded) { s += parseCast(r).qty; }
sink += s;
}));
print('sink ${sink != 0}');
}
import 'dart:io';
final class MinMax { final int min, max; const MinMax(this.min, this.max); }
final class Pt { final double x, y; Pt(this.x, this.y); }
void main(List<String> a) {
const n = 1000000;
final before = ProcessInfo.currentRss;
final List<Object> l = switch (a[0]) {
'record' => List.generate(n, (i) => (i, i + 1)),
'class' => List.generate(n, (i) => MinMax(i, i + 1)),
'record-d' => List.generate(n, (i) => (i * 0.5, i + 0.5)),
'class-d' => List.generate(n, (i) => Pt(i * 0.5, i + 0.5)),
'named' => List.generate(n, (i) => (min: i, max: i + 1)),
_ => List.generate(n, (i) => i),
};
final after = ProcessInfo.currentRss;
print('${a[0].padRight(9)} 1M: +${((after - before) / 1048576).toStringAsFixed(1)} MiB (${l.length})');
}
判断表
| やりたいこと | 使うもの | 理由 |
|---|---|---|
| private なヘルパーから2〜3個の値を返す | レコード (a, b) |
クラス名が要らず、== も正しい |
| 複数ファイルで使う値型、メソッド付き | クラス(または final class) |
名前・ドキュメント・メソッド |
| 数値を何百万個もメモリに持つ | final double フィールドのクラス |
レコードは double をボックス化(77.5 対 31.0MiB) |
| 決まった種類の状態や結果 | sealed class + switch 式 |
抜けた case をコンパイラが見つける |
| 決まった種類の単純な定数 | enum |
switch で網羅性検査が効く |
| 信用できない JSON | if-case のマップパターン |
例外を投げずに型違いを弾く |
| 信用できる、生成された JSON モデル | キャストかコード生成の fromJson |
わずかに速く、コードが短い |
複数フィールドからなる Set/Map のキー |
レコード | 構造的な hashCode が付いてくる |
正直な限界とバージョンのメモ
「Dart 3.0 以降」という説明は、レコード型・sealed・分解代入を使うファイルの先頭に // @dart=2.19 を付けて確かめました。
error - old219.dart:2:1 - This requires the 'records' language feature to be enabled. Try updating your pubspec.yaml to set the minimum SDK constraint to 3.0.0 or higher, and running 'pub get'. - experiment_not_enabled
error - old219.dart:3:1 - This requires the 'sealed-class' language feature to be enabled. … - experiment_not_enabled
error - old219.dart:5:9 - Expected an identifier. - missing_identifier
error - old219.dart:5:9 - Expected to find ';'. - expected_token
…
21 issues found.
レコードと sealed には分かりやすい「機能」のメッセージが出ますが、パターンには出ません。2.19 では final (a, b) = pair(); がそもそも構文として読めず、missing_identifier/expected_token が大量に出ます。Dart 3 として正しく見えるコードでこういうエラーが出たら、まず pubspec.yaml の SDK 制約を確認してください。// @dart=3.0 にすると同じファイルが 1 a one と出力しました。
- 実行は Dart 3.11.5 だけで、3.0 の SDK はインストールしていません。
- メモリの数値は RSS の増加で、GC ヒープの余裕分を含みます。2回の実行で安定していましたが、相対比較として見てください。
- never-inline の計測は、実際のコードにはないかもしれない呼び出しを強制しています。インライン化されればレコードとクラスは同じでした。
- この回では Flutter アプリは動かしていません。Flutter の部分は、ウィジェットの
build()から呼ぶ普通の Dart コードです。
練習問題
問題 1. sealed class Shape と Circle(double r)・Rect(double w, double h) に対して、ワイルドカードなしの switch 式で double area(Shape s) を書いてください。そのあと Square(double side) を追加してエラーを読んでください。
問題 2. {"user": {"id": 7, "tags": ["a", "b"]}} を if-case 1つでレコード (int id, List<String> tags) にしてください。tags がない、または文字列以外を含む場合は null を返します。
解答
1.
sealed class Shape {}
class Circle extends Shape { final double r; Circle(this.r); }
class Rect extends Shape { final double w, h; Rect(this.w, this.h); }
double area(Shape s) => switch (s) {
Circle(:final r) => 3.141592653589793 * r * r,
Rect(:final w, :final h) => w * h,
};
class Square extends Shape { ... } を足すと non_exhaustive_switch_expression ... doesn't match the pattern 'Square()' になりました。
2.
(int, List<String>)? parseUser(Object? json) {
if (json case {'user': {'id': final int id, 'tags': final List<Object?> raw}}
when raw.every((t) => t is String)) {
return (id, raw.cast<String>());
}
return null;
}
jsonDecode が作るのは List<dynamic> なので、List<String> のパターンは決して一致しません(実際に試すと null でした)。要素はガードで確かめます。上の入力では (7, [a, b]) と出力されました。
関連記事:Java のクラス・レコード・インターフェース · Swift のエラー処理とジェネリクス · PHP の enum とインターフェース
前回: 第 3 回 クラス・mixin・拡張メソッド · 次回: 第 5 回 非同期・Future・Stream・Isolate
よくある質問
Dart のレコードは小さなクラスより速い・小さいのですか?
場合によります。インライン化しない関数から2値を返す処理 1,000 万回は、レコードで 23.1ms、クラスで 43.1ms でした(AOT、Apple Silicon Mac M1 Pro)。一方リストに 100 万個保持すると、(int, int) レコードは RSS +40.9MiB、クラスは +31.0MiB、(double, double) レコードは double がボックス化されて +77.5MiB でした。
sealed クラスに新しいサブクラスを足すと何が起きますか?
その型を扱う switch 式のうち新しい case のないものがすべて non_exhaustive_switch_expression でコンパイルできなくなり、"doesn't match the pattern 'Refreshing()'" のように抜けている case を名指しします。ただの abstract クラスでは「ワイルドカードを足せ」としか言われず、switch 文は警告なしで通ります。
レコードやパターンは Dart 3 が必要ですか?
必要です。// @dart=2.19 を付けると、レコードと sealed には experiment_not_enabled が出て、final (a, b) = pair(); のような分解代入は missing_identifier や expected_token の構文エラーの山になります。// @dart=3.0 なら同じファイルが動きます。