Effective C++ 学习笔记 条款34 区分接口继承和实现继承

📅 2026/7/15 3:28:52
Effective C++ 学习笔记 条款34 区分接口继承和实现继承
乍看起来颇为直白的public继承概念仔细审视之后会发现它实际上由两个可分离的部分组成函数接口的继承和函数实现的继承。这两种继承之间的区别恰好对应于本书导言中讨论的函数声明与函数定义之间的区别。作为类设计者有时候你希望派生类只继承成员函数的接口声明有时候你希望派生类同时继承函数的接口和实现但又允许它们覆盖所继承的实现还有时候你希望派生类继承函数的接口和实现但禁止它们覆盖任何内容。为了更真切地理解这些选项之间的差异考虑一个用于图形应用程序中表示几何形状的类层次结构Shape 是一个抽象类它的纯虚函数 draw 使其成为抽象类。因此客户无法创建 Shape 类的实例只能创建从它派生出的类的实例。尽管如此Shape 对所有public继承自它的类都影响深远因为成员函数接口总是会被继承。如条款32所述public 继承意味着 is-a所以凡是适用于基类的东西也必须适用于其派生类。因此如果一个函数能作用于某个类它也必然能作用于该类的派生类。Shape 类中声明了三个函数。第一个是 draw它在隐式显示器上绘制当前对象。第二个是 error当成员函数需要报告错误时调用它。第三个是 objectID返回当前对象的唯一整数标识符。每个函数的声明方式各不相同draw 是纯虚函数error 是普通虚函数objectID 是非虚函数。这些不同的声明方式分别意味着什么首先来看纯虚函数 draw纯虚函数最显著的两个特征是任何具象类继承它之后都必须重新声明而且它们在抽象类中通常没有定义。把这两个特点结合在一起就能认识到声明纯虚函数的目的是让派生类只继承函数接口。这对 Shape::draw 来说完全合理因为要求所有 Shape 对象都可绘制这是合理的但 Shape 类无法为该函数提供合理的默认实现。比如绘制椭圆的算法与绘制矩形的算法就大相径庭。Shape::draw 的声明就是在告诉具象派生类的设计者“你必须提供一个 draw 函数但我不知道你打算怎么实现它。”顺便提一下为纯虚函数提供定义是可行的。也就是说你可以为 Shape::draw 提供一个实现C 不会对此有异议但调用它的唯一方式是用类名来限定调用除了能在鸡尾酒会上让同行觉得你知识广博之外这个特性的实用价值通常有限。不过正如下文你会看到的它可以作为一种机制为普通非纯虚函数提供一个比通常更安全的默认实现。普通虚函数背后的故事与纯虚函数略有不同。跟往常一样派生类会继承该函数的接口但普通虚函数还提供了一个实现派生类可以选择覆盖它。稍加思考就会意识到声明普通虚函数的目的是让派生类同时继承函数接口和默认实现。以 Shape::error 为例这个接口表明每个类都必须支持一个在遇到错误时调用的函数但每个类都可以自由地以自己认为合适的方式来处理错误。如果某个类不想做任何特殊处理它可以直接退而使用 Shape 类中提供的默认错误处理。也就是说Shape::error 的声明在向派生类的设计者传达这样的信息“你必须支持一个 error 函数但如果你不想自己写可以回退到 Shape 类中的默认版本。”这确实有点让人担忧。为了说明这一点考虑一家名为 XYZ 航空公司的飞机继承体系。XYZ 只有两种机型——A 型和 B 型两者的飞行方式完全相同。因此XYZ 设计了如下继承结构为了表明所有飞机都必须支持 fly 函数同时考虑到不同型号的飞机原则上可能需要不同的 fly 实现Airplane::fly 被声明为虚函数。然而为了避免在 ModelA 和 ModelB 类中编写重复的代码默认的飞行行为被提供为 Airplane::fly 的函数体ModelA 和 ModelB 都继承了这个默认实现。这是一个经典的面向对象设计。两个类共享一个共同特性它们实现 fly 的方式所以这个共同特性被上移至基类再由两个派生类继承。这种设计让公共特性显式化避免了代码重复便于后续功能增强也简化了长期维护——这正是面向对象技术备受推崇的诸多优点所在。XYZ 航空公司应当为此感到自豪。现在假设XYZ公司业务蒸蒸日上决定引进一种新型飞机——C型。C型在某些方面与A型和B型不同尤其是它的飞行方式不一样。XYZ的程序员在继承体系中添加了ModelC类但在急于让新机型投入运营的过程中他们忘记重新定义fly函数于是在他们的代码中出现了类似这样的调用这真是一场灾难试图将 ModelC 对象当作 ModelA 或 ModelB 来飞行。这种行为可不会让乘客对航空公司产生信心。这里的问题不在于 Airplane::fly 有默认行为而在于 ModelC 在没有明确声明需要的情况下被允许继承了该行为。幸运的是向派生类提供默认行为、但要求它们主动索取才给予这一点不难做到。诀窍在于切断虚函数接口与其默认实现之间的联系。以下是一种实现方式注意观察Airplane::fly 已被改为纯虚函数它提供了飞行的接口。默认实现仍然存在于 Airplane 类中但现在是作为一个独立的函数 defaultFly。像 ModelA 和 ModelB 这样希望使用默认行为的类只需在它们的 fly 函数体内内联调用 defaultFly 即可但关于内联与虚函数之间的交互参见条款30对于 ModelC 类来说不会再有不慎继承错误的 fly 实现的风险了因为 Airplane 中的纯虚函数强制 ModelC 必须提供自己的 fly 版本这种方案并非万无一失程序员仍然可能通过复制粘贴把自己带入麻烦但它比最初的设计可靠得多。至于 Airplane::defaultFly将其设为 protected 是因为它确实是 Airplane 及其派生类的实现细节。使用飞机的客户只应关心飞机能飞而不关心飞行是如何实现的。同样重要的是Airplane::defaultFly 是一个非虚函数。这是因为任何派生类都不应重新定义该函数这一道理在条款36中会有详细论述。如果 defaultFly 是虚函数就会产生一个循环问题万一某个派生类在应该重新定义它时却忘记了会发生什么有些人反对将接口和默认实现分别由两个独立函数如上面的 fly 和 defaultFly来提供的做法。他们指出这样做会用大量相近的函数名污染类命名空间。尽管如此他们仍然认同接口与默认实现应该分离。那他们如何解决这个看似矛盾的问题呢答案是利用这样一个事实纯虚函数必须在具象派生类中被重新声明但它们也可以有自己的实现。下面展示 Airplane 继承体系如何利用“为纯虚函数提供定义”这一能力这几乎与之前的设计完全相同唯一的区别在于纯虚函数 Airplane::fly 的函数体取代了独立的 Airplane::defaultFly 函数。本质上fly 被分解成了两个基本组成部分它的声明指定了接口派生类必须使用而它的定义指定了默认行为派生类可以使用但必须显式请求。不过在将 fly 和 defaultFly 合并的过程中你失去了为两个函数赋予不同访问层级的能力原本是 protected 的代码因为放在 defaultFly 中现在变成了 public因为放到了 fly 中。最后我们来看 Shape 的非虚函数 objectID当成员函数是非虚函数时它就不应在派生类中有不同的行为。实际上非虚成员函数定义的是“特化过程中的不变式”因为它标识的是无论派生类如何特殊化都不应改变的行为。因此声明非虚函数的目的是让派生类同时继承函数接口和强制实现。你可以把 Shape::objectID 的声明理解为在说“每个 Shape 对象都有一个能返回对象标识符的函数而且该标识符总是以同一种方式计算得出。这种方式由 Shape::objectID 的定义决定任何派生类都不应试图改变它的做法。”由于非虚函数标识的是特化过程中的不变式它绝不应该在派生类中被重新定义——这一点在条款36中有详细讨论。纯虚函数、普通虚函数和非虚函数在声明上的差异让你能够精确地指定你希望派生类继承什么分别对应“仅接口”、“接口加默认实现”或“接口加强制实现”。由于这几类声明方式含义截然不同你在声明成员函数时必须谨慎选择。如果选对了就能避免那些缺乏经验的类设计者最容易犯的两个常见错误。第一个错误是把所有函数都声明为非虚函数。这样做不给派生类留下任何特化的空间非虚析构函数尤其成问题参见条款7。当然设计一个不打算作为基类使用的类是完全合理的——这种情况下一套清一色的非虚成员函数是适当的。但往往这类类的出现要么是因为设计师不清楚虚函数与非虚函数的区别要么是出于对虚函数性能代价毫无根据的担忧。事实上几乎所有打算被用作基类的类都会有虚函数同样参见条款7。如果你担心虚函数的开销容我搬出那条经验法则——80-20法则另见条款30。它指出在典型程序中80% 的运行时间花在仅 20% 的代码上。这条法则之所以重要是因为它意味着平均而言你调用函数的 80% 都可以是虚函数而程序的整体性能不会受到丝毫可察觉的影响。在你为是否承受得起虚函数的代价而愁白了头之前先做一件简单的事确保你关注的是那 20% 真正可能因这个决定而受影响的代码。另一个常见的问题是把所有成员函数都声明为虚函数。有时候这样做是对的——比如条款31中的接口类就是如此。但这也可能表明类设计者缺乏做出决断的底气。有些函数在派生类中就是不该被重新定义遇到这种情况你就必须通过把它们声明为非虚函数来表达这一立场。幻想“只要别人愿意花时间去重新定义你所有的函数你的类就能满足所有人的所有需求”这对谁都没有好处。如果你有一个特化过程中的不变式那就大大方方地说出来切记1.接口继承不同于实现继承。在 public 继承下派生类总是继承基类的接口。2.纯虚函数只指定接口继承。3.普通非纯虚函数指定接口继承以及默认实现继承。4.非虚函数指定接口继承以及强制实现继承。