Item36--绝不重新定义继承而来的 non-virtual 函数
1. 现象:行为分裂(Schizophrenic Behavior)
让我们看一个反面教材。假设有一个基类 B 和一个派生类 D。基类中有一个普通函数 mf(非虚函数),但你在派生类中错误地重新定义(隐藏/Shadow)了它:
#include <iostream>
class B {
public:
void mf() {
std::cout << "B::mf()" << std::endl;
}
};
class D : public B {
public:
// 错误!重新定义了继承来的非虚函数
// 注意:这在C++中被称为隐藏(Hiding),而不是重写(Overriding)
void mf() {
std::cout << "D::mf()" << std::endl;
}
};
现在的行为将会非常令人困惑:
D x; // x 是一个 D 对象
B* pB = &x; // pB 指向 x (静态类型是 B*)
D* pD = &x; // pD 指向 x (静态类型是 D*)
// 诡异的现象发生了:
pB->mf(); // 输出: "B::mf()"
pD->mf(); // 输出: "D::mf()"
问题所在: 明明 pB 和 pD 指向的是内存中的同一个对象 x,但调用同一个函数名 mf() 时,结果却不一样。这意味着这个对象的行为取决于你“通过什么类型的窗口(指针)”去观察它。这在软件设计中是不可接受的。
2. 技术层面的解释:静态绑定 vs 动态绑定
为什么会发生这种情况?这是由 C++ 确定函数调用的机制决定的:
- Virtual 函数(虚函数):动态绑定 (Dynamic Binding) 如果
mf是虚函数,运行期会根据指针实际指向的对象类型来决定调用哪个函数。如果是这样,上述两个调用都会输出D::mf()。 - Non-virtual 函数(非虚函数):静态绑定 (Static Binding) 非虚函数在编译期就决定了。编译器只看指针的声明类型(Static Type)。
- 因为
pB被声明为B*,编译器看到mf是非虚函数,直接生成调用B::mf()的代码。 - 因为
pD被声明为D*,编译器生成调用D::mf()的代码。
- 因为
结论: 当你在派生类中写了一个与基类同名的 non-virtual 函数时,你并没有“重写”它,而是遮盖(Shadow/Hide) 了它。
3. 设计层面的解释:违反 "Is-a" 关系
从面向对象设计的理论高度来看,这条规则更为重要。Item 34 曾讨论过“接口继承”与“实现继承”的区别:
- Pure Virtual 函数:继承接口。
- Impure Virtual 函数:继承接口 + 提供一份可被覆盖的默认实现。
- Non-virtual 函数:继承接口 + 强制继承一份不可修改的实现。
逻辑推导:
- 前提: Public 继承意味着 "Is-a"(是一个)的关系。适用于基类
B的所有性质,必须同样适用于派生类D。 - 基类的承诺: 在
B中声明mf为 non-virtual,意味着B的设计者在声明:“mf的行为在该继承体系中是不变的(Invariant),无论你是B还是D,mf做的事情都应该一模一样。” - 派生类的背叛: 如果你在
D中重新定义了mf,你实际上是在说:“在这个子类中,这个承诺无效了。”
矛盾产生:
- 如果你认为
D需要不同的mf实现,那么mf在基类中一开始就应该被声明为virtual。 - 如果你认为
mf在基类中就应该是non-virtual,那么D就不应该重新定义它。
4. 常见的误区
很多从 Java 或 Python 转到 C++ 的开发者容易犯这个错,因为在 Java/Python 中,所有方法默认都是“虚函数”(动态绑定的),重新定义即意味着重写。
但在 C++ 中,如果你想要多态,必须显式使用 virtual。如果你不使用 virtual,就必须遵守“绝不修改”的约定。
5. 析构函数的特例
虽然本条目讲的是普通成员函数,但析构函数也遵循类似的逻辑(尽管稍有不同)。
- 如果你希望通过基类指针删除派生类对象,基类析构函数必须是 virtual 的(参见 Item 7)。
- 如果不这样做,不仅会发生静态绑定导致只调用了基类的析构函数,还会导致资源泄漏。
总结
为了保证继承体系的一致性:
- 基类的 Non-virtual 函数代表了一种强制性实现(Mandatory Implementation)。
- 永远不要在派生类中重新定义它。
浙公网安备 33010602011771号