Effective C++ 条款36:绝不重新定义继承而来的 non-virtual 函数
本篇为《Effective C++:改善程序与设计的 55 个具体做法》读书笔记系列第 36 篇。
开篇引言
在 C++ 的面向对象编程中,继承和多态是两个核心概念。很多开发者习惯性地认为:“子类可以重写父类的任何函数”。然而,Scott Meyers 在条款 36 中明确警告:绝不重新定义继承而来的 non-virtual 函数。这看似反直觉的建议背后,隐藏着 C++ 对象模型的深层机制。理解这一点,对于编写健壮、可维护的 C++ 代码至关重要。
核心问题:一个令人困惑的代码示例
让我们从一个简单的例子开始,看看会发生什么意想不到的事情:
#include<iostream>classBase{public:voidfunc(){std::cout<<"Base::func() called"<<std::endl;}};classDerived:publicBase{public:voidfunc(){// 警告:重新定义了继承而来的 non-virtual 函数!std::cout<<"Derived::func() called"<<std::endl;}};intmain(){Derived d;Base*pB=&d;// 基类指针指向派生类对象Derived*pD=&d;// 派生类指针指向派生类对象pB->func();// 输出:Base::func() calledpD->func();// 输出:Derived::func() calledreturn0;}令人震惊的结果
同一个对象d,通过不同类型的指针调用同一个函数,却产生了完全不同的行为!
| 调用方式 | 实际调用的函数 | 原因 |
|---|---|---|
pB->func() | Base::func() | 静态绑定:指针类型是Base* |
pD->func() | Derived::func() | 静态绑定:指针类型是Derived* |
这种行为的分裂性,正是条款 36 要禁止重新定义 non-virtual 函数的根本原因。
原理深度解析
静态绑定 vs 动态绑定
要理解这个问题,我们必须深入 C++ 的函数调用机制:
1. 静态绑定(Static Binding)
Non-virtual 函数采用静态绑定(也称为早期绑定):
classBase{public:voidnonVirtualFunc(){/* ... */}// non-virtual};Base*p=newDerived();p->nonVirtualFunc();// 编译器根据 p 的声明类型(Base*)决定调用 Base::nonVirtualFunc- 调用哪个函数在编译期就已经确定
- 只与指针/引用的声明类型有关
- 与指针实际指向的对象类型无关
2. 动态绑定(Dynamic Binding)
Virtual 函数采用动态绑定(也称为晚期绑定):
classBase{public:virtualvoidvirtualFunc(){/* ... */}// virtual};Base*p=newDerived();p->virtualFunc();// 运行期根据 p 实际指向的对象类型决定调用哪个版本- 调用哪个函数在运行期才能确定
- 与指针实际指向的对象类型有关
- 通过虚函数表(vtable)机制实现
虚函数表机制简析
// 编译器为包含 virtual 函数的类生成虚函数表classBase{public:virtualvoidvf(){/* Base 实现 */}voidnf(){/* Base 实现 */}// 无 vtable 条目};classDerived:publicBase{public:voidvf()override{/* Derived 实现 */}// 覆盖 vtable 条目voidnf(){/* Derived 实现 */}// 与 vtable 无关};| 机制 | Non-virtual 函数 | Virtual 函数 |
|---|---|---|
| 绑定时机 | 编译期 | 运行期 |
| 决定因素 | 指针/引用的声明类型 | 对象的实际类型 |
| 实现方式 | 直接函数调用 | 通过 vtable 间接调用 |
| 性能开销 | 无额外开销 | 一次间接寻址 |
为什么这是设计上的矛盾?
Public 继承的 is-a 关系
回顾条款 32:public 继承意味着 is-a 关系。如果Derivedpublic 继承自Base,那么 “每一个 Derived 对象都是一个 Base 对象”。
Non-virtual 函数在设计上代表不变性凌驾于特异性之上:
classBase{public:voidinvariantBehavior(){// 这个行为对所有 Base 及其派生类都应该是一致的// 它反映了 Base 的"不变性"}};如果Derived重新定义了invariantBehavior(),就会出现逻辑矛盾:
- 如果 Derived 确实需要不同的行为:说明 “Derived is a Base” 不成立,那么不应该使用 public 继承
- 如果 Derived 确实应该是 Base 的一种:那么它不应该改变 Base 承诺的不变性
- 如果行为应该因类型而异:那么函数应该声明为 virtual
代码示例:设计矛盾的三难困境
#include<iostream>// 场景1:如果 Base::func 应该反映"不变性"classAnimal{public:voidbreathe(){// non-virtual:所有动物呼吸方式相同std::cout<<"Breathing..."<<std::endl;}};classFish:publicAnimal{public:voidbreathe(){// 错误!鱼用鳃呼吸,但不应该重写 non-virtualstd::cout<<"Breathing through gills..."<<std::endl;}};// 场景2:正确的做法 —— 使用 virtualclassAnimalCorrect{public:virtualvoidbreathe(){std::cout<<"Breathing..."<<std::endl;}virtual~AnimalCorrect()=default;};classFishCorrect:publicAnimalCorrect{public:voidbreathe()override{std::cout<<"Breathing through gills..."<<std::endl;}};// 场景3:如果行为确实应该统一,不需要 virtualclassShape{public:voidprintType()const{// 所有形状都需要打印类型信息,方式相同std::cout<<"This is a shape"<<std::endl;}};实际应用场景
场景 1:企业级系统中的账户类
#include<iostream>#include<string>classAccount{public:// non-virtual:所有账户的日志记录方式应该一致voidlogTransaction(conststd::string&info)const{std::cout<<"[LOG] Account transaction: "<<info<<std::endl;}// virtual:不同账户类型计算利息的方式不同virtualdoublecalculateInterest()const=0;virtual~Account()=default;};classSavingsAccount:publicAccount{public:doublecalculateInterest()constoverride{returnbalance*0.03;// 年利率 3%}// 错误做法:// void logTransaction(const std::string& info) const {// std::cout << "[SAVINGS LOG] " << info << std::endl;// }// 这会导致通过 Account* 和 SavingsAccount* 调用产生不同行为!private:doublebalance=10000.0;};classCheckingAccount:publicAccount{public:doublecalculateInterest()constoverride{return0.0;// 支票账户无利息}private:doublebalance=5000.0;};voidprocessAccount(Account*account){// 统一的日志记录(non-virtual,行为一致)account->logTransaction("Interest calculated");// 多态的利息计算(virtual,行为因类型而异)doubleinterest=account->calculateInterest();std::cout<<"Interest: "<<interest<<std::endl;}场景 2:游戏引擎中的组件系统
classGameComponent{public:// non-virtual:所有组件的启用/禁用逻辑相同voidsetEnabled(boolenabled){if(this->enabled!=enabled){this->enabled=enabled;onEnableStateChanged();}}boolisEnabled()const{returnenabled;}// virtual:不同组件的更新逻辑不同virtualvoidupdate(floatdeltaTime)=0;virtual~GameComponent()=default;protected:// virtual:允许派生类响应状态变化virtualvoidonEnableStateChanged(){}private:boolenabled=true;};classRenderComponent:publicGameComponent{public:voidupdate(floatdeltaTime)override{if(!isEnabled())return;// 渲染逻辑...}// 错误:不要重写 setEnabled!// void setEnabled(bool enabled) { ... }};常见误区与解决方案
误区 1:“我只是想加个默认参数”
classBase{public:voidfunc(intx=10){/* ... */}};classDerived:publicBase{public:voidfunc(intx=20){/* ... */}// 错误!同时改变了默认参数和隐藏了基类版本};注意:这还涉及条款 37(绝不重新定义继承而来的缺省参数值)的问题。
误区 2:“我想隐藏基类的实现”
classBase{public:voidfunc(){/* 基类实现 */}};classDerived:publicBase{private:voidfunc(){/* 派生类实现 */}// 极度危险!不是重写,而是隐藏!};这不会重写基类函数,而是隐藏了它。通过Base*调用的仍然是Base::func()。
正确的设计模式
| 需求 | 正确做法 | 说明 |
|---|---|---|
| 所有派生类行为一致 | non-virtual | 反映不变性 |
| 不同派生类行为不同 | virtual | 支持动态绑定 |
| 需要扩展基类行为 | virtual + 基类默认实现 | impure virtual |
| 必须强制派生类实现 | pure virtual | 接口继承 |
classBase{public:// 情况1:不变性 —— non-virtualvoidinvariantOperation(){// 所有派生类共享相同实现}// 情况2:可定制行为 —— pure virtualvirtualvoidmustImplement()=0;// 情况3:有默认实现但可覆盖 —— impure virtualvirtualvoidcustomizableOperation(){// 默认实现}virtual~Base()=default;};编译器警告与最佳实践
现代编译器通常会对隐藏基类 non-virtual 函数的行为发出警告:
# GCC/Clang-Woverloaded-virtual# 警告隐藏的虚函数-Wshadow# 警告名称隐藏# MSVC/w14263# 警告隐藏的函数最佳实践清单
- 明确设计意图:在声明函数时就想清楚它应该是 virtual 还是 non-virtual
- 使用
override关键字:C++11 引入的override可以帮助捕获错误(虽然不能防止 non-virtual 的重定义,但可以防止 virtual 函数的签名错误) - 遵循 Liskov 替换原则:派生类应该能够替换基类而不改变程序正确性
- 代码审查:特别关注派生类中是否有与基类同名的 non-virtual 函数
总结
核心要点
| 要点 | 说明 |
|---|---|
| Non-virtual 函数是静态绑定的 | 调用哪个版本由指针/引用的声明类型决定 |
| Public 继承意味着 is-a | 重定义 non-virtual 函数破坏这一语义 |
| Non-virtual 函数代表不变性 | 它应该在继承体系中保持一致 |
| 需要多态时使用 virtual | 这是 C++ 支持运行时多态的正确机制 |
记忆口诀
Non-virtual 不覆盖,is-a 语义要维护。
静态绑定看类型,动态绑定看对象。
不变性用 non-virtual,特异性用 virtual。
条款 36 的核心建议
绝不重新定义继承而来的 non-virtual 函数。如果你发现需要这样做,请重新审视你的继承关系:
- 也许不应该使用 public 继承
- 也许这个函数应该声明为 virtual
- 也许你的设计需要重构
参考阅读:
- 《Effective C++》Scott Meyers,条款 36
- 《C++ Primer》Stanley B. Lippman 等,关于虚函数和绑定的章节
- 《设计模式》GoF,关于继承与组合的探讨
系列预告:下一篇将深入解析条款 37——绝不重新定义继承而来的缺省参数值,探讨静态绑定与动态绑定在参数默认值上的微妙陷阱。
如果本文对你有帮助,欢迎点赞、收藏、转发!有任何问题可以在评论区留言讨论。