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 的核心建议

明智而审慎地使用多重继承。当你考虑使用多重继承时:

  1. 首先考虑替代方案:单一继承 + 复合往往足够
  2. public 继承接口 + private 继承实现是最安全的模式
  3. 避免 virtual base classes 包含数据,以减少初始化复杂性
  4. 明确解决所有名称歧义,不要依赖编译器的默认行为
  5. 理解 virtual 继承的成本,在性能和正确性之间做出权衡

参考阅读:

  • 《Effective C++》Scott Meyers,条款 40
  • 《C++ Primer》Stanley B. Lippman 等,关于多重继承的章节
  • 《STL 源码剖析》侯捷,关于 iostream 继承体系的分析
  • 《设计模式》GoF,Adapter 模式和 Mixin 模式

系列预告:至此,Effective C++ 第 6 章"继承与面向对象设计"的条款 32-40 已经全部介绍完毕。下一章将进入模板与泛型编程的世界。


如果本文对你有帮助,欢迎点赞、收藏、转发!有任何问题可以在评论区留言讨论。