返回资讯中心
外部精选
软件工程
#开发者实时流

专家反继承警告30年,可为什么开发者仍痴迷继承?

来源标题:Do not ask "Is A a B?": Inheritance and the Is-A Trap

1994年GoF就提出"优先使用对象组合而非类继承",2013年Sean Parent更称继承是"万恶之源",Go和Rust等新语言干脆不提供继承。然而继承在遗留代码和新建项目中依然无处不在,常表现为没人能理清的深层类层次和不敢触碰的基类。作者认为,大部分问题可追溯到一条特定的指导原则。

Hacker News 最新theanonymousone2026/11/5 · 5 天前30 分钟阅读

以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。

继承的历史,就是一部专家们反复告诫慎用继承的历史。1994 年,Gang of Four 在《设计模式》中就把“优先使用对象组合,而非类继承”列为早期的要点之一。2013 年,Sean Parent 在题为“继承是万恶之源”的演讲中把话说得更重。而这还只是其中几例。一些较新的语言干脆完全绕开了这个问题:Go 和 Rust 根本不提供继承,而是用完全不同的机制来支持运行时多态和代码复用。

然而据我所见,继承依然无处不在。我说的不只是遗留代码,还包括我工作过的组织里新写的代码、开源项目、技术文章,以及你最爱的 LLM 也照做不误。而且它通常恰恰呈现出批评者所警告的形态:深不见底、盘根错节、没人能理清的类层次结构,没人敢碰的基类,以及拒绝履行基类所承诺契约的派生类。

我认为这些损害很大程度上可以追溯到一个特定的准则。它没有解释继承为什么有问题,反而告诉开发者什么时候该用它:继承建模的是 is-a 关系。

每一门面向对象编程的入门课都会以某种形式教这一点:如果 A 是 B,那么 A 继承自 B;如果 A 有一个 B,那么 A 就把 B 作为成员变量包含进来。

这种说法如此普遍,以至于它实际上已经不再是一条准则了。“继承”和“is-a 关系”经常被当作同义词使用,而“继承建模的是 is-a 关系”被当作事实而非建议来陈述。于是,推导出一个设计测试也就顺理成章:要判断 A 是否应该继承自 B,只需检查“A 是 B”这句话是否成立。

这个启发式方法有显而易见的吸引力。测试很短,不需要技术背景,在直觉上有一定道理,而且可以靠看问题域而不是看代码来回答。因此,大多数开发者在实践中用这个测试来决定是否使用继承,也就不足为奇了。但这恰恰就是问题所在。它把一个关于类型层次的技术决策,变成了一个关于自然英语的问题——而自然英语常常导向错误的答案。

用通俗英语来说,“A 是 B”是一个关于分类的陈述:每个 A 都满足 B 的描述。

而在编程中,类型不是描述。它是值的集合加上操作的集合,而每个操作都附带承诺。继承自 B 意味着保证 A 遵守 B 的所有承诺。

问题在于,这两者重合得足够频繁,让这个测试显得可靠;又背离得足够频繁,让它变得危险。让我们快速看几个能通过朴素测试、但继承却栽了跟头的例子。

如果有一件事是所有几何老师都能达成共识的,那就是正方形是矩形。所以:

class Rectangle { 
 public: 
 virtual ~Rectangle() = default; 
 virtual void setWidth(double w) { width_ = w; } 
 virtual void setHeight(double h) { height_ = h; } 
 double area() const { return width_ * height_; } 
 private: 
 double width_ = 0.0, height_ = 0.0; 
}; 
class Square : public Rectangle { 
 public: 
 void setWidth(double w) override { Rectangle::setWidth(w); Rectangle::setHeight(w); } 
 void setHeight(double h) override { Rectangle::setWidth(h); Rectangle::setHeight(h); } 
}; 
void resize(Rectangle& r) { 
 r.setWidth(2.0); 
 r.setHeight(3.0); 
 assert(r.area() == 6.0); // 对每个 Square 都会失败

Rectangle 承诺它的宽和高可以独立修改。Square 无法遵守这个承诺,所以无论几何老师怎么说,Square 都不是 Rectangle 的子类型。

类似这样的类相当常见,而且还有一种常见的讨价还价策略:严格来说,area() 函数从未承诺返回你刚设置的宽和高的乘积。但接受这一点,就意味着削弱基类的契约,直到几乎什么都不剩。一个面积可能跟它显式设置尺寸有关也可能无关的矩形,不是这个类的任何用户——比如写 resize() 函数的人——所期望的,也不是你能合理依赖它编程的东西。一个只有被掏空到没有任何子类能破坏它时才成立的契约,不是有用的契约。

不过请注意,如果两个类都不提供修改成员的方法:没有任何 setter,只有构造函数和 area(),会发生什么。这时 Square 实际上就是 Rectangle 的合理子类型了。“正方形是矩形吗?”的答案没有变。“我的 Square 类能继承自 Rectangle 吗?”的答案变了。

Circle 和 Ellipse 有同样的问题,只是拐角是圆的:椭圆可以沿一个轴拉伸,圆不能。

还是留在几何领域:单位向量是向量。如果 UnitVector3 继承自 Vector3,它很可能免费获得一些有用的函数,比如 rotate。不幸的是,它也会获得 operator+=、operator*= 和 setX() 之类的函数,这些都可能破坏单位向量“长度为 1”的类不变式。子类可以重写它们来抛异常、静默地重新归一化,或者什么都不做,而每个选项都会让拿到 Vector3 引用的人感到意外。与前一个例子不同,这里大概没法挽救这个继承关系,因为一个基本的 3D 向量总会暴露改变其长度的方法。

企鹅是鸟。如果 Bird 有一个 fly() 函数,那么 Penguin::fly() 要么必须抛错,要么必须安静地什么都不做。一种常见的权宜之计是引入一个 FlyingBird 中间类,只让真正会飞的鸟继承它。这意味着我们不能用直观的生物学分类作为继承层次结构,而必须围绕能力来重新组织它。

(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →