Dart 之扩展类型

0 阅读17分钟

扩展类型知识导图:底层表示、接口选择、构造与校验、运行时行为,以及类型设计的取舍

前言

18 号柜子里的“18”,和 18 件物品里的“18”,虽然是同一个数字,含义却不一样。物品增加一件,数量变成 19;柜子编号加一,指的已经是另一个柜子。

程序中也有这样的区别。用户编号和订单编号都可能用 int 保存,如果函数只要求传入一个整数,把订单编号传给查询用户的函数,类型检查并不会发现问题。

给整数起一个更贴切的变量名,能提醒写代码的人,却不能让编译器帮忙区分。Dart 的扩展类型可以在保留底层数据表示的同时,为这些值定义不同的类型和使用方式。不过,这种区分发生在编译期;程序运行起来以后,它与普通类有一个重要差别。

一、扩展类型是什么

扩展类型是在编译期为已有类型定义另一套接口的机制,运行时不额外创建一层包装对象。 这里的接口,指的是通过这个类型能够访问哪些方法、属性和操作符。

例如,用户编号底层仍用整数保存,但查询用户的函数要求的是 UserId,而不是任意 int:

extension type UserId(int value) implements Object {}

extension type OrderId(int value) implements Object {}

void loadUser(UserId id) {
  print('查询用户:${id.value}');
}

void main() {
  final userId = UserId(42);
  final orderId = OrderId(42);

  loadUser(userId);
  print(orderId.value);
}

输出依次为“查询用户:42”和“42”。两种编号保存的数值相同,但编译器认识 UserId 和 OrderId 是不同的静态类型。静态类型是代码分析时用来检查赋值、参数和成员访问的类型;运行时类型则是程序运行时对象实际所属的类型,两者不一定相同。

把 loadUser(userId) 改成 loadUser(orderId) 或 loadUser(42),都会产生类型错误:函数要求的是用户编号,另一个是订单编号,后一个只是整数。

数值相同,不代表业务含义相同;扩展类型让这种区别进入类型检查。 变量名只是在提醒人,参数类型则会参与检查。

声明中的 implements Object 表示这个扩展类型可以作为非空 Object 使用,不会因此开放 int 的全部操作。Object 是 Dart 中非空对象的共同基础类型。

扩展类型从 Dart 3.3 开始正式支持。项目除了安装支持它的 SDK,还需要使用允许这一特性的语言版本;包通常通过 pubspec.yaml 中 environment.sdk 的下限确定默认语言版本。例如,已有项目若仍把下限写成 3.0.0,仅安装更新的 SDK 并不等于这个包已启用 3.3 的语言特性。扩展类型

二、声明中的括号,决定底层保存什么

2.1 表示类型与表示对象

拆开 UserId 的声明:

extension type UserId(int value) implements Object {
  String get label => '用户编号:$value';
}

void main() {
  final id = UserId(42);

  print(id.value);
  print(id.label);
}

输出依次为 42 和“用户编号:42”。

括号中的 int 是表示类型,也就是这个扩展类型采用的底层数据类型。传入的整数 42 则是这里的表示对象:程序真正使用的那个值。

value 是访问底层值的名字。这个声明会提供一个返回 int 的 getter,因此外部可以写 id.value;类型内部可以直接写 value,也可以写 this.value。

它还声明了主构造函数 UserId(int value)。调用 UserId(42) 时,从源代码的角度是在构造一个 UserId 值,但不会因此在整数外面额外分配一个 UserId 包装对象。

可以把它理解为给同一份数据换了一份使用说明:按照 UserId 这份说明,允许读取 value 和 label;数据本身仍然是那个整数。这个比喻说明的是接口变化,不是运行时真的在整数上贴了一张标签。

表示对象不局限于整数。它也可以是字符串、列表、记录、已有类的对象,或者其他合法的类型。扩展类型有一个表示对象;如果业务确实需要多份数据,可以让表示类型本身是记录或类,但这些数据也会有它们自己的存储与构造成本。

2.2 私有入口、命名构造与校验

UserId(42) 这种公开入口接受任意 int,其中也包括负数。如果业务规定用户编号必须大于零,就需要实际写出校验逻辑。

extension type UserId._(int _value) implements Object {
  factory UserId(int value) {
    if (value <= 0) {
      throw ArgumentError.value(value, 'value', '用户编号必须大于零');
    }
    return UserId._(value);
  }

  factory UserId.fromText(String text) => UserId(int.parse(text));

  int get value => _value;
}

void main() {
  final first = UserId(42);
  final second = UserId.fromText('43');

  print(first.value);
  print(second.value);

  try {
    UserId(-1);
  } on ArgumentError {
    print('编号不合法');
  }
}

输出依次为 42、43 和“编号不合法”。

这次主构造函数命名为 UserId._,以下划线开头,表示它只在所属库中可见。外部库正常构造时走公开的工厂构造函数 UserId,由它先检查数值,再交给私有入口。UserId.fromText 是命名工厂构造函数,它把文字解析成整数后,继续调用同一个校验入口。

_value 也只在所属库可见,外部通过我们明确提供的 value getter 读取。Dart 的私有范围是库,不是单独某个类或扩展类型;同库代码仍能访问这些私有名称。

扩展类型还支持其他生成式构造函数、重定向构造函数和工厂构造函数。生成式构造函数最终需要确定表示对象;它不像普通类那样,可以顺便初始化一组新增的实例字段。

需要常量构造时,可以在声明中加入 const:

extension type const Port(int value) implements Object {
  const Port.http() : value = 80;

  const Port.https() : this(443);
}

void main() {
  const port = Port(8080);
  print(port.value);
  print(const Port.http().value);
  print(const Port.https().value);
}

输出依次为 8080、80 和 443。Port.http 是命名生成式构造函数,冒号后的 value = 80 确定表示对象;Port.https 则把构造重定向给 Port(443)。两者都只是在决定底层整数,没有新增实例字段。

const 允许在满足常量表达式要求时创建常量值,不会自动替你检查端口范围,也不表示每次调用都必须使用 const。

前面的工厂确实能拦住 UserId(-1),但它还不能保证任何来源的 UserId 都一定有效。原因与运行时的类型转换有关。

三、只把有意义的操作交给调用者

3.1 底层是整数,不等于对外开放所有整数操作

在没有 implements int 的情况下,UserId 不会自动获得 int 的全部接口。即使知道底层是 42,也不能直接把 id 加上 1,更不能仅凭它的表示类型就写 id.isEven。把 id 直接赋给 int 变量也不允许;需要整数时,可以明确读取 id.value。

如果编号的业务规则只需要比较先后,可以只开放比较:

extension type UserId(int value) implements Object {
  bool operator <(UserId other) => value < other.value;

  String get label => '用户编号:$value';
}

void main() {
  final first = UserId(42);
  final second = UserId(50);

  print(first < second);
  print(first.label);
}

输出依次为 true 和“用户编号:42”。这里的 < 接受另一个 UserId,而不是任意 int。没有声明的 + 不会因为底层支持加法就自动出现。

当然,公开的 value getter 仍然允许调用者主动取出底层整数,然后做整数运算。这说明限制针对的是通过扩展类型接口进行的操作,不是把底层值封锁起来。

扩展类型的成员可以是方法、getter、setter、操作符,也可以提供静态辅助成员;普通 Dart 扩展类型不能另外声明实例存储字段或抽象成员。例如,在类型体内新增 int retryCount = 0,不会为每个编号增加重试次数,而是产生编译错误。与 JS 互操作相关的 external 声明有专门规则,不能据此推断可以随意增加 Dart 实例字段。

同样不能在扩展类型里重新声明 Object 已定义的成员,包括 toString、hashCode 和 == 等。想显示业务文字,可以使用 label;如果要定义一个独立对象的相等性与哈希规则,就需要考虑普通类。Object 同名成员的限制

3.2 表示对象不能换,不等于表示对象里的内容不能变

表示对象的引用不能通过给表示 getter 赋值来更换。例如,不能写 id.value = 43,期待把一个 UserId 的底层整数改掉。

但如果底层值是一张可修改的列表,扩展类型仍然可以修改列表里的元素:

extension type NameList(List<String> _items) {
  String get first => _items.first;

  set first(String name) {
    _items[0] = name;
  }
}

void main() {
  final source = <String>['小林'];
  final names = NameList(source);

  names.first = '小周';

  print(names.first);
  print(source);
}

输出依次为“小周”和 [小周]。source 与 names 的底层表示指向同一张列表,没有因为构造 NameList 就复制一份。

这里的 setter 修改的是已有列表的第一个元素,不是在扩展类型中存了一个新的 first 字段。若列表为空或不可修改,原列表相应的限制仍然存在。

四、implements 决定哪些接口可以继续使用

4.1 实现表示类型,会开放它的成员

有时并不想限制原类型的操作,只想在保留原有接口的同时增加几个有意义的成员。这时可以实现表示类型:

extension type TaggedNumber(int value) implements int {
  String get label => '编号:$value';
}

void main() {
  final number = TaggedNumber(42);
  int raw = number;
  final result = number + 1;

  print(number.isEven);
  print(number.label);
  print(raw);
  print(result);
}

输出依次为 true、“编号:42”、42 和 43。

implements int 有两方面的效果:TaggedNumber 可以使用 int 的成员,也可以用在需要 int 的位置。因此,int raw = number 能通过类型检查。

反过来,int 不会因此变成 TaggedNumber 的子类型。需要 TaggedNumber 的位置,仍不能直接传入 42;而 raw 的静态类型已经是 int,所以它也不能直接访问 label。

还要留意 result 的类型:这里使用的是 int 原有的 +,它返回 int,不会自动把结果重新包成 TaggedNumber。实现底层接口,不意味着底层操作的返回值会自动变成扩展类型。

如果设计目标是禁止编号参与普通加法,implements int 就会重新开放这项操作,与目标相冲突。是否实现它,应由需要公开的接口决定。implements 与两种接口设计

4.2 只开放一部分能力,也可以保留泛型

不一定要实现整个表示类型。还可以实现它的某个父接口。

List 支持按下标读写、添加和删除元素,而 Iterable 主要表达可以逐项读取。下面让底层继续使用 List,但对外提供 Iterable:

extension type Sequence<T>(List<T> _items) implements Iterable<T> {}

void main() {
  final source = <String>['茶', '水'];
  final sequence = Sequence<String>(source);

  print(sequence.first);
  print(sequence.join('、'));

  source.add('牛奶');
  print(sequence.length);
}

输出依次为“茶”、“茶、水”和 3。

T 是元素类型。这里 T 为 String,因此 first 的结果仍是 String,不会丢失成一个未知类型。泛型参数放在扩展类型名称之后,用法与泛型类类似。

通过 sequence 的接口可以遍历和读取,但不能直接调用 List 的 add,因为 Iterable 没有这个成员。原列表通过 source 添加“牛奶”后,sequence 的长度也变了。

所以,这种接口收窄不是创建不可修改的列表,也不是制作快照。如果要求任何持有者都不能改动列表,或者要求保留当时的数据,应进一步采用不可修改集合或复制等对应设计。

4.3 还可以复用其他扩展类型的接口

扩展类型也可以实现另一个扩展类型,前提是底层表示类型满足相应关系。例如,同样以 int 表示的温度值,可以复用一般读数的接口:

extension type Reading(int value) implements Object {}

extension type Temperature(int value) implements Reading {
  bool get isFreezing => value <= 0;
}

void main() {
  final temperature = Temperature(0);
  Reading reading = temperature;

  print(temperature.isFreezing);
  print(reading.value);
}

输出依次为 true 和 0。Temperature 能用在要求 Reading 的位置,但 Reading 类型的表达式不会自动拥有 isFreezing。

这里仍然没有建立一套新的运行时对象层次。扩展类型支持的是这些受限制的 implements 关系,不能把它当作普通类使用 extends 继承,也不能随意实现与表示类型无关的接口。底层是 int,不能只写一句 implements List<String>,就让它变成列表。

五、同名成员是重新声明,不是运行时重写

实现某个接口后,扩展类型可以重新声明同名成员,改变通过这一静态接口访问时的行为。它与普通类的 override 不同:原对象的运行时方法没有被替换。

extension type TextUnits(String value) implements String {
  int operator [](int index) => value.codeUnitAt(index);
}

void main() {
  final text = TextUnits('A');
  String original = text;

  print(text[0]);
  print(original[0]);
}

输出依次为 65 和 A。

在 text 的静态类型 TextUnits 下,[] 使用我们重新声明的操作,返回 UTF-16 代码单元的整数值。大写字母 A 对应 65;这个例子不能推广成“任何可见字符都只占一个代码单元”。

在 original 的静态类型 String 下,[] 使用 String 本来的操作,返回字符串 'A'。两处使用的是同一个底层字符串,区别在于编译器按照哪一个静态接口选择操作。

如果这是真正的运行时重写,把对象交给父类型变量后,重写的方法通常仍会参与动态分派;这里却会回到 String 的原有操作。因此,这种关系称为重新声明,而不是重写。

package:meta 提供 @redeclare 注解,用来明确表达“我有意重新声明一个父接口成员”。使用时需要添加 meta 依赖并导入 package:meta/meta.dart;它有助于分析器发现名称拼写错误等问题,不会把这种静态行为变成运行时重写。上面的最小示例没有引入这个额外依赖。重新声明成员

六、运行时没有独立的扩展类型身份

6.1 is、as 和泛型中的类型都会看底层表示

编译期,UserId 与 OrderId 能帮助检查参数;运行时,这两个类型本身会被擦除。擦除的意思是:运行时使用底层表示,不再保留一个能区分两种业务编号的包装类型身份。

extension type UserId(int value) implements Object {}

extension type OrderId(int value) implements Object {}

void main() {
  Object raw = UserId(42);

  print(raw is int);
  print(raw is UserId);
  print(raw is OrderId);

  final recovered = raw as UserId;
  print(recovered.value);

  Object values = <UserId>[UserId(42)];
  print(values is List<int>);
}

输出依次为 true、true、true、42 和 true。

其中最容易意外的是 raw is OrderId。它并没有证明这个数来自订单系统,只是运行时针对 OrderId 的表示类型 int 完成了检查。同样,as UserId 能让结果表达式按 UserId 接口使用,但运行时检查的也是对应的底层类型。

泛型也不能例外:List<UserId> 在运行时按 List<int> 处理。静态检查仍会要求向它正常添加 UserId,但不能依靠运行时的 List 类型测试证明其中每个整数都经过了用户编号的业务处理。

扩展类型在编译期区分UserId和OrderId,运行时都采用int表示;运行时类型检查无法追溯编号的业务来源

图中从两个静态接口指向 int,不表示 UserId 与 OrderId 在静态类型上可以随意互换,而是说明运行时不再保留这份区别。需要判断“这是用户编号还是订单编号”时,不应使用这种 is 测试来还原业务来源。运行时的类型行为

6.2 强制转换不会补跑构造函数校验

把前面的编号校验简化成下面的完整例子:

extension type PositiveId._(int value) implements Object {
  factory PositiveId(int value) {
    if (value <= 0) {
      throw ArgumentError.value(value);
    }
    return PositiveId._(value);
  }
}

void main() {
  Object raw = -1;

  print(raw is PositiveId);

  final id = raw as PositiveId;
  print(id.value);

  try {
    PositiveId(-1);
  } on ArgumentError {
    print('构造时校验失败');
  }
}

输出依次为 true、−1 和“构造时校验失败”。

名称叫 PositiveId,不会让运行时自动知道“大于零”的业务规则。raw as PositiveId 只检查它能否作为底层 int 使用,不会调用 PositiveId 的工厂;直接调用 PositiveId(-1),才会真正执行我们写出的判断。

两条入口的区别:调用构造函数会执行数值校验,强制转换只进行相应的运行时类型检查,不调用构造函数

这也说明为什么“主构造函数是私有的”仍不等于有运行时的安全封装。私有名称限制的是库之间的正常访问,不能为已经擦除的类型创造独立身份。

因此,从网络响应、JSON 或其他不确定来源拿到值时,应先确认底层数据类型,再明确调用执行校验的构造入口。不要用一次 as 转换代替数据校验。模式匹配中针对扩展类型的检查同样遵循底层表示规则,也不会替你执行构造函数。

6.3 dynamic 不会保留扩展类型的成员

extension type UserId(int value) implements Object {
  String get label => '用户编号:$value';
}

void main() {
  final id = UserId(42);
  dynamic raw = id;

  print(id.label);

  try {
    print(raw.label);
  } on NoSuchMethodError {
    print('底层整数没有 label');
  }
}

输出依次为“用户编号:42”和“底层整数没有 label”。

id.label 在编译期按 UserId 的接口确定。dynamic 会在运行时查找成员,但此时真正的值是 int,它没有 label,所以调用失败。

把值交给 dynamic 并没有得到一个“自动携带全部扩展成员”的对象。如果需要在运行时通过统一接口调用业务方法,应使用适合动态分派的普通类与接口设计。

七、空安全还要看扩展类型声明

7.1 非空表示类型,不等于自动成为非空 Object 的子类型

没有显式建立相应关系的扩展类型,不能仅因为表示类型是非空 int,就认定它能传给任何要求 Object 的位置。下面给编号明确声明 implements Object:

extension type UserId(int value) implements Object {}

void acceptObject(Object value) {
  print(value);
}

void main() {
  acceptObject(UserId(42));

  UserId? selected;
  print(selected?.value);
}

输出依次为 42 和 null。

implements Object 明确建立与非空 Object 的子类型关系,便于与要求非空对象的 API 配合。UserId? 则是在静态类型中允许空值;访问时依然要处理 null,示例中的 ?. 会在值为空时跳过 getter。

这不是说省略 implements Object 后,就可以直接把 null 赋给一个底层为 int 的扩展类型变量。它的可赋值规则与“是不是 Object 的子类型”是不同的问题,不能用一句“扩展类型默认都可空”代替分析。

7.2 可空表示值,可能本来就是这个类型的数据

如果表示类型就是 String?,扩展类型本身便可能承载一个 null:

extension type OptionalText(String? value) {
  bool get isMissing => value == null;
}

void main() {
  final text = OptionalText(null);

  print(text.isMissing);
  print(text.value);
}

输出依次为 true 和 null。text 的静态类型是 OptionalText,它的接口明确允许在底层值为空时执行 isMissing。

这与“一个非空表示类型的外部变量暂时没有值”不是同一种建模方式。底层允许 null 时,也不能为了消除报错就随意添加 implements Object:这会要求类型保证非空,与当前表示类型不相容。应先决定 null 在业务中表示什么,再选择表示类型、问号与接口关系。

八、与类型别名、扩展方法和普通类怎样选择

写法适合解决的问题需要留意的区别
类型别名 typedef给已有类型起一个更易读的名字不建立独立类型,不能靠别名区分两种整数编号
扩展方法 extension给已有类型补充方便的操作不产生新的类型,也不收窄原类型接口
扩展类型 extension type为底层值建立编译期接口,区分含义或调整可用操作不新增包装对象,也不保留独立的运行时类型身份
普通包装类 class建立独立对象,保存状态、维护对象规则或进行动态分派从对象模型看多了一层包装;运行时优化和实际成本仍需测量

例如,typedef UserId = int 只是别名,需要 UserId 的位置仍然接受普通整数。给 int 增加一个显示编号的扩展方法,也不会使整数从此分成“用户编号”与“订单编号”。

如果希望编译器提醒“这里不能把订单编号传给用户查询”,又不需要独立的运行时对象身份,扩展类型可以表达这种约束。

如果要求运行时能可靠地区分两种业务对象、按接口动态调用不同实现,或者每个对象需要额外保存状态,普通类通常更合适。普通类也不是自动的数据校验器;它的价值在于可以建立独立的对象身份和封装,再由构造及成员逻辑维护规则。

扩展类型还常用于静态 JS 互操作,为已有 JavaScript 对象声明 Dart 侧接口,避免再创建一层 Dart 包装。相关 external 与互操作类型约束属于专门的互操作规则,不能直接套成普通业务对象的写法。

“没有额外包装对象”也不等于整段代码没有任何成本。构造函数里的解析、校验,以及成员方法中的计算和集合创建,仍会执行。是否能改善一个实际项目的性能,需要结合调用方式和测量结果,不能只凭类型名称判断。

九、总结

扩展类型让同一份底层数据,在编译期按照不同的接口使用。表示类型决定底层保存什么;构造函数负责取得或检查值;声明的成员与 implements 关系决定可以调用哪些操作。

它既可以收窄原接口,也可以复用已有接口。实现 int 会重新开放整数操作,实现 Iterable 则能只公开迭代能力,但两者都不会自动改变底层对象,也不会把所有返回值变成扩展类型。

最需要记住的是编译期与运行时的区别:运行时使用表示类型,is、as、模式匹配和泛型类型检查不会恢复业务身份,也不会执行构造校验。需要静态约束时考虑扩展类型;需要独立运行时身份、额外状态和动态分派时,考虑普通类。

十、测试题

  1. UserId(42) 与 OrderId(42) 的底层数值相同,为什么不能直接把后者传给要求 UserId 的函数?换成 typedef UserId = int 后,还能靠类型检查区分吗?
  2. extension type UserId(int value) 中,int 与 value 分别是什么?读取 id.value 是否意味着运行时存在一个包含 value 字段的 UserId 包装对象?
  3. 需要限制用户编号不能参与普通整数加法,是否应直接写 implements int?这样写还会怎样影响赋值和操作结果的类型?
  4. Sequence<T> 实现 Iterable<T> 后,为什么不能通过 sequence.add 修改列表,但通过 source.add 修改后,sequence.length 又会变化?
  5. TextUnits 示例中,为什么 text[0] 得到 65,而把它赋给 String 后得到 A?这与普通类中的方法重写有什么区别?
  6. 一个 Object 变量保存 UserId(42),为什么对它做 is UserId 和 is OrderId 都可能得到 true?List<UserId> 的运行时类型测试又要注意什么?
  7. PositiveId 的公开构造函数拒绝负数,为什么 Object raw = -1 之后的 raw as PositiveId 仍能成功?从 JSON 取得编号时应该怎样完成校验?
  8. UserId 有 label getter,为什么把值交给 dynamic 后,raw.label 可能抛出 NoSuchMethodError?
  9. UserId? 与 OptionalText(String? value) 各自怎样容纳空值?能否为了让 OptionalText 传入要求 Object 的函数,就直接给它加 implements Object?
  10. 如果每个账户都要保存独立的重试次数,并在运行时按接口调用不同账户的实现,仅用一个以 int 表示的扩展类型是否合适?说明需要哪种能力。