Effective C++ 条款37:绝不重新定义继承而来的缺省参数值
本篇为《Effective C++:改善程序与设计的 55 个具体做法》读书笔记系列第 37 篇。
开篇引言
在 C++ 中,virtual 函数支持动态绑定 (运行时多态),而函数的缺省参数值 却是静态绑定 的。这种不一致性导致了一个极其隐蔽的陷阱:当你通过基类指针调用派生类的 virtual 函数时,使用的缺省参数值可能来自基类,而非派生类。Scott Meyers 在条款 37 中警告我们:绝不重新定义继承而来的缺省参数值。本文将深入剖析这一陷阱的本质,并提供安全的替代方案。
核心问题:一个令人困惑的示例
让我们从一个直观的例子开始:
cpp
#include <iostream>
class Shape {
public:
enum ShapeColor { Red, Green, Blue };
// virtual 函数带有缺省参数
virtual void draw(ShapeColor color = Red) const {
std::cout << "Shape::draw with color " << color << std::endl;
}
virtual ~Shape() = default;
};
class Rectangle : public Shape {
public:
// 危险!重新定义了继承而来的缺省参数值
virtual void draw(ShapeColor color = Green) const override {
std::cout << "Rectangle::draw with color " << color << std::endl;
}
};
class Circle : public Shape {
public:
// 没有指定缺省参数
virtual void draw(ShapeColor color) const override {
std::cout << "Circle::draw with color " << color << std::endl;
}
};
int main() {
Shape* ps = new Shape();
Shape* pr = new Rectangle();
Shape* pc = new Circle();
ps->draw(); // Shape::draw with color 0 (Red)
pr->draw(); // Rectangle::draw with color 0 (Red) ------ 注意!不是 Green!
// pc->draw(); // 编译错误:Circle::draw 没有缺省参数
delete ps;
delete pr;
delete pc;
return 0;
}
令人震惊的结果
| 调用语句 | 实际调用的函数 | 实际使用的缺省参数 | 预期参数 |
|---|---|---|---|
ps->draw() |
Shape::draw |
Red (0) |
Red |
pr->draw() |
Rectangle::draw |
Red (0) |
Green |
通过 Shape* 指针调用 Rectangle::draw() 时,函数体是 Rectangle 的,但缺省参数却是 Shape 的!这就是静态绑定与动态绑定的分裂行为。
原理深度解析
静态类型 vs 动态类型
要理解这个问题,我们需要明确两个核心概念:
1. 静态类型(Static Type)
静态类型是变量在声明时的类型,在编译期就已确定:
cpp
Shape* pr = new Rectangle(); // pr 的静态类型是 Shape*
Rectangle* pr2 = new Rectangle(); // pr2 的静态类型是 Rectangle*
2. 动态类型(Dynamic Type)
动态类型是变量实际指向的对象的类型,在运行期才能确定:
cpp
Shape* pr = new Rectangle(); // pr 的动态类型是 Rectangle*
pr = new Circle(); // pr 的动态类型变为 Circle*
绑定机制的分裂
| 特性 | 绑定方式 | 决定因素 |
|---|---|---|
virtual 函数的调用 |
动态绑定 | 对象的动态类型 |
| 缺省参数值 | 静态绑定 | 指针/引用的静态类型 |
cpp
Shape* pr = new Rectangle();
pr->draw(); // 等价于:
// 1. 调用哪个 draw?动态绑定 → Rectangle::draw
// 2. 缺省参数是什么?静态绑定 → Shape::draw 的缺省参数 = Red
为什么 C++ 这样设计?
你可能会问:为什么 C++ 不让缺省参数也动态绑定呢?
答案是运行期效率 。如果缺省参数是动态绑定的,编译器必须在运行期为每次 virtual 函数调用决定适当的缺省参数值。这需要:
- 在虚函数表中额外存储缺省参数信息
- 每次调用时进行额外的查找和解析
- 增加编译器的复杂度和运行期开销
C++ 的设计哲学倾向于零开销抽象(zero-overhead abstraction)。为了程序的执行速度和编译器实现的简易度,C++ 选择了在编译期决定缺省参数值。
cpp
// 编译器实际生成的代码(概念上)
// pr->draw() 被编译为类似:
// pr->vptr[draw_index](pr, Shape::draw_default_color); // 缺省参数在编译期硬编码
代码示例:更复杂的场景
场景 1:多层继承体系
cpp
#include <iostream>
class Base {
public:
virtual void func(int x = 10) const {
std::cout << "Base::func(" << x << ")" << std::endl;
}
virtual ~Base() = default;
};
class Middle : public Base {
public:
virtual void func(int x = 20) const override { // 危险!
std::cout << "Middle::func(" << x << ")" << std::endl;
}
};
class Derived : public Middle {
public:
virtual void func(int x = 30) const override { // 更危险!
std::cout << "Derived::func(" << x << ")" << std::endl;
}
};
void test() {
Base* pb = new Derived();
Middle* pm = new Derived();
Derived* pd = new Derived();
pb->func(); // Derived::func(10) ------ Base 的缺省值
pm->func(); // Derived::func(20) ------ Middle 的缺省值
pd->func(); // Derived::func(30) ------ Derived 的缺省值
delete pb;
delete pm;
delete pd;
}
场景 2:引用同样受影响
cpp
void drawShape(const Shape& 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 函数负责实际工作。
cpp
#include <iostream>
class Shape {
public:
enum ShapeColor { Red, Green, Blue };
// public non-virtual 接口:指定缺省参数
void draw(ShapeColor color = Red) const {
doDraw(color); // 调用 private virtual 实现
}
virtual ~Shape() = default;
private:
// private virtual 实现:派生类可自定义行为
virtual void doDraw(ShapeColor color) const {
std::cout << "Shape::doDraw with color " << color << std::endl;
}
};
class Rectangle : public Shape {
private:
virtual void doDraw(ShapeColor color) const override {
std::cout << "Rectangle::doDraw with color " << color << std::endl;
}
// 不需要指定缺省参数!
};
class Circle : public Shape {
private:
virtual void doDraw(ShapeColor color) const override {
std::cout << "Circle::doDraw with color " << color << std::endl;
}
};
int main() {
Shape* ps = new Shape();
Shape* pr = new Rectangle();
Shape* pc = new Circle();
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)
delete ps;
delete pr;
delete pc;
return 0;
}
NVI 模式的优势
| 优势 | 说明 |
|---|---|
| 缺省参数一致性 | 所有派生类共享相同的缺省参数值 |
| 接口与实现分离 | public 接口稳定,private 实现可扩展 |
| 前置/后置处理 | 可以在 non-virtual 函数中添加通用逻辑 |
| 符合条款 36 | non-virtual 函数不会被派生类重定义 |
cpp
class Shape {
public:
void draw(ShapeColor color = Red) const {
// 前置处理:所有派生类共享
prepareForDrawing();
doDraw(color); // 多态调用
// 后置处理:所有派生类共享
cleanupAfterDrawing();
}
private:
virtual void doDraw(ShapeColor color) const = 0;
void prepareForDrawing() const {
std::cout << "Preparing canvas..." << std::endl;
}
void cleanupAfterDrawing() const {
std::cout << "Cleaning up..." << std::endl;
}
};
实际应用场景
场景 1:GUI 框架中的绘图系统
cpp
#include <iostream>
#include <string>
class Widget {
public:
enum RenderMode { Normal, Highlighted, Disabled };
// NVI 模式:统一的缺省参数和前置/后置处理
void render(RenderMode mode = Normal) const {
beginRender();
doRender(mode);
endRender();
}
virtual ~Widget() = default;
protected:
// 派生类可访问的辅助函数
bool isVisible() const { return visible; }
private:
virtual void doRender(RenderMode mode) const = 0;
void beginRender() const {
std::cout << "[Begin Render]" << std::endl;
}
void endRender() const {
std::cout << "[End Render]" << std::endl;
}
bool visible = true;
};
class Button : public Widget {
private:
virtual void doRender(RenderMode mode) const override {
std::cout << "Button rendering in mode " << mode << std::endl;
}
};
class TextBox : public Widget {
private:
virtual void doRender(RenderMode mode) const override {
std::cout << "TextBox rendering in mode " << mode << std::endl;
}
};
void renderUI(const Widget& widget) {
widget.render(); // 总是使用 Widget::Normal 作为缺省值
}
场景 2:网络请求库
cpp
#include <iostream>
#include <string>
class HttpClient {
public:
enum Timeout { Default = 30, Long = 120, Short = 5 };
// 统一的缺省超时时间
void sendRequest(const std::string& url, Timeout timeout = Default) {
setupConnection();
doSendRequest(url, timeout);
teardownConnection();
}
virtual ~HttpClient() = default;
private:
virtual void doSendRequest(const std::string& url, Timeout timeout) = 0;
void setupConnection() {
std::cout << "Setting up connection..." << std::endl;
}
void teardownConnection() {
std::cout << "Tearing down connection..." << std::endl;
}
};
class SecureHttpClient : public HttpClient {
private:
virtual void doSendRequest(const std::string& url, Timeout timeout) override {
std::cout << "Sending HTTPS request to " << url
<< " with timeout " << timeout << "s" << std::endl;
}
};
class ProxyHttpClient : public HttpClient {
private:
virtual void doSendRequest(const std::string& url, Timeout timeout) override {
std::cout << "Sending HTTP request through proxy to " << url
<< " with timeout " << timeout << "s" << std::endl;
}
};
常见误区与解决方案
误区 1:"我在派生类中重复相同的缺省参数就安全了"
cpp
class Base {
public:
virtual void func(int x = 10) { /* ... */ }
};
class Derived : public Base {
public:
virtual void func(int x = 10) override { /* ... */ } // 危险!代码重复!
};
问题:
- 代码重复(DRY 原则被破坏)
- 如果基类的缺省参数改变,所有派生类都必须同步修改
- 仍然可能产生不一致(如果某个派生类忘记修改)
误区 2:"我可以用宏来避免重复"
cpp
#define DEFAULT_PARAM 10
class Base {
public:
virtual void func(int x = DEFAULT_PARAM) { /* ... */ }
};
class Derived : public Base {
public:
virtual void func(int x = 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)与继承的区别,以及何时应该选择复合而非继承。
如果本文对你有帮助,欢迎点赞、收藏、转发!有任何问题可以在评论区留言讨论。