Effective C++ 条款33:避免遮掩继承而来的名字
在 C++ 的继承体系中,你是否遇到过"明明基类有这个方法,为什么编译器说找不到"的困惑?
这很可能是名字遮掩(name hiding)在作祟。本条款将揭开这个隐秘陷阱的面纱。
一、问题引入:消失的基类成员
先看一个令人困惑的例子:
cpp
#include <iostream>
#include <string>
class Base {
public:
void mf1() {
std::cout << "Base::mf1()\n";
}
void mf1(int x) {
std::cout << "Base::mf1(int): " << x << "\n";
}
void mf2() {
std::cout << "Base::mf2()\n";
}
void mf3() {
std::cout << "Base::mf3()\n";
}
void mf3(double x) {
std::cout << "Base::mf3(double): " << x << "\n";
}
};
class Derived : public Base {
public:
void mf1() { // 注意:这里只声明了 mf1(),没有参数版本
std::cout << "Derived::mf1()\n";
}
void mf3() { // 同样只声明了 mf3()
std::cout << "Derived::mf3()\n";
}
};
int main() {
Derived d;
d.mf1(); // OK: 调用 Derived::mf1()
// d.mf1(10); // 编译错误!Base::mf1(int) 被遮掩了!
d.mf2(); // OK: 调用 Base::mf2()
d.mf3(); // OK: 调用 Derived::mf3()
// d.mf3(3.14); // 编译错误!Base::mf3(double) 也被遮掩了!
}
奇怪的现象: Derived 明明 public 继承了 Base,但 Base::mf1(int) 和 Base::mf3(double) 却无法通过 Derived 对象访问!
二、名字遮掩的原理
2.1 C++ 的名字查找规则
要理解这个问题,我们需要了解 C++ 的**名字查找(name lookup)**机制:
C++ 的名字查找规则:只隐藏名字(hiding names),与类型无关。
当编译器遇到一个名字时,它会按照以下顺序查找:
- 局部作用域(当前函数体内)
- 包含作用域(当前类的成员)
- 下一个包含作用域(基类的成员)
- 命名空间作用域
关键规则: 一旦在某个作用域中找到了匹配的名字,查找就会停止,不会再继续搜索外层作用域中的同名标识符。
cpp
int x = 10; // 全局变量
void someFunc() {
double x = 3.14; // 局部变量,名字与全局变量相同
std::cout << x; // 输出 3.14,全局的 int x 被"遮掩"了
// 编译器在局部作用域找到了 x,就不再查找全局的 x
}
2.2 继承中的名字遮掩
在继承体系中,这个规则同样适用:
cpp
class Base {
public:
void mf1(); // 版本1
void mf1(int); // 版本2(重载)
void mf1(double); // 版本3(重载)
};
class Derived : public Base {
public:
void mf1(); // 派生类中声明了同名函数
// 结果:Base 中所有名为 mf1 的函数都被遮掩了!
// 包括 mf1()、mf1(int)、mf1(double)
};
| 现象 | 说明 |
|---|---|
| 名字相同即遮掩 | 不需要参数列表相同,甚至不需要是函数 |
| 全部重载版本被遮掩 | 派生类中一个同名函数会遮掩基类中所有同名函数 |
| 与 virtual 无关 | 即使是 non-virtual 函数也会发生遮掩 |
| 与访问权限无关 | private 成员也会参与名字查找并导致遮掩 |
2.3 变量和类型也会被遮掩
cpp
class Base {
public:
int x = 10;
enum Color { Red, Green, Blue };
typedef int Integer;
void func(int) {}
};
class Derived : public Base {
public:
double x = 3.14; // 遮掩了 Base::x
enum Color { Cyan }; // 遮掩了 Base::Color
typedef double Integer; // 遮掩了 Base::Integer
void func(double) {} // 遮掩了 Base::func(int)
};
int main() {
Derived d;
std::cout << d.x; // 输出 3.14,Base::x 被遮掩
// d.func(10); // 错误!Base::func(int) 被遮掩
}
三、为什么 C++ 要这样设计?
你可能会问:为什么 C++ 不设计成"只遮掩参数完全相同的函数,保留重载版本"?
原因:防止意外的行为变化。
cpp
// 假设你开发了一个库
class LibraryBase {
public:
void process(int x) { /* ... */ }
};
// 用户继承你的类
class UserDerived : public LibraryBase {
public:
void process(const std::string& s) { /* ... */ }
// 用户只关心处理字符串
};
// 后来库升级,LibraryBase 新增了一个重载:
class LibraryBase {
public:
void process(int x) { /* ... */ }
void process(double x) { /* 新增! */ } // 如果 C++ 不遮掩,
// 这会突然在 UserDerived 中可用!
};
如果 C++ 不采用"名字级别"的遮掩,那么库的作者新增一个重载函数,可能会意外地改变派生类的行为。这种设计确保了派生类对继承来的名字有完全的控制权。
四、解决方案:让被遮掩的名字重见天日
4.1 方案一:using 声明式(推荐用于 public 继承)
using 声明式可以将基类中的名字引入到派生类的作用域中:
cpp
class Base {
public:
void mf1() {
std::cout << "Base::mf1()\n";
}
void mf1(int x) {
std::cout << "Base::mf1(int): " << x << "\n";
}
void mf1(double x) {
std::cout << "Base::mf1(double): " << x << "\n";
}
void mf2() {
std::cout << "Base::mf2()\n";
}
};
class Derived : public Base {
public:
// 使用 using 声明,将 Base 中所有名为 mf1 的成员引入 Derived 作用域
using Base::mf1;
using Base::mf2;
void mf1() { // 现在这是重载,不是遮掩!
std::cout << "Derived::mf1()\n";
}
void mf3() {
std::cout << "Derived::mf3()\n";
}
};
int main() {
Derived d;
d.mf1(); // OK: 调用 Derived::mf1()
d.mf1(10); // OK: 调用 Base::mf1(int)
d.mf1(3.14); // OK: 调用 Base::mf1(double)
d.mf2(); // OK: 调用 Base::mf2()
return 0;
}
using 声明的作用:
| 特性 | 说明 |
|---|---|
| 引入所有重载 | using Base::mf1 引入 Base 中所有名为 mf1 的函数 |
| 保持访问权限 | 在 public 区域 using,引入的就是 public 成员 |
| 可配合重载 | 派生类可以声明自己的重载版本,与引入的版本共存 |
| 适用于变量/类型 | 也可以用于引入基类的成员变量、嵌套类型等 |
4.2 方案二:转交函数(Forwarding Functions)
如果你只想暴露基类的部分重载版本,或者需要在 private 继承中暴露特定接口,可以使用转交函数:
cpp
class Base {
public:
void mf1() {
std::cout << "Base::mf1()\n";
}
void mf1(int x) {
std::cout << "Base::mf1(int): " << x << "\n";
}
void mf1(double x) {
std::cout << "Base::mf1(double): " << x << "\n";
}
};
class Derived : public Base {
public:
void mf1() { // 派生类自己的版本
std::cout << "Derived::mf1()\n";
}
// 转交函数:只暴露 Base::mf1(int),不暴露 mf1(double)
void mf1(int x) {
std::cout << "[Derived forwarding] ";
Base::mf1(x); // 显式调用基类版本
}
// 注意:Base::mf1(double) 仍然被遮掩,无法通过 Derived 访问
};
int main() {
Derived d;
d.mf1(); // OK: Derived::mf1()
d.mf1(10); // OK: 通过转交函数调用 Base::mf1(int)
// d.mf1(3.14); // 错误:Base::mf1(double) 仍然被遮掩
}
转交函数的优势:
| 场景 | 使用转交函数 |
|---|---|
| 选择性暴露 | 只暴露基类的部分重载版本 |
| 添加前置/后置逻辑 | 在调用基类前后添加日志、校验等 |
| private 继承 | 将基类接口包装后暴露为 public |
| 改变参数 | 对参数进行转换后再转发给基类 |
4.3 两种方案的对比
| 特性 | using 声明 | 转交函数 |
|---|---|---|
| 代码量 | 少(一行) | 多(每个函数都要写) |
| 灵活性 | 引入所有重载 | 可精确控制引入哪些 |
| 可扩展性 | 基类新增重载自动可用 | 需要手动添加转交函数 |
| 可添加逻辑 | 否 | 是 |
| 适用场景 | public 继承,需要全部重载 | private 继承,或需要包装 |
五、实际应用场景
场景1:GUI 框架中的事件处理
cpp
class Widget {
public:
// 多种事件处理重载
virtual void onEvent(const MouseEvent& e) {
std::cout << "Widget 处理鼠标事件\n";
}
virtual void onEvent(const KeyEvent& e) {
std::cout << "Widget 处理键盘事件\n";
}
virtual void onEvent(const ResizeEvent& e) {
std::cout << "Widget 处理尺寸变化事件\n";
}
};
class Button : public Widget {
public:
// 如果不使用 using,下面这个声明会遮掩 Widget 中所有 onEvent 重载!
void onEvent(const MouseEvent& e) override {
std::cout << "Button 处理鼠标点击\n";
// 用户可能还想处理键盘事件(比如空格键触发按钮)
// 但 Widget::onEvent(KeyEvent) 已经被遮掩了!
}
};
// 正确的做法
class Button : public Widget {
public:
using Widget::onEvent; // 引入基类的所有事件处理
void onEvent(const MouseEvent& e) override {
std::cout << "Button 处理鼠标点击\n";
Widget::onEvent(e); // 可选:调用基类默认处理
}
};
// 使用
int main() {
Button btn;
btn.onEvent(MouseEvent{}); // Button::onEvent(MouseEvent)
btn.onEvent(KeyEvent{}); // Widget::onEvent(KeyEvent) - 仍然可用!
btn.onEvent(ResizeEvent{}); // Widget::onEvent(ResizeEvent) - 仍然可用!
}
场景2:自定义容器的接口暴露
cpp
#include <vector>
#include <algorithm>
// 自定义一个有序数组,继承自 std::vector(仅为示例,实际不推荐 public 继承 STL 容器)
template<typename T>
class SortedVector : private std::vector<T> {
public:
// 使用转交函数暴露需要的接口
using typename std::vector<T>::size_type;
using typename std::vector<T>::iterator;
using typename std::vector<T>::const_iterator;
// 转交函数:暴露 size()
size_type size() const {
return std::vector<T>::size();
}
// 转交函数:暴露迭代器
iterator begin() { return std::vector<T>::begin(); }
iterator end() { return std::vector<T>::end(); }
const_iterator begin() const { return std::vector<T>::begin(); }
const_iterator end() const { return std::vector<T>::end(); }
// 自定义:插入时保持有序
void insert(const T& value) {
auto it = std::lower_bound(std::vector<T>::begin(),
std::vector<T>::end(), value);
std::vector<T>::insert(it, value);
}
// 注意:我们不暴露 push_back,因为它会破坏有序性!
// 也不暴露 operator[],因为直接修改元素也会破坏有序性
// 但暴露 at() 用于只读访问
const T& at(size_type index) const {
return std::vector<T>::at(index);
}
};
场景3:游戏开发中的技能系统
cpp
class Skill {
public:
virtual ~Skill() = default;
// 多种使用方式
virtual void cast() {
std::cout << "释放技能\n";
}
virtual void cast(Entity& target) {
std::cout << "对目标释放技能\n";
}
virtual void cast(const Vec3& position) {
std::cout << "对位置释放技能\n";
}
virtual void cast(Entity& target, const Vec3& position) {
std::cout << "对目标在指定位置释放技能\n";
}
};
class Fireball : public Skill {
public:
using Skill::cast; // 引入所有 cast 重载
void cast() override {
std::cout << "释放火球术!\n";
createFireEffect();
}
void cast(Entity& target) override {
std::cout << "向 " << target.getName() << " 发射火球!\n";
applyDamage(target, 50);
createFireEffect();
}
// cast(Vec3) 和 cast(Entity&, Vec3) 继承自 Skill,仍然可用
private:
void createFireEffect() {
std::cout << "创建火焰特效\n";
}
void applyDamage(Entity& target, int damage) {
target.takeDamage(damage);
}
};
六、重载、重写、隐藏的区别
这是 C++ 继承中最容易混淆的三个概念,我们来彻底理清:
cpp
class Base {
public:
void func(int) {} // 1. 基类版本
virtual void vfunc(int) {} // 2. 虚函数
};
class Derived : public Base {
public:
void func(int) {} // 3. 隐藏(hide)基类的 func(int)
void func(double) {} // 4. 重载(overload)Derived::func(int)
// 同时也隐藏了 Base::func(int)
void vfunc(int) override {} // 5. 重写(override)Base::vfunc(int)
};
| 概念 | 英文 | 发生条件 | 作用域 | 是否要求 virtual |
|---|---|---|---|---|
| 重载 | Overload | 同名函数,参数不同 | 同一作用域 | 否 |
| 重写 | Override | 函数签名完全相同 | 基类与派生类 | 是(基类必须有 virtual) |
| 隐藏 | Hide | 同名即可(参数可不同) | 基类与派生类 | 否 |
cpp
// 详细对比示例
class Base {
public:
void func(int) { std::cout << "Base::func(int)\n"; }
virtual void vfunc(int) { std::cout << "Base::vfunc(int)\n"; }
};
class Derived : public Base {
public:
void func(int) { std::cout << "Derived::func(int)\n"; } // 隐藏
void func(double) { std::cout << "Derived::func(double)\n"; } // 重载 + 隐藏
void vfunc(int) override { std::cout << "Derived::vfunc(int)\n"; } // 重写
};
int main() {
Derived d;
Base& b = d;
d.func(10); // Derived::func(int) - 静态绑定
d.func(3.14); // Derived::func(double) - 重载解析
b.func(10); // Base::func(int) - 不是虚函数,静态绑定
b.vfunc(10); // Derived::vfunc(int) - 虚函数,动态绑定
// d.func(10) 调用的是 Derived::func(int),Base::func(int) 被隐藏了
// 如果想调用基类版本:
d.Base::func(10); // 显式调用基类版本
}
七、private 继承中的特殊考量
在 private 继承中,基类的 public 成员在派生类中变成 private。如果你希望暴露某些接口,转交函数特别有用:
cpp
class Timer {
public:
void start() { /* ... */ }
void stop() { /* ... */ }
double elapsed() const { /* ... */ }
};
class Animation : private Timer {
public:
// 使用转交函数选择性暴露 Timer 的接口
void start() { Timer::start(); }
double elapsed() const { return Timer::elapsed(); }
// 注意:不暴露 stop(),Animation 自己控制停止时机
void update() {
if (elapsed() > duration_) {
// 动画结束
Timer::stop();
}
}
private:
double duration_;
};
八、总结
| 要点 | 说明 |
|---|---|
| 名字遮掩规则 | 派生类中的名字会遮掩基类中所有同名名字,与参数列表无关 |
| 原因 | C++ 的名字查找在找到第一个匹配后就停止 |
| 影响 | 基类的重载函数版本可能被意外隐藏 |
| using 声明 | 将基类中某个名字的所有重载引入派生类作用域 |
| 转交函数 | 精确控制暴露哪些基类接口,可添加额外逻辑 |
请记住:
- derived classes 内的名称会遮掩 base classes 内的名称。在 public 继承下从来没有人希望如此。
- 为了让被遮掩的名称再见天日,可使用 using 声明式或转交函数(forwarding functions)。
- using 声明适用于需要暴露基类所有重载版本的场景。
- 转交函数适用于需要选择性暴露或添加额外处理的场景。
名字遮掩是 C++ 中一个隐蔽但重要的陷阱。在 public 继承中,我们期望派生类扩展基类的行为,而不是限制它。养成使用 using 声明的习惯,可以让你的继承体系更加健壮和透明。
参考:《Effective C++》第三版,Scott Meyers 著
相关条款:条款32(确定 public 继承塑模出 is-a 关系)、条款34(区分接口继承和实现继承)、条款39(明智而审慎地使用 private 继承)