继承和面向对象设计
设计和实现类的层次结构与C语言有根本不同;
C++提供了多种面向对象构造部件: 公有, 保护, 私有基类; 虚拟和非虚拟基类; 虚拟和非虚拟成员函数; 这些部件互相联系, 而且和C++其他部分互相作用;
C++中很多不同部件好像在做相同的事:
e.g. 设计一组具有共同特性的类, 是该使用继承使所有类都派生于一个共同基类, 还是使用模板使得它们都从共同的代码框架中产生? 类A的实现要用到类B, 是让A拥有一个B的数据成员, 还是让A私有继承于B? 设计一个标准库中没有提供的, 类型安全的同族容器类, 使用模板, 还是为某个"自身用普通void*指针来实现"的类建立类型安全的接口?
本章的条款集中解释: C++不同部件的真正含义; e.g. 公有继承意味着"是一个Is-a"; 虚函数的含义是"接口必须被继承"; 非虚函数的含义是"接口和实现都要被继承";
条款44总结了C++面向对象构造部件间的对应关系和含义, 可以作为简明参考;
条款35 使公有继承体现"是一个"的含义
C++面向对象编程规则之一: 公有继承意味着"是一个 Is-a";
当写下类D/Derived从类B/Base公有继承时, 意味着类型D的每一个对象也是类型B的一个对象; 反之不成立; B表示比D更广泛的概念, D表示B更特定的概念; 任何可以使用类型B对象的地方, 类型D对象也可以使用; 反之, 如果需要一个类型D的对象, 类型B的对象不适用; 每个D 是一个Is-a B, 反之不成立;
1
2
|
class
Person { ... };
class
Student:
public
Person { ... };
|
>每个学生是人, 但并非每个人都是学生; 任何人都有年龄, 但不一定有当前班级; 人是广泛概念, 学生是特定类型的人;
C++中, 任何一个参数为Person类型的参数(或指针/引用)可以实际取一个Student对象(或指针/引用):
1
2
3
4
5
6
7
8
|
void
dance(
const
Person& p);
// 任何人可以跳舞
void
study(
const
Student& s);
// 只有学生才学习
Person p;
// p 是一个人
Student s;
// s 是一个学生
dance(p);
// 正确,p 是一个人
dance(s);
// 正确,s 是一个学生,// 一个学生"是一个"人
study(s);
// 正确
study(p);
// 错误! p 不是学生
|
只是公有继承才有这样的使用; 私有继承不一样(条款42);
Note Student是一个Is-a Person不代表Student的数组是一个Is-a Person数组;
公有继承和'是一个'的等价关系看似简单, 但在实际应用中可能并不那么直观; e.g. 企鹅是鸟类, 鸟会飞:
1
2
3
4
5
6
7
8
|
class
Bird {
public
:
virtual
void
fly();
// 鸟会飞
...
};
class
Penguin:
public
Bird {
// 企鹅是鸟
...
};
|
按直觉写出的代码造成了困惑, 原因是语言(中文表达)的不严密, 鸟会飞并不是指所有的鸟都会飞;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
class
Bird {
...
// 没有声明fly 函数
};
class
FlyingBird:
public
Bird {
public
:
virtual
void
fly();
...
};
class
NonFlyingBird:
public
Bird {
...
// 没有声明fly 函数
};
class
Penguin:
public
NonFlyingBird {
...
// 没有声明fly 函数
};
|
>这样的层次就更接近现实逻辑;
在有的软件系统中, 把企鹅继承于鸟是完全合适的; 例如程序只和鸟的嘴, 翅膀有关系而不涉及到飞的功能, 那最初的设计就合适; 这反映了一个简单的事实: 没有任何一种设计可以理想到适用于任何软件; 好的设计是和软件系统现在和将来要完成的功能密不可分的(M32); 如果程序不涉及飞, 以后也不会, 那让Penguin派生自Bird就非常合理; 甚至比区别飞的设计还好, 因为在设计层次中增加多余的类是糟糕的设计, 就像在类之间制定了错误的继承关系;
另一个处理方法:
1
2
3
4
5
6
|
void
error(
const
string& msg);
// 在别处定义
class
Penguin:
public
Bird {
public
:
virtual
void
fly() { error(
"Penguins can't fly!"
); }
...
};
|
>产生一个运行错误; 解释型语言如Smalltalk喜欢采用这类方法;
区别: "企鹅不会飞"是编译器给出的提示; "企鹅飞是一种错误"只能在运行时检测到;
为了表示"企鹅不会飞", 就不要再Penguin中定义fly():
1
2
3
4
5
6
7
8
9
|
class
Bird {
...
// 没有声明fly 函数
};
class
NonFlyingBird:
public
Bird {
...
// 没有声明fly 函数
};
class
Penguin:
public
NonFlyingBird {
...
// 没有声明fly 函数
};
|
>如果企鹅想飞, 编译器就会报错: Penguin::fly() -- Error;
使用Smalltalk的方式, 编译器完全没有反应; C++的处理方法和Smalltalk不同, 而且, 在编译时检测错误比在运行时检测错误有某些技术上的优点;(条款46)
e.g. 正方形Square, 矩形Rectangle;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
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.width() + 10);
// 对r 的宽度增加10
assert
(r.height() == oldHeight);
// 断言r 的高度未变
}
|
>assert永远不会失败, 高度没有被修改;
1
2
3
4
5
6
|
class
Square:
public
Rectangle { ... };
Square s;
...
assert
(s.width() == s.height());
// 这对所有正方形都成立
makeBigger(s);
// 通过继承,s "是一个" 矩形// 所以可以增加它的面积
assert
(s.width() == s.height());
// 这还是对所有正方形成立
|
>这里的断言也不会失败;
问题: 调用makeBigger()前, s的宽和高相等; makeBigger()内, s的宽度改变, 高度未变; makeBigger()返回后, s的高度和宽度又相等[直觉的理论上来说];(s是引用传递)
对矩形适用的规则(宽度和高度没联系)不适用于正方形(宽度和高度必须相等); 但公有继承表示: 对基类对象适用的任何东西也适用于派生类对象; 在矩形和正方形的例子(以及条款40中set的例子)中, 这个原则不适用; 编译器不会报错, 但程序的逻辑上有冲突; [觉得没啥冲突, 完美体现了继承, 只是正方形的限制大于矩形--长宽必须相等, 而不是矩形的规则不适用正方形, 因为矩形没有限制长宽必须不等, 而是长宽之间关系没有限制条件]
'是一个Is-a' 的关系不是类之间的唯一关系; 另两种常见关系是'有一个Has-a' 和'用...来实现'; 条款40/42; 当这两种关系被错误地表示成'是一个Is-a'的情况会导致错误的设计, 一定要确保理解这些关系的区别;
条款36 区分接口继承和实现继承
公有继承由两个部分组成: 函数接口的继承和函数实现的继承;
作为类的设计者, 有时希望派生类只继承成员函数的接口(声明), 有时候希望派生类同时继承函数的接口和实现, 但允许派生类改写实现; 有时则希望同时继承接口和实现, 不允许派生类改写;
e.g. 图形程序中的几何形状;
1
2
3
4
5
6
7
8
9
|
class
Shape {
public
:
virtual
void
draw()
const
= 0;
virtual
void
error(
const
string& msg);
int
objectID()
const
;
...
};
class
Rectangle:
public
Shape { ... };
class
Ellipse:
public
Shape { ... };
|
>纯虚函数draw使得Shape成为抽象类, 用户不能创建Shape类的实例, 只能创建其派生类实例;
从Shape公有继承的类都受到Shape的影响: 成员函数的接口被继承; 公有继承的含义是"是一个Is-a", 所以对基类成立的事实必须对派生类也成立; 如果一个函数适用于某个类, 也必须使用于它的子类;
>Shape声明了三个函数; draw绘制对象; error被其他成员函数调用, 报告出错信息; objectID返回当前对象唯一整数标识符(条款17); draw是纯虚函数; error是简单虚函数; objectID是非虚函数;
虚函数draw必须在派生类中重新声明, 在抽象类中往往没有定义[可以有]: 定义纯虚函数的目的在于, 使派生类仅仅继承函数的接口; Shape类无法为Shape::draw提供一个合理的缺省实现; 例如, 绘制椭圆的算法和绘制矩形的算法不同;
Note 可以为纯虚函数提供定义; C++编译器不会报错, 但调用它的唯一方式是通过类名完整地指明调用:
1
2
3
4
5
6
7
|
Shape *ps =
new
Shape;
// 错误! Shape 是抽象的
Shape *ps1 =
new
Rectangle;
// 正确
ps1->draw();
// 调用 Rectangle::draw
Shape *ps2 =
new
Ellipse;
// 正确
ps2->draw();
// 调用 Ellipse::draw
ps1->Shape::draw();
// 调用Shape::draw
ps2->Shape::draw();
// 调用Shape::draw
|
>一般这么做没什么作用...可以被应用为一种机制: 为简单的(非纯)虚函数提供"更安全"的缺省实现;
Note 声明一个除了纯虚函数外什么[数据]也不包含的类叫协议类Protocol class, 为派生类提供函数接口, 完全没有实现;
简单虚函数[非纯虚]一般提供了实现, 派生类可以选择改写或不该写; 声明简单函数使派生类继承函数的接口和缺省实现;
>Shape::error接口: 每个类必须提供一个出错时可以被调用的函数, 每个类可以按合适的方式处理错误; 也可以借助Shape类提供的缺省出错处理函数;
为简单虚函数同时提供函数声明和缺省实现是危险的: e.g. XYZ航空公司有两种飞机A型, B型, 飞行方式完全一样;
1
2
3
4
5
6
7
8
9
10
11
12
|
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 { ... };
|
>Airplane::fly是virtual的, 表明所有飞机必须支持fly, 不同型号的飞机原则上对fly有不同实现; 为了避免ModelA和ModelB写重复代码, 缺省的行为是继承自Airplane::fly;
典型的面向对象设计, 两个类项有共同特性fly, 所以fly被转移到基类, 让子类继承这个特性; 这个设计使得共性清晰, 避免代码重复, 易于维护;
假设XYZ公司引进新机型C, C型的飞行方式和A, B不一样, 程序员增加了一个类, 但是忘了重新定义fly:
1
2
3
|
class
ModelC:
public
Airplane {
...
// 没有声明fly 函数
};
|
然后试图让C对象像A或B那样飞行:
1
2
3
4
|
Airport JFK(...);
// JFK 是纽约市的一个机场
Airplane *pa =
new
ModelC;
...
pa->fly(JFK);
// 调用 Airplane::fly!
|
问题在于ModelC可以不用明确声明, 就可以继承这一行为;
Solution: 为子类提供缺省行为, 同时只是在子类需要的时候才给他们; 切断虚函数的接口和缺省实现之间的联系:
1
2
3
4
5
6
7
8
9
10
11
|
class
Airplane {
public
:
virtual
void
fly(
const
Airport& destination) = 0;
...
protected
:
void
defaultFly(
const
Airport& destination);
};
void
Airplane::defaultFly(
const
Airport& destination)
{
//飞机飞往某一目的地的缺省代码
}
|
>Airplane::fly变成了纯虚函数, 提供了飞行的接口, 缺省实现作为独立函数defaultFly的形式存在;
1
2
3
4
5
6
7
8
9
10
|
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); }
...
};
|
>ModelA和ModelB想执行缺省行为的话, 只需要简单地在他们的fly函数体中对defaultFly进行一个内联调用(条款33);
1
2
3
4
5
6
7
8
9
|
class
ModelC:
public
Airplane {
public
:
virtual
void
fly(
const
Airport& destination);
...
};
void
ModelC::fly(
const
Airport& destination)
{
//ModelC 飞往某一目的地的代码
}
|
>对于ModelC类, 因为纯虚函数的特性, 被强迫实现自己版本的fly, 就不会出现无意间继承不正确的fly行为的可能;
这个方法可能还有"拷贝粘贴"出错的问题[程序员的粗心], 但比之前的设计安全可靠;
>Airplane::defaultFly声明为protected, 因为它是实现细节, 使用Airplane的用户只关心飞机可以飞, 不关心是如何实现的; Airplane::defaultFly是非虚函数, 没有子类会重新定义这个函数; 如果声明为virtual, 又会出现有些子类忘记重新定义它的问题;
一些声音反对将接口和缺省实现作为单独函数分开, 认为这样至少会污染类的名字空间, 有太多相近的函数名称在扩散; 然而他们还是赞同接口和缺省实现应该分离;
Solution: 纯虚函数必须在子类中重新声明, 但它还是可以在基类中有自己的实现;
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
|
class
Airplane {
public
:
virtual
void
fly(
const
Airport& destination) = 0;
...
};
void
Airplane::fly(
const
Airport& destination)
{
//飞机飞往某一目的地的缺省代码
}
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)
{
//ModelC 飞往某一目的地的代码
}
|
>区别: 纯虚函数Airplane::fly的函数体取代了独立的函数Airplane:;defaultFly;
本质上说, fly被分成了两个基本部分: 声明说明了他的接口(派生类必须实现), 定义说明了他的缺省行为(派生类可以使用, 需要明确地请求); 但是将fly和defaultFly合并后, 就不能在为两个函数设定不同保护级别了: protected的defaultFly实现部分变成了public;
最后, 讨论下Shape的非虚函数objectID; 当一个成员函数为非虚函数时, 他在派生类中的行为就应该一致; 非虚函数表明了特殊性--不变性, 不管派生类有多么特殊, 他的行为不会改变;
Note 声明非虚函数的目的在于, 使派生类继承函数的接口和强制性实现;
Shape::onejctID说明每个Shape对象有一个函数用来产生对象的标识符, 并且对象标识符的产生方式总是一样的; 派生类无法改变, 非虚函数不能在派生类重新定义(条款37);
理解纯虚, 简单虚, 非虚函数在声明上的区别, 可以明确指定你想让派生类继承的方式: 接口, 接口和缺省实现还是接口和强制实现; 声明成员函数时慎重选择, 避免错误;
常犯的错误:
1) 所有的函数都声明为非虚; 这样派生类没有特殊化的余地, 非虚析构也会导致问题(条款14); 如果是设计的类不准备作为基类, 这样的情况是合理的(M34), 可以专门声明一组非虚成员函数;
如果担心虚函数开销, 参照80-20定律: 在一个典型的程序中, 80%的运行时间都花在执行20%的代码上; 平均起来看, 80%的函数调用可以用虚函数, 而且他们不会对程序整体性能带来影响; 担心虚函数开销之前, 不如把注意力集中在会真正带来影响的20%的代码上[优化];
2) 所有的函数都声明为虚函数; 有时是没错, 比如协议类Protocol class; 但是对一些函数不能再派生类中重定义的情况, 要确定声明为非虚函数;
e.g. 基类B; 派生类D; 成员函数mf:
1
2
3
4
|
D *pd =
new
D;
B *pb = pd;
pb->mf();
// 通过基类指针调用mf
pd->mf();
// 通过派生类指针调用mf
|
>有时必须将mf声明为非虚函数才能保证一切都按照预期的方式工作(不变性);