学习面向对象时,最常见的例子是动物会叫、汽车会跑。代码很容易照着写,理解却很浅:似乎只要出现 class、extends 和 interface,就完成了抽象。
真正让我理解抽象的,是一次很普通的课程练习。程序要处理几种不同零件的面积和重量,我一开始为每种零件复制一套计算流程。数量少时没有问题,公式一改,所有分支都要跟着修改。
抽象保护的是什么
我尝试把共同属性提出来,结果第一次抽得太多:不同零件被迫拥有并不适合它们的字段,调用方仍然到处判断类型。第二次则只保留计算需要的最小契约,让每个对象自己回答“面积是多少”“重量是多少”。
第二版的核心不是继承树,而是一个很窄的接口:
interface Weighable {
double volumeM3();
default double weightKg(Material material) {
return volumeM3() * material.densityKgPerM3();
}
}
record Material(String name, double densityKgPerM3) {}
record Plate(double lengthM, double widthM, double thicknessM) implements Weighable {
public double volumeM3() {
return lengthM * widthM * thicknessM;
}
}
record Cylinder(double radiusM, double heightM) implements Weighable {
public double volumeM3() {
return Math.PI * radiusM * radiusM * heightM;
}
}调用方只依赖 volumeM3(),材料密度也不再复制到每种零件里。新加圆柱时,重量统计代码没有变化,这才是抽象带来的真实收益。
第一版为什么失败也很具体。我曾经设计过一个包含 length、width、height、radius 的 Part 基类。板材的 radius 永远是空,圆柱的 width 又没有意义。为了让字段“通用”,我制造了一组对象必须互相猜测的无效状态。
这时我才意识到,抽象的目的不是消除所有差异,而是保护调用方不受无关差异影响。它应该把稳定的认识写进接口,把变化留在实现里。
不要急着复用
重复代码让人不舒服,但过早抽象会制造更隐蔽的问题。两段代码今天相似,不代表它们由同一种原因变化。如果只是为了少写几行就合并,它们以后可能被迫一起演进。
判断是否应该抽象,可以先问三个问题:调用者真正依赖什么;哪些规则会一起变化;删除某个实现后,契约是否仍然成立。回答不清楚时,保留少量重复比制造错误关系更安全。
我后来会用一张变化表检查抽象是否站得住:
| 变化 | 应该修改的位置 | 不应该被影响的部分 |
|---|---|---|
| 增加一种零件形状 | 新实现的体积公式 | 重量统计与材料选择 |
| 增加材料 | 材料数据 | 所有形状类 |
| 改变显示单位 | 输出适配层 | 内部体积计算 |
如果一次变化需要同时修改接口两侧很多文件,边界可能选错了。如果完全不同的变化总要进入同一个“通用类”,它也许只是一个名字好听的耦合点。
命名是抽象的测试
如果很难给一个类或接口起准确名字,通常不是词汇不足,而是它承担了几种无关责任。Manager、Helper 这类名字可以容纳任何行为,也因此没有提供信息。我开始尝试用领域中的名词描述对象,用可观察的动作描述方法,并删除那些只能通过注释解释的模糊概念。
一个好名字不会让设计自动正确,却能迫使自己回答“这个东西究竟代表什么”。当名字需要频繁改变,往往说明对问题的认识仍在变化,此时保持实现简单比急着建立继承体系更合适。
抽象不是代码技巧,而是分类能力。它要求我们承认自己对问题的理解有边界,也要求这个边界经得起下一次变化。
图:语法相似不等于共享边界,三类一致性决定抽象能否长期成立。
判断抽象是否有用的三个问题
我后来不会因为出现两段相似代码就立即抽象,而是先问:它们会不会因为同一个业务原因变化;调用者是否需要理解相同语义;错误和生命周期能否用同一套规则处理。三项都接近,才提取共同边界。
| 信号 | 适合抽象 | 暂时保持重复 |
|---|---|---|
| 同一规则多处实现 | 是 | |
| 只是语法长得像 | 是 | |
| 未来变化方向一致 | 是 | |
| 为了减少几行代码 | 是 |
抽象的验收不是文件更少,而是下一次规则变化只需要在一个清楚的位置解释。