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



