条款32:确定你的 public
继承塑膜出 is-a 关系
Make sure public inheritance models “is-a.”
故事引入规则
在《Some Must Watch While Some Must Sleep》( W. H. Freeman and Company,1974)这本书中,作者William Dement说了一个故事,谈到他曾经试图让学生记下课程中最重要的一些教导。书上说,他告诉他的班级,一般英国学生对于发生在1066年的黑斯廷斯(Hastings) 战役所知不多。如果有学生记得多一些, Dement 强调,无非也只是记得1066这个数字而已。然后Dement继续其课程,其中只有少数重要信息,包括“安眠药反而造成失眠症”这类有趣的事情。他一再要求学生,纵使忘了课程中的其他每一件事, 也要记住这些数量不多的重要事情。Dement 在整个学期中不断耳提面命这样的话。
课程结束后,期末考的最后一道题目是:“写下你从本课程获得的一件永生不忘的事”。当Dement批改试卷,他目瞪口呆。几乎每一个人都写下 “1066”。
这个故事也是作者想对我们的声明的:以C++进行面向对象变成,最重要的一个规则是:public inheritance
(公开继承) 意味 “is-a” (是一种)的关系。这个规则要牢牢地烙印在你的心中!
案例说明
如果你令 class D ("Derived")
以 public
形式继承 classB ("Base")
,你便是告诉 C++ 编译器(以及你的代码读者)说,每一个类型为 D
的对象同时也是一个类型为 B
的对象,反之不成立。你的意思是 B
比 D
表现出更一般化的概念, 而 D
比 B
表现出更特殊化的概念。你主张“B
对象可派上用场的任何地方,D
对象一样可以派上用场” (译注:此即所谓Liskov Substitution Principle),因为每一个 D
对象都是一种(是一个) B
对象。反之如果你需要一个 D
对象,B
对象无法效劳,因为虽然每个 D
对象都是一个 B
对象,反之并不成立。
以上是书本原文描述,太绕, 看个范恩图理解一下:
总而言之:D
是一个B
,但每个B
不一定是D
C++ 对于“public
继承”严格奉行上述见解。考虑以下例子:
class Person { ... };
class Student: public Person { ... };
根据生活经验我们知道,每个学生都是人,但并非每个人都是学生。这便是这个继承体系的主张。我们预期,对人可以成立的每一件事——例如每个人都有生日——对学生也都成立。但我们并不预期对学生可成立的每一件事——例如他或她注册于某所学校——对人也成立,人的概念比学生更一般化,学生是人的一种特殊形式。
再用范恩图辅助了解一下:
小结上代码
综上所述,C++ 领域中,任何函数如果期望获得一个类型为 Person
或者(pointer-to-Person
或 reference-to-Person
)的实参,都也愿意接受一个 Student
对象(或pointer-to-Student
或 reference-to-Student
):
void eat(const Person& p); //任何人都会吃
void study(const Student& s); //只有学生能到校学习
Person p; //p是人
Student s; //s是学生
eat(p); //没问题,p是人
eat(s); //没问题,s是学生而且学生也是人(is-a)
study(s); //没问题,s是个学生
study(p); //错误!p不是学生
这个论点只对 public
继承才成立。只有当 Student
以 public
形式继承 Person
, C++ 的行为才会如我所描述。private
继承的意义于此完全不同(条款39讲到),甚至还有 protected
继承,作者在原文说:那是一种其意义至今仍然困惑我的东西。
公有继承用法
public
继承和 is-a之间的等价关系听起来颇为简单,但有时候你的直觉可能会误导你,举个鸟的例子:
企鹅(penguin)是一种鸟,这是事实,鸟可以飞,这也是事实。如果我们以 C++ 描述这层关系。结果:
class Bird
{
public:
virtual void fly(); //鸟可以飞
...
};
class Penguin: public Bird //企鹅是一种鸟
{
...
};
这个体系说企鹅可以飞,但在现实中这不成立!!!
在这个例子中,我们成了不严谨语言(原文说:英语)下的牺牲品。当我们说鸟会飞的时候,我们真正的意思并不是说所有的鸟都会飞,我们要说的只是一般的鸟都有飞行能力。
企鹅不会飞
如果谨慎一点,我们应该承认一个事实:有数种鸟不会飞。我们来到以下继承关系,它塑膜出较佳的真实性:
class Bird
{
.... //没有声明 fly 函数
};
class FlyingBird:public Bird
{
public:
virtual void fly();
...
};
class Penguin:public Bird
{
... //没有声明 fly 函数
};
这样的继承体系比原先的设计更能忠实反映我们真正的意思。
即便如此,此刻我们仍然未能完全处理好这些鸟事,因为对某些软件系统而言,可能不需要区分会飞的鸟和不会飞的鸟。如果你的程序忙着处理鸟喙和鸟翅,完全不在乎飞行,原先的 “双classes
继承体系” 或许就相当令人满足了。
这反映出一个事实:世界上并 不存在 一个“适用于所有软件”的完美设计。所谓最佳设计,取决于 系统希望做什么事,包括现在与未来。如果你的程序对飞行一无所知,而且也不打算未来对飞行“有所知”,那么不去区分会飞的鸟和不会飞的鸟,不失为一个完美而有效的设计。实际上它可能比“对两者做出区隔”更受欢迎,因为这样的区隔在你企图塑模的世界中并不存在。
企鹅会飞,但那是错的!
另有一种思想派别处理我所谓 “所有的鸟都会飞,企鹅是鸟,但是企鹅不会飞,喔欧” 的问题,就是为企鹅重新定义 fly
函数,令它产生一个 运行期 错误:
void error(const std::string& msg); //定义于另外某处
class Penguin": public Bird
{
public:
virtual void fly() {error("Attempt to make a penguin fly!");}
}
很重要的是,你必须认知这里所说的某些东西可能和你所想的不同。这里并不是说“企鹅不会飞”,而是说“企鹅会飞,但尝试那么做是一种错误”。
如何描述其间的差异?从错误被侦测出来的时间点观之,“企鹅不会飞”这一限制可由编译期强制实施,但若违反“企鹅尝试飞行,是一种错误”这一条规则,只有运行期才能检测出来。
为了表现 “企鹅不会飞,就这样” 的限制,你不可以为 Penguin
定义 fly 函数:
class Bird
{ ... }; // 没有声明 fly 函数
class Penguin: public Bird
{ ... }; // 没有声明 fly 函数
现在如果你试图让企鹅飞,编译器会对你的背信加以谴责,编译报错!:
Penguin p;
p.fly(); //错误!
这和采取 “令程序于运行期发生错误” 的解法极为不同,若以那样的做法,编译器不会对 p.fly
调用式发出任何抱怨,条款18说过:好的接口可以防止无效的代码通过编译,因此你应该宁可采取 “在编译器拒绝企鹅飞行” 的设计,而不是 “只在运行期才能侦测它们” 的设计。
鸟类例子可能还不够清晰,我们来考虑一下数学问题:正方形和矩形之间可能有多么复杂?
回答这个简单的问题:class Square
应该以 public
形式继承 class Rectangle
吗?
“咄!” 你说,“当然应该如此!每个人都知道正方形是一种矩形,反之则不一定”,这是真理,至少学校是这么教的。但是我不认为我们还在象牙塔内。
考虑这段代码:
class Rectangle
{
public:
virtual void setHeight(int newHeight);
virtual void setWidth(int newWidth);
virtual int height() const; //返回当前值
virtual int width() const;
...
};
void makeBigger(Rectangle& r) //这个函数用以增加 r 的面积
{
int oldHeight = r.height();
r.setWidth(r.with() + 10); //为 r 的宽度加10
assert(r.height() == oldHeight); //判断 r 的高度是否未曾改变
}
很显然,上述的 assert
结果永远为真,因为 makeBigger
只改变 r
的宽度;r
的高度从未改变。
现在考虑这段代码,其中使用 public
继承,允许正方形被视为一种矩形:
class Square: public Rectangle { ... };
Square s;
...
assert(s.width() == s.height()); //对所有正方形一定为真
makeBigger(s); //由于继承,s 是一种(is-a)矩形,所以我们可以增加其面积
assert(s.width() == s.height); //对所有正方形仍然该为真
这也很明显,第二个 assert
结果也永远为真,因为根据定义,正方形读的宽度和其高度相同。
但现在我们遇上一个问题,我们如何调解下面的各个 assert
判别式:
- 调用
makeBigger
之前,s
的高度和宽度相同 - 在
makeBigger
函数内,s
的宽度改变,但高度不变 makeBigger
返回之后,s
的高度再度和其宽度相同(注意s
是以*by reference
* 方式传递给makeBigger
,所以makeBigger
修改的是s
自身,而不是s
副本)
总结
欢迎来到“public
继承”的精彩世界。你在其他领域(包括数学)学习而得的直觉,在这里恐怕无法如预期般地帮助你。本例的根本困难是,某些可施行于矩形身上的事情(例如宽度可独立于其高度被外界修改)却不可施行于正方形身上(宽
度总是应该和高度一样)。
但是 public
继承主张,能够施行于 base class
对象身上的每件事情,每件事情唷,也可以施行于 derived class
对象身上。在正方形和矩形例子中(另一个类似例子是条款38的 sets
和 lists
),那样的主张无法保持,所以 public
继承塑模它们之间的关系并不正确。编译器会让你通过,但是一如我们所见,这并不保证程序的行为正确。
就像每一位程序员一定学过的(某些人也许比其他人更常学到):代码通过编译并不表示就可以正确运作。
不要因为你发展经年的软件直觉在与面向对象观念打交道的过程中失去效用,便心慌意乱起来。那些知识还是有价值的,但现在你已经为你的“设计”军械库加上继承(inheritance)这门大炮,你也必须为你的直觉添加新的洞察力,以便引导你适当运用“继承”这一支神兵利器。
当有一天有人展示一个长达数页的函数给你看,你终将回忆起“令 Penguin
继承 Bird
,或是令 Square
继承 Rectangle
"的概
念和趣味;这样的继承有可能接近事实真象,但也有可能不。
is-a并非是唯一存在于 classes
之间的关系。另两个常见的关系是has-a (有一个)和 is-implemented-in-terms-of (根据某物实现出)。这些关系将在条款38和39讨论。将上述这些重要的相互关系中的任何一个误塑为 is-a而造成的错误设计,在 C++ 中并不罕见,所以你应该确定你确实了解这些个“ classes
相互关系”之间的差异,并知道如何在 C++ 中最好地塑造它们。
请记住
- “
public
继承” 意味is-a。适用于base classes
身上的每一件事一定也适用于derived classes
身上,因为每一个derived class
对象也都是一个base class
对象
条款33:避免遮掩继承而来的名称
Avoid hiding inherited names.
简单的遮掩案例
关于“名称”,莎士比亚说过这样一句话:“ 名称是什么呢?”他问,“一朵玫瑰叫任何名字还是一样芬芳。”吟游诗人也写过这样的话:‘
“偷了我的好名字的人…害我变得好可怜。”完全正确。这把我们引到了C++“继承而来的名称”。
这个题材和继承其实无关,而是和作用域(scopes)有关。我们都知道在诸如这般的代码中:
int x; // global 变量
void someFunc()
{
double x; // local 变量
std::cin >> x; // 读一个新值赋予local变量x
}
这个读取数据的语句指涉的是 local 变量 x,而不是 global 变量 x
,因为内层作用域的名称会遮掩(遮蔽)外围作用域的名称。我们可以这样看本例的作用域形势:
当编译器处于 someFunc
的作用域内并遭遇名称 x
时,它先在 local 作用域内查找是否有什么东西带着这个名称。如果找到了就不再找其他作用域,本例的 someFunc
的 x
是 double
类型而 global x
是 int
类型,但那不要紧。
C++ 的名称遮掩规则(name-hiding rules)所做的唯一事情就是:遮掩名称。至于名称是否应和相同或不同类型,并不重要。本例中一个名为 x
的 double
遮掩了一个名为 x
的 int
。
继承中的遮掩
导入继承后,我们可以知道,当位于一个 derived class
成员函数内指涉(refer to)base class
内的某物(也许是个成员函数、typedef
、或成员变量)时,编译器可以找出我们所指涉的东西,因为 derived classes
继承了声明于 base classes
内的所有东西。实际运作方式是,derived class
作用域被嵌套在 base class
作用域内,像这样:
class Base
{
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf2();
void mf3();
...
};
class Derived: public Base
{
public:
virtual void mf1();
void mf4();
...
};
此例内含一组混合了 public
和 private
名称,以及一组成员变量和成员函数名称。这些成员函数包括 pure virtual
,impure virtual
和 non-virtual
三种,这是为了强调我们谈的是名称,和其他无关。这个例子也可以加入各种类型:enums
,nested classes
和 typedefs
,这些东西是什么不重要,我们主要关注这些东西的名称。
本条例使用单一继承,然而一旦了解单一继承下发生的事,很容易就可以推想 C++ 在多重继承下的行为。
假设 derived class
内的 mf4
的实现码部分像这样:
void Derived::mf4()
{
...
mf2();
...
}
当编译器看到这里使用名称 mf2
,必须估算它指涉(refer to)什么东西。编译器的做法是查找各作用域,看看有没有某个名为 mf2
的声明式。首先查找 local 作用域(也就是 mf4
覆盖的作用域),在那儿没找到任何东西名为 mf2
。于是查找其外围作用域,也就是 class Derived
覆盖的作用域。还是没找到任何东西名为 mf2
,于是再往外围移动,本例为 base class
。在那儿编译器找到一个名为 mf2
的东西了,于是停止查找。如果 Base
内还是没有 mf2
,查找动作便继续下去,首先找内含 Base
的那个 namespace(s)
的作用域(如果有的话),最后往 global 作用域找去。
刚才我描述的程序虽然精确,但范围不够广。我们的目标并不是为了 知道撰写编译器必须实践的名称查找规则,而是希望知道足够的信息,用以避免发生让人不快的惊讶。对于后者,现在我们有了丰富的信息。
再考虑前一个例子,这次让我们重载 mf1
和 mf3
,并且添加一个新版 mf3
到 Derived
去。如条款36所说,这里发生的事情是:Derived
重载了 mf3
,那是一个继承而来的 non-virtual
函数。这会使整个设计立刻显得疑云重重,但为了充分认识继承体系内的 “名称可视性”,我们暂时安之若素。
class Base
{
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf1(int);
virtual void mf2();
void mf3();
void mf3(double);
...
};
class Derived: public Base
{
public:
virtual void mf1();
void mf3();
void mf4();
};
这段代码的行为会让每一位第一次面对它的 C++ 程序员大吃一惊!以作用域为基础的 “名称掩盖规则” 并没有改变,因此 base class
内所有名为 mf1
和 mf3
都被derived class
内的 mf1
和 mf3
函数遮掩掉了。从名称查找观点来看,Base::mf1
和 Base::mf3
不再被 Derived
继承。
Derived d;
int x;
....
d.mf1(); //没问题,调用 Derived::mf1
d.mf1(x); //错误!因为 Derived::mf1 遮掩了 Base::mf1
d.mf2(); //没问题,调用 Base mf2
d.mf3(); //没问题,调用 Derived::mf3
d.mf3(x); //错误!因为 Derived::mf3 遮掩了 Base::mf3
如你所见,上述规则都适用,即使 base classes
和 derived classes
内的函数有不同的参数类型也适用,而且不论函数是 virtual
或non-virtual
一体适用。这和本条款一开始展示的道理相同,当时函数 someFunc
内的 double x
遮掩了 global 作用域内
的 int x
,如今 Derived
内的函数 mf3
遮掩了一个名为 mf3
但类型不同的 Base
函数。
这些行为背后的基本理由是为了防止你在程序库或应用框架(application framework)内建立新的 derived class
时附带地从疏远的 base classes
继承重载函数。
实际上如果你正在使用 public
继承而又不继承那些重载函数,就是违反 base
和 derived classes
之间的 is-a 关系,而条款32说过 is-a 是 public
继承的基石。
因此如果你又想继承重载函数,那就是与 C++ 对 “继承而来的名称” 的缺省遮掩行为,背道而驰。但是有办法解决:
用 using
声明式实现
class Base
{
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf1(int);
virtual void mf2();
void mf3();
void mf3(double);
};
class Derived: public Base
{
public:
using Base::mf1; //让 Base class 内名为 mf1 和 mf3 的所有东西
using Base::mf3; //让 Derived 作用域内都可见(并且 public)
virtual void mf1();
void mf3();
void mf4();
...
};
现在继承机制将一如既往地运作:
Derived d;
int x;
...
d.mf1(); //仍然没问题,仍然调用 Derived::mf1
d.mf1(x); //现在没问题了,调用 Base::mf1
d.mf2(); //仍然没问题,仍然调用 Base::mf2
d.mf3(); //没问题,调用 Derived::mf3
d.mf3(x); //现在没问题了,调用 Base::mf3
这意味如果你继承 base class
并加上重载函数,而你又希望重新定义或覆写(推翻)其中一部分,那么你必须为那些原本会被遮掩的每个名称引入一个 using
声明式,否则某些你希望继承的名称会被遮掩。
有时候你并不想继承 base classes
的所有函数,这是可以理解的。但是在 public
继承下,这种操作违反了 public
继承所提出的“base
和 derived classes
之间的 is-a 关系”。
这也就是为什么上述
using
声明式被放在derived class
的public
区域的原因:base class
内的public
名称在publicly derived class
内也应该是public
。(继承过来的结果是在public
)
private
继承实现
例如假设 Derived
以 private
形式继承 Base
,而 Derived
唯一想继承的 mf1
是那个无参数版本。using
声明式在这里派不上用场,因为 using
声明式会令继承来的给定名称的所有同名函数在 derived class
中都可见。
然而我们可以使用不同的技术实现这个需求,即一个简单的转交函数(forwarding function)这里就用到了 private
继承:
class Base
{
public:
virtual void mf1() = 0;
virtual void mf1(int);
... //与前同
};
class Derived: private Base
{
public:
virtual void mf1() //转交函数(forwarding function);
{ Base::mf(); } //暗自成为 inline(条款30)
...
};
...
Derived d;
int x;
d.mf1(); //很好,调用的是 Derived::mf1
d.mf1(x); //错误! Base::mf1() 被遮掩了
inline
转交函数的另一个用途是为那些不支持 using
声明式的老旧编译器另辟新路,将继承而得的名称汇入 derived class
作用域内。(不能使用using
不太正确)
以上就是继承和名称遮掩的全部内容,但是当继承结合 templates
我们又将面对 “继承名称被遮掩” 的一个全然不同的形式。关于 “以角括号定界” 的所有东西,详见条款43.
请记住
derived class
内的名称会遮掩base classes
内的名称。在public
继承下从来没有人希望如此。- 为了让被遮掩的名称再见天日,可使用
using
声明式或转交函数(forwarding functions)。
条款34:区分接口继承和实现继承
Differentiate between inheritance of interface and inheritance of implementation.
问题引入
表面上直截了当的 public
继承概念,经过更严密的检查之后,发现它由两部分组成:函数接口(function interfaces)继承和函数实现(function implementations)继承。这两种继承的差异,就像前面有些条例提到的函数声明于函数定义之间的差异。
在设计 class
的时候,有时候希望 derived classes
只继承成员函数的接口(也就是声明);有时候你又希望 derived classes
同时继承函数的接口和实现,但又希望能够重写(override)它们所继承的实现;又有时候你希望 derived classes
同时继承函数的接口和实现,并且不允许重写任何东西。
案例说明接口和实现
为了更好描述上述表达的差异,用一个展现绘图程序中各种几何形状的 class
继承体系:
class Shape
{
public:
virtual void draw() const = 0;
virtual void error(const std::string& msg);
int objectID() const;
...
};
class Rectangle: public Shape { ... };
class Ellipse: public Shape { ... };
Shape
是个抽象 class
;它的 pure virtual
函数 draw
使它称为一个抽象 class
所以客户不能够创建 Shape class
的实体,只能创建其 derived classes
的实体,尽管如此,Shape
韩式强烈影响了所有以 public
形式继承它的 derived classes
,因为:
- 成员函数的接口总是会被继承。(条款32所说)
public
继承意味着 is-a(是一种),所以对base class
为真的任何事情一定也对其derived classes
为真。因此如果某个函数可施行于某个class
身上,一定也可施行于其derived classes
身上。
Shape class
其中声明了三个函数:
函数名 | 意义 | virtual 类型 |
---|---|---|
draw | 于某个隐喻的视屏中画出当前对象 | pure virtual 函数 |
error | 准备让那些 “需要报导某个错误” 的成员函数调用 | (非纯)impure virtual 函数 |
objectID | 返回当前对象的一个独一无二的整数识别码 | non-virtual 函数 |
pure virtual
函数
class Shape
{
public:
virtual void draw() const = 0;
...
};
pure virtual
函数有两个最突出的特性:它们必须被任何 “继承了它们” 的具象 class
重新声明,而且它们在抽象 class
中通常没有定义。把这两个性质摆在一起,你就会明白:
- 声明一个
pure virtual
函数的目的是为了让derived classes
只继承函数接口
这对 Shape::draw
函数是再合理不过的事了,因为所有 Shape
对象都应该是可绘出的,这是对于绘图程序来说合理的需求。
但
Shape class
无法为此函数提供合理的缺省实现,毕竟椭圆形绘法迥异于矩形绘法。
Shape::draw
的声明乃是对具象 derived classes
设计者说 “你必须提供一个 draw
函数,但我不干涉你怎么实现它”。
意外的是!!我们可以为 pure virtual
函数提供定义,也就是说你可以为 Shape::draw
供应一份实现代码,C++并不会发出怨言,但调用它的唯一途径是 “调用时明确指出其class
名称”:
Shape* ps = new Shape; //错误!Shape是抽象的
Shape* ps1 = new Ractangle; //没问题
ps1 -> draw(); //调用 Rectangle::draw
Shape* ps2 = new Ellipse; //没问题
ps2 -> draw(); //调用 Ellipse::draw
ps1 -> Shape::draw(); //调用 Shape::draw
ps2 -> Shape::draw(); //调用 Shape::draw
一般而言,这项性质用途有限,但是后面告诉你们,它可以实现一种机制:为简朴的(非纯)impure virtual
函数提供更平常更安全的缺省实现。
简朴的 impure virtual
函数
非纯虚函数的设计:derived class
继承其函数接口,但 impure virtual
函数会提供一份实现代码 derived classes
可能覆写(override)它。
- 声明简朴的(非纯)
impure virtual
函数的目的是,让derived classes
继承该函数的接口和缺省实现
考虑 Shape::error
这个例子:
class Shape
{
public:
virtual void error{const std::string& msg};
...
};
这个接口表示,每个 class
都必须支持一个 “当遇上错误时可调用” 的函数,但每个 class
可自由处理错误。
如果某个 class
不想针对错误做出任何特殊行为,它可以退回到 Shape class
提供的从缺省错误处理行为。也就是说 Shape::error
的声明式告诉 derived class
的设计者,“你必须支持一个 error
函数”,但如果你不想自己写一个,可以使用 Shape class
提供的缺省版本。
但是允许 impure virtual
函数同时指定函数声明和函数缺省行为,有可能造成危险。
飞机的继承体系案例
给出条件:
- 该共公司只有 A 型和 B 型两种飞机
- 两者都以相同方式飞行
由上设计出以下的继承体系:
class Airport { ... }; //用于表现机场
class Airplane
{
public:
virtual void fly(const Airport& destination);
...
};
void Airplane::fly(const Airport& destination)
{
//缺省代码,将飞机飞至指定的目的地
}
class ModelA: public Airplane { ... };
class ModelB: public Airplane { ... };
- 不同飞机有不同的
fly
实现,所以Airplane::fly
被声明为virtual
- 为了避免在
ModelA
和ModelB
中撰写相同代码,缺省飞行由Airplane::fly
提供,ModelA
和ModelB
继承Airplane::fly
这样设计的好处:这是典型的面向对象设计,两个 classes
的共同性质放到 base class
中,再被 classes
继承,突出共同性质,避免代码重复,提升未来的强化能力,减缓长期维护所需的成本。
但是,当飞机(class
类型)增加时,会遇到问题,比如说当飞行方式不同时:
若增加一个 C 类型飞机,针对其添加一个 class
,若 C 类型飞机的 class
中没有添加 fly
的具体实现,那么在直接调用 ModelC
时就会调用原有的飞行方式来飞行 ModelC
。比如:
class ModelC: public Airplane
{
... //未声明fly函数
};
然后代码中(可以允许)出现这种情况:
Airport PDX(...);
Airplane* pa = new ModuleC;
...
ps->fly(PDX); //调用Airport::fly
第一种解决方案
让 derived classes
自己需要缺省实现的时候,再使用的方法来实现。这个方法在于切断“virtual
函数接口” 和其 “缺省实现” 之间的连接,比如说:
class Airplane
{
public:
virtual void fly(const Airport& destination) = 0;
...
protected:
void defaultFly(const Airport& destination);
};
void Airplane::defaultFly(const Airport& destination)
{
缺省行为,将飞机飞至指定的目的地
}
这里的 Airplane::fly
已经被改成一个纯虚函数,只提供飞行接口,其缺省行为在 Airplane class
中,但缺省函数是独立的 defaultFly
做一个 inline
调用:
class ModelA: public Airplane
{
public:
virtual void fly(const Airport& destination)
{ defaultFly(destination); }
...
};
class ModelB: public Airplane
{
public:
virtual void fly(const Airport& destination)
{ defaultFly(destination); }
...
};
这样写就不会让 Model C class
意外地继承不正确的 fly
实现代码。因为 Airplane
中的纯虚函数迫使 Model C
必须提供自己的 fly
:
class ModelC: public Airplane
{
public:
virtual void fly(const Airport& destination);
...
};
void ModelC::fly(const Airport& destination)
{
将C飞机飞至指定的目的地
}
现在 Airplane::defaultFly
是 protected
,因为它是 Airplane
及其 derived class
的实现细目。使用时,只知道飞机可以飞,但不知道怎么飞。
如果 defaultFly
必须是一个 non-virtual 函数,否则就会出现上面的类似问题,无限循环。
第二种解决方案
如果觉得定义太多函数和雷同名称的函数造成 class
命名空间污染,那么可以利用 “pure virtual 函数必须在 derived class
中重新声明,但它们也可以有自己的实现” 这个规则,比如:
class Airplane
{
public:
virtual void fly(const Airport& destination) = 0;
...
};
void Airplane::fly(const Airport& destination) //pure virtual 函数实现
{
缺省行为,将飞机飞至指定的目的地
}
class ModelA: public Airplane
{
public:
virtual void fly(const Airport& destination)
{ Airplane::fly(destination); }
...
};
class ModelB: public Airplane
{
public:
virtual void fly(const Airport& destination)
{ Airplane::fly(destination); }
...
};
class Modelc: public Airplane
{
public:
virtual void fly(const Airport& destination)
...
};
void ModelC::fly(const Airport& destination)
{
将飞机飞至指定目的地
}
这里用纯虚函数 Airplane::fly
替换了独立函数 Airplane::defaultFly
。本质上,现在的 fly
被分割为两个基本要素:
- 其声明部分表现的是接口(那是 derived classes必须使用的)
- 其定义部分则表现出缺省行为(那是 derived classes可能会使用的,但只有在它们明确提出申请时才会)
如果合并 fly
和 defalutFly
就丧失了 “让两个函数享有不同保护级别” 的机会:习惯被设为 protected
的函数(defaultFly
)如今成了 public
(因为它在fly
之中)
总结
最后我们来看下Shape
的非虚函数 objectID
:
class Shape
{
public:
int objectID() const;
...
};
如果成员函数是个非虚函数,一味着这个函数不打算在子类中有不同的行为。实际上一个 non-virtual 成员函数所表现的不变性(invariant)凌驾其特异性,因为它不论derived class 变得多么特异化,它的行为都不可以改变。就其自身而言:
- 声明 non-virtual函数的目的是为了令
derived classes
继承函数的接口及一份强制性实现。
条款36表明:由于 non-virtual 函数代表的意义是不变性凌驾特异性,所以它绝不该在 derived class 中被重新定义。
pure virtual函数、simple (impure) virtual 函数、non-virtual 函数之间的差异,使你得以精确指定你想要 derived classes
继承的东西:只继承接口,或是继承接口和一份缺省实现,或是继承接口和一份强制实现。
由于这些不同类型的声明,意味根本意义并不相同的事情,当你声明你的成员函数时,必须谨慎选择,这样做应该能够避免经验不足的 class
设计者最常犯的两个错误:
-
将所有函数声明为 non-virtual。这会让
derived classes
没办法特化,条款7说明了一些会带来的问题。但是这样做对于声明一个不做为base class
的class
是绝对可以的。但是这种声明如果不是忽略了 virtual 和 non-virtual函数之间的差异,就是过度担心 virtual 函数的效率成本,实际上任何class
如果打算用来作为一个base class
都会有若干 virtual 函数。 -
将所有成员函数声明为virtual。有时候这样做是正确的,例如条款31的 Interface classes。但是某些函数不该在
derived class
中被重新定义,你可以把它们声明为 non-virtual 没有绝对说你的class
适用于任何人,任何事,任何物,而使用(修改)的人只需花点时间重新定义你的函数就可以满足需求。如果你的不变性(invariant)凌驾特异性(specialization) ,别害怕说出来。
80-20法则
如果你关心 virtual 函数的成本,作者在本章末尾介绍了 80-20 法则(也可见条款30)。这个法则说,一个典型的程序有80号的执行时间花费在20号的代码身上。
此法则十分重要,因为它意味:平均而言你的函数调用中可以有 80% 是 virtual而不冲击程序的大体效率。所以当你担心是否有能力负担 virtual 函数的成本之前,请先将心力放在那举足轻重的 20% 代码上头,它才是真正的关键。
请记住
- 接口继承和实现继承不同。在
public
继承下,derived classes
总是继承base class
的接口。 pure virtual
函数只具体指定接口继承。- 简朴的(非纯)
impure virtual
函数具体指定接口继承及缺省实现继承。 non-virtual
函数具体指定接口继承以及强制性实现继承。
条款35:考虑 virtual
函数以外的其他选择
Consider alternativers to virtual functions.
案例引入
- 假设你正在写一个视频游戏软件,你打算为游戏内的 人物设计 一个继承体系。
- 你的游戏属于暴力砍杀类型,剧中人物被伤害或因其他因素而降低 健康状态 的情况并不罕见。
你因此决定提供一个成员函数 healthValue
,它会返回一个整数,表示人物的健康程度。由于不同的人物可能以不同的方式计算他们的健康指数,将healthvalue
声明为 virtual 似乎是再明白不过的做法:
class GameCharacter
{
public:
virtual int healthValue() const; //返回人物的健康指数;
... //derived classes 可重新定义它。
};
healthValue
并未被声明为 pure virtual ,这暗示我们将会有个计算健康指数的缺省算法(条款34)。
但是可以考虑用其他方案的设计,原文说的原因:
这的确是再明白不过的设计,但是从某个角度说却反而成了它的弱点。由于这个设计如此明显,你可能因此没有认真考虑其他替代方案。为了帮助你跳脱面向对象设计路上的常轨,让我们考虑其他一些解法。
籍由 Non-Virtual Interface 手法实现 Template Method 模式
从一个有趣的思想流派开始,这个流派主张的 virtual 函数应该几乎总是 private
。流派的拥护者建议,较好的设计是保留 healthValue
为 public
’ 成员函数,但让他成为 non-virtual 并调用一个 private virtual 函数(例如 doHealthValue
)进行实际工作:
class GameCharacter
{
public:
int healthValue() const //derived classes 不重新定义它 (条款36 不变性)
{
... //一些事前工作
int retVal = doHealthValue(); //做真正的工作
... //一些事后工作
return retVal;
}
...
private:
virtual int doHealthValue() const //derived classes 可重新定义它
{
... //缺省算法,计算健康指数
}
};
以上的 class
定义式,成员函数都成为 inline
。
这一基本设计,也就是“令客户通过 public non-virtual 成员函数间接调用 private virtual
函数”,称为 non-virtual interface (NVI) 手法。
它是所谓 Template Method设计模式 (与 C++ templates 并无关联)的一个独特表现形式。
本书把 non-virtual函数(healthValue
)称为 virtual 函数的外覆器(wrapper)。
优缺点和考虑
NVI 手法的一个优点:隐身在上述代码注释 “做一些事前工作” 和 “做一些事后工作” 之中。
那些注释用来告诉你当时的代码保证在 “virtual 函数进行真正工作之前和之后” 被调用。这意味外覆器(wrapper) 确保得以在一个 virtual 函数被调用之前设定好适当场景,并在调用结束之后清理场景。
“事前工作” 可以包括锁定互斥器(locking a mutex)、制造运转日志记录项(log entry)、验证class约束条件、验证函数先决条件等等。
“事后工作” 可以包括互斥器解除锁定(unlocking a mutex)、验证函数的事后条件、再次验证class
约束条件等等。
如果你让客户直接调用virtual
函数,就没有任何好办法可以做这些事。
注意:NVI 手法涉及在 derived classes
内重新定义 private virtual
函数。可以也可以不重新定义 若干个 derived classes
并不调用的函数!
“重新定义
virtual
函数”表示某些事“如何”被完成,“ 调用virtual
函数”则表示它“何时”被完成。这些事情都是各自独立互不相干的。
NVI 手法允许 derived classes
重新定义 virtual
函数,derived classes
可以控制 “如何实现机能” ,但 base class
保留 “函数何时被调用” 的权利。
上述方式,在C++的这种 “derived classes
可重新定义继承而来的 private virtual
函数” 的规则下完全合情合理。
应用场景
在 NVI 手法下其实没有必要让 virtual
函数一定得是 private
。某些 class
继承体系要求 derived class
在 virtual
函数的实现内必须调用其 base class
的对应兄弟(P120),而为了让这样的调用合法,virtual
函数必须是 protected
, 不能是 private
。有时候 virtual
函数甚至一定得是 public
(例如具多态性的 base classes
的析构函数-见条款7),这么一来就不能实施NVI手法了。
籍由 Function Pointers 实现 Strategy 模式
NVI 手法对 public virtual 函数而言,本质上还是使用 virtual
函数来计算每个人的健康指数。还有另一个设计主张 “人物健康指数的计算与人物类型无关”,这样的计算完全不需要 “人物” 这个成分。例如我们可能会要求每个人物的构造函数接受一个指针,指向一个健康计算函数,而我们可以调用该函数进行实际计算:
class GameCharacter; //前置声明(forward declaration)
//以下函数是计算健康指数的缺省算法
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter
{
public:
typedef int (*HealthCalcFunc) (const GameCharacter&);
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc):healthFunc(hcf)
{}
int healthValue() const
{ return healthFunc(*this); }
...
private:
HealthCalcFunc healthFunc;
};
这个做法是常见的 Stragtegy 设计模式的简单应用,拿它和 “植基于 GameCharacter
继承体系内之 virtual
函数” 的做法比较,它提供了某些有趣弹性:
- 同一人物类型之不同实体,可以有不同的健康计算函数。例如:
class EvilBadGuy: public GameCharacter
{
public:
explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc):GameCharacter(hcf)
{ ... }
...
};
int loseHealthQuickly(const GameCharacter&); //健康指数计算函数1
int loseHealthSlowly(const GameCharacter&); //健康指数计算函数2
EvilBadGuy ebg1(loseHealthQuickly); //相同类型的人物搭配
EvilBadGuy ebg2(loseHealthSlowly); //不同的健康计算方式
- 某已知人物之健康指数计算函数可在运行期变更,例如
GameCharacter
可提供一个成员函数setHealthCalculator
,用来替换当前的健康指数计算函数
换句话说。“健康指数计算函数不再是 GameCharacter
继承体系内的成员函数,“,这个事实说明,这些计算函数并未特别访问”即将被计算健康指数“的那个对象的 非public
部分(内部成分)。
总结
如果人物的健康完全可以通过 public
接口获取到的信息 来计算,这样做是没有问题的。但如果需要 non-public
信息进行精准计算,就有问题。
实际上当你将 class
内的某个机能,也许取自某个成员函数,替换为 class
外部的某个等价技能(non-mumber non-friend
或者另一个class
的 non-friend
成员函数),这些都存在争议,持续到这个条款后面的篇幅,因为往后的替代设计也都涉及使用 GameCharacter
继承体系外的函数。
一般而言,唯一能解决”需要以 non-mumber
函数访问 class
的 non-public
成分“的办法就是:弱化 class
的封装。比如说,class
可声明那个 non-member
函数为 friends
,或是为其实现的某一部分提供 public
访问函数(其他非 public
都隐藏起来 ),运用函数指针替换 virtual
函数,要看它带来的优点(比如:每个对象各有各自的计算函数,可在运行其改变计算函数)是否可以弥补缺点(可能降低了GameCharacter
封装性),这个是你根据每个设计情况不同的选择。
换句话说。“健康指数计算函数不再是 GameCharacter
继承体系内的成员函数,“,这个事实说明,这些计算函数并未特别访问”即将被计算健康指数“的那个对象的 非public
部分(内部成分)。
籍由 tr1::function 完成 Strategy 模式
一旦习惯了 templates
以及它们对隐式接口(条款41)的使用,基于函数指针的做法看起来便过分苛刻而死板!
这个替换方式解决的问题:
- 为什么要求“健康指数之计算”必须是个函数,而不能是某种“像函数的东西”(例如函数对象) 呢?
- 如果一定得是函数,为什么不能够是个成员函数?
- 为什么一定得返回
int
而不是任何可被转换为int
的类型呢?
如果我们不再使用函数指针,而是改用一个类型为 tr1::function
的对象,这些约束就全都挥发不见了。这样的对象可持有任何可调用物(callable entity
, 也就是函数指针、函数对象、或者成员函数指针),只要签名式兼容于需求端,则可以把刚才的设计改成使用 tr1::function
:
class GameCharacter; //如前
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter
{
public:
//HealthCalcFunc 可以是任何”可调用物“(callable entity),可被调用并接受任何兼容于 GameCharacter 之物,返回任何兼容于 int 的东西,如下
typedef std::tr1::function<int (const GameCharacter&)> HealthCalcFunc;
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc) : healthFunc(hcf)
{}
int healthValue() const
{ return healthFunc(* this); }
...
private:
HealthCalcFunc healthFunc;
};
HealthCalcFunc
是个 typedef
用来表现 tr1::function
的某个具现体,意味该具体现的行为像一般的函数指针:
std::tr1::function<int (const GameCharacter&)>
<int (const GameCharacter&)>
表示接受一个指向 const GameCharacter
(可隐式转换)的引用并返回 int
(可隐式转换),这个tr1::function
类型产生的对象可以持有任何与此签名兼容的可调用物(callable entity
)。
和函数指针的做法享笔,这个设计几乎相同,唯一不同就是 GameCharacter
持有一个 tr1::function
对象,相当于一个指向函数的泛化指针,这个改变很细小,当客户在“指定健康计算函数” 这件事上需要更惊人的弹性:
short calcHealth(const GameCharacter&); //健康计算函数。返回值类型为 non-int
struct HealthCalculator //为计算健康而设计的函数对象
{
int operator()(const GameCharacter&) const
{ ... }
};
class GameLevel
{
public:
float health(const GameCharacter&) const; //成员函数,用于计算健康 返回 non-int 类型
...
};
class EvilBadGuy: public GameCharacter //同前
{ ... };
class EyeCandyCharacter: public GameCharacter //另一个人物类型;假设其构造函数与 EvilBadGuy 同
{ ... };
EvilBadGuy ebg1(calcHealth); //人物1,使用某个函数计算健康指数
EyeCandyCharacter ecc1(HealthCalculator()); //人物2,使用函数对象计算健康指数
GameLevel currentLevel;
...
EvilBadGuy ebg2 //人物3,使用某个成员函数计算健康指数
(std::tr1::bind(&GameLevel::health, currentLevel, _1));
代码解析:
这段代码重点说明了:若以tr1::function
替换函数指针,可以实现允许客户在计算人物健康指数时,使用任何可兼容的可调用物(callable entity)。
古典的 Strategy 模式
传统(典型)的 Strategy 做法会将健康计算函数做成一个分离的继承体系中的 virtual
成员函数:
(不用关注UML 书上有解析)这个图是 GameCharacter
的某个继承体系的根类。
EvilBadGuy
和EyeCandyCharacter
都是GameCharacter
的derived classes
;
HealthCalcFunc
是另一个继承体系的根类,体系中的SlowHealthLoser
和FastHealthLoser
都是derived classes
;
每一个GameCharacter
对象都内含一个指针,指向一个来自HealthCalcFunc
继承体系的对象。
下面是对应的代码骨干:
class GameCharacter; //前置声明(forward declaration)
class HealthCalcFunc
{
public:
...
virtual int calc(const GameCharacter& gc) const
{ ... }
...
};
HealthCalcFunc defaultHealthCalc;
class GameCharacter
{
public:
explicit GameCharacter (HealthCalcFunc* phcf = &defaultHealthCalc): pHealthCalc()
{}
int healthValue() const
{ return pHealthCalc->calc(*this);}
...
private:
HealthCalcFunc* pHealthCalc;
};
这个解法的吸引力在于:熟悉Strategy模式的人很容易辨认它,而且它还提供“将一个既有的健康算法纳入使用”的可能性——只要为 HealthCalcFunc
继承体系添加一个 derived class
即可。
摘要
本条款的根本忠告是:当你为解决问题寻找某个设计方法时,不妨考虑 virtual
函数的替代方案。
下面总结我们验证过的几个替代方案:
- 使用
non-virtual interface
(NVI) 手法。那是 Template Method 设计模式的一种特殊形式。它以public non-virtual
成员函数包裹较低访问性(private
或protected
)的virtual
函数。 - 将
virtual
函数替换为 “函数指针成员变量”,这是Strategy 设计模式的一种分解表现形式。 - 以
tr1::function
成员变量替换virtual
函数,因而允许使用任何可调用物(callable entity) 搭配一个兼容于任何可调用物。这也是 Strategy 设计模式的某种形式。 - 将继承体系内的
virtual
函数替换为另一个继承体系内的virtual
函数,这是 Strategy 设计模式的传统实现手法。
以上还没有彻底详细地列出来 virtual
函数的所有替代方案,但我们应该都学到 virtual
有不少替代方案,而且各自有他们的优缺点,在使用的时候要考虑到这些。
请记住
virtual
函数的替代方案包括 NVI 手法及 Strategy 设计模式的多种形式。NVI 手法自身是一个特殊形式的 Template Method 设计模式。- 将机能从成员函数移到
class
外部函数,带来的一个缺点是,非成员函数无法访问class
的non-public
成员 tr1::function
对象的行为就像一般函数指针。这样的对象可接纳 “与给定之目标签名式(target signature)兼容 ” 的所有可调用物(callable entities)。