假设你正在开发一款电子游戏为游戏中的角色设计一个继承体系。你的游戏属于那种打打杀杀的类型角色受伤或健康状况下降是常事。因此你决定提供一个名为 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 对象的行为类似于泛化函数指针。这类对象支持所有与给定目标签名兼容的可调用实体。
Effective C++ 学习笔记 条款35 考虑virtual函数以外的其他选择
发布时间:2026/7/18 4:42:02
假设你正在开发一款电子游戏为游戏中的角色设计一个继承体系。你的游戏属于那种打打杀杀的类型角色受伤或健康状况下降是常事。因此你决定提供一个名为 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 对象的行为类似于泛化函数指针。这类对象支持所有与给定目标签名兼容的可调用实体。