当前位置: 首页 > news >正文

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.非虚函数指定接口继承以及强制实现继承。

http://www.jsqmd.com/news/1191189/

相关文章:

  • AI情感计算与拟人化交互:从技术原理到工程实践
  • AI编程助手选型指南:按开发摩擦类型匹配四大工具
  • C语言时间戳操作全解析:从获取到转换的完整指南
  • Kimi K2.5:原生多模态与并行智能体的操作系统级架构
  • C# 实战:用原生WebSocket API打造高性能聊天服务 (告别轮询,真香!)
  • 图像形态学处理
  • AI Token失控真相:工具链与业务流的时序错配
  • 2026年袋式/精密/保安/自清洗等工业过滤器品牌厂家:高效过滤与稳定的源头供应商 - 甄选服务推荐
  • RAG知识库维护实战:增量更新、灰度发布与可观测性设计
  • 从零搭建一台Prusa i3:300元预算的桌面制造方案
  • 奥赛级AI基准:面向高阶认知的零样本推理压力测试
  • TPS204xB电源开关在USB集线器热插拔保护与电源管理中的实践
  • 为了“和蒂法视频通话”,他做了一个开源数字人项目,2个月获得1.3K star!
  • 上海工字钢企业选择指南:精准匹配需求,解决选型难题
  • 2026年7月隧道紧急电话系统厂家推荐,隧道亮度检测器/区域控制器/洞内光强检测器,隧道紧急电话系统生产厂家哪家好 - 品牌推荐师
  • 模块电源(三):PCB Layout中的热管理与PDN阻抗平衡
  • 大模型API调用实战:从环境配置到生产部署完整指南
  • 异常检测(Anomaly Detection):从理论到实战的算法全景与应用解析
  • Cursor从SQLite到PostgreSQL迁移全链路实战(生产环境压测数据:迁移耗时缩短63%)
  • 2026年Windows 11运行《看门狗2》:兼容性优化与性能提升指南
  • Claude Cookbooks实战指南:从API集成到多模态应用开发
  • 豆包同声传译2.0:端到端语音到语音实时复刻技术解析
  • OBS高级计时器终极指南:6种专业模式让你的直播时间管理轻松自如
  • Windows下Obsidian多实例隔离实战:AppData沙箱方案
  • MoE架构与Router设计:大模型专家混合系统核心原理与实践
  • OpenClaw 网关持续离线如何修复?Win11 系统全套排错实操记录
  • C 语言集中实践全记录:顺序表 + 单链表原理实现与通讯录项目实战【附可运行源码】
  • 国内合规AI编程助手选型与VS Code集成实践
  • 宝玑中国官方售后服务中心|网点地址与客服电话权威信息通告(2026年7月更新) - 亨得利钟表维修中心
  • Camera Graph全域拓扑:镜像视界跨镜无缝接力,终结跨区目标追踪ID跳变难题