Effective C++ 条款40:明智而审慎地使用多重继承
本篇为《Effective C++:改善程序与设计的 55 个具体做法》读书笔记系列第 40 篇。
开篇引言
多重继承(Multiple Inheritance, MI)是 C++ 中最具争议的特性之一。它提供了强大的表达能力,允许一个类从多个基类继承特性。然而,这种强大能力也带来了显著的复杂性:名称歧义、菱形继承问题、virtual 继承的性能开销等。Scott Meyers 在条款 40 中提醒我们:多重继承比单一继承复杂,可能导致新的歧义性,以及对 virtual 继承的需要,但确有正当用途。本文将深入探讨多重继承的风险与收益,帮助你明智而审慎地使用这一特性。
核心问题:多重继承的歧义性
场景 1:同名成员函数的歧义
cpp
#include <iostream>
class BorrowableItem {
public:
void checkOut() {
std::cout << "BorrowableItem::checkOut()" << std::endl;
}
};
class ElectronicGadget {
private:
bool checkOut() const { // 注意:这是 private 的!
std::cout << "ElectronicGadget::checkOut()" << std::endl;
return true;
}
};
class MP3Player : public BorrowableItem, public ElectronicGadget {
// 继承了两个 checkOut()
};
int main() {
MP3Player mp;
// mp.checkOut(); // 错误!歧义:调用哪个 checkOut?
// 即使 ElectronicGadget::checkOut() 是 private 的,仍然会产生歧义!
// C++ 首先确认最佳匹配,然后才检验可取用性
// 解决方案:明确指定
mp.BorrowableItem::checkOut(); // OK
// mp.ElectronicGadget::checkOut(); // 错误:private
return 0;
}
歧义性解析规则
| 步骤 | C++ 编译器行为 |
|---|---|
| 1. 名称查找 | 在所有基类中查找匹配的名称 |
| 2. 重载解析 | 确定最佳匹配(不考虑可取用性) |
| 3. 访问检查 | 检查选定的函数是否可取用 |
关键洞察:即使只有一个函数是可访问的,如果存在多个同等匹配的候选,仍然会产生歧义!
场景 2:类型转换的歧义
cpp
class File {
public:
virtual ~File() = default;
std::string fileName;
};
class InputFile : public File {
public:
void read() {}
};
class OutputFile : public File {
public:
void write() {}
};
class IOFile : public InputFile, public OutputFile {
// 同时继承自 InputFile 和 OutputFile
};
void test() {
IOFile io;
// io.fileName = "test.txt"; // 错误!歧义:通过哪条路径访问 fileName?
// 解决方案:明确指定路径
io.InputFile::fileName = "test.txt"; // OK
io.OutputFile::fileName = "test.txt"; // OK(但这是另一个副本!)
// 更危险的是:
File* f = &io; // 错误!歧义:转换为 InputFile* 还是 OutputFile*?
}
菱形继承问题与 virtual 继承
问题:重复继承
cpp
#include <iostream>
class File {
public:
std::string fileName = "default";
int fileDescriptor = -1;
};
class InputFile : public File {
public:
void read() {
std::cout << "Reading from " << fileName << std::endl;
}
};
class OutputFile : public File {
public:
void write() {
std::cout << "Writing to " << fileName << std::endl;
}
};
class IOFile : public InputFile, public OutputFile {
// IOFile 包含两份 File 成员!
};
int main() {
IOFile io;
// io 对象内存布局:
// [InputFile::File::fileName]
// [InputFile::File::fileDescriptor]
// [OutputFile::File::fileName]
// [OutputFile::File::fileDescriptor]
std::cout << "sizeof(File): " << sizeof(File) << std::endl;
std::cout << "sizeof(InputFile): " << sizeof(InputFile) << std::endl;
std::cout << "sizeof(OutputFile): " << sizeof(OutputFile) << std::endl;
std::cout << "sizeof(IOFile): " << sizeof(IOFile) << std::endl;
// IOFile 的大小 ≈ InputFile + OutputFile(包含两份 File)
return 0;
}
解决方案:virtual 继承
cpp
#include <iostream>
class File {
public:
std::string fileName = "default";
int fileDescriptor = -1;
File() {
std::cout << "File constructor" << std::endl;
}
};
// 使用 virtual 继承
class InputFile : virtual public File {
public:
InputFile() {
std::cout << "InputFile constructor" << std::endl;
}
void read() {
std::cout << "Reading from " << fileName << std::endl;
}
};
class OutputFile : virtual public File {
public:
OutputFile() {
std::cout << "OutputFile constructor" << std::endl;
}
void write() {
std::cout << "Writing to " << fileName << std::endl;
}
};
class IOFile : public InputFile, public OutputFile {
public:
IOFile() {
std::cout << "IOFile constructor" << std::endl;
}
// IOFile 只包含一份 File 成员!
};
int main() {
IOFile io;
// 构造函数调用顺序:
// 1. File constructor(virtual base 最先构造)
// 2. InputFile constructor
// 3. OutputFile constructor
// 4. IOFile constructor
io.fileName = "test.txt"; // OK:只有一份 fileName
io.read(); // OK
io.write(); // OK
std::cout << "sizeof(File): " << sizeof(File) << std::endl;
std::cout << "sizeof(InputFile): " << sizeof(InputFile) << std::endl;
std::cout << "sizeof(OutputFile): " << sizeof(OutputFile) << std::endl;
std::cout << "sizeof(IOFile): " << sizeof(IOFile) << std::endl;
return 0;
}
virtual 继承的成本
| 成本类型 | 说明 |
|---|---|
| 对象大小增加 | 需要额外的指针(vbptr)指向 virtual base class |
| 访问速度降低 | 访问 virtual base 成员需要间接寻址 |
| 初始化复杂 | 最底层派生类负责初始化 virtual base |
| 赋值操作复杂 | 编译器生成的拷贝赋值操作符需要特殊处理 |
cpp
// virtual 继承的内存布局(概念上)
class InputFile : virtual public File {
// 实际布局:
// [vbptr] -> 指向 virtual base table
// [InputFile 成员]
// [File 成员](通过 vbptr 偏移访问)
};
virtual 继承的初始化规则
cpp
class File {
public:
explicit File(const std::string& name) : fileName(name) {
std::cout << "File(" << name << ")" << std::endl;
}
std::string fileName;
};
class InputFile : virtual public File {
public:
InputFile() : File("InputFile-default") {
// 这个初始化会被忽略!
std::cout << "InputFile()" << std::endl;
}
};
class OutputFile : virtual public File {
public:
OutputFile() : File("OutputFile-default") {
// 这个初始化也会被忽略!
std::cout << "OutputFile()" << std::endl;
}
};
class IOFile : public InputFile, public OutputFile {
public:
IOFile() : File("IOFile") {
// 只有最底层派生类能初始化 virtual base!
std::cout << "IOFile()" << std::endl;
}
};
int main() {
IOFile io;
std::cout << "fileName: " << io.fileName << std::endl;
// 输出:File(IOFile)
// InputFile()
// OutputFile()
// IOFile()
// fileName: IOFile
return 0;
}
多重继承的正当用途
尽管有多重风险,多重继承在某些场景下确实是最简洁、最合理的解决方案。
场景 1:public 继承接口 + private 继承实现
这是多重继承最经典、最无可争议的用法:
cpp
#include <iostream>
#include <string>
#include <memory>
// 接口类(纯抽象类)
class IPerson {
public:
virtual ~IPerson() = default;
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
};
// 辅助实现的类
class PersonInfo {
public:
explicit PersonInfo(int personId) : id(personId) {}
virtual ~PersonInfo() = default;
virtual std::string theName() const {
return valueDelimOpen() + getNameFromDB() + valueDelimClose();
}
virtual std::string theBirthDate() const {
return valueDelimOpen() + getBirthDateFromDB() + valueDelimClose();
}
protected:
// 允许派生类自定义分隔符
virtual std::string valueDelimOpen() const { return "["; }
virtual std::string valueDelimClose() const { return "]"; }
private:
int id;
std::string getNameFromDB() const { return "John Doe"; }
std::string getBirthDateFromDB() const { return "1990-01-01"; }
};
// CPerson:public 继承接口(is-a IPerson)
// private 继承实现(is-implemented-in-terms-of PersonInfo)
class CPerson : public IPerson, private PersonInfo {
public:
explicit CPerson(int personId) : PersonInfo(personId) {}
// 实现 IPerson 接口
std::string name() const override {
return PersonInfo::theName();
}
std::string birthDate() const override {
return PersonInfo::theBirthDate();
}
private:
// 自定义分隔符(重写 PersonInfo 的 virtual 函数)
std::string valueDelimOpen() const override { return ""; }
std::string valueDelimClose() const override { return ""; }
};
void test() {
std::unique_ptr<IPerson> person = std::make_unique<CPerson>(12345);
std::cout << "Name: " << person->name() << std::endl;
std::cout << "Birth: " << person->birthDate() << std::endl;
}
场景 2:混入类(Mixin)
cpp
#include <iostream>
// 可序列化混入
template <typename Derived>
class Serializable {
public:
void serialize() const {
static_cast<const Derived*>(this)->serializeImpl();
}
};
// 可克隆混入
template <typename Derived>
class Cloneable {
public:
std::unique_ptr<Derived> clone() const {
return std::unique_ptr<Derived>(
static_cast<const Derived*>(this)->cloneImpl()
);
}
};
class Document : public Serializable<Document>, public Cloneable<Document> {
public:
void serializeImpl() const {
std::cout << "Serializing document: " << title << std::endl;
}
Document* cloneImpl() const {
return new Document(*this);
}
std::string title;
};
class Image : public Serializable<Image>, public Cloneable<Image> {
public:
void serializeImpl() const {
std::cout << "Serializing image: " << width << "x" << height << std::endl;
}
Image* cloneImpl() const {
return new Image(*this);
}
int width = 0;
int height = 0;
};
场景 3:适配器模式
cpp
#include <iostream>
// 旧接口
class OldInterface {
public:
virtual void oldMethod() {
std::cout << "Old method" << std::endl;
}
};
// 新接口
class NewInterface {
public:
virtual void newMethod() = 0;
virtual ~NewInterface() = default;
};
// 适配器:同时继承旧接口和新接口
class Adapter : public OldInterface, public NewInterface {
public:
void newMethod() override {
// 将新接口调用转换为旧接口调用
oldMethod();
}
};
C++ 标准库中的多重继承
C++ 标准库本身就使用了多重继承,最经典的例子是 IOStream 体系:
cpp
// 简化版的标准库 IO 继承体系
class ios { /* ... */ };
class istream : virtual public ios { /* ... */ };
class ostream : virtual public ios { /* ... */ };
class iostream : public istream, public ostream { /* ... */ };
这个设计使用了 virtual 继承来避免 ios 成员的重复。
最佳实践与建议
1. 避免 virtual base classes 包含数据
cpp
// 好的设计:virtual base 只包含接口,不包含数据
class InterfaceBase {
public:
virtual ~InterfaceBase() = default;
virtual void pureVirtual() = 0;
// 没有数据成员!
};
// 不好的设计:virtual base 包含数据
class DataBase {
public:
int sharedData; // 这会导致初始化复杂性!
};
2. 使用虚析构函数
cpp
class Base1 {
public:
virtual ~Base1() = default; // 虚析构函数
};
class Base2 {
public:
virtual ~Base2() = default; // 虚析构函数
};
class Derived : public Base1, public Base2 {
public:
~Derived() override = default;
};
3. 明确解决歧义
cpp
class A { public: void func(); };
class B { public: void func(); };
class C : public A, public B {
public:
// 方案 1:使用 using 引入一个
using A::func;
// 方案 2:重写并明确调用
void func() {
A::func(); // 明确指定
}
};
决策流程图
需要使用多重继承?
├── 是否可以用单一继承 + 复合替代?
│ └── 是 → 优先使用单一继承 + 复合
├── 是否是 "public 接口 + private 实现" 模式?
│ └── 是 → 这是 MI 的最佳实践
├── 是否需要混入(Mixin)功能?
│ └── 是 → 考虑使用模板 + MI
├── 是否出现菱形继承?
│ ├── 是 → 使用 virtual 继承
│ └── 但注意 virtual 继承的成本
└── 是否有名称歧义?
└── 是 → 使用作用域解析或重写解决
总结
核心要点
| 要点 | 说明 |
|---|---|
| 多重继承的复杂性 | 名称歧义、菱形继承、virtual 继承开销 |
| virtual 继承的成本 | 对象大小增加、访问速度降低、初始化复杂 |
| 最佳实践 | 避免 virtual base 包含数据 |
| 正当用途 | public 接口 + private 实现、Mixin 模式 |
记忆口诀
多重继承虽强大,歧义菱形要小心。
virtual 继承解难题,大小速度有代价。
接口公开实现私,Mixin 混入也合理。
审慎使用莫滥用,单一继承优先行。
条款 40 的核心建议
明智而审慎地使用多重继承。 当你考虑使用多重继承时:
- 首先考虑替代方案:单一继承 + 复合往往足够
- public 继承接口 + private 继承实现 是最安全的模式
- 避免 virtual base classes 包含数据,以减少初始化复杂性
- 明确解决所有名称歧义,不要依赖编译器的默认行为
- 理解 virtual 继承的成本,在性能和正确性之间做出权衡
参考阅读:
- 《Effective C++》Scott Meyers,条款 40
- 《C++ Primer》Stanley B. Lippman 等,关于多重继承的章节
- 《STL 源码剖析》侯捷,关于 iostream 继承体系的分析
- 《设计模式》GoF,Adapter 模式和 Mixin 模式
系列预告: 至此,Effective C++ 第 6 章"继承与面向对象设计"的条款 32-40 已经全部介绍完毕。下一章将进入模板与泛型编程的世界。
如果本文对你有帮助,欢迎点赞、收藏、转发!有任何问题可以在评论区留言讨论。