Type system(类型系统)
Dart 使用带有类型推断的健全静态类型系统。静态类型让分析器在程序运行前发现不兼容的赋值和调用;类型推断则让局部代码不必重复书写显而易见的类型。对于无法仅靠静态信息确认的操作,Dart 会保留必要的运行时检查,从而维护类型安全。
String describeLength(String text) => '$text: ${text.length}';
void main() {
var language = 'Dart'; // 推断为 String
print(describeLength(language)); // Dart: 4
// language = 3; // 编译错误:int 不能赋值给 String
}
学习 Dart 类型系统时,重点不是给每个变量都手写类型,而是理解以下几件事:
- 静态类型决定哪些操作能够通过分析。
- 运行时类型表示对象实际由什么类型创建。
- 子类型关系决定值能否赋给某个变量或传给某个参数。
- 健全空安全把“可能为
null”直接编码在类型中。 dynamic可以暂时绕过部分静态检查,但不会取消运行时类型约束。
以下示例按启用健全空安全的 Dart 3 编写。
静态类型与类型推断
每个 Dart 表达式都有静态类型。显式类型注解直接指定变量的静态类型,var 则让分析器根据初始化表达式推断类型:
void main() {
String explicitTopic = 'Type system';
var inferredTopic = 'Type inference'; // String
explicitTopic = 'Soundness';
inferredTopic = 'Null safety';
// inferredTopic = 1; // 编译错误:int 不能赋值给 String
print('$explicitTopic, $inferredTopic');
}
var 不是类型,也不等于 dynamic。变量一旦被推断为 String,后续赋值仍必须满足 String 的约束。
类型推断还会使用代码所在位置提供的上下文。例如,下面的集合字面量根据变量声明被确定为 List<num>:
void main() {
List<num> measurements = [1, 2.5];
measurements.add(3);
print(measurements); // [1, 2.5, 3]
}
局部变量的类型明显时,使用推断通常更简洁;公开函数的参数和返回值则适合显式标注,因为它们构成调用者需要理解的接口。
没有足够上下文时,推断结果可能比预期宽。例如,空列表无法从元素推断类型:
void main() {
var looselyTyped = []; // List<dynamic>
var topics = <String>[];
looselyTyped.add('types');
looselyTyped.add(1);
topics.add('type system');
// topics.add(1); // 编译错误:int 不能作为 String 添加
print(looselyTyped);
print(topics);
}
当初始化表达式不能表达真实意图时,应补充变量类型或字面量的类型参数,而不是接受无意产生的 dynamic。
健全性:静态保证与运行时检查
“健全”表示类型系统会保证表达式产生的值符合它的静态类型。Dart 优先在静态检查阶段拒绝确定不安全的代码:
void printLabel(String label) {
print(label.toUpperCase());
}
void main() {
printLabel('dart');
// printLabel(42); // 编译错误:参数必须是 String
}
有些值来自 dynamic、平台互操作或其他只能在运行时确认的边界。这时代码可以通过静态检查,但 Dart 会在值进入更具体的类型时检查它:
void main() {
dynamic input = 42;
try {
String title = input;
print(title);
} catch (error) {
print('类型检查失败:${error.runtimeType}');
}
}
运行时检查并不与静态类型系统矛盾。它负责阻止一个实际为 int 的对象被当作 String 继续使用,从而守住静态类型作出的保证。
子类型与赋值兼容性
如果类型 Child 是 Parent 的子类型,那么 Child 的值可以用在需要 Parent 的位置。int 和 double 都是 num 的子类型:
num total(num left, num right) => left + right;
void main() {
int completed = 3;
num count = completed;
print(total(count, 2.5)); // 5.5
}
反方向赋值通常不成立,因为一个 num 值也可能是 double,不能直接保证它是 int:
void main() {
num value = 3.5;
// int count = value; // 编译错误:num 不能赋值给 int
print(value);
}
Dart 不会因为两个类拥有相同成员,就自动认为它们属于同一类型。普通类和类的隐式接口主要采用名义类型关系:需要通过 extends、implements 或 with 等声明建立关系。
abstract interface class Named {
String get name;
}
class Course implements Named {
Course(this.name);
final String name;
}
void printName(Named value) {
print(value.name);
}
void main() {
printName(Course('Dart')); // Dart
}
Record type 是一个重要例外:它由字段结构决定,而不是由声明名称决定。类型别名同样不会创建新的类型身份。相关规则分别见 Records(记录) 和 Typedefs(类型别名)。
空安全与可空类型
在健全空安全下,String 不包含 null,String? 才表示值可能是字符串或 null:
int titleLength(String? title) {
if (title == null) {
return 0;
}
return title.length;
}
void main() {
print(titleLength('Types')); // 5
print(titleLength(null)); // 0
}
通过 title == null 分支后,分析器知道剩余路径中的 title 一定是 String,因此允许访问 length。这种根据控制流把变量缩小为更具体类型的行为称为类型提升(type promotion)。
常见的空安全操作包括:
| 写法 | 含义 |
|---|---|
T? | 值的类型是 T,也可能为 null |
value?.member | 仅在 value 非空时访问成员 |
value ?? fallback | value 为空时使用备用值 |
value! | 断言 value 非空,并把失败风险留到运行时 |
String normalizeTitle(String? title) {
return title?.trim().toUpperCase() ?? 'UNTITLED';
}
void main() {
print(normalizeTitle(' dart ')); // DART
print(normalizeTitle(null)); // UNTITLED
}
优先通过条件判断、?. 和 ?? 处理空值。只有当程序逻辑已经保证值非空、但分析器无法证明时,才应使用 !;它不会检查业务逻辑,只会在值确实为 null 时抛出运行时错误。
类型检查、类型提升与类型转换
is 在运行时检查对象是否属于某个类型。检查成功后,分析器通常会在对应控制流中提升变量:
String describe(Object value) {
if (value is String) {
return '文本长度:${value.length}';
}
if (value is num) {
return '数字的两倍:${value * 2}';
}
return '其他类型';
}
void main() {
print(describe('Dart'));
print(describe(3));
}
如果变量可能在检查后被重新赋值,分析器就不能始终保留提升结果:
void main() {
Object value = 'Dart';
if (value is String) {
print(value.length);
value = 42;
// print(value.length); // 编译错误:赋值后 value 不再是 String
}
}
as 执行类型断言,不负责把数据转换成另一种表示。断言错误会在运行时失败:
void main() {
Object input = '42';
var text = input as String;
var number = int.parse(text);
print(number + 1); // 43
}
这里的 as String 只确认 input 已经是字符串;真正把字符串转换为整数的是 int.parse。已知值可能有多种类型时,优先使用 is 分支处理,而不是依赖可能失败的 as。
也不应通过比较 runtimeType 判断类型。value is ParentType 能正确考虑子类型关系,而 value.runtimeType == ParentType 只是在比较一个具体的运行时类型对象。
常见的类型边界
Dart 类型层次中有几种用于表达边界或特殊控制流的类型:
| 类型 | 适合表达的含义 |
|---|---|
Object | 任意非空对象 |
Object? | 任意对象,包括 null,同时保留静态成员检查 |
dynamic | 允许动态调用,将部分检查推迟到运行时 |
Null | null 这个值的类型,可用于可空类型 |
Never | 表达式不可能正常产生值 |
void | 调用者不应使用函数的返回结果 |
接收未知输入时,Object? 通常比 dynamic 更安全,因为使用值前必须先检查类型:
String readLabel(Object? value) {
if (value is String) {
return value;
}
return 'unknown';
}
void main() {
print(readLabel('Dart')); // Dart
print(readLabel(null)); // unknown
}
相比之下,对 dynamic 的成员调用可以通过静态检查,但如果对象没有对应成员,就会在运行时失败。dynamic 适合放在无法获得静态类型信息的边界,并应尽快通过检查或转换把值收窄为明确类型。
Never 位于类型层次的底部,没有任何正常值。总是抛出异常的函数可以返回 Never:
Never fail(String message) {
throw StateError(message);
}
int requirePositive(int value) {
if (value <= 0) {
fail('value 必须大于 0');
}
return value;
}
void main() {
print(requirePositive(3)); // 3
}
因为 fail 不可能正常返回,分析器知道后续路径中的 value 已经满足前面的条件。更多特殊类型和常用数据类型见 Built-in types(内置类型)。
泛型类型与协变
Dart 的泛型类型参数会保留到运行时。泛型类的类型参数默认是协变的,因此 List<int> 可以作为 List<num> 使用,因为 int 是 num 的子类型:
void main() {
List<int> integers = [1, 2];
List<num> numbers = integers;
numbers.add(3);
print(integers); // [1, 2, 3]
}
协变并不表示可以通过较宽的引用写入任意 num。底层对象仍是 List<int>,写入 double 时会触发运行时类型检查:
void main() {
List<int> integers = [1, 2];
List<num> numbers = integers;
try {
numbers.add(2.5);
} catch (error) {
print('写入失败:${error.runtimeType}');
}
}
如果集合确实需要同时保存 int 和 double,应从一开始创建 List<num>,而不是依赖较宽的引用向 List<int> 写入值。泛型约束和运行时类型信息见 Generics(泛型)。
函数类型的替换关系
函数也是带有参数类型和返回类型的对象。一个函数要替代另一个函数,必须能够接收调用方可能传入的参数,并返回调用方能够接受的结果。
void main() {
num Function(int) operation = (num value) => value.round();
print(operation(2)); // 2
}
右侧函数能够接收更宽的 num,返回更具体的 int,所以它可以用在“接收 int、返回 num”的位置。一般来说,函数返回值允许向更具体的子类型变化,参数则需要能接收至少同样宽的输入。
覆盖方法时也要遵守调用者看到的接口。把父类方法的参数擅自缩小会让原本合法的调用失效;确实需要协变参数时,可以使用 Dart 的 covariant 机制,但这会增加运行时检查,应只在接口语义需要时使用。
与 TypeScript 的关键差异
Dart 和 TypeScript 都提供静态检查、类型推断、可空类型表达和泛型,但二者建立在不同的运行时与兼容性目标上。
abstract interface class Named {
String get name;
}
class Topic implements Named {
Topic(this.name);
final String name;
}
interface Named {
name: string
}
const topic = { name: 'Type system' }
const named: Named = topic
| 对比项 | Dart | TypeScript |
|---|---|---|
| 类与接口兼容性 | 主要依赖声明的名义关系 | 主要依赖成员结构 |
| 空值表达 | T? | 通常是 T | null 或 T | undefined |
| 动态边界 | dynamic 推迟部分检查,错误可能在运行时出现 | any 会绕过大量检查,但与 dynamic 并非完全等价 |
| 泛型运行时 | 具体类型参数通常保留 | 类型信息通常在编译为 JavaScript 后擦除 |
| 类型系统目标 | 维护健全的静态类型保证 | 优先兼容 JavaScript,并不以完全健全为设计目标 |
TypeScript 的 unknown 可以帮助理解“先检查未知值再使用”的习惯,但 Dart 中更接近这一用途的通常是 Object?,二者并非完全相同。不要把 TypeScript 的结构类型规则直接套用到 Dart 类,也不要根据 JavaScript 的类型擦除推断 Dart 的运行时行为。
常见误区
误区一:把 var 当成 dynamic
var 请求类型推断,初始化为字符串后,变量的静态类型就是 String。只有显式使用 dynamic 或推断上下文确实产生 dynamic 时,相关静态检查才会被推迟。
误区二:认为 as 会转换数据
as 是类型断言。把 '42' 变成整数需要 int.parse,把数字转换成字符串需要 toString;错误的 as 只会导致运行时异常。
误区三:对未知值直接使用 dynamic
如果只需要接收任意值并在使用前检查,优先选择 Object?。它会迫使代码处理 null 和实际类型,减少把拼写错误或错误假设带到运行时的机会。
误区四:滥用非空断言 !
! 不会让值自动变得非空,只是把验证责任交给开发者。能通过控制流检查、默认值或数据模型消除空值时,不应依赖断言。
误区五:比较 runtimeType 代替 is
runtimeType 的精确相等比较不会表达完整的子类型关系。需要判断对象能否作为某个类型使用时,应使用 is。
误区六:认为较宽的泛型引用可以安全写入任何值
List<int> 可以赋给 List<num>,但底层列表仍只接受 int。通过较宽引用写入 double 会在运行时失败。
小结
- Dart 使用带类型推断的健全静态类型系统,错误会尽量在运行前被发现。
var会推断出具体静态类型,不等于dynamic。- 子类型值可以用于父类型位置;普通 Dart 类主要依赖声明建立类型关系。
T?明确表示值可能为null,控制流分析能够把已检查的变量提升为更具体的类型。is用于检查并提升类型,as只做断言,不负责数据转换。Object?适合安全承接未知值,dynamic应限制在缺少静态信息的边界。- Dart 泛型保留运行时类型信息,协变写入可能触发运行时检查。
- Dart 类与 TypeScript 对象的兼容规则不同,不能把结构类型和类型擦除经验直接套用过来。