二义性(ambiguity)="这个名字有两个候选,我不知道你要哪个"——C++ 的处理方式是直接报错,绝不替你猜。它有两种面貌:① 横向的:两个父类有同名成员(obj.x 不知道选哪个);② 纵向的:菱形继承——两条路径通向同一个祖先,祖先的成员在对象里存在两份,obj.x 同样不知道选哪一份。解药:横向用 obj.A::x 指名;纵向用 虚继承(11.4.3);但最好的解药是 重新设计,避开菱形。
11.3.1 已经露出了"同名成员必须指名"的头,本节把它讲透,并引入本章最著名的一张图——菱形继承(A → B/C → D)。菱形问题最后会在 11.4.3 虚继承里正式解决,所以本节的目标是"把问题看清楚"。另外,本节会用到 7.4.1 关于指针/地址的直觉:"两份数据"意味着两个不同的地址。
一、第一种二义性:横向的(两个父类同名)
class A { public: int x; void f(); };
class B { public: int x; void f(); };
class C : public A, public B { };
C obj;
obj.x = 1; // ❌ 二义性:A::x 还是 B::x?
obj.f(); // ❌ 二义性
报错长这样:
error: request for member 'x' is ambiguous
note: candidates are: int A::x
note: int B::x
注意 note: candidates are: 这一行——编译器会列出全部候选。看到 ambiguous,第一件事就是往下读这两行 note,它会告诉你到底哪两个打架了。
解药:指名
obj.A::x = 1; // ✅ 明确:用 A 那一份
obj.B::x = 2; // ✅ B 那一份是另一个变量!
obj.A::f(); // ✅
关键认识:
obj.A::x和obj.B::x是两个完全不同的变量,各占内存、各存各的值。"改成 A 的"完全不影响"B 的"。 这一点和"隐藏成员变量"(11.1.5)的道理一样。
另一种解药(不推荐但要知道):在 C 里自己定义一个同名函数
class C : public A, public B {
public:
void f() { cout << "C 自己的 f" << endl; } // 这个名字把 A::f、B::f 都盖住了
};
obj.f(); // ✅ 现在调的是 C::f(A、B 的那两个版本被"名字支配"了)
这叫"名字支配规则":子类里定义了同名函数,父类的同名函数就在"名字查找"这一关止步(11.1.5 的隐藏规则,在这里变成了解药)。但它有副作用:你可能再也不容易调到父类的版本了(要用 A::f() 指名)。
二、第二种二义性:纵向的(菱形继承)
这是本章最著名的坑。看这张经典的图:
┌─────────┐
│ A │ ← 共同祖先(比如"动物",有数据 member)
└────┬────┘
┌───────┴───────┐
┌───▼───┐ ┌───▼───┐
│ B │ │ C │
└───┬───┘ └───┬───┘
└───────┬───────┘
┌────▼────┐
│ D │ ← D 里到底有几份 A?
└─────────┘
用代码写出来:
class A {
public:
int member; // 比如"体重"
};
class B : public A { };
class C : public A { };
class D : public B, public C { }; // ★ 菱形!
问题一:D 里有几份 A?——两份
D 对象的内存:
┌──────────── D ────────────┐
│ ┌── B 子对象 ──┐ │
│ │ ┌─ A 子对象 ─┐ │ ← 第一份 A(member)
│ │ │ member │ │
│ │ └────────────┘ │
│ └───────────────┘ │
│ ┌── C 子对象 ──┐ │
│ │ ┌─ A 子对象 ─┐ │ ← 第二份 A(member)!白占内存
│ │ │ member │ │
│ │ └────────────┘ │
│ └───────────────┘ │
└────────────────────────────┘
问题二:d.member 直接报二义性
D d;
d.member = 5; // ❌ ambiguous!B 里那份还是 C 里那份?
d.B::member = 5; // ✅ 改"B 那条路径"上的 A
d.C::member = 5; // ✅ 改"C 那条路径"上的 A
问题三(最阴的):改了一份,另一份没变
D d;
d.B::member = 5; // 只改了 B 那条路的 A
cout << d.C::member << endl; // 还是垃圾值!完全不知道"我在改哪一份"
这就是菱形继承真正危险的地方:"一份数据"的语义,被拆成了两份物理存储。你以为自己在改"这个物品的编号",实际上改的是它两个身份之一里的那一份。
三、菱形的正规解药:虚继承
一句话预告(11.4.3 细讲):在"B 继承 A""C 继承 A"这两处都加 virtual:
class B : virtual public A { };
class C : virtual public A { };
class D : public B, public C { }; // 现在 D 里只有一份 A
加上 virtual 之后:
A的那一份被"共享",D 里只有一份(d.member不再二义)。- 代价:对象里多存了"指向虚基类的指针",对象变大、访问要绕一层;构造顺序也变了(虚基类永远最先构造,11.4.3)。
四、动手 Demo:菱形的完整现场
#include <iostream>
using namespace std;
class Animal {
public:
int weight;
};
class Dog : public Animal { };
class Cat : public Animal { };
// ★ 菱形:Dog 和 Cat 各自带了一份 Animal
class DogCat : public Dog, public Cat {
public:
void setWeight(int w) {
// weight = w; // ❌ 二义性:是 Dog::weight 还是 Cat::weight?
Dog::weight = w; // ✅ 明确指名
Cat::weight = w; // 两份各改一次 —— 这就是"数据冗余"的麻烦
}
};
int main() {
DogCat dc;
dc.setWeight(5);
cout << "Dog 那条路:weight = " << dc.Dog::weight << endl;
cout << "Cat 那条路:weight = " << dc.Cat::weight << endl;
// 演示"两份"的危险:只改一份
dc.Dog::weight = 90;
cout << "只改 Dog 那份后 → Dog:" << dc.Dog::weight
<< ",Cat:" << dc.Cat::weight << "(另一份没动!)" << endl;
cout << "sizeof(Animal) = " << sizeof(Animal)
<< " 字节;而 DogCat 里存了两份 Animal" << endl;
return 0;
}
看到什么算成功:
Dog 那条路:weight = 5
Cat 那条路:weight = 5
只改 Dog 那份后 → Dog:90,Cat:5(另一份没动!)
sizeof(Animal) = 4 字节;而 DogCat 里存了两份 Animal
读结果:
| 现象 | 结论 |
|---|---|
dc.Dog::weight 和 dc.Cat::weight 能分别取到 |
对象里真的有两份 Animal |
只改 Dog 那份,Cat 那份没动 |
"重量"这个概念在对象里被撕裂成两个! 任何依赖 weight 的逻辑都会读到"随机的一半" |
dc.weight 根本编不过 |
编译器直接拒绝猜——这是 C++ 最值得称赞的地方 |
30 秒实验:
1. 把 Dog::weight = w; 和 Cat::weight = w; 两行换成注释里那行 weight = w;,重新编译。
2. 你会看到:error: request for member 'weight' is ambiguous,并列出 Animal::weight 的两个候选(一个经 Dog,一个经 Cat)。
3. 把注释改回去,就好。
4. 再往前一步:把 Dog、Cat 的声明改成 class Dog : virtual public Animal { };(Cat 也一样),然后尝试恢复 weight = w;——这次能编过了,而且 dc.Dog::weight 与 dc.Cat::weight 是同一个变量(改一个另一个跟着变)。这就是虚继承在做的事,11.4.3 会把机制讲完。
五、常见误区
- "二义性会自动挑一个(比如第一个父类的)"——不会,直接报错。因为"猜错"的代价比"编译不过"大得多——尤其当两个候选是不同变量的时候,猜错就是逻辑错。
- "指名
obj.A::x之后,obj.B::x就失效了"——完全无关。指名的意思是"这次用这一份",另一份还在原地、还是原来的值。 - "菱形继承只是多浪费点内存,不影响正确性"——错,这是它最危险的地方。"应该只有一份的数据"变成了两份,改一份、另一份不知道 → 程序行为变得不可预测。内存浪费是小事,"两份真相"是大事。
- "虚继承就是万能解药"——不是免费的。它会让对象变大(要存虚基类指针)、访问变慢(绕一层)、构造顺序变化(虚基类最先、且由最终派生类负责初始化,11.4.3)。能用"重新设计"避开菱形,就别用虚继承。
- "只要没重名就不会有二义性"——菱形里即使不重名,也可能有麻烦。比如
A有两个不同的成员,两份A里的值可能不一致(一个被改了、一个没改)——这时不报错,但结果是错的,更难查。
自测三问(点开对答案)
1. 两个父类都有 `x` 时,`obj.x` 会怎样?怎么改?——**编译错**(`request for member 'x' is ambiguous`,会列出候选)。改法是**指名**:`obj.A::x` / `obj.B::x`(**它们是两个不同的变量**)。 2. 什么是菱形继承?它带来哪两个后果?——**A → B、A → C、B+C → D** 的结构。后果:**① `D` 里存在两份 `A` 的数据(冗余);② 访问 `A` 的成员时二义**——而且**只改其中一份时,另一份会保持旧值,程序行为变得不可预测**。 3. 菱形继承的正规解药是什么?代价是什么?——**虚继承**(`class B : virtual public A { };`)。代价:**对象变大**(存虚基类指针)、**访问绕一层**、**构造顺序改变**(虚基类最先构造,且由最终派生类负责初始化)。- ☐ 11.3.3 多重继承的构造顺序(下一节)
相关笔记
- 多重继承的定义 —— 上一节:语法与对象布局
- 多重继承的构造顺序(含实例05) —— 下一节:多个父类谁先构造
- 虚继承(含实例07) —— 菱形问题的正规解药
- 子类隐藏父类的成员函数(含实例03) —— "名字支配"的原始规则
- 变量与指针 —— "两份数据=两个地址"
- 🔷 C++基础 · 索引 —— 全课路线图