《你真的了解C++吗》No.030:非虚函数在继承体系中的设计原则——NVI 模式
导言:谁才是流程的控制者?
在传统的面向对象设计中,我们习惯于直接调用虚函数。但在复杂的工业级代码中,这种做法会产生一个弊端:基类无法在虚函数执行前后进行“统一管理”(比如加锁、记录日志或合法性检查)。
为了解决这个问题,C++ 社区推崇一种设计模式:NVI (Non-Virtual Interface,非虚接口) 原则。
一、 NVI 的核心:公有接口非虚,虚函数私有化
NVI 原则的核心思想非常简单:
- 外界接口:声明为
public,但必须是非虚(non-virtual)的。 - 具体实现:声明为
virtual,但通常建议设为private(或者protected,如果子类需要显式调用)。
classGameCharacter{public:// 外界调用的唯一接口:非虚voidheal(intamount){// [前置工作]:记录日志,检查角色是否已经死亡if(isDead())return;doHeal(amount);// 调用真正的业务逻辑// [后置工作]:更新 UI,触发数值溢出检查clampHealth();}private:// 真正的逻辑:虚函数,私有virtualvoiddoHeal(intamount)=0;};classWarrior:publicGameCharacter{private:virtualvoiddoHeal(intamount){// 战士特有的回血逻辑}};二、 为什么这样做?(NVI 的三大优势)
1. 强制性的“切面”逻辑
通过非虚的heal(),基类确保了无论哪个子类,在执行回血逻辑之前,都必须先经过“死亡检查”。子类只需要关注“怎么回血”,而不需要关注“什么时候能回血”。
2. 接口与实现的分离
public接口代表了稳定性,而private virtual代表了灵活性。你可以随意更改虚函数的签名或逻辑,只要非虚的包装函数不变,外界调用者甚至不需要感知。
3. 解决“虚函数可见性”的悖论
很多人奇怪:基类私有的虚函数,子类能重写吗?
答案是:能!虚函数的重写(Override)取决于函数名和签名,而不是访问权限。派生类虽然不能“调用”基类的私有虚函数,但完全可以“提供”一个新的实现放入虚表(vtbl)中。
三、 特例:什么时候该用protected virtual?
在 NVI 模式下,如果派生类在实现自己的虚函数时,需要显式地调用基类的实现(即Base::doSomething()),那么这个虚函数必须设为protected。否则,请坚持使用private,以实现最强的封装。
总结:架构师的权力
- NVI 原则是对“模板方法(Template Method)”设计模式的完美实践。
- 它让基类成为了流程的独裁者,而让子类成为了任务的执行者。
- 在设计复杂的类层次结构时,先写一个非虚的
public函数作为入口,往往是更成熟的做法。
🟢 第三阶段·总结
至此,我们已经看透了 C++03 继承体系的每一层皮肉:从虚表的内存布局(No.021),到菱形继承的纠缠(No.024),再到现在的 NVI 设计准则(No.030)。
下一阶段预告:我们将告别运行时的多态,进入一个“在编译阶段就解决一切”的神奇领域。在这里,代码不是运行出来的,而是“算”出来的。
➡️《你真的了解C++吗》No.031:模板是“宏”的加强版吗?——模板推导的类型安全与物理真相。