继承与面向对象设计

条款32:确定你的 public 继承塑模出 is-a 关系

以 C++ 进行面向对象编程,最重要的一个规则是:public 继承意味着 is-a 的关系。 Derived 对象同时也是一个类型为 Base 的对象,反之不成立。B 比 D 表现出更一般化的概念,而 D 比 B 表现出更特殊化的概念。

在 C++ 领域中,任何函数如果期望获得一个类型为 Base class 的实参,都也愿意接受一个 Derived class 的对象。

正方形是一种矩形,反之则不一定。但是某些可施行于矩形身上的事情(例如宽度可独立于其高度可被外界修改)却不可施行于正方形身上(高度总是应该和宽度一样)。所以 public 继承塑模它们之间的关系并不正确。代码通过编译并不表示就可以正确运作。

小结:

  • public 继承意味着 is-a。适用于 base classes 身上的每一件事情一定也适用于 derived classes 身上,因为每一个 derived class 对象也都是一个 base class 对象。

条款33:避免遮掩继承而来的名称

我们知道,内层作用域的名称会遮掩外围作用域的名称。

derived class 作用域被嵌套在 base class 作用域内。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

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();

...

};



Derived d;

int x;

d.mf1(); // 没问题 call Derived::mf1

d.mf1(x); // 错误!因为 Derived::mf1 遮蔽了Base::mf1

d.mf2(); // 没问题 call Base::mf2

d.mf3(); // 没问题 call Derived::mf3

d.mf3(x); // 错误!因为 Derived::mf3 遮蔽了Base::mf3

is-a 是 public 继承的基石,实际上如果你正在使用 public 继承而又不继承那些重载函数,就是违反 base 和 derived classes 之间的 is-a 关系。

使用 using 声明式可以推翻(override)C++ 对“继承而来的名称”的缺省遮掩行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

class Derived : public Base {

public:

using Base::mf1; // 让Base class 内名为 mf1 和 mf3 的所有东西

using Base::mf3; // 在 derived 作用域内都可见

virtual void mf1();

void mf3();

void mf4();

...

};

这次上面失败的调用就没问题了。这意味着如果你继承 base class 并加上重载函数,而你又希望重新定义或覆写其中一部分,那么你必须为那些原本会被遮掩的每个名称引入一个 using 声明式,否则某些你希望继承的名称会被遮掩。

小结:

  • derived class 内的名称会遮掩 base class 内的名称,在 public 继承下从来没有人希望如此。

  • 为了让被遮蔽的名称再见天日,可使用 using 声明式或转交函数(forwarding functions)。

条款34:区分接口继承和实现继承

身为 class 的设计者,有时候你会希望 derived class 只继承成员函数的接口(也就是声明);有时候你又会希望 derived class 同时继承函数的接口和实现,但又希望能够覆写它们所继承的实现;有时候你希望 derived class 同时继承接口和实现 ,并且不允许覆写任何东西。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

class Shape {

public:

virtual void draw() const = 0; // 纯虚函数使它成为一个抽象 class

virtual void error(const std::string& msg);

int objectId() const;

...

};

class Rectange : public Shape { ... };

class Ellipse : public Shape { ... };
  • 成员函数的接口总是会被继承。public 继承意味着 is-a,如果某个函数可作用于 base class 身上,一定可施行于其 derived class 身上。

  • 声明一个 pure virtual 函数的目的是为了让 derived class 只继承函数接口。pure virtual 函数的两个最突出特性:它们必须被任何继承了它们的具象类重新声明,而且它们在抽象类中通常没有定义。其实可以为 pure virtual 函数提供定义,但调用它的唯一途径是“调用时明确指出其class名称”。

  • impure virtual 函数的目的,是让 derived class 继承该函数的接口和缺省实现。imoure virtual 函数会提供一份实现 代码,derived class 可能覆写(override)它。

  • 声明 non-virtual 函数的目的,是令 derived class 继承函数的接口及一份强制性实现。non-virtual 函数意味着它并不打算在 derived class 中有不同的行为。

简单总结一下就是:

  • pure virtual :你必须提供一个 draw 函数,但我不干涉你怎么实现它。

  • impure virtual:你必须支持一个 error 函数,但如果你不想自己写一个,可以使用 Shape class 提供的缺省版本。

  • non-virtual :每个 Shape 对象都有一个用来产生对象识别码的函数;该方法由 Shape::objectID 的定义式决定,任何 derived class 都不应该尝试改变其行为。

这些不同类型的声明意味着根本意义并不相同的事情,当你声明你的成员函数时,必须谨慎选择。如果你确实履行,应该能够避免经验不足的 class 设计者最常犯的两个错误:

  1. 将所有函数声明为 non-virtual。这使得 derived class 没有余裕空间进行特化工作。non-virtual 析构函数尤其会带来问题。
  2. 将所有成员函数都声明为 virtual 。某些函数就是不应该在 derived class 中被重新定义,如果你的不变性凌驾特异性,别害怕说出来。

小结:

  • 接口继承和实现继承不同。在 public 继承之下,derived class 总是继承 base class 的接口。
  • pure virtual 函数只具体指定接口继承。
  • impure virtual 函数具体执行接口继承及缺省实现继承。
  • non-virtual 函数具体指定接口继承以及强制性实现继承。

条款35:考虑virtual函数以外的其他选择

1
2
3
4
5
class GameCharacter {
public:
virtual int healthValue() const;
...
};

藉由 Non-Virtual Interface 手法实现 Template Method 模式:

这个流派建议,较好的设计是保留 healthValue 为 public 成员函数,但让他成为 non-virtual,并调用一个 private virtual 函数进行实际工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
class GameCharacter {
public:
int healthValue() const {
... // 做一些事前工作
int retVal = doHealthValue(); // 做真正的工作
... // 做一些事后工作
return retVal;
}
private:
virtual int doHealthValue() const { // derived class可重新定义它
...
}
};

NVI 手法的一个优点隐身在上述代码注释“做一些事前工作”和“做一些事后工作”之中。
在 NVI 手法下其实没有必要让 virtual 函数一定得是 private

藉由 Function Pointers 实现 Strategy 模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class GameCharacter; // 前置声明
// 以下函数是计算健康指数的缺省算法
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter {
public:
typedef int (*HealthCalcFunc) (const GameCharacter&);
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
: healthFunc(hcf)
{}
int healthValue() const {
return healFunc(*this);
}
private:
HealthCalcFunc healthFunc;
};

这个做法是常见的 Strategy 设计模式的简单应用。拿它和“植基于 GameCharacter 继承体系内之virtual函数”的做法比较,它提供了某些有趣弹性:

  • 同一人物类型之不同实体可以有不同的健康计算函数。
  • 某已知人物之健康指数计算函数可在运行期变更。例如 GameCharacter 可提供一个成员函数 setHealthCalculator ,用来替换当前的健康指数计算函数。

但是这样可能必须降低类的封装性。

藉由 function 完成 Strategy 模式

使用 std::function 替换函数指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class GameCharacter; // 前置声明
// 以下函数是计算健康指数的缺省算法
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter {
public:
typedef std::function<int (const GameCharacter&)> HealthCalcFunc;
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
: healthFunc(hcf)
{}
int healthValue() const {
return healFunc(*this);
}
private:
HealthCalcFunc healthFunc;
};

std::function<int (const GameCharacter&)>
这个签名代表的函数是“接受一个 reference 指向 const GameCharacter,并返回 int”。这个类型产生的对象可以持有任何与此签名式兼容的可调用物。所谓兼容,意思是这个可调用物的参数可被隐式转换为 const GameCharacter&,而其返回类型可转换为 int。

这样的话,在“指定健康计算函数”这件事上有更惊人的弹性。比如可以使用某个返回值 non-int 的函数、或者某个函数对象、或者某个成员函数计算健康指数。

小结:

  • 使用 non-virtual interface NVI 手法,那是 Template Method 设计模式的一种特殊形式。它以 public non-virtual 成员函数包裹低访问性(protected、private)的 virtual 函数。
  • 将 virtual 函数替换为“函数指针成员变量”,那是 Strategy 设计模式的一种分解表现形式。
  • 以 std::function 成员变量替换 virtual 函数,因而允许使用任何可调用物搭配一个兼容于需求的签名式。这也是 Strategy 设计模式的某种形式。
  • 将继承体系内的 virtual 函数替换为另一个继承体系内的 virtual 函数。这是 Strategy 设计模式的传统实现手法。

条款36:绝不重新定义继承来的 non-virtual 函数

1
2
3
4
5
6
7
8
class Base {
public:
void mf();
...
};
class Derived : public Base {
...
};

non-virtual 函数如 Base::mfDerived::mf 都是静态绑定。如果 pB 被声明为一个 pointer-to-Base,那么通过 pB 调用的 non-virtual 函数永远都是 Base 定义的版本,即使 pB 指向一个类型为“Base派生的class”的对象。

另一方面,virtual 函数却是动态绑定,如果 mf 是个 virtual 函数,不论是通过 pB 或 pD 调用 mf,都会导致调用 Derived::mf,因为 pB 和 pD 真正指的都是一个类型为 Derived 的对象。

如果 Derived 重新定义 mf,你的设计便出现矛盾。

请记住:

  • 绝对不要重新定义一个继承来的 non-virtual 函数。

条款37:绝不重新定义继承而来的缺省参数值

本条款的讨论局限于“继承一个带有缺省参数值的 virtual 函数” 。这种情况下,本条款成立的理由就非常直接而明确了:virtual 函数系动态绑定,而缺省参数却是静态绑定。意思是你可能会在“调用一个定义于 derived class 内的 virtual 函数”的同时,却使用 base class 为它所指定的缺省参数值。

virtual 函数系动态绑定,静态绑定和动态绑定之间的差异???
对象的静态模型,就是它在程序中被声明时所采用的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Shape {
public:
enum ShapeColor { Red, Green, Blue };
virtual void draw(ShapeColor color = Red) const = 0;
};
class Rectangle : public Shape {
virtual void draw(ShapeColor color = Green) const;
...
};

Shape* ps;
Shape* pc = new Circle;
Shape* pr = new Rectangle;

本例中 ps、pc、pr 都被声明为 pointer-to-Shape 类型,不论它们真正指向什么,它们的静态类型都是 Shape*

对象的所谓动态类型则是指“目前所指对象的类型”。也就是说,动态类型可以表现出一个对象将会有什么行为。以上例而言,pc 的动态类型是 Circle*,pr 的动态类型是 Rectangle*,ps 没有动态类型,因为它尚未指向任何对象。

virtual 函数系动态绑定而来,意思是调用一个 virtual 函数时,究竟调用哪一份函数实现代码,取决于发出调用的那个对象的动态类型。

pr->draw(); // 调用 Rectangle::draw(Shape::Red)!
virtual 函数系动态绑定,而缺省参数却是静态绑定。如果重新定义了缺省参数值的话,比如 Rectangle::draw 函数的缺省参数值是 GREEN,由于 pr 的静态类型是 Shape* ,所以此调用的缺省参数值来自 Shape class 而非 Rectangle class !结局是这个函数调用有着奇怪并且几乎绝对没人预料得到的组合,由Shape classRectangle classdraw 声明式各出一半力。

为什么C++采用这种运作方式呢?答案在于运行期效率。如果缺省参数值是动态绑定,编译器就必须有某种办法在运行期为 virtual 函数决定适当的参数缺省值。这比目前实行的“在编译器决定”的机制更慢而且更复杂。

当你想令 virtual 函数表现出你所想要的行为但却遭遇麻烦,聪明的做法是考虑替代设计。如条款35所列的方案设计,其中之一是 NVI,令 base class 内的一个 non-virtual public 函数调用 private virtual 函数,后者可被 derived class 重新定义。这里让 non-virtual 函数指定缺省参数值,而 private virtual 函数负责真正的工作。这样 derived class 绝对不会覆写 non-virtual 函数,缺省参数值就不会被改变。

请记住:

  • 绝对不要重新定义一个继承而来的缺省参数值。因为缺省参数值都是静态绑定,而 virtual 函数——你唯一应该覆写的东西——却是动态绑定。

条款38:通过复合塑模出 has-a 或“根据某物实现出”

复合是类型之间的一种关系,当某种类型的对象内含其他类型的对象,便是这种关系。复合意味 has-a(有一个) 或 is-implemented-in-terms-of(根据某物实现出)。

那是因为你正打算在你的软件世界中处理两个不同的领域。程序中的对象其实相当于你所塑造的世界中的事物,例如人、汽车等。这样的对象属于应用域部分。其他对象则纯碎是实现细节上的人工制品,像是缓冲区、互斥器、查找树等。这些对象相当于你的软件实现域。当复合发生于应用域内的对象之间,表现出 has-a 关系;当它发生于实现域内则是表现 is-implemented-in-terms-of 关系。

stl 容器比如 setmap等底层实现基本都是通过 has-a 这种关系。

请记住:

  • 复合(composition)的意义和 public 继承完全不同。

  • 在应用域,复合意味着 has-a(有一个)。在实现域,复合意味着 is-implemented-in-terms-of(根据某物实现出)。

条款39:明智而审慎地使用 private 继承

private 继承的行为如何呢?

如果 classes 之间的继承关系是 private ,编译器不会自动将一个 derived class 对象转换为一个 base class 对象。

private base class 继承而来的所有成员,在 derived class 中都会变成 private 属性,纵使它们在 base class 中原本不是 private 属性。

private 继承意味着什么呢?

Private 继承意味着 implemented-in-terms-of(根据某物实现出)。如果你让 class Dprivate 方式继承 class B,你的用意是为了采用 class B 内已经备妥的某些特性,不是因为 B 对象和 D 对象存在有任何观念上的关系。

private 继承纯粹只是一种实现技术,private 继承意味只有实现部分被继承,接口部分应略去。private 继承在软件“设计”层面上没有意义,其意义只及于软件实现层面。

这好像看着和复合的意义很相似。那如何在两者之间取舍?

答案很简单:尽可能使用复合,必要时才使用 private 继承。何时才是必要?主要是当 protected 成员或 virtual 函数牵扯进来的时候。

这个例子修改 Widget,让它记录每个成员函数的被调用次数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

class Timer {

explicit Timer(int tickFrequency);

virtual void onTick() const; // 定时器每嘀嗒一次 此函数被自动调用一次

...

};

class Widget : private Timer {

private:

virtual void onTick() const; // 查看Widget的数据...等等

...

};

可以以复合取而代之:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

class Widget {

private:

class WidgetTimer : public Timer {

public:

virtual void onTick() const;

...

};

WidgetTimer timer;

...

};

为什么更愿意或说应该选择这样的 public 继承加复合,而不是选择原先的 private 继承设计。

首先,如果想设计 Widget 使它得以拥有 derived class ,但同时你可能会想阻止 derived class 重新定义 onTick。如果 Widget 继承自 Timer,上面的想法就不可能实现,即使是 private 继承也不可能。但如果 WidgetTimerWidget 内部的一个 private 成员并继承 TimerWidgetderived class 将无法取用 WidgetTimer ,因此无法继承它或者重新定义它的virtual函数。Java 的 final 和 C# 的 sealed,拥有“阻止 derived class 重新定义 virtual 函数”的能力。

第二,将 Widget 的编译依存性降至最低。

请记住:

  • private 继承意味着 is-implemented-in-terms-of(根据某物实现出)。它通常比复合的级别低。但是当 derived class 需要访问 protected base class 的成员,或需要重新定义继承而来的 virtual 函数时,这么设计是合理的。

  • 和复合不同,private 继承可以造成 empty base 最优化。这对致力于“对象尺寸最小化”的程序库开发者而言,可能很重要。

条款40:明智而审慎地使用多重继承

最需要认清的一件事是:当多重继承进入设计景框,程序有可能从一个以上的 base classes 继承相同的名称。那会导致较多的歧义机会。

多重继承的意思是继承一个以上的 base classes,但这些 base classes 并不常在继承体系中又有更高级别的 base classes,因为那会导致要命的“钻石型多重继承”。

钻石继承

当出现这种情况时,有两种方案:假设 File class 有个成员变量 filename ,那么 IOFile 内该有多少笔这个名称的数据呢?如果它从每个基类继承一份,那么其对象内应该有两份 filename 成员变量。但从另一个角度看,IOFile 对象只应该有一个文件名称。

C++ 对两个方案都支持,其缺省做法是执行复制。如果不是你想要的,就必须令那个带有数据的 class 成为一个 virtual base class,必须令所有继承它的 classes 采用“virtual 继承”。

虚继承

如果让 public 继承总是 virtual,不就万事大吉了吗?使用虚继承得付出代价。

为了避免继承得来的成员重复,编译器必须做许多幕后工作,而其后果是:使用 virtual 继承的那些 classes 所产生的对象往往比使用 non-virtual 继承的兄弟们体积大,访问 virtual base classes 的成员变量时,也比访问 non-virtual base classes 的成员变量更慢。

另外, virtual base 的初始化责任是由继承体系中的最底层(`most derived class)负责。

第一,非必要不使用 virtual base classes,平时请使用 non-virtual 继承。

第二,如果你必须使用 virtual base classes,尽可能避免在其中放置数据。这么一来你就不需担心这些 class 身上的初始化和赋值所带来的诡异事情了。

请记住:

  • 多重继承比单一继承复杂。它可能导致新的歧义性,以及对 virtual 继承的需要。

  • virtual 继承会增加大小、速度、初始化及赋值复杂度等等成本。如果 virtual base classes 不带任何数据,将是最具有实用价值的情况。

  • 多重继承的确有正当用途。其中一个情节涉及“public 继承某个 Interface class” 和 “private 继承某个协助实现的 class”的两相组合。

Donate comment here