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

Effective C++ 学习笔记 条款35 考虑virtual函数以外的其他选择

假设你正在开发一款电子游戏,为游戏中的角色设计一个继承体系。你的游戏属于那种打打杀杀的类型,角色受伤或健康状况下降是常事。因此你决定提供一个名为 healthValue 的成员函数,返回一个整数表示角色的健康程度。由于不同角色可能以不同方式计算自己的健康值,将 healthValue 声明为虚函数似乎是顺理成章的设计:

healthValue 未声明为纯虚函数,这一事实表明存在一个计算健康的默认算法(参见条款34)。

这确实是一种显而易见的设计方式,而从某种意义上说,这也正是它的弱点。正因为这种设计太过明显,你可能不会对它的替代方案给予充分的考虑。为了帮助你跳出面向对象设计的思维定势,让我们来探讨几种解决这个问题的其他方法。

通过非虚接口实现的模板方法模式

我们先从一种有趣的学派开始。该学派认为虚函数几乎总是应该为私有(private)的。遵循这一学派的设计者会建议,更好的设计是保留 healthValue 作为公有成员函数,但将其改为非虚,并让它调用一个私有虚函数来完成实际工作,比如 doHealthValue:


在这段代码中(以及本条款后续所有代码中),我都在类定义内部展示了成员函数的函数体。如条款30所述,这样做会隐式地将它们声明为内联。我采用这种方式展示代码,纯粹是为了让读者更容易理解设计意图。这些设计与是否内联无关,所以请不要认为成员函数定义在类内部有什么特殊含义——它并没有。

这种基本设计——让客户通过公有的非虚成员函数间接调用私有虚函数——被称为非虚接口(NVI)惯用法。它是更通用的设计模式——模板方法模式(Template Method pattern)——的一个具体体现(这个模式的名字虽然叫“模板方法”,但跟C++模板没有关系)。我把这个非虚函数(比如 healthValue)称为虚函数的包装器(wrapper)。

NVI 惯用法的一个优势,可以从代码中“做事前工作”和“做事后工作”的注释看出来。这些注释标明了在实际干活的那个虚函数被调用之前和之后,一定会被执行的代码段。这意味着包装器保证了在虚函数被调用之前,正确的上下文已被设置好;调用结束后,上下文会被清理干净。比如,“事前”工作可以包括加锁、写日志、验证类不变式和函数前置条件是否满足等;“事后”工作可以包括解锁、验证函数后置条件、重新检查类不变式等。如果让客户直接调用虚函数,这些工作就很难做得周全。

你可能会想到一个问题:NVI 惯用法要求派生类重新定义私有的虚函数——也就是重新定义一个它们自己无法调用的函数!这在设计上并不矛盾。重新定义虚函数,指定的是“如何做”某件事;而调用虚函数,指定的是“何时”做这件事。这两个关注点是独立的。NVI 惯用法允许派生类重新定义虚函数,从而让它们控制功能的实现方式,但基类保留了决定函数何时被调用的权利。这乍看可能有些奇怪,但 C++ 允许派生类重新定义继承来的私有虚函数,这条规则其实是完全合理的。

在NVI惯用法中,虚函数并不一定非得是私有的。在某些类层次结构中,派生类的虚函数实现会期望调用其基类的对应版本(例如第120页的例子),为了让这种调用合法,虚函数就必须是 protected 而非 private。有时虚函数甚至必须是公有的(例如多态基类中的析构函数——参见条款7),这种情况下NVI惯用法就不太适用了。

通过函数指针实现的策略模式

NVI惯用法是公有虚函数的一个有趣替代方案,但从设计角度来看,它多少有点“换汤不换药”的意味——毕竟我们仍然在用虚函数来计算每个角色的健康值。一个更具突破性的设计思路是:角色的健康计算与角色类型无关——也就是说,这种计算完全可以独立于角色本身。例如,我们可以要求每个角色的构造函数接收一个指向健康计算函数的指针,然后调用该函数来完成实际的计算:


这种做法是另一种常见设计模式——策略模式(Strategy)的简单应用。相比基于 GameCharacter 继承体系中虚函数的做法,它具有一些有趣的灵活性:
1.同一角色类型的不同实例可以拥有不同的健康计算函数。例如:

2.特定角色的健康计算函数可以在运行时被更改。比如,GameCharacter 可以提供一个名为 setHealthCalculator 的成员函数,允许替换当前的健康计算函数。

另一方面,健康计算函数不再是 GameCharacter 继承体系中的成员函数,这意味着它无法特殊访问被计算对象的内部数据。例如,defaultHealthCalc 无法访问 EvilBadGuy 的非公开部分。如果角色的健康值仅凭其公有接口就能计算,那就不成问题;但如果精确计算需要非公开信息,就会遇到麻烦。事实上,只要用类外部的等效功能(比如通过非成员非友元函数或另一个类的非友元成员函数)来替换类内部的功能(比如通过成员函数),这个问题都可能出现。本条款后续所有替代设计方案都涉及使用 GameCharacter 继承体系之外的函数,因此这个问题会一直存在。

通常,让非成员函数能访问类非公开部分的唯一办法是削弱类的封装性。比如,类可以把这些非成员函数声明为友元,或者为它原本想隐藏的实现细节提供公有访问函数。使用函数指针而非虚函数的种种优势(比如支持每个对象拥有各自的健康计算函数,以及能在运行时更换这些函数)是否值得以降低 GameCharacter 的封装性为代价,这需要你在具体设计中权衡取舍。

通过 tr1::function 实现的策略模式

一旦你习惯了模板及其对隐式接口的使用(参见条款41),基于函数指针的做法就显得有些死板了。为什么健康计算器必须是一个函数,而不能是任何“行为像函数”的东西(比如函数对象)?如果必须是一个函数,为什么不能是成员函数?为什么返回值必须是 int,而不能是任何可转换为 int 的类型?

如果我们用 tr1::function 类型的对象(比如 healthFunc)来取代函数指针,这些限制就会消失。如条款54所述,tr1::function 对象可以持有任何可调用实体(即函数指针、函数对象或成员函数指针),只要其签名与预期兼容。下面就是我们刚才看到的设计,这次使用了 tr1::function:


如你所见,HealthCalcFunc 是 tr1::function 实例化后的一个 typedef,这意味着它就像一个泛化函数指针类型。仔细看看 HealthCalcFunc 的 typedef 定义了什么:

我在这里标出了这个 tr1::function 实例化的目标签名。该目标签名为“接受一个 const GameCharacter& 参数并返回 int 的函数”。一个此 tr1::function 类型(即 HealthCalcFunc 类型)的对象,可以持有任何与目标签名兼容的可调用实体。所谓兼容,是指该实体的参数可以隐式转换为 const GameCharacter&,且其返回类型可以隐式转换为 int。

与上一个设计(GameCharacter 持有函数指针)相比,这个设计几乎相同。唯一的区别是 GameCharacter 现在持有一个 tr1::function 对象——一个泛化的函数指针。这个改动如此之小,我甚至可以说它微不足道,但它的后果是:客户在指定健康计算函数时,获得了令人震惊的灵活性:



就我个人而言,tr1::function 所赋予的能力实在令人叹为观止,甚至让我全身为之一振。如果你还没这种感觉,可能是因为你正盯着 ebg2 的定义,琢磨着 tr1::bind 的调用是怎么回事。容我解释一下。

我们的意图是:计算 ebg2 的健康值时,使用 GameLevel 类中的 health 成员函数。然而,GameLevel::health 声明为接受一个参数(一个指向 GameCharacter 的引用),但实际上它接受两个参数,因为它还隐含了一个 GameLevel 参数——即 this 所指向的那个对象。而 GameCharacter 的健康计算函数只接受一个参数:即待计算健康值的那个 GameCharacter。如果我们要用 GameLevel::health 来计算 ebg2 的健康值,就必须对它做些“适配”,使它从接受两个参数(一个 GameCharacter 和一个 GameLevel)变成只接受一个参数(一个 GameCharacter)。在这个例子中,我们始终希望使用 currentLevel 作为 ebg2 健康计算中的 GameLevel 对象,所以我们把 currentLevel“绑定”为每次调用 GameLevel::health 计算 ebg2 健康值时所用的 GameLevel 对象。这就是 tr1::bind 调用所做的:它指明 ebg2 的健康计算函数应始终使用 currentLevel 作为 GameLevel 对象。

我跳过了大量细节,比如为什么 _1 表示“在调用 GameLevel::health 计算 ebg2 健康值时,使用 currentLevel 作为 GameLevel 对象”。这些细节本身没有太大启发意义,反而会分散你对核心观点的注意力——我的核心观点是:通过使用 tr1::function 而非函数指针,我们允许客户在计算角色健康值时使用任何兼容的可调用实体。这难道不是很酷吗?

“经典”策略模式

如果你是那种更看重设计模式而非“C++ 有多酷”的人,那么策略模式还有一种更传统的实现方式:将健康计算函数放到一个独立的继承体系中,作为其中的虚成员函数。最终的继承体系设计大致如下:

如果你不熟悉 UML 表示法,这张图表达的是:GameCharacter 是一个继承体系的根,其中 EvilBadGuy 和 EyeCandyCharacter 是派生类;HealthCalcFunc 是另一个继承体系的根,其派生类有 SlowHealthLoser 和 FastHealthLoser;每个 GameCharacter 类型的对象都包含一个指向 HealthCalcFunc 继承体系中某个对象的指针。

以下是相应的代码框架:


这种方法的优点是:熟悉“标准”策略模式实现的人一眼就能认出来,而且它还允许通过向 HealthCalcFunc 继承体系添加派生类来对现有的健康计算算法进行调整。

总结

本条款的核心建议是:在为要解决的问题寻找设计方案时,应当考虑虚函数的替代方案。下面快速回顾一下我们讨论过的几种替代方案:
1.使用非虚接口(NVI)惯用法,即模板方法设计模式的一种形式,将公有非虚成员函数作为包裹层,围绕在访问权限较低的虚函数外围。

2.用函数指针数据成员取代虚函数,这是策略设计模式的一种精简体现。

3.用 tr1::function 数据成员取代虚函数,从而允许使用任何签名与你所需兼容的可调用实体。这同样是策略设计模式的一种形式。

4.用另一个继承体系中的虚函数来替代当前继承体系中的虚函数。这是策略设计模式的传统实现方式。

这并非虚函数替代方案的完整清单,但已足以让你相信——虚函数确实有替代方案可循。此外,从它们各自的优缺点来看,你也确实应该认真考虑这些替代方案。

为避免陷入面向对象设计的思维定势,不妨时不时猛打一把方向盘。还有很多别的路可走,花些时间去探索它们是值得的。

切记:
1.虚函数的替代方案包括 NVI 惯用法以及策略设计模式的多种变体。NVI 惯用法本身也是模板方法设计模式的一个实例。

2.将功能从成员函数移到类外部的一个缺点是,非成员函数无法访问类的非公有成员。

3.tr1::function 对象的行为类似于泛化函数指针。这类对象支持所有与给定目标签名兼容的可调用实体。

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

相关文章:

  • 大模型应用国产数据库首选:阿里云 PolarDB-X 一体化支撑实践
  • 伯爵中国官方售后服务中心|服务热线及网点地址权威信息通告(2026年7月更新) - 亨得利钟表维修中心
  • 自制交流线断点检测器:电磁感应原理与实用制作指南
  • 加密通讯原理与应用:从生活场景到技术实践
  • 2024年Web框架趋势:HTMX、Astro、Wasp与Solid.js解析
  • STM32内部温度传感器原理与应用指南
  • Analyzing Multi-Head Self-Attention: Specialized Heads Do the Heavy Lifting, the Rest Can Be Pruned
  • 武汉宝珀回收价格查询及各大回收平台实测排行(2026年7月最新数据) - 收的高名表回收平台
  • Chomsky(乔姆斯基)将文法分为四类
  • Node.js安装与卸载完全指南:从基础到多版本管理
  • 伯爵中国官方专柜服务热线权威信息声明(2026年7月最新) - 亨得利官方服务中心
  • Windows WSL2环境下Hermes Agent安装与优化指南
  • 从代码到认知:WSaiOS SDK的范式革命与认知系统构建理论
  • GPT-5.6多智能体协作技术解析与开发实践指南
  • 2026年7月农村四合院别墅/江西乡村别墅设计工程公司哪家权威_江西筑福家科技有限公司 - 品牌宣传支持者
  • Godot游戏逆向工程:从PCK包到可编辑项目的完整恢复指南
  • EDA元件Designator与Comment属性详解与应用技巧
  • C++二维数组鞍点查找算法详解:从原理到代码实现
  • 后端SQL数据加密实战:从原理到四种方案选型与AES-GCM实现
  • 起底“数字跟踪狂”:为什么你刚聊完天,手机App就精准推荐了广告?
  • Zpdf OCR:本地离线OCR工具部署与实战测试指南
  • 前端开发中的HTTP代理中间件实战指南
  • 部署Qwen2-VL-2B进行图片OCR识别及VLM推理
  • Agent Harness 案例集锦
  • 开放智能体(Agentic)系统的三重断层与契约化落地路径
  • Apollo配置中心实战:从零搭建到生产级应用全流程指南
  • 2026 年现阶段邢台优秀的铝箔袋订做厂家怎么联系,揭秘它:厨房收纳的终极秘密武器 - 行业甄选官
  • Reducing Transformer Depth on Demand with Structured Dropout 解读
  • C++ Vector深度解析:从内存模型到高效编程实践
  • ACM模式与核心代码模式:编程竞赛与面试的两种解题方式