Effective C++ 条款40:明智而审慎地使用多重继承
Effective C 条款40明智而审慎地使用多重继承本篇为《Effective C改善程序与设计的 55 个具体做法》读书笔记系列第 40 篇。开篇引言多重继承Multiple Inheritance, MI是 C 中最具争议的特性之一。它提供了强大的表达能力允许一个类从多个基类继承特性。然而这种强大能力也带来了显著的复杂性名称歧义、菱形继承问题、virtual 继承的性能开销等。Scott Meyers 在条款 40 中提醒我们多重继承比单一继承复杂可能导致新的歧义性以及对 virtual 继承的需要但确有正当用途。本文将深入探讨多重继承的风险与收益帮助你明智而审慎地使用这一特性。核心问题多重继承的歧义性场景 1同名成员函数的歧义#includeiostreamclassBorrowableItem{public:voidcheckOut(){std::coutBorrowableItem::checkOut()std::endl;}};classElectronicGadget{private:boolcheckOut()const{// 注意这是 private 的std::coutElectronicGadget::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::fileNametest.txt;// OKio.OutputFile::fileNametest.txt;// OK但这是另一个副本// 更危险的是File*fio;// 错误歧义转换为 InputFile* 还是 OutputFile*}菱形继承问题与 virtual 继承问题重复继承#includeiostreamclassFile{public:std::string fileNamedefault;intfileDescriptor-1;};classInputFile:publicFile{public:voidread(){std::coutReading from fileNamestd::endl;}};classOutputFile:publicFile{public:voidwrite(){std::coutWriting to fileNamestd::endl;}};classIOFile:publicInputFile,publicOutputFile{// IOFile 包含两份 File 成员};intmain(){IOFile io;// io 对象内存布局// [InputFile::File::fileName]// [InputFile::File::fileDescriptor]// [OutputFile::File::fileName]// [OutputFile::File::fileDescriptor]std::coutsizeof(File): sizeof(File)std::endl;std::coutsizeof(InputFile): sizeof(InputFile)std::endl;std::coutsizeof(OutputFile): sizeof(OutputFile)std::endl;std::coutsizeof(IOFile): sizeof(IOFile)std::endl;// IOFile 的大小 ≈ InputFile OutputFile包含两份 Filereturn0;}解决方案virtual 继承#includeiostreamclassFile{public:std::string fileNamedefault;intfileDescriptor-1;File(){std::coutFile constructorstd::endl;}};// 使用 virtual 继承classInputFile:virtualpublicFile{public:InputFile(){std::coutInputFile constructorstd::endl;}voidread(){std::coutReading from fileNamestd::endl;}};classOutputFile:virtualpublicFile{public:OutputFile(){std::coutOutputFile constructorstd::endl;}voidwrite(){std::coutWriting to fileNamestd::endl;}};classIOFile:publicInputFile,publicOutputFile{public:IOFile(){std::coutIOFile constructorstd::endl;}// IOFile 只包含一份 File 成员};intmain(){IOFile io;// 构造函数调用顺序// 1. File constructorvirtual base 最先构造// 2. InputFile constructor// 3. OutputFile constructor// 4. IOFile constructorio.fileNametest.txt;// OK只有一份 fileNameio.read();// OKio.write();// OKstd::coutsizeof(File): sizeof(File)std::endl;std::coutsizeof(InputFile): sizeof(InputFile)std::endl;std::coutsizeof(OutputFile): sizeof(OutputFile)std::endl;std::coutsizeof(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::stringname):fileName(name){std::coutFile(name)std::endl;}std::string fileName;};classInputFile:virtualpublicFile{public:InputFile():File(InputFile-default){// 这个初始化会被忽略std::coutInputFile()std::endl;}};classOutputFile:virtualpublicFile{public:OutputFile():File(OutputFile-default){// 这个初始化也会被忽略std::coutOutputFile()std::endl;}};classIOFile:publicInputFile,publicOutputFile{public:IOFile():File(IOFile){// 只有最底层派生类能初始化 virtual basestd::coutIOFile()std::endl;}};intmain(){IOFile io;std::coutfileName: io.fileNamestd::endl;// 输出File(IOFile)// InputFile()// OutputFile()// IOFile()// fileName: IOFilereturn0;}多重继承的正当用途尽管有多重风险多重继承在某些场景下确实是最简洁、最合理的解决方案。场景 1public 继承接口 private 继承实现这是多重继承最经典、最无可争议的用法#includeiostream#includestring#includememory// 接口类纯抽象类classIPerson{public:virtual~IPerson()default;virtualstd::stringname()const0;virtualstd::stringbirthDate()const0;};// 辅助实现的类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{returnJohn Doe;}std::stringgetBirthDateFromDB()const{return1990-01-01;}};// CPersonpublic 继承接口is-a IPerson// private 继承实现is-implemented-in-terms-of PersonInfoclassCPerson: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_ptrIPersonpersonstd::make_uniqueCPerson(12345);std::coutName: person-name()std::endl;std::coutBirth: person-birthDate()std::endl;}场景 2混入类Mixin#includeiostream// 可序列化混入templatetypenameDerivedclassSerializable{public:voidserialize()const{static_castconstDerived*(this)-serializeImpl();}};// 可克隆混入templatetypenameDerivedclassCloneable{public:std::unique_ptrDerivedclone()const{returnstd::unique_ptrDerived(static_castconstDerived*(this)-cloneImpl());}};classDocument:publicSerializableDocument,publicCloneableDocument{public:voidserializeImpl()const{std::coutSerializing document: titlestd::endl;}Document*cloneImpl()const{returnnewDocument(*this);}std::string title;};classImage:publicSerializableImage,publicCloneableImage{public:voidserializeImpl()const{std::coutSerializing image: widthxheightstd::endl;}Image*cloneImpl()const{returnnewImage(*this);}intwidth0;intheight0;};场景 3适配器模式#includeiostream// 旧接口classOldInterface{public:virtualvoidoldMethod(){std::coutOld methodstd::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()overridedefault;};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 继承体系的分析《设计模式》GoFAdapter 模式和 Mixin 模式系列预告至此Effective C 第 6 章继承与面向对象设计的条款 32-40 已经全部介绍完毕。下一章将进入模板与泛型编程的世界。如果本文对你有帮助欢迎点赞、收藏、转发有任何问题可以在评论区留言讨论。