跳到主要内容
约 10 分钟7 张卡

Dart 类型系统

Dart 用静态检查和必要的运行时检查共同维护类型约束。变量可以省略显式类型,但不等于失去类型;对象可以被多个变量引用,但不等于函数能替调用者重新赋值。Flutter 项目中的空值崩溃、共享状态和集合类型错误,往往都需要回到这些语言规则解释。本文以启用健全空安全的 Dart 3 为基础。

1. 面试核心​

先记住这几条,再读正文
  • var 让编译器推断类型,dynamic 把部分成员检查推迟到运行时,类型推断和动态调用是两回事。卡 · 类型推断与宽类型
  • 普通类型默认不可空,T? 允许 null,空检查后的类型提升依赖编译器能证明值仍然有效。卡 · 空安全与初始化
  • late 允许延后初始化或惰性初始化,它把相应的初始化责任交给运行时检查,不会自动提供一个有效值。卡 · 空安全与初始化
  • final 限制变量再次赋值,const 表达编译期常量,final 集合仍可能修改内容,const 构造函数也不强制每次调用都产生常量对象。卡 · final、const 与对象传递
  • 函数传参复制当前值,对对象来说复制的是引用值,修改共享对象与给形参重新赋值是两种效果。卡 · final、const 与对象传递
  • Dart 泛型保留运行时类型信息,泛型协变允许的赋值仍可能在写入时触发类型检查。卡 · 泛型与集合
  • == 可以按类型定义值相等,identical 判断身份,相等对象需要保持一致的 hashCode,集合键不宜依赖会改变的相等性字段。卡 · 相等性与哈希

2. 类型推断与宽类型​

类型推断根据初始化和上下文确定静态类型。var name = 'Lin'; 推断出 String,之后不能随意给它赋整数。需要接受不同对象时,可以声明更宽的类型,但要明白宽到什么程度。

声明可以保存的值使用时的检查
Object非空对象只能直接使用 Object 约定的能力,其他能力需检查或转换
Object?包括 null 的对象同时处理类型和可空性
dynamic任意对象,包括 null允许更多动态成员调用,相应错误可能推迟到运行时
var由上下文推断按推断出的静态类型检查,并非固定表示 dynamic
String describe(Object? value) {
if (value is String) {
return value.toUpperCase();
}
return 'other';
}

void main() {
print(describe('Lin')); // LIN
print(describe(null)); // other
dynamic value = 'Lin';
print(value.length); // 3
}

is String 让成功分支知道 value 可以按字符串使用。dynamic 的 length 则由实际对象决定;如果把 value 换成整数,相同调用可能在运行时失败。静态检查少了,不代表类型不匹配会被自动修复。

as T 是类型转换检查,不是把任意数据解析成 T。例如字符串数字变整数要使用 int.parse 或 int.tryParse,不能靠 as int 完成。泛型参数默认也可能允许可空类型,需要排除空值时可以使用 T extends Object 等明确约束。

3. 空安全与初始化​

可空类型 T? 表达值可能是 T,也可能是 null。需要使用非空能力时,可以先检查、使用空感知运算,或在确有保证时显式断言。

int lengthOrZero(String? text) {
if (text == null) return 0;
return text.length;
}

void main() {
print(lengthOrZero(null)); // 0
print(lengthOrZero('Lin')); // 3
}

在早返回之后,分析器能证明当前局部变量非空,所以允许访问 length。字段、getter 或被其他闭包修改的变量能否提升,还取决于其稳定性与语言规则;无法证明时可以先读取到一个局部变量,再对那次读取结果检查。

value?.member 在空值处跳过成员访问,value ?? fallback 只在左侧为空时使用右侧,value! 则要求当前不是空值。! 不会把 null 变成默认对象,前提失败会产生运行时错误。required 命名参数表示调用时必须提供,也不自动等于不可空:required String? name 仍允许显式传入 null。

late 变量允许把初始化检查延后;带初始化表达式的 late 还可以在首次读取时惰性求值。

String loadLabel() {
print('loaded');
return 'ready';
}

class Item {
late final String label = loadLabel();
}

void main() {
final item = Item();
print('created');
print(item.label);
print(item.label);
}

输出依次为 created、loaded、ready、ready。读取 label 才执行初始化,并缓存这个值。没有初始化表达式的 late 若在赋值前读取,会抛出初始化错误;late final 也不能随意多次赋值。

late 适合确有生命周期先后保证的属性,不应成为绕过空安全报错的默认办法。如果业务本来就允许值缺失,使用可空类型并处理缺失通常更准确。

4. final、const 与对象传递​

final 变量只能赋值一次,但引用对象是否可变由对象自身决定。const 要求编译期可确定的常量值,常量集合不允许修改。

class Point {
final int x;
const Point(this.x);
}

void main() {
final values = <int>[1];
values.add(2);
print(values); // [1, 2]

const first = Point(1);
const second = Point(1);
print(identical(first, second)); // true
final runtimePoint = Point(1);
print(identical(first, runtimePoint)); // false
}

values 不能被重新赋成另一个列表,但现有列表可以添加元素。两个等价常量对象可以被规范化并复用;非 const 上下文中的 Point(1) 则是普通运行时构造,即使构造函数本身声明了 const。

因此 const 构造函数提供了常量构造的能力,性能收益需要调用真的处于常量上下文。Flutter 中重用相同的不可变 Widget 配置也可能减少不必要的更新工作,但不会自动跳过所有布局、绘制或内部状态变化;不能仅凭构造函数前有 const 就宣称整个组件零成本。

值传递表示函数获得实参当前值的副本。Dart 对象变量保存引用,副本仍可指向同一个对象,所以函数能修改共享对象,但给形参重新赋值不会替调用者改变量。

class Box {
int value;
Box(this.value);
}

void update(Box box) {
box.value = 2;
box = Box(99);
print(box.value); // 99
}

void main() {
final original = Box(1);
update(original);
print(original.value); // 2
}

第一次修改作用于共享对象,第二次只让局部形参指向新对象。若函数需要让调用者采用新结果,应通过返回值或明确的状态容器表达,不能把这种行为称为“传入变量本身的引用”。

5. 泛型与集合​

泛型把类型关系写进接口,例如 List<int> 限制元素类型,Map<String, int> 约束键值类型。Dart 泛型保留运行时类型信息,不能照搬 Java 的泛型擦除结论。

bool isStringList(Object value) => value is List<String>;

void main() {
print(isStringList(<String>['Lin'])); // true
final integers = <int>[1];
List<num> numbers = integers;
try {
numbers.add(1.5);
} on TypeError {
print('checked');
}
print(integers); // [1]
}

泛型协变让 List<int> 可以用于 List<num> 的位置,但底层对象仍是只接受整数的列表。写入 double 会在运行时被拒绝,而不是因为变量声明变宽就改变原列表的真实元素类型。

若接口只需要读取,应考虑暴露读取所需能力;若需要新建可容纳多种 num 的列表,可以显式构造 <num>[...integers]。不要把扩大静态视图和复制成新的目标类型容器混为一谈。

List 保存有序元素,Set 表达唯一元素,Map 按键查值。字面量可使用类型参数、展开以及集合中的 if、for:

void main() {
final input = <int>[1, 2, 2];
final unique = <int>{...input};
final doubled = <int>[for (final value in unique) value * 2];
final counts = <String, int>{'ready': 3};
print(doubled); // [2, 4]
print(counts['missing'] ?? 0); // 0
}

查询 Map 中的键可能找不到,所以 Map<String, int> 的下标读取结果仍然是 int?。集合变换也要看返回类型:map 等操作通常返回惰性的 Iterable,需要 toList() 才建立列表;重复遍历可能重复执行变换,不应把带副作用的转换当成只执行一次。

6. 函数、类与接口​

函数也是可以传递的对象,建议用准确的函数类型表达参数和返回值,而不是一律声明成宽泛的 Function。闭包可以保留定义处的状态:

int Function() makeCounter() {
var value = 0;
return () => ++value;
}

String greeting({required String name, String prefix = 'Hi'}) => '$prefix $name';

void main() {
final next = makeCounter();
print(next()); // 1
print(next()); // 2
print(greeting(name: 'Lin')); // Hi Lin
}

next 继续使用同一份捕获变量;命名参数则让调用方按名称提供信息。捕获状态不会自动变成线程同步机制;涉及跨 isolate 通信时,应使用相应的消息规则,见 Dart 并发。

类的 extends 复用父类关系和实现,implements 要求实现接口能力,with 应用 mixin 提供的实现。Dart 的类会形成相应的隐式接口,但构造函数和静态成员不因此变成需要实例实现的接口成员。

abstract interface class Named {
String get name;
}

mixin Greeting {
String get name;
String greet() => 'Hi $name';
}

class User with Greeting implements Named {
@override
final String name;
User(this.name);
}

void main() {
print(User('Lin').greet()); // Hi Lin
}

Named 只规定读取名称的能力,Greeting 在这个能力基础上提供方法体,User 提供实际字段。类修饰符会限制库外继承、实现或混入的方式;本文使用 Dart 3 的 abstract interface class 表达接口,不把所有类都当作可以任意跨库继承的开放接口。

构造函数也要按责任区分:生成式构造函数建立实例,命名构造函数提供不同创建入口,factory 构造函数可以返回缓存对象或符合类型的其他实现。factory 不保证每次都分配新实例,也不能通过构造语法推导结果一定有新的身份。

7. 相等性与哈希​

相等性说明两个对象在当前类型定义下是否表达相同的值;身份相同说明它们是不是同一个对象。== 可以重写,identical 用于身份判断。

class UserId {
final int value;
const UserId(this.value);

@override
bool operator ==(Object other) => other is UserId && other.value == value;

@override
int get hashCode => value.hashCode;
}

void main() {
final first = UserId(7);
final second = UserId(7);
print(first == second); // true
print(identical(first, second)); // false
print(<UserId>{first, second}.length); // 1
}

默认对象相等性按身份判断,重写后可以表达值相等。相等的对象必须有相同哈希值,反过来相同哈希值不保证相等。参与 Map 键或 Set 元素比较的字段若在加入后变化,查找可能失败;使用不可变身份字段可以避免这种不稳定。

默认 List 的 == 不表示逐元素深度比较。需要内容相等时,应选择明确的比较方式,并同时说明嵌套元素怎样判断;不能只因为两个列表打印结果一样就认定它们相等。

8 · 面试题卡片​

本页题目与参考回答

8.1 var、dynamic、Object 和 Object? 有什么区别?​

考察重点:静态推断、可空性与动态调用边界。

相关内容:类型推断与宽类型。

参考回答

var 让编译器推断静态类型,dynamic 允许部分成员操作延后到运行时检查,Object 与 Object? 则保留宽类型的静态约束。 Object 不允许空值,Object? 允许;要使用具体类型能力,需要检查或转换。var name = 'Lin' 仍然是字符串变量,不能因为省略类型就改存整数。

8.2 Dart 空安全能否保证任何代码都不会因空值失败?​

考察重点:静态保证、显式断言与运行时前提。

相关内容:空安全与初始化。

参考回答

不能这样无限推断,空安全在相应类型规则下阻止未处理的可空访问,但显式断言和动态操作仍有运行时前提。 value! 在实际为空时会失败,late 也需要先完成初始化。应该让正常缺失通过可空类型和分支处理,不把断言当作自动补值。

8.3 late 与 late final 分别意味着什么?​

考察重点:延后初始化、惰性求值与一次赋值。

相关内容:空安全与初始化。

参考回答

late 允许初始化检查延后,带初始化表达式时还可在首次读取时求值;late final 同时要求一次赋值。 未初始化就读取会失败,重复给 late final 赋值也不合法。它适合确有先后保证的生命周期,不适合掩盖业务上可能一直缺失的值。

8.4 const 构造函数为什么可能改善性能?​

考察重点:常量上下文、对象规范化与实际工作量。

相关内容:final、const 与对象传递。

参考回答

在常量上下文中,等价常量对象可以规范化并复用,减少重复构造及部分依赖身份的更新工作。 构造函数声明 const 只是允许常量构造,普通运行时调用不因此自动成为常量。Flutter 的布局、绘制和状态变化仍有各自触发条件,不能把 const 理解成整个组件永远不更新。

8.5 Dart 是值传递还是引用传递?​

考察重点:引用值复制、共享修改与形参重新赋值。

相关内容:final、const 与对象传递。

参考回答

是值传递,对对象变量来说复制的是引用值。 函数和调用方可以通过各自引用修改同一个对象,但给形参重新赋成另一个对象只改变局部变量,不会让调用者的变量同步改指向。需要更新调用方采用的值,应返回新值或通过明确的共享状态接口处理。

8.6 Dart 泛型是否擦除,为什么 List<num> 写入仍可能失败?​

考察重点:运行时类型、协变与写入检查。

相关内容:泛型与集合。

参考回答

Dart 泛型保留运行时类型信息,静态类型变宽不改变原对象的实际类型参数。 将 List<int> 赋给 List<num> 是允许的协变使用,但原列表仍只接受整数,写入 double 会在运行时失败。若确实需要混合数值列表,应建立新的目标类型容器。

8.7 ==、identical 与 hashCode 有什么关系?​

考察重点:值相等、对象身份与哈希容器不变量。

相关内容:相等性与哈希。

参考回答

== 可以定义值相等,identical 判断是否同一对象,hashCode 要与相等规则一致。 相等对象必须有相同哈希值,哈希相同却不保证相等。作为 Map 键或 Set 元素时,应避免修改影响相等性与哈希的字段;默认列表也不会仅凭内容相同就按深度相等处理。

9. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明