本文初发于 “天目中云的小站”,同步转载于此。
条款35 : 考虑virtual函数以外的其他选择
我们都知道使用virtual函数是有代价的, 它会带来额外的开销, 譬如占用内存, 降低效率, 不好控制安全性等问题, 因此如果我们想构建一个逻辑缜密且标准的项目, 可以考虑一些virtual函数的替换方案, 或许会得到更强的安全性或更高的效率, 本条款将会介绍一些替换方案给你, 也许你会觉得复杂了许多, 但是这是在提升我们对各种程序设计方案的理解, 是对我们内核的修炼.
先引入书中的一个例子, 假设我们要建立一个游戏人物类, 基类希望用virtual函数继承一个计算人物健康程度的成员函数healthValue
, 我们之后都会围绕这个例子来讨论 :
class GameCharacter { // 游戏人物基类
public:
virtual int healthValue() const; // 返回一个人物健康值, 基类也会提供一个缺省版本的计算函数
...
};
这样的设计十分合理, 但不一定就是最可控最高效的方案, 让我们来考虑一些其他的解法 :
藉由Non-Vitual Interface手法实现Template Method模式
这个方案并没有不使用virtual函数, 而是将virtual函数从public/protect
转为了private
, 进而提升了vitual函数的可控性和安全性.
这一切的前提建立在C++允许private virtual函数可以被派生类继承并重写之上, 有了这个共识我们再来看下面的分析.
先把上面两个专有名词介绍一下 :
-
Template Method模式
一种行为型设计模式,用于定义一个算法的骨架,并允许派生类在不改变算法结构的前提下重新定义算法的某些步骤。
其内核在于 : 将算法的固定部分写在基类中,而将可变部分(即具体实现)延迟到派生类中. 就是说基类部分提供固定的整体流程, 做出一些必要的准备工作(如加解锁, 记录日志, 验证约束条件等), 然后具体计算方式由派生类实现.
-
Non-Vitual Interface(简称NVI, 非虚接口)
这是对上述设计模式的一种实现手法, 这种手法主张所有的virtual函数应该几乎总是private, 通过基类的non-virtual函数作为接口, 也就是
NVI
, 去通过动态绑定调用派生类的virtual函数, 以实现对virtual函数的控制与规范化.
让我们来看代码理解 :
class GameCharacter {
public:
int healthValue() const // 一个non-virtual函数
{
... // 事前工作 : 加锁/写日志/验证约束条件等
int retVal = doHealthValue(); // 真正的工作逻辑, 可以继承自派生类, 也可以使用自己的缺省版本
... // 事后工作 : 解锁/写日志等
return retVal;
}
...
private: // 在基类和派生类中都是private, 唯一通过基类的healthValue调用
virtual int doHealthValue() const // 可以被派生类重写
{
... // 缺省版本
}
};
这种设计模式可以确保virtual函数被正确合理的使用, 只要其内部工作逻辑正确.
如何使用? “令客户通过调用基类的public non-virtual成员函数间接调用派生类的private virtual成员函数”, 我们一般称这种public non-virtual成员函数
为virtual函数的外覆器(wrapper).
NVI
手法涉及在派生类内重新定义private virtual函数, 这种虚函数不能被任何对象调用, 但其内核是合理的, 书中认为 :
base class
保留诉说"函数何时被调用"的权利, 以控制调用.derived classes
则被赋予"如何实现机能"的控制能力, 以控制实现.
并且虽然NVI
手法主张都用private virtual函数, 但是也并非完全必要, 因为确实有些情况必须要直接调用(比如子类成员函数要先调用父类对应成员函数的设计), 我们可以灵活变通, 只要使用手法保持一致即可.
藉由function实现Strategy模式
相比于上一种方案其实还在使用virtual函数机制, 只是在将其变得更加可控安全, 这个方案则是完全摒弃了这种机制, 以一种另辟蹊径的方式实现了类似public virtual函数的功能.
还是先了解专有名词 :
-
Strategy模式
这也是一种行为型设计模式,定义了一系列算法(策略),并将每种算法封装起来,使它们可以互相替换而不影响使用这些算法的客户端代码. 其核心就在于, 通过将算法或行为抽象为独立的策略类,允许在运行时动态地更改对象的行为,而无需修改其代码.
-
function
这是
C++11
引入的新特性, 意在优化类函数方法的调用, 是一个"可调用物", 具体细节不再阐释. 这里主要讲解其如何实现Strategy
模式, 简单来说就是基类不再定义virtual函数, 而是定义一个non-virtual函数, 其内部调用一个函数方法, 这个函数方法实际是基类内部的一个function成员变量, 它可以是基类给出的缺省方法, 也可以是通过构造函数参数传入的外部function函数方法, 这种function可以被视为一种Strategy(策略). 光看比较难以理解, 我们看代码 :
class GameCharacter;
int defaultHealthCalc(const GameCharacter& gc); // 在外部写一个默认方法
class GameCharacter {
public:
// function的定义不再解释, 这里主要是通过typdef简化类型的书写
typedef std::function<int (const GameCharacter&)> HealthCalcFunc;
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc) // 这里指定默认方法, 也可以传入自定义的方法
: healthFunc(hcf)
{}
int healthValue() const
{ return healthFunc(*this); } // 这里调用内部的函数方法
...
private:
HealthCalcFunc healthFunc; // 保存function的成员变量
};
这样的实现方法实际上是将健康值的计算和类型本身完全解耦, 客户需要在使用时传入希望的健康值计算策略, 本质是将策略赋予某个对象, 而非virtual函数由类型决定策略, 让我们看如下代码 :
class EvilBadGuy: public GameCharacter { // 创建一个恶魔坏男孩的派生类
public:
explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc)
: GameCharacter(hcf) // 调用基类构造函数, 将方法传进去
{ ... }
};
int loseHealthQuickly(const GameCharacter&); // 快速流失血量的健康值计算方法
EvilBadGuy ebg(loseHealthQuickly); // 将上面自定义的健康值计算方法赋予创建出来的ebg对象
优势说明
这种方法虽然不及virtual函数来得简单便捷, 但运行效率和安全性上有了很大提升, 并且在很多方面都展现出了极为惊人的弹性! 让我们逐一见识 :
-
同一类型的不同实体可以有不同的健康计算函数.
int loseHealthQuickly(const GameCharacter&); // 快速流失健康值的健康值计算方法 int loseHealthSlowly(const GameCharacter&); // 缓慢流失健康值的健康值计算方法 EvilBadGuy ebg1(loseHealthQuickly); EvilBadGuy ebg2(loseHealthSlowly);
这样相同类型的不同对象就可以设置不同的计算方法了!
-
一个实体可以在不同的运行时期可以有不同的健康计算函数.
class GameCharacter { public: ... void setHealthCalculator(HealthCalcFunc hcf); ... }; // --------------------------------------// EvilBadGuy ebg(loseHealthSlowly); // 初始设置为缓慢流失 ebg.setHealthCalculator(loseHealthQuickly); // 例如中途ebg中毒了, 更改设置为快速流失
我们可以在
GameCharacter
中提供一个设置计算方法的函数, 这样便可以中途更改方法了! -
funtion本身可以带来极大的隐式转换弹性.
function可以接受几乎所有的可调用物, 包括普通函数, 仿函数, 成员函数, lambda表达式, 只要参数与返回值对应即可, 并且这里的对应是支持隐式转换的! 也就是说就算写的funtion类型是
function<int(const GameCharacter&)>
, 它依然可以接受一个short(const GameCharacter&)
类型的函数, 因为short可以隐式转换为int.这是非常强大的功能, 可以让我们面对不同的使用场景使用不同的方式传入计算方法, 让我们通过以下代码来深入了解 :
short calcHealth(const GameCharacter&); // 1.一个返回short类型的普通计算函数 struct HealthCalculator { int operator()(const GameCharacter&) const // 2.一个用于计算的仿函数 {...} }; class GameLevel { // 这个类代表游戏等级, 可能会根据不同的等级返回不同的计算策略 public: float health(const GameCharacter&) const; // 3.一个返回float类型的成员函数 ... };
于是我们可以这样子使用它们 :
class EvilBadGuy: public GameCharacter { // 恶魔坏男孩 ... }; class EyeCandyLady: public GameCharacter { // 大眼甜心 ... }; //-----------------------------------------// EvilBadGuy ebg1(calcHealth); // 1.传入普通函数 EyeCandyCharacter ecc1(HealthCalculator()); // 2.传入仿函数 GameLevel currentLevel; // 获取当前等级 ... EvilBadGuy ebg2( // 3.传入成员函数 // 因为成员函数有隐藏的this指针, 这里用bind将当前等级绑定到成员函数中 std::bind(&GameLevel::health, currentLevel, _1) ); EyeCandyCharacter ecc2([](const GameCharacter&){...}); // 4.传入lambda表达式
以上便是该手法的优势所在, 这些都是光凭virtual函数所无法实现的功能.
古典Strategy模式
这个是书中简单介绍的方法, 简单来说就是上一个方案的加重版, 原本只是传入一个健康计算函数, 现在改成传入一个健康计算类, 其可以派生出loseHealthSlowly
和loseHealthQuickly
等派生类, 实际就是将本体系中的virtual成员函数, 改为一个分离的继承体系中的virtual成员函数. 这个方案的优势在于更加体系化, 使其可能可以纳入外部现成的既有健康计算体系, 拿别人早就经过时间验证过的成果未必不是一种更安全更高效的方法. 代码如下, 其他不再详述 :
class GameCharacter;
class HealthCalcFunc { // 一个全新的健康值计算类体系
public:
...
virtual int calc(const GameCharacter& gc) const
{ ... }
...
};
HealthCalcFunc defaultHealthCalc;
class GameCharacter {
public:
explicit GameCharacter(HealthCalcFunc *phcf = &defaultHealthCalc)
: pHealthCalc(phcf)
{}
int healthValue() const
{ return pHealthCalc->calc(*this);} // 调用这个体系中的指定计算方法
...
private:
HealthCalcFunc *pHealthCalc;
};
请记住 :
- virtual函数固然有其便捷多态的优势所在, 但其依然会有内存花销, 运行花销, 安全性问题, 我们可以思考一些替换方案来解决这些问题并且带来一些独特的额外优势.
- 藉由
Non-Vitual Interface
手法实现Template Method
模式, 使得virtual函数的使用更加规范可控, 提高了安全性. - 藉由
function
实现Strategy
模式, 使得改为以策略赋予对象, 在使用时有巨大的弹性空间. 古典Strategy模式
可以引入一些前人早就构建好的体系, 基本可以保证安全高效.
by 天目中云