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

虚继承解决 11.3.2 的菱形继承:只要那些"通向同一个祖先"的中间类在继承时都写 virtual(class Dog : virtual public Animal),祖先在最终对象里就只保留一份。代价有三样:① 对象变大(要存"虚基类指针");② 访问祖先成员要绕一层;③ 构造顺序变了——虚基类永远最先构造,而且由"最终派生类"负责初始化(中间类在列表里写的初值会被忽略)。

📝 本节和前面几节的关系

本节是 11.3.2"菱形继承"的正规解药,也是 virtual 的第二个用途(第一个用途是虚函数,11.4.1——同一个词,两个毫不相干的意思,看它在哪个位置)。另外它把 11.3.3 的构造顺序又改了一次规则:有虚基类时,顺序表要更新。

一、问题回顾:菱形里有"两份祖先"

class Animal { public: int weight; };

class Dog : public Animal { };
class Cat : public Animal { };

class DogCat : public Dog, public Cat { };    // D 里有两份 Animal!

症状:dc.weight 编译报二义性;就算指名 dc.Dog::weight,另一份还留在那儿(11.3.2)。

二、解药:中间类在继承时加 virtual

class Animal { public: int weight; };

class Dog : virtual public Animal { };        // ★ 加 virtual
class Cat : virtual public Animal { };        // ★ 加 virtual

class DogCat : public Dog, public Cat { };    // 现在只有一份 Animal

效果:

DogCat dc;
dc.weight = 5;              // ✅ 不再二义性!
cout << dc.Dog::weight;     // 5
cout << dc.Cat::weight;     // 5 —— 和上面是"同一个变量"(改一个另一个一起变)

"虚基类"(virtual base class)这个概念由此而来:被 virtual 继承的那个祖先类,就叫虚基类。它在最终对象里只存在一个共享的副本。

注意 virtual 写在哪:

class Dog : virtual public Animal { };   // ✅ 写在"继承方式"这一侧
class Dog : public virtual Animal { };   // ✅ 这样写也行(等价)
class Dog : virtual Animal { };          // ⚠️ 能写,但 Animal 就成了 private 继承(11.1.2)

位置的含义:"我这一层不想独占 Animal,我把它交给最终派生类去共享"。

三、构造顺序变了:虚基类最先,且由"最终派生类"初始化

新的顺序表(有虚基类时):

步骤 做什么
① 虚基类构造(最先,不管它在继承层次里多远)
② 非虚的各个父类,按继承声明顺序
③ 自己的成员对象(按声明顺序)
④ 自己的构造函数体

"由最终派生类初始化"是什么意思?

class Dog : virtual public Animal {
public:
    Dog() : Animal(1) { }        // ← 这个初值只在"Dog 自己单独创建对象"时才生效
};

class Cat : virtual public Animal {
public:
    Cat() : Animal(2) { }
};

class DogCat : public Dog, public Cat {
public:
    DogCat() : Animal(9) { }     // ★ 创建 DogCat 时,只有这一句起作用!
};

规则:

为什么这么规定:因为 Animal 只有一份,它唯一的一次构造——只能由"最外层的那个类"决定,否则 DogCat 里"到底听 Dog 的还是听 Cat 的"就成了新的二义性。语言的答案是:听最终派生类的。

另一个后果:每个被虚继承的类,都必须提供一个"能被最终派生类直接调用"的构造函数——通常是无参构造(或者在最终派生类里明确点名它并传参)。上面 Animal(int w = 0) 带默认参数,就是为了这个:不写的时候也能编过。

四、动手 Demo(含实例07:虚继承)

#include <iostream>
using namespace std;

class Animal {
public:
    int weight;
    Animal(int w = 0) : weight(w) {
        cout << "Animal 构造,weight = " << w << endl;
    }
};

class Dog : virtual public Animal {        // ★ 虚继承
public:
    Dog() : Animal(1) { }                  // 这个初值只在"单独创建 Dog"时生效
};

class Cat : virtual public Animal {        // ★ 虚继承
public:
    Cat() : Animal(2) { }
};

class DogCat : public Dog, public Cat {
public:
    // ★ 最终派生类负责初始化虚基类(这里 9 才是真正生效的那个值)
    DogCat() : Animal(9) { }
};

int main() {
    cout << "── 单独创建 Dog ──" << endl;
    Dog d;

    cout << "── 单独创建 Cat ──" << endl;
    Cat c;

    cout << "── 创建 DogCat ──" << endl;
    DogCat dc;

    cout << "dc.weight = " << dc.weight << endl;     // 只有一份,不再是两份

    dc.weight = 100;                                // 不再有二义性
    cout << "改完之后 dc.Dog::weight = " << dc.Dog::weight << endl;
    cout << "        dc.Cat::weight = " << dc.Cat::weight << endl;

    cout << "sizeof(Animal) = " << sizeof(Animal)
         << "(Animal 自己只有这一点的数据)" << endl;
    cout << "DogCat 里只有一份 Animal —— 但对象会比"
         << "「各部分之和」更大一些,因为要多存指向虚基类的指针" << endl;
    return 0;
}

看到什么算成功:

── 单独创建 Dog ──
Animal 构造,weight = 1
── 单独创建 Cat ──
Animal 构造,weight = 2
── 创建 DogCat ──
Animal 构造,weight = 9
dc.weight = 9
改完之后 dc.Dog::weight = 100
        dc.Cat::weight = 100
sizeof(Animal) = 4(Animal 自己只有这一点的数据)
DogCat 里只有一份 Animal —— 但对象会比「各部分之和」更大一些,因为要多存指向虚基类的指针

读结果(三句话):

现象 结论
单独建 Dog 时 weight = 1;单独建 Cat 时 weight = 2 中间类自己初始化虚基类的值,在"它自己当最终派生类"时才生效
建 DogCat 时 weight = 9 最终派生类说的算——Dog()、Cat() 里的 1、2 被忽略
改完之后 dc.Dog::weight 和 dc.Cat::weight 都是 100 它们现在是同一个变量(虚继承把它们合并成了一份)

30 秒实验: 1. 把 Dog、Cat 两处继承里的 virtual 都删掉(恢复成普通菱形),重新编译。 2. 立刻报错:'dc.weight' is ambiguous(request for member 'weight' is ambiguous)。 3. 加回 virtual,编译通过。 4. 再试一次"只加一处":只给 Dog 加 virtual、Cat 不加 → 依然是两份(一份虚的 + 一份普通的),二义性照样存在。 5. 结论:"通向同一个祖先的所有路径"上都要 virtual,只要漏了一条,祖先就还是两份。

五、常见误区

  1. "虚继承里的 virtual 就是虚函数的那个意思"——完全无关。同一个关键字,两个用途:函数前=虚函数(动态绑定);继承方式前=虚继承(共享基类)。看位置。
  2. "只要一个中间类写了 virtual 就行"——不行。菱形里的每一条中间路径都要写(Dog 和 Cat 都要 virtual);漏一条,祖先就还是两份,二义性还在。
  3. "虚继承是免费的"——不是。对象会变大(要存指向虚基类的指针)、访问虚基类成员要绕一层、构造顺序和初始化责任都变复杂。能用"重新设计(避开菱形)"解决的,就别用虚继承。
  4. "Dog() : Animal(1) 的初值总是有效"——只在 Dog 当最终派生类时有效。在 DogCat 里,Dog 的这行初值被忽略(DogCat 自己初始化虚基类)。这个"被忽略"是静态的、编译期就定好的(在虚继承的构造里,中间类对虚基类的初始化语句会被"跳过")。
  5. "虚基类没有无参构造也能编过"——取决于最终派生类。如果每个最终派生类都在列表里点名并传参(像上面的 : Animal(9)),就没有问题;一旦有一个最终派生类忘了点名,就报 no matching function——所以实践上,给虚基类加个带默认参数的构造(Animal(int w = 0))最省事。
  6. "多重继承 + 虚继承很高级,我要多用"——恰恰相反:这是"设计出了问题"的信号。菱形继承在 UML 类图里一眼就能看出来(9.3.2)。能改成"组合"(把 Animal 作为成员)就改。 虚继承是补救手段,不是设计目标。
自测三问(点开对答案) 1. 怎么解决菱形继承的"两份祖先数据"?——在**通向同一个祖先的每一条中间路径上**都用**虚继承**:`class Dog : virtual public Animal { };`、`class Cat : virtual public Animal { };`。这样祖先在最终对象里**只保留一份共享副本**,**不再二义、不再冗余**。 2. 有虚基类时,构造函数顺序变成什么样?谁负责给虚基类传参?——**虚基类最先构造**(在一切父类之前),然后才是非虚父类、成员、自己的函数体。**由"最终派生类"负责初始化虚基类**(它说的算);**中间类在列表里给虚基类写的初值会被忽略**。 3. 虚继承有什么代价?——**① 对象变大**(要存指向虚基类的指针);**② 访问虚基类成员要绕一层**(先找虚基类指针);**③ 构造顺序和"谁负责初始化"变复杂**。所以**优先重新设计,少用虚继承**。

相关笔记