1. 诡异的现象:函数与参数的“精神分裂”
假设我们设计一个图形类体系,绘制图形时可以指定颜色。基类提供了一个默认颜色 Red,而派生类希望将默认颜色改为 Green。
class Shape {
public:enum ShapeColor { Red, Green, Blue };// 虚函数,默认参数是 Redvirtual void draw(ShapeColor color = Red) const = 0;
};class Rectangle : public Shape {
public:// 错误!重新定义了默认参数值 (Red -> Green)virtual void draw(ShapeColor color = Green) const {std::cout << "Rectangle drawing with color: " << color << std::endl;}
};class Circle : public Shape {
public:// 这个没改默认值,但是如果客户端通过 Shape* 调用,// 必须依赖基类的声明,否则 Circle 自己的定义里没有默认值virtual void draw(ShapeColor color) const {std::cout << "Circle drawing with color: " << color << std::endl;}
};
现在我们来看调用结果:
Shape* pr = new Rectangle; // 静态类型 Shape*, 动态类型 Rectangle*// 调用 draw(),不传参数
pr->draw();
你期望发生什么? 因为 pr 实际上指向 Rectangle,且 draw 是虚函数,你可能期望调用 Rectangle::draw(Green)。
实际发生了什么?
- 函数体:执行的是
Rectangle::draw(因为是虚函数)。 - 参数值:使用的是
Red(来自Shape的默认值)。 - 最终结果:
Rectangle用Red画了出来!
这就是所谓的行为分裂:你调用了一个派生类的函数,却用了一半基类的特性(参数)。
2. 原理解析:静态绑定 vs 动态绑定
造成这种现象的原因在于 C++ 为了执行效率所做的妥协:
- 虚函数 (Virtual Functions) 是动态绑定 (Dynamically Bound)。 这意味着调用哪个函数是在运行期根据对象的实际类型决定的。
- 缺省参数值 (Default Parameter Values) 是静态绑定 (Statically Bound)。 这意味着参数的默认值是在编译期根据指针的声明类型(静态类型)决定的。
回到上面的例子:
- 编译器看到
pr->draw()。 pr的静态类型是Shape*。- 编译器查看
Shape::draw的声明,发现默认参数是Red。于是编译器就把Red硬编码进了函数调用指令中。 - 运行时,虚函数机制找到了
Rectangle::draw的实现。 - 合体:
Rectangle::draw(Red)被执行。
为什么 C++ 要这么设计? 如果默认参数值也是动态绑定的,编译器就必须在运行期为每个虚函数调用生成代码来决定参数值,这比目前的机制(编译期直接填入常数)要慢且复杂得多。C++ 选择了速度,牺牲了直观性。
3. 怎么解决?(NVI 手法)
既然不能重定义默认参数,那如果我们确实希望提供默认参数,该怎么办?
Scott Meyers 推荐使用 NVI (Non-Virtual Interface) 惯用手法。即:用一个非虚函数(wrapper)做对外接口,在里面处理默认参数,然后调用一个受保护的虚函数做实际工作。
class Shape {
public:enum ShapeColor { Red, Green, Blue };// 1. 这是一个非虚函数 (Non-virtual)// 它是唯一的入口,并且它定义了默认参数 (Red)void draw(ShapeColor color = Red) const {doDraw(color); // 调用虚函数}private:// 2. 这是一个虚函数 (Virtual)// 它负责实际的工作,但它不包含默认参数virtual void doDraw(ShapeColor color) const = 0;
};class Rectangle : public Shape {
private:// 子类只需要重写这个虚函数// 不需要(也不能)关心默认参数的问题virtual void doDraw(ShapeColor color) const override {std::cout << "Rectangle drawing with color: " << color << std::endl;}
};
这种设计的好处:
- 消除了歧义:
draw是非虚函数,根据 Item 36,子类不应该隐藏它。所以无论你是Shape*还是Rectangle*,调用的都是Shape::draw,默认参数永远是Red。 - 多态依然存在:
Shape::draw内部调用了doDraw,这是虚函数,所以会正确地调用Rectangle::doDraw。 - 控制权在基类:基类完全掌控了默认参数的逻辑,避免了代码重复和不一致。
总结
- Virtual 函数是动态绑定的。
- 缺省参数值是静态绑定的。
- 绝对不要重新定义继承而来的缺省参数值,因为你很容易陷入“调用子类函数但使用基类参数”的陷阱。
- 解决方案:如果需要默认参数,使用 NVI (Non-Virtual Interface) 模式:让基类的 public non-virtual 函数接受默认参数,并调用 private virtual 函数执行实际任务。