
前言
给插座接上转换头,原本不匹配的插头也能使用了,但墙里的插座并没有被重新制造。我们增加的是一种使用方式。
写代码时也会遇到类似的需要:String 已经提供了很多方法,我们却希望再加一个,把字符串转换成整数。修改 Dart 自带的 String 不现实;每次都把字符串交给工具函数,又希望能写得更顺手。
Dart 的扩展方法允许我们在类型外面定义行为,再用对象调用方法的形式使用它。不过,这个比喻只帮助理解"增加用法":Dart 不会真的给对象装上一个新部件,也不会把新方法写进原来的类。正因为如此,扩展方法有一套与普通实例方法不同的选择规则。
一、扩展方法写在类型外面
扩展方法是在原类型声明之外定义、可以通过对象调用语法使用的方法。 它适合为不能修改或不适合修改的类型补充行为。
例如,把一段文字解析成整数,原本可以写成 int.tryParse('42')。如果希望从这段文字出发调用,可以声明一个扩展:
dart
extension NumberParsing on String {
int? toInteger() {
return int.tryParse(this);
}
}
void main() {
print('42'.toInteger());
print('hello'.toInteger());
}
输出依次为 42 和 null。转换失败返回 null,是这里选用 int.tryParse 的结果,不是所有扩展方法都必须这样处理错误。
这段声明中,extension 表示定义扩展;NumberParsing 是扩展的名字;on String 表示这个扩展适用于 String 类型的值;toInteger 是提供的方法。在方法里,this 就是调用左边的字符串:调用 '42'.toInteger() 时,this 对应 '42'。
NumberParsing 是给这一组扩展成员起的名字,普通调用通常不需要写出来。把 '42'.toInteger() 与 NumberParsing 混为一谈,就容易误以为必须先创建一个扩展对象。
这里没有创建 String 的子类,也没有修改 String 的源码。它只是把"将字符串传给转换函数"写成了"从字符串调用转换方法"。编译器能在点号后提示这个方法,连续处理数据时也更容易顺着对象往下读。Dart 扩展方法
二、方法怎样选,先看静态类型
普通实例方法属于对象的类。扩展方法则不同:编译器根据调用处表达式的静态类型选择扩展,不会等到运行时再去对象身上查找。
静态类型就是编译器在代码的这个位置,知道这个值是什么类型。它可以来自显式声明,也可以来自类型推断或类型提升。
2.1 var 可以使用,dynamic 不会自动找到
下面仍然使用刚才的 NumberParsing,区别只在变量的类型。
dart
extension NumberParsing on String {
int? toInteger() => int.tryParse(this);
}
void main() {
var text = '42';
print(text.toInteger());
dynamic unknown = '42';
print(unknown.toInteger());
}
第一处输出 42。var 并不表示"没有类型",编译器已经把 text 推断为 String,因此能够选择 on String 的扩展。
第二处会在运行时抛出 NoSuchMethodError。dynamic 会把这次成员查找留到运行时;而真正的 String 类里并没有 toInteger,扩展又没有把它添加进 String,所以找不到。
如果已经确认 unknown 确实是 String,可以通过类型检查让编译器也知道这一点:
dart
extension NumberParsing on String {
int? toInteger() => int.tryParse(this);
}
void printInteger(dynamic value) {
if (value is String) {
print(value.toInteger());
}
}
void main() {
printInteger('42');
}
输出 42。在 if 分支里,value 已提升为 String,扩展因此可用。这比不检查就强制转换更稳妥;强制转换遇到不匹配的值时,本身就会失败。
2.2 同一个对象,也可能选中不同扩展
再看一个更容易暴露区别的例子:
dart
extension ObjectLabel on Object {
String get typeLabel => '按 Object 处理';
}
extension StringLabel on String {
String get typeLabel => '按 String 处理';
}
void showLabel(Object value) {
print(value.typeLabel);
if (value is String) {
print(value.typeLabel);
}
}
void main() {
showLabel('你好');
}
输出依次为"按 Object 处理"和"按 String 处理"。传入的始终是同一个字符串。
第一次访问时,参数的静态类型是 Object,on String 的扩展不适用于这个位置,使用的是 ObjectLabel。进入类型检查分支后,value 被提升为 String,两个扩展都适用,而 String 比 Object 更具体,因此选择 StringLabel。
对象没有变,编译器掌握的类型信息变了,结果就不同。这也是为什么不能用"运行时明明是字符串"来推断扩展一定可用。静态类型与 dynamic

图中左右两条路径,对应同一个函数中的两个 print,不是两个新对象。左边只有 Object 的类型信息,右边已经通过 String 检查;扩展选择因此改变。
三、扩展要在当前库中可用
把扩展写在一个文件里,不会让项目里的所有文件自动拥有它。调用所在的库需要声明这个扩展,或者导入提供它的库。
例如,建立两个同目录文件。
string_tools.dart:
dart
extension TextChecks on String {
bool get isBlank => trim().isEmpty;
}
main.dart:
dart
import 'string_tools.dart';
void main() {
print(' '.isBlank);
print('Dart'.isBlank);
}
运行 main.dart,输出 true 和 false。这里的 isBlank 是 getter,所以读取时不写括号;trim() 得到去掉首尾空白后的字符串,isEmpty 再判断它是否为空。原字符串不会被修改。
导入时也可以只开放这一个扩展:
dart
import 'string_tools.dart' show TextChecks;
void main() {
print(' '.isBlank);
}
show 后面写的是扩展名 TextChecks,而不是成员名 isBlank。相应地,hide TextChecks 会排除这个扩展;如果当前没有其他来源提供它,isBlank 就不能这样调用了。
导入前缀不会阻止扩展的普通调用。 即使写成 import 'string_tools.dart' as tools,' '.isBlank 仍然可用。前缀能在明确指定扩展时帮助区分名字,却不是把扩展成员隔离成必须带前缀的调用。
如果扩展只在当前库内部使用,可以省略名字,写成 extension on String。无名扩展只在声明它的库中可用,其他库导入这个文件也不能使用它;而且没有名字,就不能在重名时明确指定它。这里的范围是"库",不一定等于一个文件,因为同一个库也可能由 part 拆成多个文件。以下划线开头命名的扩展同样只在所属库内可见。
四、成员重名时,按什么规则选择
给已有类型补充行为,难免可能碰到相同名字。要先区分:这个名字原本就在类型上,还是几个扩展彼此撞名。
4.1 类型本身的成员优先
dart
class Printer {
String label() => '类中的方法';
}
extension PrinterLabel on Printer {
String label() => '扩展中的方法';
}
void main() {
final printer = Printer();
print(printer.label());
print(PrinterLabel(printer).label());
}
输出依次为"类中的方法"和"扩展中的方法"。普通的 printer.label() 使用类本身的方法,扩展不会覆盖它。即使类中同名方法的参数不符合你这次传入的参数,也不会因此退回去搜索扩展来补救。
PrinterLabel(printer).label() 则明确指定使用 PrinterLabel。这种写法叫显式扩展调用,看起来像构造包装对象,实际没有创建一个 PrinterLabel 实例,也没有把 printer 的类型改掉;它只为这一次成员访问指定扩展。
4.2 多个扩展都适用,不是按导入顺序决定
第二节的 ObjectLabel 与 StringLabel 都有 typeLabel。对于 String,后者的适用类型更具体,因此能够选出它。
但两个外部库都为 String 提供相同名字的成员,且没有唯一更合适的候选时,普通调用就会产生歧义。交换 import 的先后顺序不能解决问题。
下面把两组同名成员放在不同库中。
friendly_labels.dart:
dart
extension FriendlyLabel on String {
String get displayLabel => '你好,$this';
}
formal_labels.dart:
dart
extension FormalLabel on String {
String get displayLabel => '尊敬的$this';
}
main.dart:
dart
import 'friendly_labels.dart';
import 'formal_labels.dart';
void main() {
print(FriendlyLabel('小林').displayLabel);
print(FormalLabel('小林').displayLabel);
}
输出"你好,小林"和"尊敬的小林"。这里通过扩展名把选择写清楚。若直接写 '小林'.displayLabel,就会产生编译错误,因为两个导入扩展同样适用。
如果只想保留一种写法,可以在导入时 hide FormalLabel。若两个库连扩展名都相同,可以为其中一个库加前缀,再使用 prefix.ExtensionName(value).member 的形式明确选择。仅仅添加前缀,并不能保证普通的 value.member 不再歧义。

流程图从上往下判断,每个菱形只回答一个问题。类型本身的成员包括继承得到的实例成员;图中的"适用"至少要满足两件事:扩展在当前库可用,并且调用处的静态类型符合它的 on 类型。泛型扩展还要结合类型参数判断。"更具体"不是按名字更长、导入更晚或运行时类型来决定。
实际遇到重名时,最直接的处理通常是明确指定扩展,或通过 show、hide 减少候选,不必靠调整文件顺序猜测编译器会选谁。扩展的 API 冲突
五、扩展不只提供普通方法
5.1 getter、setter 和静态成员
扩展还可以声明 getter、setter 和允许的操作符。声明操作符时仍需遵守 Dart 对操作符名称和参数的要求,不是任意符号都能自定义。
getter 看起来像读取属性,但可以随时根据现有数据计算结果;setter 则可以把赋值动作转成对现有数据的修改。两者都不意味着增加了新的存储字段。
dart
extension NameFields on Map<String, String> {
static const nameKey = 'name';
String get displayName => this[nameKey] ?? '未命名';
set displayName(String value) {
this[nameKey] = value.trim();
}
}
void main() {
final fields = <String, String>{};
print(fields.displayName);
fields.displayName = ' 小林 ';
print(fields[NameFields.nameKey]);
}
输出依次为"未命名"和"小林"。最初 Map 中没有 name,所以 getter 返回默认文字;赋值时,setter 去掉两端空白,再写进原 Map。变化发生在 Map 已有的键值存储里,并没有给每个 Map 安装一个 displayName 字段。
这个例子也包含一个静态常量。扩展可以提供静态字段和静态辅助方法,在扩展声明外通过扩展名访问,例如 NameFields.nameKey,而不是 fields.nameKey。它不属于某个 Map 实例。
setter 本身也不会让对象自动变得可修改。如果 fields 来自一个不可修改的 Map,写入时仍会受原 Map 的限制。
5.2 泛型扩展保留元素类型
为 List 补充操作时,往往希望同一段代码能处理整数列表和字符串列表,又不丢掉元素类型。
dart
extension ListTools<T> on List<T> {
List<T> withoutFirst() {
return isEmpty ? <T>[] : sublist(1);
}
}
void main() {
final numbers = <int>[10, 20, 30];
final names = <String>['小林', '小周'];
List<int> remainingNumbers = numbers.withoutFirst();
List<String> remainingNames = names.withoutFirst();
print(remainingNumbers);
print(remainingNames);
print(numbers);
}
输出依次为 20, 30、小周 和 10, 20, 30。
T 表示调用处列表的元素类型。对 List<int> 使用时,结果仍是 List<int>;对 List<String> 使用时,结果仍是 List<String>。空列表返回相应类型的空列表,非空列表通过 sublist 创建结果,原列表没有被删除元素。
T 同样根据静态类型推断。如果传入位置只知道它是 List<num>,就不能因为里面恰好全是整数,便期待编译器把结果自动认成 List<int>。泛型扩展
5.3 扩展可以专门处理可空值
on String 与 on String? 并不是一回事。前者要求调用时能确定是非空字符串;后者允许接收到 null,因此方法内部必须处理这种情况。
dart
extension NullableText on String? {
bool get isNullOrBlank {
final value = this;
return value == null || value.trim().isEmpty;
}
}
void showText(String? text) {
print(text.isNullOrBlank);
print(text?.isNullOrBlank);
}
void main() {
showText(null);
}
输出依次为 true 和 null。
第一处是普通点号调用。因为扩展声明在 String? 上,允许 this 为 null,getter 得以执行并返回 true。第二处使用空值感知访问:接收者为 null 时就停止后续访问,不执行 getter,结果是 null。
这两种写法表达不同的需求:"把空值也交给方法处理",与"遇到空值就不调用",不能互相替代。若扩展只声明在 String 上,对一个尚未排除 null 的 String? 值,通常需要先检查非空,或使用空值感知访问。
六、扩展增加行为,不增加对象身份和实例字段
回到转换头的比喻:增加一种使用方式,不等于重新制造了原来的设备。在 Dart 中,这个区别会带来几条实际限制。
扩展不能声明实例字段,也不能定义构造函数。 例如,不能在扩展里写 int count = 0,期待每个被扩展对象从此多存一个 count。需要每个对象保存额外数据时,应考虑修改自己拥有的类,或者用普通类包装原对象。
getter 可以从现有内容算出值,setter 可以修改原对象允许修改的内容,静态字段可以保存属于扩展声明的共享数据,但这些都不是新增实例字段。扩展中的实例字段限制
扩展名也不是类型。不能把 NumberParsing 当作变量类型,更不能用 NumberParsing('42') 得到一个可保存、可传递的新类型对象;它只能出现在显式扩展访问等允许的位置。扩展不会自动让对象实现某个接口,也没有继承和重写普通实例方法的效果。
扩展也不会突破库的私有可见性规则。给其他库的类写扩展,不能因此读取那个库里以下划线开头的私有字段;能够使用哪些成员,仍由正常的库访问规则决定。
此外,扩展不能声明与 Object 成员同名的成员,例如 toString、hashCode 和 ==。如果需要改变对象本身的相等规则或字符串表示,应在合适的类中实现,而不是通过扩展替换。Object 成员的扩展声明限制
选择写法时,可以从实际需要判断:
| 实际需要 | 可以考虑 |
|---|---|
| 不能改原类型,希望操作自然地跟在值后面 | 扩展方法 |
| 只是一个通用计算,函数形式已经很清楚 | 顶层函数 |
| 自己拥有这个类,要定义它本身的行为或多态规则 | 类中的实例成员 |
| 每个对象需要保存额外数据、保持新的业务约束 | 普通包装类等类型设计 |
同一个整数既可以表示米,也可以表示用户编号。如果给 int 增加一个扩展,所有符合类型条件的 int 都可能使用它;扩展不会自动区分业务含义。业务上需要区分两种值时,仅仅增加便捷方法并不够,还需要类型本身表达这种区别。
七、总结
扩展方法让已有类型的使用方式更方便,但没有修改原类。声明中的 on 指定适用类型,this 对应当前接收者;方法之外,还能提供 getter、setter、操作符和静态成员。
判断一次调用能否使用扩展,要看当前库有哪些扩展可用、调用处的静态类型是什么,以及是否存在同名成员。类型本身的成员优先;多个适用扩展之间需要有唯一更具体的候选,否则通过导入限制或显式扩展调用解决。dynamic 的运行时查找不会自动使用扩展。
泛型扩展可以保留元素类型,可空类型扩展可以把 null 纳入处理。它们仍不增加实例字段、不产生新类型、不替代继承。让调用更自然时使用扩展,需要新状态或新身份时则回到类型设计。
八、测试题
- '42'.toInteger() 为什么能写成对象调用的形式?它是否意味着 String 类已经被修改?扩展里的 this 对应什么?
- var text = '42' 与 dynamic text = '42' 的值相同,为什么调用扩展时结果可能不同?怎样通过类型检查安全地使用扩展?
- showLabel 示例中,为什么同一个字符串先得到"按 Object 处理",进入 if 后却得到"按 String 处理"?
- string_tools.dart 中声明了 TextChecks。另一个库应该怎样导入,才能使用 isBlank?show 后写扩展名还是 getter 名?改为带前缀导入后,普通调用是否仍然可用?
- Printer 本身和 PrinterLabel 都有 label。printer.label() 使用哪一个?PrinterLabel(printer).label() 是否创建了一个新对象?
- 两个导入库都对 String 提供同样适用的 displayLabel,为什么交换导入顺序没有用?有哪些方式可以让选择明确?
- NameFields 的 setter 修改了什么?displayName 是否成为 Map 的新实例字段?静态常量 nameKey 应通过什么名字访问?
- ListTools<T> 用在 List<String> 上时,withoutFirst 的返回类型是什么?若调用处静态类型是 List<num>,能否仅凭当前元素都是整数就推断返回 List<int>?
- text 为 null 时,为什么 text.isNullOrBlank 返回 true,而 text?.isNullOrBlank 返回 null?如果扩展声明在 String 上,直接对尚未排除 null 的 String? 调用是否可行?
- 需要为每个账户保存独立的重试次数,或者区分"米"和"用户编号"这两种整数含义,仅使用扩展是否足够?说明理由。