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 이전 습관 두 가지가 꼭 보입니다. "값 두 개를 돌려주려고" 만든 작은 클래스나 Map, 그리고 isLoading·error·data 를 nullable 로 늘어놓아 말이 안 되는 조합까지 허용하는 화면 상태. 레코드·패턴·sealed 클래스는 이 둘을 모두 해결하며, 셋 다 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 전체를 쓰지 않고 패턴 하나만 맞춰 봅니다.v는Object타입이지만 리스트 패턴이 "첫 요소가 int 인 리스트인가"를 확인하면서first와rest를 꺼내므로 캐스트가 하나도 없습니다.when은 가드로, 추가로 참이어야 하는 평범한 조건식입니다.
(리다이렉트 코드는 switch 예제로 딱 좋습니다. Dart http 가 POST 의 301 을 따라가지 않는 문제가 바로 이 네 코드 이야기입니다.)
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 getter 를 같은 이름의 변수로 꺼냅니다. 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 클래스로 표현하는 이유가 바로 이것입니다.
망라성(exhaustiveness)에서 흔한 실수
실수 1: sealed 대신 abstract 를 쓴다. abstract class LoadState 에 같은 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>
패턴 하나가 예외 하나 없이 잘못된 입력 네 가지를 걸러냈습니다. 모르는 플랫폼, 문자열 "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 두 개를 돌려주는 함수 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
두 번째 실행은 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; } 입니다. 두 번째 실행도 0.7MiB 안쪽으로 같았습니다. 담아 두면 레코드가 같은 내용의 클래스보다 더 크고, double 이면 두 배가 넘었습니다. 클래스는 final double 필드를 박싱하지 않고 들고 있지만, 레코드 필드는 참조를 들고 있어서 double 하나하나가 힙 객체가 됩니다. 좌표 100만 개를 들고 있어야 한다면 클래스를 쓰세요.
3. 맵 패턴 대 as 캐스트. 디코딩된 JSON 객체 20만 개를 5번 파싱(총 100만 번). 패턴 45.6ms, 캐스트 41.2ms 로 약 10% 느렸고, JIT 에서는 42.7 대 41.0ms 였습니다. 맵 패턴은 키가 있는지 먼저 확인한 뒤 읽기 때문에 그만큼 일이 늘어난 것으로 보입니다. 객체 하나당 약 45ns 차이입니다. API 응답은 보통 한 번만 파싱하니, 잘못된 입력에 강한 쪽을 고르세요.
직접 돌려 보기
둘 다 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 힙 여유분이 섞여 있습니다. 두 번 실행해도 안정적이었지만 상대 비교로 보세요.
- 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 하나로 레코드 (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]) 가 출력됐습니다.
관련 글: 자바 클래스·레코드·인터페이스 · Swift 에러 처리와 제네릭 · PHP enum 과 인터페이스
이전: 3강: 클래스·mixin·확장 메서드 · 다음: 5강: 비동기·Future·Stream·Isolate
자주 묻는 질문
Dart 레코드는 작은 클래스보다 빠르거나 작은가요?
경우에 따라 다릅니다. 인라인되지 않는 함수에서 값 두 개를 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 이면 같은 파일이 실행됩니다.