跳到主要内容

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 继续使用,从而守住静态类型作出的保证。

子类型与赋值兼容性

如果类型 ChildParent 的子类型,那么 Child 的值可以用在需要 Parent 的位置。intdouble 都是 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 不会因为两个类拥有相同成员,就自动认为它们属于同一类型。普通类和类的隐式接口主要采用名义类型关系:需要通过 extendsimplementswith 等声明建立关系。

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 不包含 nullString? 才表示值可能是字符串或 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 ?? fallbackvalue 为空时使用备用值
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允许动态调用,将部分检查推迟到运行时
Nullnull 这个值的类型,可用于可空类型
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> 使用,因为 intnum 的子类型:

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}');
}
}

如果集合确实需要同时保存 intdouble,应从一开始创建 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
对比项DartTypeScript
类与接口兼容性主要依赖声明的名义关系主要依赖成员结构
空值表达T?通常是 T | nullT | 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 对象的兼容规则不同,不能把结构类型和类型擦除经验直接套用过来。