Effective C++ 条款37:绝不重新定义继承而来的缺省参数值
Effective C++ 条款37:绝不重新定义继承而来的缺省参数值
本篇为《Effective C++:改善程序与设计的 55 个具体做法》读书笔记系列第 37 篇。
开篇引言
在 C++ 中,virtual函数支持动态绑定(运行时多态),而函数的缺省参数值却是静态绑定的。这种不一致性导致了一个极其隐蔽的陷阱:当你通过基类指针调用派生类的virtual函数时,使用的缺省参数值可能来自基类,而非派生类。Scott Meyers 在条款 37 中警告我们:绝不重新定义继承而来的缺省参数值。本文将深入剖析这一陷阱的本质,并提供安全的替代方案。
核心问题:一个令人困惑的示例
让我们从一个直观的例子开始:
#include<iostream>classShape{public:enumShapeColor{Red,Green,Blue};// virtual 函数带有缺省参数virtualvoiddraw(ShapeColor color=Red)const{std::cout<<"Shape::draw with color "<<color<<std::endl;}virtual~Shape()=default;};classRectangle:publicShape{public:// 危险!重新定义了继承而来的缺省参数值virtualvoiddraw(ShapeColor color=Green)constoverride{std::cout<<"Rectangle::draw with color "<<color<<std::endl;}};classCircle:publicShape{public:// 没有指定缺省参数virtualvoiddraw(ShapeColor color)constoverride{std::cout<<"Circle::draw with color "<<color<<std::endl;}};intmain(){Shape*ps=newShape();Shape*pr=newRectangle();Shape*pc=newCircle();ps->draw();// Shape::draw with color 0 (Red)pr->draw();// Rectangle::draw with color 0 (Red) —— 注意!不是 Green!// pc->draw(); // 编译错误:Circle::draw 没有缺省参数deleteps;deletepr;deletepc;return0;}令人震惊的结果
| 调用语句 | 实际调用的函数 | 实际使用的缺省参数 | 预期参数 |
|---|---|---|---|
ps->draw() | Shape::draw | Red(0) | Red |
pr->draw() | Rectangle::draw | Red(0) | Green |
通过Shape*指针调用Rectangle::draw()时,函数体是Rectangle的,但缺省参数却是Shape的!这就是静态绑定与动态绑定的分裂行为。
原理深度解析
静态类型 vs 动态类型
要理解这个问题,我们需要明确两个核心概念:
1. 静态类型(Static Type)
静态类型是变量在声明时的类型,在编译期就已确定:
Shape*pr=newRectangle();// pr 的静态类型是 Shape*Rectangle*pr2=newRectangle();// pr2 的静态类型是 Rectangle*2. 动态类型(Dynamic Type)
动态类型是变量实际指向的对象的类型,在运行期才能确定:
Shape*pr=newRectangle();// pr 的动态类型是 Rectangle*pr=newCircle();// pr 的动态类型变为 Circle*绑定机制的分裂
| 特性 | 绑定方式 | 决定因素 |
|---|---|---|
virtual函数的调用 | 动态绑定 | 对象的动态类型 |
| 缺省参数值 | 静态绑定 | 指针/引用的静态类型 |
Shape*pr=newRectangle();pr->draw();// 等价于:// 1. 调用哪个 draw?动态绑定 → Rectangle::draw// 2. 缺省参数是什么?静态绑定 → Shape::draw 的缺省参数 = Red为什么 C++ 这样设计?
你可能会问:为什么 C++ 不让缺省参数也动态绑定呢?
答案是运行期效率。如果缺省参数是动态绑定的,编译器必须在运行期为每次virtual函数调用决定适当的缺省参数值。这需要:
- 在虚函数表中额外存储缺省参数信息
- 每次调用时进行额外的查找和解析
- 增加编译器的复杂度和运行期开销
C++ 的设计哲学倾向于零开销抽象(zero-overhead abstraction)。为了程序的执行速度和编译器实现的简易度,C++ 选择了在编译期决定缺省参数值。
// 编译器实际生成的代码(概念上)// pr->draw() 被编译为类似:// pr->vptr[draw_index](pr, Shape::draw_default_color); // 缺省参数在编译期硬编码代码示例:更复杂的场景
场景 1:多层继承体系
#include<iostream>classBase{public:virtualvoidfunc(intx=10)const{std::cout<<"Base::func("<<x<<")"<<std::endl;}virtual~Base()=default;};classMiddle:publicBase{public:virtualvoidfunc(intx=20)constoverride{// 危险!std::cout<<"Middle::func("<<x<<")"<<std::endl;}};classDerived:publicMiddle{public:virtualvoidfunc(intx=30)constoverride{// 更危险!std::cout<<"Derived::func("<<x<<")"<<std::endl;}};voidtest(){Base*pb=newDerived();Middle*pm=newDerived();Derived*pd=newDerived();pb->func();// Derived::func(10) —— Base 的缺省值pm->func();// Derived::func(20) —— Middle 的缺省值pd->func();// Derived::func(30) —— Derived 的缺省值deletepb;deletepm;deletepd;}场景 2:引用同样受影响
voiddrawShape(constShape&shape){shape.draw();// 同样的问题!}Rectangle rect;drawShape(rect);// 调用 Rectangle::draw,但使用 Shape::Red 作为缺省值解决方案:NVI 设计模式
当你确实需要为virtual函数提供缺省参数时,NVI(Non-Virtual Interface)设计模式是最优雅的解决方案。
NVI 模式的核心思想
将virtual函数设为private,通过一个public的non-virtual函数来调用它。non-virtual函数负责提供缺省参数,virtual函数负责实际工作。
#include<iostream>classShape{public:enumShapeColor{Red,Green,Blue};// public non-virtual 接口:指定缺省参数voiddraw(ShapeColor color=Red)const{doDraw(color);// 调用 private virtual 实现}virtual~Shape()=default;private:// private virtual 实现:派生类可自定义行为virtualvoiddoDraw(ShapeColor color)const{std::cout<<"Shape::doDraw with color "<<color<<std::endl;}};classRectangle:publicShape{private:virtualvoiddoDraw(ShapeColor color)constoverride{std::cout<<"Rectangle::doDraw with color "<<color<<std::endl;}// 不需要指定缺省参数!};classCircle:publicShape{private:virtualvoiddoDraw(ShapeColor color)constoverride{std::cout<<"Circle::doDraw with color "<<color<<std::endl;}};intmain(){Shape*ps=newShape();Shape*pr=newRectangle();Shape*pc=newCircle();ps->draw();// Shape::doDraw with color 0 (Red)pr->draw();// Rectangle::doDraw with color 0 (Red) —— 一致且正确!pc->draw();// Circle::doDraw with color 0 (Red)// 也可以显式指定参数pr->draw(Shape::Green);// Rectangle::doDraw with color 1 (Green)deleteps;deletepr;deletepc;return0;}NVI 模式的优势
| 优势 | 说明 |
|---|---|
| 缺省参数一致性 | 所有派生类共享相同的缺省参数值 |
| 接口与实现分离 | public接口稳定,private实现可扩展 |
| 前置/后置处理 | 可以在non-virtual函数中添加通用逻辑 |
| 符合条款 36 | non-virtual函数不会被派生类重定义 |
classShape{public:voiddraw(ShapeColor color=Red)const{// 前置处理:所有派生类共享prepareForDrawing();doDraw(color);// 多态调用// 后置处理:所有派生类共享cleanupAfterDrawing();}private:virtualvoiddoDraw(ShapeColor color)const=0;voidprepareForDrawing()const{std::cout<<"Preparing canvas..."<<std::endl;}voidcleanupAfterDrawing()const{std::cout<<"Cleaning up..."<<std::endl;}};实际应用场景
场景 1:GUI 框架中的绘图系统
#include<iostream>#include<string>classWidget{public:enumRenderMode{Normal,Highlighted,Disabled};// NVI 模式:统一的缺省参数和前置/后置处理voidrender(RenderMode mode=Normal)const{beginRender();doRender(mode);endRender();}virtual~Widget()=default;protected:// 派生类可访问的辅助函数boolisVisible()const{returnvisible;}private:virtualvoiddoRender(RenderMode mode)const=0;voidbeginRender()const{std::cout<<"[Begin Render]"<<std::endl;}voidendRender()const{std::cout<<"[End Render]"<<std::endl;}boolvisible=true;};classButton:publicWidget{private:virtualvoiddoRender(RenderMode mode)constoverride{std::cout<<"Button rendering in mode "<<mode<<std::endl;}};classTextBox:publicWidget{private:virtualvoiddoRender(RenderMode mode)constoverride{std::cout<<"TextBox rendering in mode "<<mode<<std::endl;}};voidrenderUI(constWidget&widget){widget.render();// 总是使用 Widget::Normal 作为缺省值}场景 2:网络请求库
#include<iostream>#include<string>classHttpClient{public:enumTimeout{Default=30,Long=120,Short=5};// 统一的缺省超时时间voidsendRequest(conststd::string&url,Timeout timeout=Default){setupConnection();doSendRequest(url,timeout);teardownConnection();}virtual~HttpClient()=default;private:virtualvoiddoSendRequest(conststd::string&url,Timeout timeout)=0;voidsetupConnection(){std::cout<<"Setting up connection..."<<std::endl;}voidteardownConnection(){std::cout<<"Tearing down connection..."<<std::endl;}};classSecureHttpClient:publicHttpClient{private:virtualvoiddoSendRequest(conststd::string&url,Timeout timeout)override{std::cout<<"Sending HTTPS request to "<<url<<" with timeout "<<timeout<<"s"<<std::endl;}};classProxyHttpClient:publicHttpClient{private:virtualvoiddoSendRequest(conststd::string&url,Timeout timeout)override{std::cout<<"Sending HTTP request through proxy to "<<url<<" with timeout "<<timeout<<"s"<<std::endl;}};常见误区与解决方案
误区 1:“我在派生类中重复相同的缺省参数就安全了”
classBase{public:virtualvoidfunc(intx=10){/* ... */}};classDerived:publicBase{public:virtualvoidfunc(intx=10)override{/* ... */}// 危险!代码重复!};问题:
- 代码重复(DRY 原则被破坏)
- 如果基类的缺省参数改变,所有派生类都必须同步修改
- 仍然可能产生不一致(如果某个派生类忘记修改)
误区 2:“我可以用宏来避免重复”
#defineDEFAULT_PARAM10classBase{public:virtualvoidfunc(intx=DEFAULT_PARAM){/* ... */}};classDerived:publicBase{public:virtualvoidfunc(intx=DEFAULT_PARAM)override{/* ... */}};虽然这解决了代码重复问题,但仍然违反了条款 37 的精神,且宏在现代 C++ 中应该避免使用。
正确做法总结
| 场景 | 推荐方案 |
|---|---|
virtual函数需要缺省参数 | 使用 NVI 模式 |
| 所有派生类共享相同缺省参数 | 在public non-virtual函数中指定 |
| 派生类需要不同的"缺省"行为 | 考虑使用策略模式或函数重载 |
总结
核心要点
| 要点 | 说明 |
|---|---|
| 缺省参数是静态绑定的 | 由指针/引用的声明类型决定 |
virtual函数是动态绑定的 | 由对象的实际类型决定 |
| 这种分裂会导致意外行为 | 调用派生类函数却使用基类缺省参数 |
| NVI 模式是最佳解决方案 | public non-virtual提供缺省参数,private virtual负责实现 |
记忆口诀
Virtual 函数动态绑,缺省参数静态定。
两者混用出大坑,NVI 模式来救场。
接口非虚参数稳,实现私有可扩展。
条款 37 的核心建议
绝不重新定义继承而来的缺省参数值。如果你需要为virtual函数提供缺省参数:
- 使用 NVI 设计模式
- 在
public non-virtual函数中指定缺省参数 - 让
private virtual函数负责实际的多态实现
参考阅读:
- 《Effective C++》Scott Meyers,条款 37
- 《C++ Primer》Stanley B. Lippman 等,关于虚函数和缺省参数的章节
- 《设计模式》GoF,Template Method 模式
系列预告:下一篇将深入解析条款 38——通过复合塑模出 has-a 或 “根据某物实现出”,探讨复合(composition)与继承的区别,以及何时应该选择复合而非继承。
如果本文对你有帮助,欢迎点赞、收藏、转发!有任何问题可以在评论区留言讨论。
