Dart 之类型系统

前言

寄快递时,箱子上写着"书籍",工作人员就知道该按书籍处理。如果箱子里装的却是玻璃杯,只看标签安排搬运,东西可能会碎。标签要和里面的东西相符,才有用。

程序里的类型也在表达"这里应该放什么"。一个变量写着 String,我们希望拿到它时确实是字符串,能够放心地取长度、拼文字。如果它在某个环节悄悄变成了数字,错误最好在程序运行前就被发现。

Dart 的类型系统就是用来检查这些关系的。不过,var 没写类型,为什么也会报类型错误?Object 和 dynamic 看起来都能装各种值,为什么用起来不一样?弄清这几个问题,才能真正看懂 Dart 给出的类型提示。

一、类型系统在检查什么

类型系统是一套规则,用来判断值能放在哪里,以及对这个值能做哪些操作。

看一个最简单的例子:

dart 复制代码
void main() {
  String name = '小林';
  print(name.length); // 2

  // name = 18; // 错误:int 不能赋给 String
}

String 说明 name 应该保存字符串。既然是字符串,读取 length 就有依据;把整数放进去,Dart 会指出类型不合适。

这里有两个容易混淆的概念:

  • 静态类型:代码中声明或推断出来的类型,Dart 分析代码时就能知道。
  • 运行时类型:程序真正运行时,值所属的类型。

多数时候二者看起来一样,所以我们感觉不到区别。等一个变量声明为较宽的类型,却装着较具体的值时,区别就出现了。

dart 复制代码
void showItem(Object item) {
  print(item.runtimeType);
  // print(item.length); // 错误:参数的静态类型 Object 上没有 length
}

void main() {
  showItem('一本书'); // 输出 String
}

这次传进来的 item 是字符串,但 showItem 的参数声明为 Object,意味着下次也可以传入整数等别的对象。Dart 检查 item.length 时,不能只根据这一次传入的值假定它永远是字符串。

Dart 结合运行前的静态检查 和必要的运行时检查 来维护类型安全:静态类型写着 String 的表达式,运行时不能悄悄产生一个整数。有些错误可以提前发现;有些涉及动态值或强制转换的错误,要等运行时才能确认。Dart 类型系统

二、什么值能赋给什么类型

2.1 较具体的值可以放进较宽的位置

把"动物"看作较宽的类别,"猫"看作其中一种。要求一只动物时,给一只猫可以;要求一只猫时,随便给一只动物不行,因为那只动物也许是狗。

Dart 中,子类型与父类型的赋值遵循这个方向:需要父类型的地方,可以提供子类型的值。反过来不能直接保证安全。

图中的箭头表示"这个值可以放进哪种变量",不是把猫变成另一种动物。Cat 的值可以放进 Animal 变量;一个只知道是 Animal 的值,不能直接保证它是 Cat。

dart 复制代码
class Animal {}
class Cat extends Animal {}
class Dog extends Animal {}

void main() {
  Animal animal = Cat(); // 可以:猫是动物
  animal = Dog();        // 也可以:狗是动物

  // Cat cat = animal;  // 错误:animal 也可能是狗
  print(animal.runtimeType); // Dog
}

再看 Dart 常见的数字类型:int 和 double 都属于 num,所以 num 变量可以保存两者。

dart 复制代码
void main() {
  num price = 12;
  price = 12.5;

  // int count = price; // 错误:price 也可能是 double
  print(price); // 12.5
}

注意,能赋值不等于自动转换 。num price = 12 没有把整数改造成小数;只是允许一个较宽的变量保存整数。若确实需要把数字转成别的形式,应明确使用相应的转换方法。Dart 类型系统:类型替换

2.2 可空类型也有自己的范围

String 表示非空字符串,String? 表示字符串或 null。因此非空字符串可以放进可空变量,可空变量却不能直接放进非空变量。

dart 复制代码
void updateName(String? maybeName) {
  String name = '小林';

  // name = maybeName; // 错误:maybeName 可能为 null
  if (maybeName != null) {
    name = maybeName;  // 检查后,这里可以当作 String 使用
  }
  print(name);
}

void main() {
  updateName(null); // 小林
  updateName('小王'); // 小王
}

检查后类型变得更具体,叫作类型提升 。它不是把值转换成另一种类型,而是 Dart 根据已经通过的条件,确认当前分支里可以怎样使用这个值。空安全篇已介绍过可空操作符,这里要记住它们背后的类型关系。理解 Dart 空安全

三、var、Object、dynamic 有什么区别

这三个写法常被误认为都是"随便放什么"。实际上,它们回答的是不同问题。

3.1 var:让 Dart 帮你写出类型

dart 复制代码
void main() {
  var count = 3; // 推断为 int
  count = 4;
  // count = '四'; // 错误:推断后,count 仍然是 int
  print(count); // 4
}

var 省略的是类型名称,不是类型检查。Dart 根据初始值推断 count 是 int,后面不会因为赋了新值就随意改成 String。

若这个位置本来就需要保存整数或小数,应把范围写宽一些:

dart 复制代码
num amount = 3;
amount = 4.5;

推断也依赖提供的信息。只写 var value; 而没有初始值时,Dart 无法从这一行推断出具体类型,value 会是 dynamic。集合的类型参数也可能因信息不足而推断为 dynamic,例如空列表 var items = \[\]; 是 List。写成 var items = \[\];,后续添加非字符串时就能提前发现错误。Dart 类型系统:类型推断

3.2 Object:能保存各种非空对象,操作仍受检查

dart 复制代码
void main() {
  Object item = '小林';
  item = 18; // 可以:两者都是 Object 的子类型

  // print(item.length); // 错误:Object 没有 length
  print(item.toString()); // 可以:Object 提供 toString()
}

Object 表示"我知道这是一个非空对象,但暂时不限定它是哪一种"。它能接收很多值,Dart 仍会检查你调用的成员是否对所有 Object 都成立。需要同时容纳 null,可以使用 Object?。

3.3 dynamic:允许动态调用,风险留到运行时

dart 复制代码
void main() {
  dynamic item = '小林';
  print(item.length); // 2

  item = 18;
  // print(item.length); // 静态检查不拦,运行时会出错
}

dynamic 不只允许放不同类型的值;它还让 item.length 这样的调用绕过通常的静态成员检查。调用能否成功,要看运行时实际装的值。因此,Object 与 dynamic 的关键差别在于使用这个值时,Dart 还能提前帮你检查多少。

写法 可以保存什么 调用特有成员时
var name = '小林' 推断后按 String 检查 可调用 String 的成员
Object item(方法参数) 各种非空对象 先确认具体类型,才能调用其特有成员
dynamic item = '小林' 各种值 静态检查通常不拦,可能在运行时报错

有时我们从外部数据得到的值确实暂时不确定类型,可以在入口处接收它,再尽早判断、转换成明确的类型;不必让 dynamic 一路传到程序各处。

四、知道值的类型,还是要求它变成某种类型

4.1 is:先判断,再使用

is 用于判断一个值是否属于某种类型。判断成立后,Dart 通常可以在当前分支把变量提升为更具体的类型。

dart 复制代码
void describe(Object value) {
  if (value is String) {
    print('文字长度:${value.length}');
  } else {
    print('不是字符串');
  }
}

这里没有把数字转换成字符串;is String 只是问"它本来是不是字符串"。

4.2 as:确认无误时再断言

as 则是断言并强制转换类型:告诉 Dart 按指定类型使用这个值。如果判断错了,运行时会失败。

dart 复制代码
void main() {
  Object value = '小林';
  String name = value as String; // 实际是 String,成功
  print(name.length);           // 2

  value = 18;
  // String other = value as String; // 运行时类型转换错误
}

拿不准类型时,先用 is 判断更稳妥。as String 也不是把 18 变成 '18';真正需要文字表示时,用 toString() 等转换方法。Dart 运算符:类型测试

五、同一规则在集合和继承中如何起作用

前面讨论的是把一个值放进变量。把一组值交给函数,或者用子类替换父类时,Dart 也要保证调用方原本能够做的事依然安全。

5.1 集合要守住元素类型

printTotal() 需要从列表里取出整数并相加,所以参数写成 List。这个类型不仅描述列表,也规定了从列表取出的元素应该是什么。

dart 复制代码
void printTotal(List<int> scores) {
  print(scores.reduce((a, b) => a + b));
}

void main() {
  final mixed = <dynamic>[10, '20'];
  // printTotal(mixed); // 错误:List<dynamic> 不能直接当 List<int> 用
  print(mixed); // 显示为 [10, 20],但第二个元素仍是字符串

  final scores = <int>[10, 20];
  printTotal(scores); // 30
}

mixed 中既有整数 10,也有字符串 '20'。打印列表时两者看起来都像数字,但 '20' 不能直接参与整数加法。即使列表当前恰好全是整数,List 也没有承诺以后取出的仍是整数,不能直接传给需要 List 的函数。

如果这些数据本来就应当全是整数,从创建列表时写 10, 20,Dart 就能在有人加入字符串时指出错误。这与单个变量的规则相同:要求整数的地方,不能只凭"现在看起来像整数"就交出未经确认的值。Dart 类型系统:不要把动态列表当作定型列表

5.2 子类重写方法,不能违背父类的约定

假设收容站 Shelter 的 receive() 方法答应接收任何 Animal。市区收容站 CityShelter 继承了它,别人可以把市区收容站当作普通收容站来调用。这种情况下,子类能改动方法的参数和返回值吗?

dart 复制代码
class Animal {
  Animal(this.name);
  final String name;
}

class Cat extends Animal {
  Cat(super.name);
}

class Dog extends Animal {
  Dog(super.name);
}

class Shelter {
  void receive(Animal animal) {
    print('接收了${animal.name}');
  }

  Animal availableForAdoption() => Animal('一只动物');
}

class CityShelter extends Shelter {
  @override
  void receive(Animal animal) {
    print('接收了${animal.name}');
  }

  // 不能改成 receive(Cat animal):调用方仍可能传入 Dog。

  @override
  Cat availableForAdoption() => Cat('小花');
}

void main() {
  Shelter shelter = CityShelter();
  shelter.receive(Dog('小黄')); // 接收了小黄
  print(shelter.availableForAdoption().name); // 小花
}

先看 receive() 的参数。shelter 声明为 Shelter,调用方按父类约定可以传入 Dog。程序运行时,真正接收请求的是 CityShelter 对象。如果子类擅自把参数改成 Cat,这次传入 Dog 就无法兑现父类的约定,因此普通重写不能把参数类型缩窄。

再看 availableForAdoption() 的返回值。父类答应返回 Animal;子类返回 Cat,调用方仍然得到一只动物,所以这是允许的。接收参数时不能比原约定挑剔,提供结果时可以比原约定具体。 特殊场景可以用 covariant 缩窄参数,但相关检查会转到运行时;它不是普通重写的默认规则。Dart 类型系统:方法重写

六、总结

Dart 的类型系统让"这里能放什么、能做什么"成为可以检查的约定。

  • 看赋值方向:需要较宽类型的地方,可以提供较具体的值;反过来要先证明或检查。
  • 看变量的静态类型:它决定哪些操作能提前通过检查,不只看变量此刻装着什么。
  • var 会推断类型,Object 保留静态检查,dynamic 把更多风险留到运行时。
  • is 用于判断,as 是带运行时风险的类型断言;它们都不负责把整数变成字符串。
  • 空安全、泛型和方法重写遵守的是同一件事:代码实际提供的值,必须符合对外承诺的类型。

遇到类型错误时,不妨先问:这个位置承诺接收什么类型?我给出的值可能是什么类型? 顺着这两个问题找,通常比直接加 dynamic 或 as 更容易找到真正的原因。

七、测试题

  1. var price = 12; price = 12.5; 为什么会报错?如果价格确实可能是整数或小数,该怎样声明?
  2. 方法的参数是 Object item,调用时传入了 '书'。为什么方法内不能直接写 item.length?写出一种安全的读取长度的方法。
  3. dynamic value = 10; print(value.length); 为什么可能通过静态检查,却在运行时出错?
  4. 一个方法需要 List,手里有 1, 2。仅凭两个元素现在都是整数,能直接把这个列表传进去吗?为什么?
  5. 父类方法接收 Animal 并返回 Animal。子类能否把参数改为只接收 Cat?能否把返回类型改为 Cat?说出理由。
相关推荐
恋猫de小郭8 小时前
Dart 3.15 新特性:this 终于支持类型提升了
android·前端·flutter
FungLeo1 天前
成为全栈·Flutter App 篇·主题与可访问性:深浅色、大字号和触控体验
flutter·移动开发·深色模式·可访问性·成为全栈·大字号适配
用户95421573334852 天前
只是选个照片,怎么留下了这么多访问记录?Flutter 图片选择器排查与优化实录
flutter
FungLeo2 天前
成为全栈·Flutter App 篇·第四批复盘:复用了什么,移动端又新增了什么
flutter·移动开发·跨端开发·成为全栈·api契约·全栈复盘
jason.zeng@15022072 天前
使用vscode+flutter开发android程序(入门)
android·vscode·flutter
jason.zeng@15022072 天前
Flutter 项目标准分层目录(repository/dto/page,对齐后端思想)
flutter
程序员老刘4 天前
跨平台开发地图 | 2026年10月
flutter·ai编程·客户端
林瞅瞅4 天前
Flutter 开发环境从零搭建与调试记录
flutter
FungLeo4 天前
成为全栈·Flutter App 篇·Markdown 编辑器:编辑、预览与离开保护
flutter·移动开发·markdown编辑器·表单设计·成为全栈·草稿保存