Effective C++ 条款39:明智而审慎地使用 private 继承
本篇为《Effective C++:改善程序与设计的 55 个具体做法》读书笔记系列第 39 篇。
开篇引言
在 C++ 中,public 继承表示is-a关系,这是面向对象设计的基石。但 C++ 还提供了另一种继承方式:private 继承。与 public 继承不同,private 继承不表示 is-a,而是表示is-implemented-in-terms-of(根据某物实现出)。Scott Meyers 在条款 39 中提醒我们:private 继承通常比复合的级别低,应当尽可能使用复合,只有在特定场景下才使用 private 继承。本文将深入探讨 private 继承的语义、与复合的对比,以及它的正当使用场景。
核心概念:private 继承的语义
private 继承不是 is-a
让我们通过一个直观的例子来理解 private 继承的语义:
#include<iostream>classPerson{public:voideat()const{std::cout<<"Person is eating"<<std::endl;}voidsleep()const{std::cout<<"Person is sleeping"<<std::endl;}};classStudent:privatePerson{// private 继承public:voidstudy()const{std::cout<<"Student is studying"<<std::endl;}// 如果需要,可以公开某些基类功能usingPerson::eat;// 将 eat 公开};voidtest(){Student s;s.eat();// OK:eat 被 using 声明公开了s.study();// OK// s.sleep(); // 错误:sleep 是 private}// 更关键的是:voideatAsPerson(constPerson&p){p.eat();}// eatAsPerson(s); // 错误!Student 不是 Person!private 继承的关键特性
| 特性 | public 继承 | private 继承 |
|---|---|---|
| 语义 | is-a | is-implemented-in-terms-of |
| 基类 public 成员 | 在派生类中仍为 public | 在派生类中变为 private |
| 基类 protected 成员 | 在派生类中仍为 protected | 在派生类中变为 private |
| 隐式转换 | 派生类可隐式转为基类 | 不允许隐式转换 |
| 设计层面意义 | 有(概念关系) | 无(纯实现技术) |
private 继承 vs 复合
场景 1:需要重写 virtual 函数
这是使用 private 继承最常见的正当理由:
#include<iostream>#include<memory>classTimer{public:explicitTimer(inttickFrequency):frequency(tickFrequency){}virtualvoidonTick()const{std::cout<<"Timer tick!"<<std::endl;}voidstart()const{// 模拟定时器触发onTick();}virtual~Timer()=default;private:intfrequency;};// 方案一:使用 private 继承classWidget:privateTimer{public:Widget():Timer(1000){}voidenableTicking(boolenable){tickingEnabled=enable;}private:virtualvoidonTick()constoverride{if(tickingEnabled){std::cout<<"Widget processing tick..."<<std::endl;// 执行 Widget 特定的定时任务}}booltickingEnabled=true;};// 方案二:使用复合(更灵活,但代码更多)classWidgetWithComposition{private:classWidgetTimer:publicTimer{public:explicitWidgetTimer(WidgetWithComposition*widget):Timer(1000),widget(widget){}virtualvoidonTick()constoverride{if(widget&&widget->tickingEnabled){widget->onTick();}}private:WidgetWithComposition*widget;};public:WidgetWithComposition():timer(std::make_unique<WidgetTimer>(this)){}voidenableTicking(boolenable){tickingEnabled=enable;}private:voidonTick()const{std::cout<<"Widget (composition) processing tick..."<<std::endl;}std::unique_ptr<WidgetTimer>timer;booltickingEnabled=true;friendclassWidgetTimer;// 允许 WidgetTimer 访问私有成员};两种方案的对比
| 特性 | private 继承 | 复合 |
|---|---|---|
| 代码复杂度 | 简单 | 较复杂(需要嵌套类) |
| 编译依赖 | 高(Widget 依赖 Timer 的定义) | 低(可以用指针前向声明) |
| 派生类能否重写 onTick | 能 | 不能(WidgetTimer 是 private) |
| 灵活性 | 低 | 高 |
| 推荐程度 | 一般 | 优先 |
场景 2:需要访问 protected 成员
#include<iostream>classBase{public:voidpublicMethod()const{}protected:voidprotectedMethod()const{std::cout<<"Protected method called"<<std::endl;}intprotectedData=42;};// 需要访问 Base 的 protected 成员classDerived:privateBase{public:voiddoSomething(){protectedMethod();// OK:可以访问 protected 成员std::cout<<"Data: "<<protectedData<<std::endl;}};// 用复合无法实现(除非修改 Base 的设计)classDerivedWithComposition{public:voiddoSomething(){// base.protectedMethod(); // 错误:无法访问 protected 成员base.publicMethod();// OK:只能访问 public 成员}private:Base base;};Empty Base Optimization(空白基类最优化)
这是 private 继承最特殊也最有价值的应用场景。
问题:空类也占用空间
#include<iostream>classEmpty{// 没有非静态成员变量// 没有 virtual 函数// 没有 virtual 基类};classHoldsAnInt{private:intx;Empty e;// 理论上不需要内存};intmain(){std::cout<<"sizeof(Empty): "<<sizeof(Empty)<<std::endl;// 通常为 1std::cout<<"sizeof(HoldsAnInt): "<<sizeof(HoldsAnInt)<<std::endl;// 通常为 8(不是 4!)return0;}原因:C++ 标准规定,独立的(非附属)对象必须有非零大小。编译器通常会为空对象插入一个char,再加上对齐填充(padding),导致HoldsAnInt的大小变成sizeof(int) + 对齐填充。
解决方案:private 继承实现 EBO
#include<iostream>classEmpty{// 空类};// 使用 private 继承替代成员变量classHoldsAnIntOptimized:privateEmpty{private:intx;};intmain(){std::cout<<"sizeof(HoldsAnIntOptimized): "<<sizeof(HoldsAnIntOptimized)<<std::endl;// 通常为 4!Empty 基类不占用额外空间return0;}EBO 的实际应用:STL 中的函数对象
#include<iostream>#include<functional>// 自定义比较器(空类)structMyLess{booloperator()(inta,intb)const{returna<b;}// 没有成员变量,典型的空类};// STL 中的 std::set 使用比较器template<typenameT,typenameCompare=std::less<T>>classOptimizedSet{// 如果 Compare 是空类,使用继承优化// 实际 STL 实现类似这样:private:structNode{T value;Node*left;Node*right;};Node*root;// 不使用:Compare comp; // 会多占用空间// 而是使用 private 继承(如果 Compare 是空类)};// 实际验证classSetWithMember{int*root;MyLess comp;// 成员变量};classSetWithInheritance:privateMyLess{int*root;};intmain(){std::cout<<"sizeof(SetWithMember): "<<sizeof(SetWithMember)<<std::endl;std::cout<<"sizeof(SetWithInheritance): "<<sizeof(SetWithInheritance)<<std::endl;// 在 64 位系统上:// SetWithMember: 16 (8 + 8,对齐)// SetWithInheritance: 8 (EBO 生效!)return0;}EBO 的限制
| 限制 | 说明 |
|---|---|
| 仅适用于单一继承 | 多继承下的 EBO 不一定有效 |
| 基类必须真正为空 | 不能包含非静态成员变量、virtual 函数、virtual 基类 |
| 编译器相关 | 大多数现代编译器支持,但不是标准强制要求 |
实际应用场景
场景 1:自定义分配器(Allocator)
#include<iostream>#include<memory>// 状态less分配器(空类)template<typenameT>classStatelessAllocator{public:usingvalue_type=T;T*allocate(std::size_t n){returnstatic_cast<T*>(::operatornew(n*sizeof(T)));}voiddeallocate(T*p,std::size_t){::operatordelete(p);}// 没有成员变量};// 使用 private 继承实现 EBOtemplate<typenameT,typenameAllocator=StatelessAllocator<T>>classCustomVector:privateAllocator{// EBO 可能生效public:explicitCustomVector(std::size_t size=0):data(size>0?Allocator::allocate(size):nullptr),sz(size),cap(size){}~CustomVector(){if(data){Allocator::deallocate(data,cap);}}T&operator[](std::size_t index){returndata[index];}std::size_tsize()const{returnsz;}private:T*data;std::size_t sz;std::size_t cap;};// 验证 EBOclassVectorWithMember{int*data;std::size_t sz;std::size_t cap;StatelessAllocator<int>alloc;// 额外 1 字节 + 对齐填充};intmain(){std::cout<<"sizeof(CustomVector<int>): "<<sizeof(CustomVector<int>)<<std::endl;std::cout<<"sizeof(VectorWithMember): "<<sizeof(VectorWithMember)<<std::endl;return0;}场景 2:策略模式的编译期版本
#include<iostream>// 日志策略(空类)classConsoleLogger{public:voidlog(conststd::string&message)const{std::cout<<"[CONSOLE] "<<message<<std::endl;}};classFileLogger{public:voidlog(conststd::string&message)const{// 模拟文件日志std::cout<<"[FILE] "<<message<<std::endl;}};// 使用 private 继承 + CRTP 模式template<typenameDerived>classLoggerMixin:privateDerived{public:voidlogMessage(conststd::string&msg){// 访问基类的 log 方法this->log(msg);}};classApplication:publicLoggerMixin<ConsoleLogger>{public:voidrun(){logMessage("Application started");// 执行业务逻辑logMessage("Application finished");}};常见误区与解决方案
误区 1:“private 继承和复合完全一样”
// 不完全一样!classA{};classB1:privateA{};// private 继承classB2{A a;};// 复合// B1 可以访问 A 的 protected 成员// B2 不能访问 A 的 protected 成员// B1 可以重写 A 的 virtual 函数// B2 不能重写 A 的 virtual 函数误区 2:“为了 EBO 到处使用 private 继承”
// 不好的做法:过度使用 private 继承classWidget:privatestd::string{// Widget is-a string?不!public:// ...};// 好的做法:除非真的需要 EBO,否则使用复合classWidget{private:std::string label;// Widget has-a label};误区 3:“private 继承可以替代所有复合”
// 错误:private 继承破坏了封装classBadDesign:privatestd::vector<int>{public:// 用户可能误以为这是 vector 的一种};// 正确:复合明确表达 has-a 关系classGoodDesign{public:voidadd(intvalue){data.push_back(value);}// 只暴露需要的接口std::size_tsize()const{returndata.size();}private:std::vector<int>data;};决策流程图
需要复用代码? ├── 需要访问 protected 成员? │ └── 是 → 考虑 private 继承 ├── 需要重写 virtual 函数? │ └── 是 → 考虑 private 继承(或复合 + 嵌套类) ├── 需要 EBO(空类最优化)? │ └── 是 → 考虑 private 继承 └── 以上都不是? └── 使用复合(推荐)总结
核心要点
| 要点 | 说明 |
|---|---|
| private 继承语义 | is-implemented-in-terms-of,不是 is-a |
| 优先使用复合 | 复合更容易理解,更灵活 |
| private 继承的正当理由 | 访问 protected 成员、重写 virtual 函数、EBO |
| EBO 的价值 | 对内存敏感的场景(如 STL 实现)很重要 |
记忆口诀
Private 继承非 is-a,实现细节藏其下。
复合优先继承后,protected 访问才用它。
空类最优化空间,审慎使用莫滥用。
条款 39 的核心建议
明智而审慎地使用 private 继承。当你考虑使用 private 继承时:
- 首先尝试复合:它更简单、更灵活、更容易理解
- 只有在以下情况使用 private 继承:
- 需要访问基类的 protected 成员
- 需要重写基类的 virtual 函数
- 需要 empty base optimization(且你确实在意那一点内存)
- 记住:private 继承纯粹是一种实现技术,在设计层面没有意义
参考阅读:
- 《Effective C++》Scott Meyers,条款 39
- 《C++ Primer》Stanley B. Lippman 等,关于继承的章节
- 《STL 源码剖析》侯捷,关于 allocator 和 EBO 的实现
系列预告:下一篇将深入解析条款 40——明智而审慎地使用多重继承,探讨多重继承的复杂性、菱形继承问题,以及 virtual 继承的成本与收益。
如果本文对你有帮助,欢迎点赞、收藏、转发!有任何问题可以在评论区留言讨论。