卷 I · 认领CH 03深度 03/24

Dart 3 的补货:记录、模式与 sealed

直觉过境 「Dart 没有 sealed class 和 when,建模会很难受。」 得扔掉

这条直觉在 2023 年之前是对的。Dart 3 一口气补齐了记录、模式匹配、sealed 类和详尽性检查——现在它不但补上了 Kotlin 的 sealed + when,某些地方还走得更远:Dart 的模式能一层层解构到底,而 Kotlin 的 when 只能判断类型然后自己手动取字段。这一章带一台真的匹配引擎,你改一个值它当场重判。

记录模式sealed详尽性switch 表达式

记录:终于能返回两个值了

Kotlin 有 PairTriple 和 data class。Dart 3 给的是记录(record)——一个轻量的、结构化的、有值语义的匿名聚合类型:

// 位置字段
(int, String) parse(String s) => (200, 'OK');

final r = parse('...');
print(r.$1);   // 200,位置字段用 $1 $2 访问
print(r.$2);   // OK

// 具名字段(推荐,可读性好得多)
({int code, String body}) fetch() => (code: 200, body: 'OK');

final res = fetch();
print(res.code);

三个关键点:

  • 记录是值类型(1, 'a') == (1, 'a')true,不用你写 ==。这一点比手写类省事得多。
  • 类型就是结构:不用声明,写出来就有。两个函数返回 ({int code, String body}),它们的类型是同一个。
  • 但它不能有方法。需要行为就老老实实定义类。记录是「顺手打包几个值」,不是「省掉建模」。
✎ 什么时候用记录,什么时候用类

用记录:函数要返回两三个值、内部临时打包、Future.wait 后拆结果。

用类:这个概念在业务里有名字(CourseStudent)、要跨文件传、要序列化。把领域模型写成裸记录会在三个月后咬你——字段一多,({String a, String b, String c, String d}) 这种类型签名会失控。

模式:解构,而且能一层套一层

有了记录,自然就有了解构。Dart 3 把「模式」做成了一等公民,可以出现在变量声明、for 循环、switch 分支三个位置:

// 1. 声明处解构
final (code, body) = fetch();
final (:code, :body) = fetch();          // 具名字段的简写,等价于 (code: code, body: body)

// 2. for 循环里解构 Map
for (final MapEntry(:key, :value) in scores.entries) {
  print('$key 得了 $value 分');
}

// 3. 交换两个变量,不用临时变量
(a, b) = (b, a);

第二条那个 MapEntry(:key, :value) 值得看两眼:它是对象模式——匹配一个 MapEntry,同时把它的 keyvalue getter 的返回值绑成同名局部变量。Kotlin 里遍历 Map 要写 for ((k, v) in map),靠的是 Map.Entry 上定义的 component1/component2 扩展;Dart 这套更通用——任何对象的任何 getter 都能在模式里解构,不需要类作者提前配合

switch 表达式 + sealed:Kotlin 那套,一比一还给你

先看你熟悉的 Kotlin:

sealed interface Result
data class Ok(val body: String) : Result
data class Err(val code: Int) : Result
data object Loading : Result

val text = when (r) {
    is Ok -> r.body                    // 智能转换后手动取字段
    is Err -> "出错 ${r.code}"
    Loading -> "加载中"
}                                       // 少写一个分支 → 编译报错

Dart 3 的版本:

sealed class Result {}
class Ok extends Result { final String body; Ok(this.body); }
class Err extends Result { final int code; Err(this.code); }
class Loading extends Result {}

final text = switch (r) {
  Ok(body: final b) => b,              // 判类型 + 取字段,一步完成
  Err(code: final c) => '出错 $c',
  Loading() => '加载中',
};                                      // 少写一个分支 → 编译报错

逐条对应:sealed class 对应 sealed interface(都要求子类型在同一个库里,编译器才能穷举);switch 表达式对应 when 表达式;详尽性检查(exhaustiveness)也一样有——漏一个子类型,编译器当场报错,这正是 sealed 的全部意义。

不同之处在于那个 Ok(body: final b):Dart 在判断类型的同一行里就把字段掏出来了,而且能继续往下套:

// 一路解构到第三层,还带条件
final label = switch (event) {
  Submit(form: Form(name: '', :final email)) => '名字空着,邮箱是 $email',
  Submit(form: Form(:final name)) when name.length > 20 => '名字太长',
  Submit() => '提交',
  Cancel() => '取消',
};

这个能力在 Kotlin 里得写成一串嵌套 if + 手动取字段。「Dart 建模难受」这条直觉,可以彻底扔了。

模式一共有哪些

不用背,用到再查,但过一眼有好处:

模式写法含义
常量200'OK'null==
变量final bvar b无条件匹配并绑定
通配_匹配但不绑定(Dart 3.7 起 _ 是真正的通配符)
类型int nString _类型判断 + 可选绑定
记录(200, var body)按位置/名字拆记录
列表[first, ...rest]按长度和元素匹配,... 吃掉中间
Map{'id': var id}按键取值再匹配(解析 JSON 很好用)
对象Circle(radius: var r)类型判断 + 调 getter 解构
关系>= 400< 0比较运算,替代一堆 if
逻辑1 || 2 || 3>0 && <10或 / 与
守卫… when r > 10匹配成功后再加一道布尔条件

亲手跑一跑:真的模式匹配求值器

下面这台不是示意图,是真的解析器加真的匹配器:它会把左边那些 case 逐条解析成模式树,再拿你输入的值去匹配,从上往下第一个成功的就是结果。改一改输入的值,看命中哪一条、绑定出了什么。

试几个有意思的:在「HTTP 响应」里把值改成 (404, 'Not Found')——它会跳过第一条(常量 200 不匹配),落到第二条的守卫 code >= 400 上。在「形状」里把半径从 3 改到 30,第一条的守卫就会翻过来。注意匹配是从上往下、第一个命中就停,所以带守卫的特例必须写在通用条的前面——这和 Kotlin 的 when 一样。

在真实 Flutter 代码里,你会在这两处用到它

一、异步三态

这是 Flutter 里模式匹配最舒服的场景。用 Riverpod 的 AsyncValue(第 16 章)时:

Widget build(BuildContext context, WidgetRef ref) {
  final courses = ref.watch(courseListProvider);
  return switch (courses) {
    AsyncData(:final value) => CourseList(courses: value),
    AsyncError(:final error) => ErrorView(message: '$error'),
    _ => const Center(child: CircularProgressIndicator()),
  };
}

三行覆盖加载中/成功/失败,而且编译器保证你没漏。对照一下你在 Compose 里写的 when (uiState) { is Loading -> …; is Success -> …; is Error -> … }——同一个东西。

二、手写 JSON 解析时的防御

Dart 没有运行时反射,从网络拿到的是 Map<String, dynamic>。Map 模式让「检查结构」和「取值」变成一步:

if (json case {'id': int id, 'name': String name, 'score': num score}) {
  // 走到这里说明三个字段都在、类型都对,而且已经绑好了
  return Student(id: id, name: name, score: score.toDouble());
}
throw const FormatException('返回格式不对');

那个 if (x case 模式)if-case 语句,是模式匹配的单分支版本,用来替代「先判空再强转再取值」的三行样板。不过日常项目还是该用代码生成(第 22 章),手写解析只适合边界少、字段少的地方。

顺手说说 Dart 3.6 之后的零碎更新

你上次认真看 Dart 大概是几年前,这几年攒了一批小东西,都很实用:

  • 数字分隔符(3.6):const oneBillion = 1_000_000_000;
  • 通配符变量(3.7):_ 正式成为「我不要这个值」的写法,(_, final b) = pair;,多个 _ 不冲突。
  • 空安全元素(3.8):[a, ?maybeNull, c]——值为 null 时这一项直接不出现在列表里,写 UI 时省掉一堆 if (x != null)
  • 点简写(3.10 起稳定):上下文类型已知时可以省掉类型名——Color c = .blue;onPressed: () => setState(…), style: .filled()。Swift 用户会觉得眼熟,写枚举和主题常量时能省很多字。
  • 私有具名参数(3.12,上一章讲过)与实验中的主构造函数

if-case:单分支的模式匹配

不是每次都要 switch。当你只关心「一种情况」时,用 if (x case 模式) 更轻——它是模式匹配的单分支版,专门替代「先判类型/判空、再强转、再取值」这种三行样板:

// ✗ 老写法:三步
final data = response.data;
if (data is Map<String, dynamic> && data['user'] != null) {
  final user = data['user'] as Map;
  print(user['name']);
}

// ✓ if-case:一步,判断 + 解构 + 绑定同时完成
if (response.data case {'user': {'name': final name}}) {
  print(name);   // 走到这里说明结构对了,name 已经绑好、类型也对
}

它在 Flutter 里特别顺手的一个场景是从 AsyncValue 或路由参数里「有就取、没有就跳过」。if-case 还能带 else,以及 when 守卫:if (x case final n when n > 0) ...

详尽性检查:sealed 真正的价值

第 16 章会让你用 AsyncValue(加载/成功/失败)和自己的 sealed 状态类。这里把「详尽性」为什么重要说透,因为它是 sealed 的全部意义所在:

sealed class SubmitState {}
class Idle extends SubmitState {}
class Submitting extends SubmitState {}
class Success extends SubmitState {}
class Failure extends SubmitState { final String msg; Failure(this.msg); }

Widget build(...) => switch (state) {
  Idle() => const SubmitButton(),
  Submitting() => const CircularProgressIndicator(),
  Success() => const CheckMark(),
  // ✗ 如果漏了 Failure,编译器直接报错:switch 不详尽
};

这就是价值所在:哪天你给状态机加了一个新分支(比如加个 Cancelled 状态),编译器会把项目里每一个处理这个状态的 switch 都标红——逼你去补上新分支的 UI,一个都漏不掉。这是「用类型系统保证你没忘事」的典型,也是把 UI 状态建成 sealed(而不是几个散落的 bool)的最大理由。你在 Kotlin 里靠 when + sealed 得到的这份安全感,Dart 3 一模一样给你。

⚠ switch 语句和 switch 表达式,别混了

Dart 里这两个是不同的东西,语法细节也不同,写混了编译器的报错还挺难懂:

// switch 语句:分支用冒号,语句块,需要 break(模式分支不需要 break)
switch (r) {
  case Ok(:final body):
    print(body);
  case Err():
    print('err');
}

// switch 表达式:分支用 =>,用逗号分隔,整体是一个值,末尾有分号
final text = switch (r) {
  Ok(:final body) => body,
  Err() => 'err',
};

另外:只有 sealed 类型才有详尽性检查。对 intString 这种开放类型做 switch 表达式,编译器会要求你写一条兜底的 _ => …

⇄ Compose 对照 · 本章小结

一比一还给你的:sealed interfacesealed classwhen 表达式 → switch 表达式,详尽性检查 → 详尽性检查,Pair → 记录。你在 Compose 里那套「UI 状态用 sealed 建模,界面用 when 分发」的架构,可以原样搬过来

比 Kotlin 强的:模式能一层层解构到底(Submit(form: Form(:final name))),Kotlin 的 when 只能判类型再手动取;列表模式、Map 模式、关系模式 Kotlin 都没有。

还缺的:没有 data class 自动生成 copy——所以状态类还是要靠 freezed(第 22 章)。

这一章的一句话

Dart 3 已经不是你印象里那门「建模难受」的语言:记录、模式、sealed 与详尽性检查全都到位,而且模式能一层解构到底——你在 Compose 里用 sealed + when 建模 UI 状态的整套习惯,可以一字不改地搬过来。

语言部分到这里就够用了。下一章我们讲一个只有一个单词的话题:const。在别的语言里它是个可有可无的修饰符,在 Flutter 里它是一个性能开关——而理解它为什么是开关,正好是打开卷 II 那三棵树的钥匙。