🔷 第十一章 继承与派生
📄 一句话概括

二义性(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 之后:

四、动手 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 会把机制讲完。

五、常见误区

  1. "二义性会自动挑一个(比如第一个父类的)"——不会,直接报错。因为"猜错"的代价比"编译不过"大得多——尤其当两个候选是不同变量的时候,猜错就是逻辑错。
  2. "指名 obj.A::x 之后,obj.B::x 就失效了"——完全无关。指名的意思是"这次用这一份",另一份还在原地、还是原来的值。
  3. "菱形继承只是多浪费点内存,不影响正确性"——错,这是它最危险的地方。"应该只有一份的数据"变成了两份,改一份、另一份不知道 → 程序行为变得不可预测。内存浪费是小事,"两份真相"是大事。
  4. "虚继承就是万能解药"——不是免费的。它会让对象变大(要存虚基类指针)、访问变慢(绕一层)、构造顺序变化(虚基类最先、且由最终派生类负责初始化,11.4.3)。能用"重新设计"避开菱形,就别用虚继承。
  5. "只要没重名就不会有二义性"——菱形里即使不重名,也可能有麻烦。比如 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 { };`)。代价:**对象变大**(存虚基类指针)、**访问绕一层**、**构造顺序改变**(虚基类最先构造,且由最终派生类负责初始化)。

相关笔记