前言
同一张公交卡,既可以在闸机上“刷一下”,也可以在手机应用里查看余额。卡片本身保存着余额,闸机负责读取它,应用则可能提供统一的查询入口。看起来都是“调用”,实际是在问不同的对象做不同的事。
Dart 里也有几种容易混在一起的写法:普通方法由某个对象完成,静态成员属于整个类,操作符让两个对象可以用 + 或 == 表达关系,可调用对象则让一个对象像函数一样被调用。它们都能减少重复代码,但负责的问题不同。
如果没有分清这些关系,就容易把不属于某个对象的功能写成实例方法,把两个对象的比较写成难懂的普通方法,或者看到括号就以为一定是在调用函数。把“谁在做事、数据属于谁、调用表达什么”分清,代码才会自然。
一、先分清方法属于谁
方法是写在类中的函数,用来提供对象的行为。 普通实例方法通过对象调用,可以读取这个对象的实例字段,也可以调用它的其他实例成员。
class Counter {
Counter(this.value);
int value;
void increase() {
value++;
}
}
void main() {
final counter = Counter(1);
counter.increase();
print(counter.value);
}
输出 2。调用 counter.increase() 时,方法操作的是 counter 的 value;换成另一个 Counter 对象,操作的就是另一个对象的 value。
这里的关键不是“方法写在类里”,而是实例方法的工作对象由调用它的实例决定。 同一份方法代码,可以服务许多对象,但每次使用的实例数据不同。
二、静态成员属于整个类
静态成员属于类本身,不属于某一个实例。 在声明前加 static,可以定义类级别的字段或方法,通过类名调用它们。
class Temperature {
static const freezingPoint = 0;
static int createdCount = 0;
Temperature(this.celsius) {
createdCount++;
}
final double celsius;
static bool isFreezing(double value) {
return value <= freezingPoint;
}
}
void main() {
final first = Temperature(18);
final second = Temperature(-2);
print(Temperature.createdCount);
print(Temperature.isFreezing(second.celsius));
print(first.celsius);
}
输出依次为 2、true 和 18.0。createdCount 记录所有 Temperature 对象的数量,不属于 first 或 second 中的某一个;isFreezing 只需要传入一个温度,也不需要知道某个 Temperature 对象是谁,所以从类名调用更合适。
2.1 静态方法为什么不能直接使用 this
this 表示当前实例。静态方法是通过类调用的,没有一个“当前实例”,所以不能在静态方法中直接读取实例字段或使用 this。
class User {
User(this.name);
final String name;
static String greeting(String name) {
return '你好,$name';
}
}
void main() {
final user = User('小林');
print(User.greeting(user.name));
}
greeting 使用的是参数 name,不是某个隐含的用户对象。若一个方法必须直接读取当前用户的 name,就应写成实例方法,再通过 user.greeting() 调用。
静态方法可以访问同一个类的静态成员,但不能把静态成员当成实例成员来使用。静态字段适合保存所有实例共享的状态或常量;如果状态应该分别属于每个对象,就不要放到 static 中。Dart 类变量和静态方法
2.2 静态方法和顶层函数怎么选
一个工具函数不一定要放进类里。若它不需要类名来表达归属,也不需要访问类的静态状态,顶层函数通常更直接。
double celsiusToFahrenheit(double celsius) {
return celsius * 9 / 5 + 32;
}
void main() {
print(celsiusToFahrenheit(20));
}
如果函数和某个类型的概念紧密相关,使用 Temperature.isFreezing() 可以让调用处看出它处理的是温度。若它是多个功能都会使用的通用转换,也可以保持为顶层函数。static 不是为了把所有函数都收进类里,而是为了表达“这个能力属于这个类的整体”。
三、操作符是有特殊写法的方法
Dart 中多数操作符实际上对应对象的方法,只是调用时使用了更自然的符号。 例如 a + b 可以理解为调用 a 的加法操作,让 a 根据 b 计算结果。
下面用二维点表示位移:
class Point {
const Point(this.x, this.y);
final int x;
final int y;
Point operator +(Point other) {
return Point(x + other.x, y + other.y);
}
}
void main() {
const start = Point(2, 3);
const move = Point(4, -1);
final result = start + move;
print('${result.x}, ${result.y}');
}
输出 6, 2。这里的加号不是把两个 Point 强行变成数字相加,而是由 Point 规定“两个点相加”代表坐标分别相加。
可重载的操作符必须使用 Dart 允许的名称,例如 +、-、*、/、%、<、>、<=、>=、==、[] 和 []= 等。声明时写 operator 加操作符名称。不是所有看起来像操作符的符号都能这样声明,例如 != 的行为由语言结合 == 处理,不能把它当作普通的自定义操作符来声明。Dart 操作符
四、操作符要表达清楚的对象关系
操作符会让代码更像数学表达式,但也更容易把含义藏起来。适合重载的情况通常是读者看到符号就能猜到含义,例如点的位移、金额的加法或集合的包含关系。
如果一个操作需要复杂业务流程,就应该使用有名字的方法。例如“计算订单最终价格”涉及优惠券、税费和库存,写成 order + coupon 往往比 order.calculateTotal(coupon) 更难读。
4.1 重写 == 时还要处理 hashCode
如果两个对象只要坐标相同就算相等,需要同时提供 hashCode,让它们在 Set 或 Map 中遵守相同的判断规则。
class Coordinate {
const Coordinate(this.x, this.y);
final int x;
final int y;
@override
bool operator ==(Object other) {
return other is Coordinate && other.x == x && other.y == y;
}
@override
int get hashCode => Object.hash(x, y);
}
void main() {
const first = Coordinate(1, 2);
const second = Coordinate(1, 2);
print(first == second);
final coordinates = <Coordinate>{};
coordinates.add(first);
coordinates.add(second);
print(coordinates.length);
}
输出 true 和 1。相等的两个对象必须拥有相同的 hashCode,否则集合可能无法按预期识别重复值。反过来,hashCode 相同不代表两个对象一定相等,因为不同对象可能发生哈希碰撞。
操作符只应根据对象真正表示的数据计算结果。不要让 == 有时按编号比较、有时按显示文字比较,也不要让同一个对象的 hashCode 在放入集合后随可变字段改变。Object 相等与哈希约定
五、下标操作符让对象像容器一样访问
[] 和 []= 也是可以声明的操作符。它们适合表达“根据键读取或写入对象中的数据”,但并不会自动让对象变成 List 或 Map。
class ScoreBoard {
ScoreBoard(this._scores);
final List<int> _scores;
int operator [](int index) => _scores[index];
void operator []=(int index, int score) {
_scores[index] = score;
}
}
void main() {
final board = ScoreBoard([80, 90]);
board[0] = 85;
print(board[0]);
}
输出 85。调用 board[0] = 85 时,实际使用的是 []=;读取 board[0] 时,使用的是 []。边界检查仍由类自己负责,这里因为内部 List 会在下标超出范围时抛出错误。
如果对象不是容器,或者访问动作不能自然解释为“按键取值”,就不要为了少写几个字符而定义下标操作符。清楚的方法名比看似简短的语法更重要。
六、让对象可以像函数一样调用
只要类定义了 call() 方法,它的实例就可以使用函数调用语法。 这不会把对象变成真正的顶层函数,而是让括号调用转交给这个对象的 call() 方法。
class AddTax {
AddTax(this.rate);
final double rate;
double call(double price) {
return (price * (1 + rate) * 100).round() / 100;
}
}
void main() {
final addTax = AddTax(0.13);
print(addTax(100));
}
输出 113.0。addTax 是一个对象,它还保存了 rate;addTax(100) 看起来像调用函数,实际执行的是 addTax.call(100)。
可调用对象适合“既要保存配置,又要反复执行同一种操作”的场景,例如带固定规则的转换器、校验器或过滤器。若对象没有需要保存的状态,直接写普通函数通常更容易读。
call() 和普通函数一样,可以有参数、可选参数、命名参数和返回值。调用者只看到括号时,不一定知道对象内部保存了哪些配置,所以类名和变量名应表达清楚用途,避免把任意对象都包装成看不出含义的调用形式。Dart 可调用对象
七、四种写法怎样选择
可以先问三个问题:行为需要哪个对象的数据,状态是所有实例共享还是各自独立,调用处是否有一种自然且不会误解的表达。
| 需求 | 合适的写法 | 调用形式 |
|---|---|---|
| 操作某个对象自己的数据 | 实例方法 | account.deposit(100) |
| 不依赖具体实例,属于类型整体 | 静态成员或顶层函数 | Temperature.isFreezing(0) |
| 两个值之间有清楚、直观的符号关系 | 操作符 | first + second |
| 对象保存配置,之后反复执行同一动作 | 可调用对象 | validator(input) |
同一功能有时有不止一种写法。例如温度转换可以是顶层函数,也可以是静态方法;选择时看调用处是否需要突出类型归属。操作符和可调用对象都能让代码变短,但如果读者必须跳回类定义才能猜到含义,就应该改用有名字的方法。
八、常见误区
8.1 把共享状态误放进实例
如果想统计所有对象的数量,应该使用静态字段;如果想记录每个对象自己的状态,应该使用实例字段。把实例状态放进 static,会让所有对象互相覆盖;把共享计数放进实例,则每个对象都会得到一份独立数字。
8.2 以为 static 方法能自动知道当前对象
static 方法没有 this。需要哪个对象,就把它作为参数传入;如果方法总是围绕一个对象工作,就改成实例方法。
8.3 为了炫技重载操作符
操作符的优势是表达熟悉的关系,不是隐藏业务流程。一个符号如果有多种解释,或者操作会产生明显副作用,就用普通方法写出动作名称。
8.4 把可调用对象当成普通函数的替代品
可调用对象的价值在于保存状态和配置。没有状态时,普通函数通常更容易发现、测试和复用;有状态时,也要让类名说明这个对象会执行什么。
九、总结
实例方法操作调用它的对象,能够使用当前实例的数据;静态成员属于类整体,没有当前实例,适合共享状态、常量和不依赖实例的能力。与类无紧密归属的通用功能,可以写成顶层函数。
操作符是特殊命名的方法,只有在符号关系自然、含义稳定时才适合使用。重写 == 时同步处理 hashCode,使用 [] 和 []= 时明确对象确实像容器一样工作。
定义 call() 后,对象可以像函数一样调用。它适合保存规则后反复执行;没有需要保存的状态时,普通函数往往更直接。最终选择的标准是:读者能否从调用处理解行为属于谁、数据从哪里来、结果代表什么。
十、测试题
- counter.increase() 修改的是哪一个 Counter 对象的数据?同一个方法为什么可以服务多个 Counter 实例?
- Temperature.createdCount 为什么通过类名访问?如果把它改成实例字段,会发生什么?
- 静态方法中为什么不能直接使用 this?如果一个方法需要读取当前用户的 name,应该改成什么形式?
start + move在 Point 示例中表达了什么?什么样的业务操作不适合重载成操作符?- 重写
==后为什么通常还要重写 hashCode?如果两个对象 hashCode 相同,是否一定相等? board[0] = 85和board[0]分别会调用哪个操作符?对象定义[]就自动拥有 List 的所有能力吗?- addTax(100) 实际调用了什么?可调用对象和普通函数各适合什么情况?
- “统计所有实例数量”“读取某个账户余额”“把两个点相加”“按固定规则校验输入”分别更适合静态成员、实例方法、操作符还是可调用对象?说明理由。