Effective C++ 条款40:明智而审慎地使用多重继承
Effective C++ 条款40:明智而审慎地使用多重继承
本篇为《Effective C++:改善程序与设计的 55 个具体做法》读书笔记系列第 40 篇。
开篇引言
多重继承(Multiple Inheritance, MI)是 C++ 中最具争议的特性之一。它提供了强大的表达能力,允许一个类从多个基类继承特性。然而,这种强大能力也带来了显著的复杂性:名称歧义、菱形继承问题、virtual 继承的性能开销等。Scott Meyers 在条款 40 中提醒我们:多重继承比单一继承复杂,可能导致新的歧义性,以及对 virtual 继承的需要,但确有正当用途。本文将深入探讨多重继承的风险与收益,帮助你明智而审慎地使用这一特性。
核心问题:多重继承的歧义性
场景 1:同名成员函数的歧义
#include<iostream>classBorrowableItem{public:voidcheckOut(){std::cout<<"BorrowableItem::checkOut()"<<std::endl;}};classElectronicGadget{private:boolcheckOut()const{// 注意:这是 private 的!std::cout<<"ElectronicGadget::checkOut()"<<std::endl;returntrue;}};classMP3Player:publicBorrowableItem,publicElectronicGadget{// 继承了两个 checkOut()};intmain(){MP3Player mp;// mp.checkOut(); // 错误!歧义:调用哪个 checkOut?// 即使 ElectronicGadget::checkOut() 是 private 的,仍然会产生歧义!// C++ 首先确认最佳匹配,然后才检验可取用性// 解决方案:明确指定mp.BorrowableItem::checkOut();// OK// mp.ElectronicGadget::checkOut(); // 错误:privatereturn0;}歧义性解析规则
| 步骤 | C++ 编译器行为 |
|---|---|
| 1. 名称查找 | 在所有基类中查找匹配的名称 |
| 2. 重载解析 | 确定最佳匹配(不考虑可取用性) |
| 3. 访问检查 | 检查选定的函数是否可取用 |
关键洞察:即使只有一个函数是可访问的,如果存在多个同等匹配的候选,仍然会产生歧义!
场景 2:类型转换的歧义
classFile{public:virtual~File()=default;std::string fileName;};classInputFile:publicFile{public:voidread(){}};classOutputFile:publicFile{public:voidwrite(){}};classIOFile:publicInputFile,publicOutputFile{// 同时继承自 InputFile 和 OutputFile};voidtest(){IOFile io;// io.fileName = "test.txt"; // 错误!歧义:通过哪条路径访问 fileName?// 解决方案:明确指定路径io.InputFile::fileName="test.txt";// OKio.OutputFile::fileName="test.txt";// OK(但这是另一个副本!)// 更危险的是:File*f=&io;// 错误!歧义:转换为 InputFile* 还是 OutputFile*?}菱形继承问题与 virtual 继承
问题:重复继承
#include<iostream>classFile{public:std::string fileName="default";intfileDescriptor=-1;};classInputFile:publicFile{public:voidread(){std::cout<<"Reading from "<<fileName<<std::endl;}};classOutputFile:publicFile{public:voidwrite(){std::cout<<"Writing to "<<fileName<<std::endl;}};classIOFile:publicInputFile,publicOutputFile{// IOFile 包含两份 File 成员!};intmain(){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)return0;}解决方案:virtual 继承
#include<iostream>classFile{public:std::string fileName="default";intfileDescriptor=-1;File(){std::cout<<"File constructor"<<std::endl;}};// 使用 virtual 继承classInputFile:virtualpublicFile{public:InputFile(){std::cout<<"InputFile constructor"<<std::endl;}voidread(){std::cout<<"Reading from "<<fileName<<std::endl;}};classOutputFile:virtualpublicFile{public:OutputFile(){std::cout<<"OutputFile constructor"<<std::endl;}voidwrite(){std::cout<<"Writing to "<<fileName<<std::endl;}};classIOFile:publicInputFile,publicOutputFile{public:IOFile(){std::cout<<"IOFile constructor"<<std::endl;}// IOFile 只包含一份 File 成员!};intmain(){IOFile io;// 构造函数调用顺序:// 1. File constructor(virtual base 最先构造)// 2. InputFile constructor// 3. OutputFile constructor// 4. IOFile constructorio.fileName="test.txt";// OK:只有一份 fileNameio.read();// OKio.write();// OKstd::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;return0;}virtual 继承的成本
| 成本类型 | 说明 |
|---|---|
| 对象大小增加 | 需要额外的指针(vbptr)指向 virtual base class |
| 访问速度降低 | 访问 virtual base 成员需要间接寻址 |
| 初始化复杂 | 最底层派生类负责初始化 virtual base |
| 赋值操作复杂 | 编译器生成的拷贝赋值操作符需要特殊处理 |
// virtual 继承的内存布局(概念上)classInputFile:virtualpublicFile{// 实际布局:// [vbptr] -> 指向 virtual base table// [InputFile 成员]// [File 成员](通过 vbptr 偏移访问)};virtual 继承的初始化规则
classFile{public:explicitFile(conststd::string&name):fileName(name){std::cout<<"File("<<name<<")"<<std::endl;}std::string fileName;};classInputFile:virtualpublicFile{public:InputFile():File("InputFile-default"){// 这个初始化会被忽略!std::cout<<"InputFile()"<<std::endl;}};classOutputFile:virtualpublicFile{public:OutputFile():File("OutputFile-default"){// 这个初始化也会被忽略!std::cout<<"OutputFile()"<<std::endl;}};classIOFile:publicInputFile,publicOutputFile{public:IOFile():File("IOFile"){// 只有最底层派生类能初始化 virtual base!std::cout<<"IOFile()"<<std::endl;}};intmain(){IOFile io;std::cout<<"fileName: "<<io.fileName<<std::endl;// 输出:File(IOFile)// InputFile()// OutputFile()// IOFile()// fileName: IOFilereturn0;}多重继承的正当用途
尽管有多重风险,多重继承在某些场景下确实是最简洁、最合理的解决方案。
场景 1:public 继承接口 + private 继承实现
这是多重继承最经典、最无可争议的用法:
#include<iostream>#include<string>#include<memory>// 接口类(纯抽象类)classIPerson{public:virtual~IPerson()=default;virtualstd::stringname()const=0;virtualstd::stringbirthDate()const=0;};// 辅助实现的类classPersonInfo{public:explicitPersonInfo(intpersonId):id(personId){}virtual~PersonInfo()=default;virtualstd::stringtheName()const{returnvalueDelimOpen()+getNameFromDB()+valueDelimClose();}virtualstd::stringtheBirthDate()const{returnvalueDelimOpen()+getBirthDateFromDB()+valueDelimClose();}protected:// 允许派生类自定义分隔符virtualstd::stringvalueDelimOpen()const{return"[";}virtualstd::stringvalueDelimClose()const{return"]";}private:intid;std::stringgetNameFromDB()const{return"John Doe";}std::stringgetBirthDateFromDB()const{return"1990-01-01";}};// CPerson:public 继承接口(is-a IPerson)// private 继承实现(is-implemented-in-terms-of PersonInfo)classCPerson:publicIPerson,privatePersonInfo{public:explicitCPerson(intpersonId):PersonInfo(personId){}// 实现 IPerson 接口std::stringname()constoverride{returnPersonInfo::theName();}std::stringbirthDate()constoverride{returnPersonInfo::theBirthDate();}private:// 自定义分隔符(重写 PersonInfo 的 virtual 函数)std::stringvalueDelimOpen()constoverride{return"";}std::stringvalueDelimClose()constoverride{return"";}};voidtest(){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)
#include<iostream>// 可序列化混入template<typenameDerived>classSerializable{public:voidserialize()const{static_cast<constDerived*>(this)->serializeImpl();}};// 可克隆混入template<typenameDerived>classCloneable{public:std::unique_ptr<Derived>clone()const{returnstd::unique_ptr<Derived>(static_cast<constDerived*>(this)->cloneImpl());}};classDocument:publicSerializable<Document>,publicCloneable<Document>{public:voidserializeImpl()const{std::cout<<"Serializing document: "<<title<<std::endl;}Document*cloneImpl()const{returnnewDocument(*this);}std::string title;};classImage:publicSerializable<Image>,publicCloneable<Image>{public:voidserializeImpl()const{std::cout<<"Serializing image: "<<width<<"x"<<height<<std::endl;}Image*cloneImpl()const{returnnewImage(*this);}intwidth=0;intheight=0;};场景 3:适配器模式
#include<iostream>// 旧接口classOldInterface{public:virtualvoidoldMethod(){std::cout<<"Old method"<<std::endl;}};// 新接口classNewInterface{public:virtualvoidnewMethod()=0;virtual~NewInterface()=default;};// 适配器:同时继承旧接口和新接口classAdapter:publicOldInterface,publicNewInterface{public:voidnewMethod()override{// 将新接口调用转换为旧接口调用oldMethod();}};C++ 标准库中的多重继承
C++ 标准库本身就使用了多重继承,最经典的例子是 IOStream 体系:
// 简化版的标准库 IO 继承体系classios{/* ... */};classistream:virtualpublicios{/* ... */};classostream:virtualpublicios{/* ... */};classiostream:publicistream,publicostream{/* ... */};这个设计使用了 virtual 继承来避免ios成员的重复。
最佳实践与建议
1. 避免 virtual base classes 包含数据
// 好的设计:virtual base 只包含接口,不包含数据classInterfaceBase{public:virtual~InterfaceBase()=default;virtualvoidpureVirtual()=0;// 没有数据成员!};// 不好的设计:virtual base 包含数据classDataBase{public:intsharedData;// 这会导致初始化复杂性!};2. 使用虚析构函数
classBase1{public:virtual~Base1()=default;// 虚析构函数};classBase2{public:virtual~Base2()=default;// 虚析构函数};classDerived:publicBase1,publicBase2{public:~Derived()override=default;};3. 明确解决歧义
classA{public:voidfunc();};classB{public:voidfunc();};classC:publicA,publicB{public:// 方案 1:使用 using 引入一个usingA::func;// 方案 2:重写并明确调用voidfunc(){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 已经全部介绍完毕。下一章将进入模板与泛型编程的世界。
如果本文对你有帮助,欢迎点赞、收藏、转发!有任何问题可以在评论区留言讨论。
