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