人话版
1. 什么是多重继承 (MI)?
单继承:你只有一个爸爸。你继承了爸爸的房子。 多重继承:你有两个(或更多)爸爸。你同时继承了爸爸A的房子和爸爸B的车子。
在代码里就是这样:
class BorrowableItem { ... }; // 可借阅物品
class ElectronicGadget { ... }; // 电子设备// MP3播放器:既是可借阅物品,又是电子设备
class MP3Player : public BorrowableItem, public ElectronicGadget { ... };
看起来很美好,对吧?集众家之所长。 但是,麻烦马上就来了。
2. 麻烦一:歧义(两个爸爸都叫你“吃饭”)
假设这两个基类里都有一个函数叫 checkOut()。
BorrowableItem的checkOut()意思是“办理借阅手续”。ElectronicGadget的checkOut()意思是“检查设备电路是否正常”。
当你手里拿着一个 MP3Player 对象,喊了一句:
mp3.checkOut(); // 编译器报错!
编译器懵了: “你到底是要借书,还是要修电路?你有两个爸爸,我听谁的?”
解决办法: 你必须指名道姓。
mp3.BorrowableItem::checkOut(); // 明确调用这一个
这虽然能解决,但用起来很别扭。
3. 麻烦二:死亡菱形 (The Deadly Diamond of Death)
这是多重继承最臭名昭著的陷阱。
想象一下这个家谱:
- 祖先:有一个类叫
File(文件),里面有个属性叫fileName。 - 父辈:
InputFile(输入文件)继承了File。 - 父辈:
OutputFile(输出文件)也继承了File。 - 你:
IOFile(输入输出文件)同时继承 了InputFile和OutputFile。
发生什么事了?
InputFile里有一份fileName。OutputFile里也有一份fileName。- 你是
IOFile,你继承了它们俩。于是,你的身体里有了两份fileName!
后果:
- 空间浪费:数据重复了。
- 逻辑精神分裂:当你修改文件名时,你改的是“输入文件爸爸”的那份,还是“输出文件妈妈”的那份?这会导致严重的 Bug。
4. 补救措施:虚继承 (Virtual Inheritance)
为了解决“两个爸爸带来两份祖父遗产”的问题,C++ 发明了 虚继承。
它的意思是:当父辈继承祖先时,要在前面加一个 virtual 关键字。
class File { ... };
class InputFile : virtual public File { ... }; // 注意 virtual
class OutputFile : virtual public File { ... }; // 注意 virtual
class IOFile : public InputFile, public OutputFile { ... };
效果: 编译器会通过复杂的魔法,保证无论有多少条继承路线,IOFile 里只有一份 File 的数据。
但是!小白请注意,这有巨大的代价:
- 体积变大:对象里多了很多指针来管理这些关系。
- 速度变慢:访问成员变量需要多绕几个弯。
- 初始化极其恶心:按照规则,孙子类 (
IOFile) 必须直接负责初始化祖先类 (File),跳过了中间的父辈。这完全打破了正常的“父债子偿”的逻辑。
小白结论: 除非万不得已,尽量别碰“虚继承”。如果不碰虚继承,那就别搞菱形继承结构。
5. 什么时候才是“明智”的用法?
说了这么多坏话,多重继承是不是一无是处? 不是。有一种情况,它是王道。
那就是:公有继承某个“接口”,同时私有继承某个“实现”。
还记得 Item 39 的例子吗?
- 你需要让一个类看起来像
IPerson(接口)。 - 你需要复用现成的
PersonInfo(实现)来存数据。
// 1. 这是一个纯接口(只有虚函数,没有数据)
class IPerson {
public:virtual ~IPerson();virtual std::string name() const = 0;
};// 2. 这是一个现成的工具类(有代码实现)
class PersonInfo {
public:const char* theName() const;
};// 3. 完美结合
// Public 继承接口:告诉世界 "我是一个人" (Is-a)
// Private 继承实现:告诉自己 "我用 PersonInfo 来存名字" (Is-implemented-in-terms-of)
class CPerson : public IPerson, private PersonInfo {
public:virtual std::string name() const override {return PersonInfo::theName(); // 借用工具类的功能}
};
这是多重继承最干净、最实用的场景:
- 一边是纯洁的接口(没有数据,不会导致菱形问题)。
- 一边是实用的工具(用来干活)。
总结
Item 40 给你的建议是:
- 多重继承是一潭浑水。它会让你的代码容易产生歧义,或者陷入“菱形继承”的复杂陷阱。
- 虚继承虽然能救命,但代价很大。除非必要,不要让你的类出现在“菱形结构”里。
- 唯一推荐的场景:
- 继承一个 Public 接口(为了多态)。
- 继承(或组合)一个 Private 实现(为了复用代码)。
详解版
1. 第一只拦路虎:歧义性 (Ambiguity)
在单继承中,如果派生类调用一个函数,编译器很容易找到它。但在多重继承中,如果两个基类有同名函数,编译器就会报错——即使其中一个是 private 的也不行(因为 C++ 先进行名字查找,再进行访问权限检查)。
场景: 假设你有一个“借阅系统”:
class BorrowableItem {
public:void checkOut(); // 借出
};class ElectronicGadget {
private: // 注意:即使这里是 private,也会导致歧义bool checkOut(); // 检查电路是否短路
};class MP3Player : public BorrowableItem, public ElectronicGadget { ... };MP3Player mp;
mp.checkOut(); // 错误!歧义!编译器不知道你要调用哪一个
解决方法: 必须显式指定调用哪个基类的函数:
mp.BorrowableItem::checkOut();
2. 核心痛点:菱形继承 (The Diamond Problem)
这是多重继承最臭名昭著的问题。当两个基类继承自同一个基类时,就会发生这种情况。
场景: C++ 标准库的 IOStream 就是典型的例子:
File(拥有filename成员)InputFile继承自FileOutputFile继承自FileIOFile同时继承自InputFile和OutputFile
问题: 如果不做特殊处理,IOFile 对象中会有两份 File 的成员变量(例如两份 filename)。这不仅浪费内存,还会导致数据不一致(改了这份,没改那份)。
解决方法:虚继承 (Virtual Inheritance) 为了让 IOFile 中只保留一份 File,中间的类必须使用 virtual 继承:
class File { ... };// 关键点:加上 virtual
class InputFile : virtual public File { ... };
class OutputFile : virtual public File { ... }; class IOFile : public InputFile, public OutputFile { ... };
这样,File 就成为了一个虚基类 (Virtual Base Class),无论在继承体系中出现多少次,最终对象中只有一份实例。
3. 虚继承的代价 (The Cost of Virtual Inheritance)
既然虚继承解决了数据冗余,为什么不默认所有继承都用 virtual 呢? 因为代价非常高昂,主要体现在以下两点:
A. 性能与空间成本 (Performance & Size)
- 体积变大: 使用虚继承的类通常需要额外的指针(vptr)指向虚基类表,导致对象体积增大。
- 速度变慢: 访问虚基类的成员变量需要通过指针间接寻址,比直接访问要慢。
B. 初始化噩梦 (Initialization Complexity)
这是最麻烦的一点。在普通继承中,派生类只负责初始化它的直接基类。 但在虚继承中,虚基类的初始化责任由继承体系中“最底层的派生类” (Most Derived Class) 承担。
- 这意味着: 如果你设计了一个
IOFile,你必须显式调用“爷爷类”File的构造函数。 - 更糟糕的是: 如果后续有人给
File加了新的构造参数,所有最底层的派生类(不管隔了多少代)全都要修改代码。
Scott Meyers 的建议:
如果必须使用虚基类,请尽量不要在虚基类中放置任何数据(成员变量)。这样就不需要担心初始化问题了(类似于 Java 的 Interface)。
4. 多重继承的“正确打开方式”
虽然有多重坑,但 MI 有一个非常经典且合理的使用模式:Public 继承某个接口 + Private 继承某个实现。
假设你想设计一个 CPerson 类:
- 接口部分: 需要支持
IPerson接口(类似于 Java 的 Interface,纯虚函数)。 - 实现部分: 你手头恰好有个遗留的
PersonInfo类,里面有复杂的数据库操作逻辑,你想复用它,但又不想把它的接口暴露给用户。
代码实现:
// 1. 接口类 (纯虚基类)
class IPerson {
public:virtual ~IPerson();virtual std::string name() const = 0;virtual std::string birthDate() const = 0;
};// 2. 辅助实现类 (数据库工具)
class PersonInfo {
public:explicit PersonInfo(DatabaseID pid);virtual ~PersonInfo();virtual const char* theName() const;virtual const char* theBirthDate() const;// ... 其他复杂逻辑
};// 3. 最终类:多重继承
// public 继承接口 (is-a)
// private 继承实现 (implemented-in-terms-of)
class CPerson : public IPerson, private PersonInfo {
public:explicit CPerson(DatabaseID pid) : PersonInfo(pid) {}// 实现 IPerson 的接口,内部转发给 PersonInfo 处理virtual std::string name() const override {return PersonInfo::theName(); }virtual std::string birthDate() const override {return PersonInfo::theBirthDate();}
};
为什么这是最佳实践?
- 解耦: 用户只看到
IPerson,看不到由于PersonInfo带来的复杂性。 - 复用: 直接复用了
PersonInfo的代码,而不需要重写。 - 灵活性:
PersonInfo中可能有虚函数,通过 Private 继承,CPerson可以重写这些虚函数来定制行为(这一点是“对象组合”做不到的)。
总结 (Takeaway)
针对 Item 40,作为一名关注底层和架构的研究生,你需要记住以下几点:
- 多重继承比单一继承复杂得多: 它会引入新的歧义性问题和虚继承的需求。
- 慎用虚继承: 虚继承会增加对象大小、降低访问速度、并使对象初始化变得极其复杂。除非是为了解决菱形继承问题,否则不要用。
- 虚基类应为空: 如果必须使用虚继承,尽量让虚基类不包含任何成员变量(即只包含纯虚函数)。
- 黄金法则: 多重继承最合理的用途是 "public 继承一个接口类" + "private 继承一个辅助实现类"。