Records(记录)
Records 用于把固定数量、可能具有不同类型的值组合成一个值。它不需要预先声明类,适合表示一次计算得到的多个结果或只在局部使用的轻量数据。
Records 是 Dart 3.0 引入的语言特性。学习时需要重点理解:record 的类型由字段结构决定,字段本身不可重新赋值,但字段引用的对象仍可能被修改。
创建和读取 record
位置字段
把多个以逗号分隔的表达式写在圆括号中,可以创建带位置字段的 record。位置字段通过 $1、$2 等 getter 读取,编号从 1 开始。
void main() {
(String, int) topic = ('Records', 3);
print(topic.$1); // Records
print(topic.$2); // 3
}
(String, int) 是 record type,表示第一个字段是 String,第二个字段是 int。('Records', 3) 是 record 表达式,用于创建对应的值。
只有一个位置字段时,末尾的逗号不能省略:
void main() {
var number = (42); // int,不是 record
var single = (42,); // (int,)
print(number); // 42
print(single.$1); // 42
}
圆括号原本就可以用来改变表达式的求值顺序,因此 Dart 使用末尾逗号区分单个表达式和单字段 record。
命名字段
命名字段使用 字段名: 值 创建,并通过字段名读取。命名 record type 需要把字段声明写在花括号中,外层仍然保留圆括号。
void main() {
({String name, int lessons}) course = (
name: 'Dart',
lessons: 12,
);
print(course.name); // Dart
print(course.lessons); // 12
}
record 也可以同时包含位置字段和命名字段:
void main() {
(String, {int completed, int total}) progress = (
'Dart',
completed: 8,
total: 12,
);
print(progress.$1); // Dart
print('${progress.completed}/${progress.total}'); // 8/12
}
当字段的含义仅靠顺序就很清楚时,可以使用位置字段;当多个字段类型相同或顺序不容易记忆时,命名字段通常更易读。
返回多个值
Records 的常见用途是让函数一次返回多个相关值,不必为只使用一次的结果声明类。
({int min, int max}) findRange(List<int> values) {
var min = values.first;
var max = values.first;
for (final value in values.skip(1)) {
if (value < min) min = value;
if (value > max) max = value;
}
return (min: min, max: max);
}
void main() {
final range = findRange([7, 2, 9, 4]);
print(range.min); // 2
print(range.max); // 9
}
这里的返回类型完整描述了两个字段的名称和类型,调用者无需通过列表索引或字符串键猜测每个值的含义。
使用模式解构
Dart 的 record patterns 可以把字段直接解构为局部变量。位置字段按照顺序匹配:
void main() {
final topic = ('Records', 3);
var (name, version) = topic;
print('$name requires Dart $version.');
}
命名字段按照名称匹配,不依赖书写顺序:
void main() {
final course = (name: 'Dart', lessons: 12);
var (lessons: lessonCount, name: courseName) = course;
print('$courseName has $lessonCount lessons.');
}
解构特别适合直接接收函数返回的多个值:
({int min, int max}) findRange(List<int> values) {
return (
min: values.reduce((left, right) => left < right ? left : right),
max: values.reduce((left, right) => left > right ? left : right),
);
}
void main() {
var (min: lowest, max: highest) = findRange([7, 2, 9, 4]);
print('$lowest - $highest'); // 2 - 9
}
record patterns 与 Records 一样需要 Dart 3.0 或更高语言版本。
Record type 由结构决定
Records 使用结构化类型。判断两个 record type 是否相同时,Dart 关注以下信息:
- 位置字段的数量和顺序;
- 命名字段的名称;
- 每个字段的类型。
命名字段的书写顺序不影响类型:
void main() {
({String name, int age}) user = (name: 'Mia', age: 20);
({int age, String name}) sameShape = user;
print(sameShape.name); // Mia
}
位置字段可以在类型声明中添加仅用于说明含义的名称。这些名称不属于 record 的结构,也不会改变类型:
void main() {
(int x, int y) point = (10, 20);
(int width, int height) size = point;
print(size.$1); // 10
print(size.$2); // 20
}
相反,命名字段的名称属于类型的一部分。即使字段类型相同,名称不同也不能直接赋值:
void main() {
({int x, int y}) point = (x: 10, y: 20);
// 编译错误:字段名称不同,record type 不同
// ({int width, int height}) size = point;
print(point);
}
所有 record type 都是 Record 的子类型。不过,只写 Record 会丢失具体字段信息,无法直接读取 $1 或命名字段;已知结构时应优先写出具体的 record type。
Records 是浅层不可变的
record 创建后不能给字段重新赋值:
void main() {
var course = (name: 'Dart', lessons: 12);
// 编译错误:record 字段没有 setter
// course.lessons = 13;
print(course);
}
这种不可变性只作用于字段本身。如果字段引用的是可变对象,仍然可以修改该对象:
void main() {
final course = (name: 'Dart', topics: <String>['Types']);
course.topics.add('Records');
print(course.topics); // [Types, Records]
}
因此,record 是浅层不可变的,而不是递归冻结其包含的所有对象。final 在这里还会阻止变量 course 被重新赋值,但它同样不会冻结 topics 列表。
相等性
两个 record 具有相同结构,并且对应字段通过 == 比较都相等时,这两个 record 就相等。命名字段的书写顺序不影响结果。
void main() {
print((1, 'Dart') == (1, 'Dart')); // true
print((1, 'Dart') == (2, 'Dart')); // false
print(
(name: 'Dart', version: 3) == (version: 3, name: 'Dart'),
); // true
}
字段的相等性仍由字段类型自己的 == 实现决定。record 不会自动把内部的可变集合变成按内容比较的值对象。
使用类型别名提高可读性
当同一种 record 结构在多个函数签名中重复出现时,可以使用 typedef 为它提供名称:
typedef CourseProgress = ({String name, int completed, int total});
CourseProgress updateProgress(CourseProgress current, int completed) {
return (
name: current.name,
completed: completed,
total: current.total,
);
}
void main() {
CourseProgress progress = (name: 'Dart', completed: 8, total: 12);
print(updateProgress(progress, 9));
}
typedef 只是为现有类型添加别名,不会创建一个与其他同结构 record 不兼容的新类型。如果需要真正独立的领域类型、构造校验或封装行为,应使用类。
何时使用 Records,何时使用类
Records 更适合以下情况:
- 函数需要返回多个相关值;
- 数据结构简单,只在较小范围内使用;
- 不需要方法、构造逻辑或封装约束。
类更适合以下情况:
- 数据代表长期存在的领域概念;
- 需要方法、命名构造函数、校验或私有状态;
- 需要为类型提供稳定名称,并让接口表达明确的业务含义;
- 预计字段会随业务持续演进。
不要仅为了少写一个类就把大型、长期使用的数据模型改成 record。record 的优势是表达临时结构,而不是替代所有数据类。
与 TypeScript 的关键差异
Dart 的位置 record 可以类比 TypeScript tuple,命名 record 可以类比对象类型,但它们并不完全等价。
(String, int) topic = ('Records', 3);
({String name, int lessons}) course = (name: 'Dart', lessons: 12);
let topic: readonly [string, number] = ['Records', 3]
let course: { readonly name: string; readonly lessons: number } = {
name: 'Dart',
lessons: 12,
}
| 对比项 | Dart Records | TypeScript tuple / object |
|---|---|---|
| 运行时形式 | Dart 内置的 record 值 | JavaScript 数组或对象 |
| 字段修改 | record 字段没有 setter | 需要 readonly 才能在类型层阻止直接修改 |
| 相等性 | 相同结构的对应字段按 == 比较 | 数组和对象通常按引用比较 |
| 命名类型 | typedef 不创建新类型 | 类型别名同样不创建新的运行时类型 |
TypeScript 的 Record<K, V> 是一种键值映射工具类型,与 Dart Records 没有直接对应关系,不要因为名称相同而混淆。
常见误区
误区一:省略单字段 record 的末尾逗号
(value) 只是加了圆括号的普通表达式。位置字段只有一个时,必须写成 (value,)。
误区二:把命名 record type 写成对象字面量形式
命名 record type 的正确写法是 ({String name, int age}),不能省略外层圆括号。
误区三:认为位置字段的说明名称会创建 getter
(int x, int y) 中的 x 和 y 只用于说明含义。读取字段时仍然使用 $1 和 $2。
误区四:认为 record 会深度冻结集合
record 字段不能重新赋值,但字段引用的 List、Set、Map 或其他可变对象仍可能被修改。
误区五:用 Record 代替具体类型
Record 只能表示“某种 record”,不会暴露具体字段。需要访问字段时,应声明 (String, int) 或 ({String name, int age}) 这样的完整类型。
小结
- Records 从 Dart 3.0 开始可用,适合组合固定数量、不同类型的值。
- 位置字段通过
$1、$2读取,命名字段通过字段名读取。 - record type 由字段结构和类型决定;命名字段名称属于类型,位置字段的说明名称不属于类型。
- record patterns 可以把字段直接解构成局部变量。
- record 字段不可重新赋值,但字段引用的对象仍可能可变。
- 同结构 record 的相等性由对应字段的
==决定。 - 临时、简单的数据适合用 record;需要行为、校验和稳定领域含义的数据更适合用类。