Effective C++ 第二版 43)多继承 44)总结

56 篇文章 0 订阅

条款43 明智地使用多继承

多继承MI; 支持者说它是对真实世界问题进行自然模型化必须的; 批评者说, 它太慢, 难以实现, 功能却不比但继承强大;

面向对象编程语言领域在这个问题上一直存在分歧: C++, Eiffel, the Common LISP Object System(CLOS)提供了MI; Smalltalk, Objective C和Object Pascal没有提供; Java只是提供有限的支持;

C++中, MI带来了但继承中绝对不会存在的复杂性, 最基本的一条是二义性; 如果一个派生类从多个基类继承了一个成员名, 所有对这个名字的访问都是二义的; 必须明确地指出是哪个成员;

e.g. 取自ARM(条款50)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Lottery {
public:
    virtual int draw();
...
};
class GraphicalObject {
public:
    virtual int draw();
...
};
class LotterySimulation: public Lottery,
public GraphicalObject {
... // 没有声明draw
};
LotterySimulation *pls = new LotterySimulation;
pls->draw(); // 错误! ---- 二义
pls->Lottery::draw(); // 正确
pls->GraphicalObject::draw(); // 正确

>即使其中一个被继承的draw是私有成员, 不能访问, 二义还是存在;

显式地限制修饰成员不仅笨拙而且还带来限制; 显式地用类名修饰一个虚函数时, 函数的行为不再具有虚拟的特征; 被调用的函数只能是指定的那个, 即使调用是作用在派生类对象上:

1
2
3
4
5
6
7
8
9
class SpecialLotterySimulation: public LotterySimulation {
public:
    virtual int draw();
...
};
pls = new SpecialLotterySimulation;
pls->draw(); // 错误! ---- 还是有二义
pls->Lottery::draw(); // 调用 Lottery::draw
pls->GraphicalObject::draw(); // 调用GraphicalObject::draw

Note 即使pls指向的是SpecialLotterySimulation对象, 也无法(没有向下转换)调用这个类中定义的draw函数;

Lottery和GraphicalObject中的draw函数都被声明为虚函数, 所以子类可以重新定义他们, 但如果LotterySimulation想对二者都重新定义该怎么办? 但是这不可能, 因为一个类只允许有唯一一个无参数名称为draw的函数; (const函数是个例外, 参见条款21);


对于这个严重的问题, ARM中讨论了一种可能, 允许被继承的虚函数可以改名, 但后来发现, 可以通过增加一对新类来巧妙地避开这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class AuxLottery: public Lottery {
public:
    virtual int lotteryDraw() = 0;
    virtual int draw() { return lotteryDraw(); }
};
class AuxGraphicalObject: public GraphicalObject {
public:
    virtual int graphicalObjectDraw() = 0;
    virtual int draw() { return graphicalObjectDraw(); }
};
class LotterySimulation: public AuxLottery,
public AuxGraphicalObject {
public:
    virtual int lotteryDraw();
    virtual int graphicalObjectDraw();
...
};

>AuxLottery和AuxGraphicalObject本质上为各自继承的draw函数声明了新的名字; 新名字以纯虚函数的形式提供, 纯虚函数必须在子类中重新定义; 因为每个类都重新定义了继承而来的draw函数, 让他们调用新的纯虚函数; 

最后: 在这个类体系结构中, 有二义的单个名字draw被有效地分成了无二义但功能等价的两个名字: lotteryDraw和graphicalObjectDraw:

1
2
3
4
5
6
7
LotterySimulation *pls = new LotterySimulation;
Lottery *pl = pls;
GraphicalObject *pgo = pls;
// 调用LotterySimulation::lotteryDraw
pl->draw();
// 调用LotterySimulation::graphicalObjectDraw
pgo->draw();

Note 值得牢记的方法: 这是一个集纯虚函数, 简单函数和内联函数综合应用之大成的方法; 

使用多继承会导致复杂性; 仅仅为了重新定义一个虚函数而不得不引入新的类, AuxLottery和AuxGraphicalObject类对于保证类层次结构的正确运转是必须的, 但他们既不是对应于问题范畴problem domain的某个抽象, 也不是对应于实现范畴implementation domain的某个抽象; 单纯为了实现而存在, 好的软件是'设备无关的';


使用MI还会面临更多问题, 二义性只是个开始;

e.g. 层次结构

1
2
3
class B { ... };
class C { ... };
class D: public B, public C { ... };

发展成:

1
2
3
4
class A { ... };
class B : virtual public A { ... };
class C : virtual public A { ... };
class D: public B, public C { ... };

>菱形/钻石结构;

Problem: 是否让A成为虚基类? 现实中的答案几乎都是, 应该要; 只有极少数情况下会想让D的对象包含A数据成员的多个拷贝; 基于这个事实, B和C要将A声明为虚基类;

遗憾的是, 在定义B和C的时候, 你不知道将来是否会有类去同时继承他们; 如果不将A声明为B和C的虚基类, 今后D的设计者可能需要修改B和C的定义, 以便有效地使用他们; 通常这很难做到, A, B, C的定义往往是只读的; e.g. A, B, C在第三方库中, D由用户来写;

另一方面, 如果真的将A声明为B和C的虚基类, 会在空间和时间上强加给用户额外的开销; 因为虚基类常常是通过对象指针来实现的, 并非对象本身; 内存中对象的分布是和编译器相关的, 但不变的是: 如果A作为"非虚"基类, 类型D的对象在内存中的分布通常占用连续的内存单元; 如果A作为"虚"基类, 有时类型D的对象在内存中的分布是占用连续的内存单元, 但其中两个单元包含的是指针, 指向包含虚基类数据成员的内存单元:

A是非虚基类是D对象通常的内存分布:

A部分+B部分+A部分+C部分+D部分

A是虚基类时D对象在某些编译器下的内存分布:

B部分+指针(指向A)+C部分+指针(指向A)+D部分+A部分

即使编译器不采用这种特殊的实现策略, 使用虚拟继承通常也会带来某种空间上的惩罚;


考虑到这些因素, 在进行高效的类设计时, 如果涉及到MI, 库的设计者需要具有超凡的远见; 这也可以看成是在虚函数和非虚函数之间的选择, 但是决定基类是否应该虚拟, 缺乏定义明确的高级含义; 决定通常取决于整个继承的层次结构, 所以除非知道了整个层次结构, 否则无法做出决定;

除了二义性和虚拟继承的问题, 还会有许多复杂问题存在:

1) 向虚基类传递构造函数参数; 非虚拟继承时, 基类构造函数的参数是由紧邻的派生类的成员初始化列表指定的; 但继承的层次结构只需要非虚基类, 继承层次结构中参数的向上传递采用的是一种很自然的方式: 第n层的类将参数传给第n-1层的类; 但是虚基类的构造函数不同, 它的参数是由继承结构中最底层派生类的成员初始化列表指定的; 这样负责初始化虚基类的那个类可能在继承图中和它相距很远; 如果有新类添加到继承结构中, 执行初始化的类还可能改变; (避免这个问题的办法: 消除对虚基类传递构造函数参数的需要; 避免在这样的类中放入数据成员, 这本质上是Java的解决之道: Java中的虚基类-接口 禁止包含数据)

2) 虚函数的优先度

e.g. ABCD钻石型继承: 假设A定义了虚成员函数mf, C重定义了它, B和D没有重定义mf:

1
2
D *pd = new D;
pd->mf(); // A::mf 或者C::mf?

D对象该调用哪个mf? 直接从C继承还是通过B间接从A继承的那个? 答案取决于B和C如何从A继承; 如果A是B或C的非虚基类, 调用具有二义性; 如果A是B和C的虚基类, C中mf的重定义优先度高于最初A中的定义, 通过pd对mf的调用将解析为C::mf;


MI的这些复杂性很多是由于使用虚基类引起的, 如果能避免使用虚基类 --- 避免产生钻石形状继承图, 事情就会比较好处理;

协议类Protocol class(条款34)的存在仅仅是为派生类制定接口, 没有数据成员, 没有构造函数, 有一个虚析构函数(条款14), 有一组用来指定接口的纯虚函数;

e.g.

1
2
3
4
5
6
7
8
class Person {
public:
    virtual ~Person();
    virtual string name() const = 0;
    virtual string birthDate() const = 0;
    virtual string address() const = 0;
    virtual string nationality() const = 0;
};

这个类的用户必须使用Person的指针或引用, 抽象类不能被实例化;

为了创建可以作为Person对象而使用的对象, Person的用户使用工厂函数factory function(条款34)来实例化具体的子类:

1
2
3
4
5
6
7
// 工厂函数,从一个唯一的数据库ID 创建一个Person 对象
Person * makePerson(DatabaseID personIdentifier);
DatabaseID askUserForDatabaseID();
DatabaseID pid = askUserForDatabaseID();
Person *pp = makePerson(pid); // 创建支持Person接口的对象
... // 通过Person 的成员函数操作*pp
delete pp; // 删除不再需要的对象

带来的问题: makePerson返回的指针所指向的对象如何创建? 必须从Person派生具体类使得makePerson可以对其进行实例化;

假设这个类是MyPerson, 它必须实现继承来的纯虚函数; 如果已经存在一些组件可以完成大多工作, 那么从软件工程的角度来说, 能利用组件是极好的;

e.g. 假设已经有一个和数据库有关的类PersonInfo:

1
2
3
4
5
6
7
8
9
10
11
12
class PersonInfo {
public:
    PersonInfo(DatabaseID pid);
    virtual ~PersonInfo();
    virtual const char * theName() const;
    virtual const char * theBirthDate() const;
    virtual const char * theAddress() const;
    virtual const char * theNationality() const;
    virtual const char * valueDelimOpen() const// 看下文
    virtual const char * valueDelimClose() const;
...
};

这是一个很老的类, 函数返回const char*而不是string对象; 但是功能很合适; 

当初设计PersonInfo是用来方便地以各种格式打印数据库字段, 默认情况下字段值的起始和结束分隔符为括号: 字段Ring-tailed Lemur会格式化成: [Ring-tailed Lemur];

括号可能不是所有用户需都想要, 虚函数valueDelimOpen和valueDelimClose允许派生类指定它们自己的起始和结束分隔符; theName, theBirthDate, theAdress, theNatinonality的实现将调用这2个虚函数;

e.g. name

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const char * PersonInfo::valueDelimOpen() const
{
    return "["// 默认起始分隔符
}
const char * PersonInfo::valueDelimClose() const
{
    return "]"// 默认结束分隔符
}
const char * PersonInfo::theName() const
{
// 为返回值保留缓冲区。因为是静态 类型,它被自动初始化为全零。
    static char value[MAX_FORMATTED_FIELD_VALUE_LENGTH];
// 写起始分隔符
    strcpy(value, valueDelimOpen());
// 将对象的名字字段值添加到字符串中...
 // 写结束分隔符
    strcat(value, valueDelimClose());
    return value;
}

theName使用了固定大小的静态缓冲区(条款23), 先不管这个设计; 

首先, theName调用valueDelimOpen/Close, 因为他们是虚函数, theName返回结果既依赖于PersonInfo, 也依赖于从PersonInfo派生的类;

作为MyPerson的实现者, 可以根据上面的关系来实现不带修饰符的返回值; PersonInfo刚好有函数使得MyPerson易于实现, 没有"Is-A"或"Has-A"的关系; 他们的关系是"用...来实现"; 两种方式: 分层(条款40)和私有继承(条款42); 分层一般更好, 但是有虚函数要被重新定义的情况下, 需要使用私有继承;

MyPerson要从PersonInfo私有继承, 还要实现Person接口(公有继承), 这样就出现了多继承的一个合理应用: 将接口的公有继承和实现的私有继承结合起来;

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
class Person { // 这个类指定了
public// 需要被实现
    virtual ~Person(); // 的接口
    virtual string name() const = 0;
    virtual string birthDate() const = 0;
    virtual string address() const = 0;
    virtual string nationality() const = 0;
};
class DatabaseID { ... }; // 被后面的代码使用;
// 细节不重要
class PersonInfo { // 这个类有些有用
public// 的函数,可以用来
    PersonInfo(DatabaseID pid); // 实现Person 接口
    virtual ~PersonInfo();
    virtual const char * theName() const;
    virtual const char * theBirthDate() const;
    virtual const char * theAddress() const;
    virtual const char * theNationality() const;
    virtual const char * valueDelimOpen() const;
    virtual const char * valueDelimClose() const;
...
};
class MyPerson: public Person, // 注意,使用了
private PersonInfo { // 多继承
public:
    MyPerson(DatabaseID pid): PersonInfo(pid) {}
// 继承来的虚分隔符函数的重新定义
    const char * valueDelimOpen() const return ""; }
    const char * valueDelimClose() const return ""; }
// 所需的Person 成员函数的实现
    string name() const return PersonInfo::theName(); }
    string birthDate() const return PersonInfo::theBirthDate(); }
    string address() const return PersonInfo::theAddress(); }
    string nationality() const return PersonInfo::theNationality(); }
};

对于这个例子, MI既有用又易于理解, 尽管可怕的钻石形状继承图不会完全消失;


必须小心这样的陷阱: 对某个需要改动的继承层次结构来说, 本来用一个更基本的重新设计可以更好, 却为了追求开发速度而去使用MI; 

e.g. 假设为可以活动的卡通角色设计一个层次结构; 至少从概念上来说, 让各种角色能跳舞唱歌将很有意义, 但每一种角色执行这些动作的方式都不同;

1
2
3
4
5
class CartoonCharacter {
public:
    virtual void dance() {}
    virtual void sing() {}
};

虚函数自然地体现了这样的约束: 唱歌跳舞对所有角色都有意义; 什么也不做的缺省行为可以通过类中函数的空定义来表示(条款36); 

假设有一个特殊类型的卡通角色是蚱蜢:

1
2
3
4
5
class Grasshopper: public CartoonCharacter {
public:
    virtual void dance(); // 定义在别的什么地方
    virtual void sing(); // 定义在别的什么地方
};

假设又有一个蟋蟀类:

1
2
3
4
5
class Cricket: public CartoonCharacter {
public:
    virtual void dance();
    virtual void sing();
};

实现Cricket时, 你意识到为Grasshopper类写的很多代码可以重用; 准备用Grasshopper类来实现Cricket类, 用虚函数使Cricket可以定制Grasshopper的行为;

两个要求: '用...来实现' 的关系以及重新定义虚函数的能力, 这意味着Cricket必须从Crasshopper私有继承, 所以要通过同时从Grasshopper和CartoonCharacter继承来重新定义Cricket:

1
2
3
4
5
6
class Cricket: public CartoonCharacter, private Grasshopper
{
public:
    virtual void dance();
    virtual void sing();
};

然后准备对Grasshopper类做修改; 特别是需要声明一些新的虚函数让Cricket重新定义:

1
2
3
4
5
6
7
8
9
class Grasshopper: public CartoonCharacter {
public:
    virtual void dance();
    virtual void sing();
protected:
    virtual void danceCustomization1();
    virtual void danceCustomization2();
    virtual void singCustomization();
};

蚱蜢跳舞的定义变成这样:

1
2
3
4
5
6
7
8
void Grasshopper::dance()
{
//执行共同的跳舞动作;
    danceCustomization1();
//执行更多共同的跳舞动作;
    danceCustomization2();
//执行最后共同的跳舞动作;
}

蚱蜢唱歌的设计类似;

明显地, Cricket必须修改, 因为它必须重新定义新的虚函数:

1
2
3
4
5
6
7
8
9
10
class Cricket:public CartoonCharacter, private Grasshopper
{
public:
    virtual void dance() { Grasshopper::dance(); }
    virtual void sing() { Grasshopper::sing(); }
protected:
    virtual void danceCustomization1();
    virtual void danceCustomization2();
    virtual void singCustomization();
};

当需要Cricket对象跳舞的时候, 它执行Grasshopper中共同的dance代码, 然后执行Cricket类中定制的dance代码, 接着继续执行Grasshopper::dance中的代码...

然而, 这个设计有个严重的缺陷, 你不小心撞上了"奥卡姆剃刀"http://zh.wikipedia.org/wiki/%E5%A5%A5%E5%8D%A1%E5%A7%86%E5%89%83%E5%88%80; 奥卡姆者鼓吹: 如果没有必要, 就不要增加实体; 这个情况下, 实体就是指继承关系; 如果你相信多继承比单继承更复杂, Cricket类的设计就没必要变复杂;

问题的根本在于, Cricket和Grasshopper之间并非"用...来实现"的关系, 而是享有共同的代码--共同的行为;

表明两个类具有共同点的方式不是让一个类从另一个继承, 而是让他们都从一个共同的积累继承, 蚱蜢和蟋蟀之间的共同代码应该属于共同的基类;

e.g. Insect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class CartoonCharacter { ... };
class Insect: public CartoonCharacter {
public:
    virtual void dance(); // 蚱蜢和蟋蟀
    virtual void sing(); // 的公共代码
protected:
    virtual void danceCustomization1() = 0;
    virtual void danceCustomization2() = 0;
    virtual void singCustomization() = 0;
};
class Grasshopper: public Insect {
protected:
    virtual void danceCustomization1();
    virtual void danceCustomization2();
    virtual void singCustomization();
};
class Cricket: public Insect {
protected:
    virtual void danceCustomization1();
    virtual void danceCustomization2();
    virtual void singCustomization();
};

CartoonCharacter <-- Insect <-- Grasshopper

                                   Insect <-- Cricket

这个设计更清晰, 只是涉及到单继承, 而且只是用到了公有继承; Grasshopper和Cricket的定义只是定制功能, 从Insect一点没变地继承了dance和sing函数;

尽管这个设计比MI的方案更清晰, 但毕竟引入了一个全新的类; 表面上看, MI好像使用起来更容易; 不需要增加新的类, 虽然要求在Grasshopper类中增加新的虚函数, 但这些函数本来就是要增加的; 

设想有个程序员在维护一个大型C++类库, 需要在库中增加一个新的类, 就像Cricket类需要被增加到现有的CartoonCharacter/Grasshopper层次中; 程序员知道, 有大量的用户使用现有的层次结构; 所以库的变化越大, 对用户的影响越大; 要将这种影响降低到最小; 对各种选择考虑: 如果增加一个从Grasshopper到Cricket的私有继承链接, 层次结构中将不需要任何其他变化; 作为程序员会受到诱惑: 今后可以大量地增加功能, 代价仅仅是增加很小的一点复杂性; 但是多继承将会带来更多复杂性, 要小心这样的陷阱;


条款44 说你想说的, 理解你所说的

理解不同的面向对象构件在C++中的含义十分重要, 这比仅仅知道C++语言的规则有很大的不同; e.g. C++规则说, 如果D从B公有继承, 从D的指针到B的指针就有一个标准转换了; B的公有成员函数将被继承为D的公有成员函数...这些规则需要知道, 但在将设计思想转化为C++的过程中, 他们起不到作用; 你需要知道公有继承表示 Is-A; D的每一个对象也 Is-A 类型B的对象;

说出你想说的 是成功的一半; 还有一半是 理解你所说的 ; e.g. 将成员函数声明为非虚函数会给子类带来限制, 除非是有意这么做, 否则将是不负责任的行为; 声明一个非虚成员函数, 实际上是在说这个函数表示了一种特殊性上的不变性;


公有继承和Is-A的等价性, 非虚成员函数--特殊性上的不变性, 是C++构件和设计思想相对应的例子;

e.g.

共同的基类意味着共同的特性, D1和D2都把B声明为基类, D1和D2将从B集成共同的数据成员/共同的成员函数; 条款43;

公有继承意味着"是一个", 如果D公有继承于B, 类型D的每一个对象也是一个类型B的对象, 反之不成立; 条款35;

私有继承意味着"用...来实现", 如果类D私有继承于类B, 类型D的对象只不过是用类型B的对象来实现; B和D的对象之间不存在概念上的关系;

分层意味着"有一个"或"用...来实现", 如果类A包含一个类型B的数据成员, A的对象要么具有一个类型B的部件, 要么在实现中使用了类型B的对象; 条款40;


下面的对于关系只适用于公有继承:

虚函数意味着仅仅继承函数的接口; 如果C声明了一个纯虚函数mf, C的子类必须继承mf的接口, C的具体子类必须提供自己的实现; 条款36;

简单虚函数意味着继承函数的接口加上一个缺省实现; 如果C声明了一个简单/非纯虚函数mf, C的子类必须继承mf的接口; 如果需要, 可以继承一个缺省实现; 条款36;

非虚函数意味着继承函数的接口加上一个强制实现; 如果C声明了一个非虚函数mf, C的子类必须同时继承mf的接口和实现; 实际上mf定义了C的"特殊性上的不变性" 条款36;

---继承和面向对象 End---YC

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值