多继承

多继承很容易产生命名冲突,即使我们很小心地将所有类中的成员变量和函数都命名为不同的名字,命名冲突依然有可能发生,比如典型的菱形继承,如下图所示:

菱形继承

类 A 派生出 B 和 C,类 D 同时继承 B 和 C,这个时候 A 中的成员变量和函数继承到 D 中变成了两份,一份来自 A --> B --> D 这条路径,另一份来自 A --> C --> D 这条路径。

在一个派生类中保留间接基类的多份同名成员,虽然可以在不同的成员变量中分别存放不同的数据,可以通过作用域解析符来正确获取需要的数据,但大多数情况下,这是多余的,因为保留多份成员变量不仅占用较多的存储空间,还容易产生命名冲突(必须通过作用域解析符显式指明)。

假设类 A 有一个成员变量 a,那么在类 D 中直接访问 a 就会产生歧义,编译器不知道它究竟来自 A --> B --> D 这条路径,还是来自 A --> C --> D 这条路径。

下面是菱形继承的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 间接基类 A 
class A {
protected:
int m_a;
};

// 直接基类 B
class B : public A {
protected:
int m_b;
};

// 直接基类 C
class C : public A {
protected:
int m_c;
};

// 派生类 D
class D : public B, public C {
public:
// void seta(int a) { m_a = a; } // [Error] reference to 'm_a' is ambiguous
void seta(int a) { B::m_a = a; } // 可以通过作用解析符显式指明
void setb(int b) { m_b = b; }
void setc(int c) { m_c = c; }
void setd(int d) { m_d = d; }
private:
int m_d;
};

int main() {
D d;
return 0;
}

这段代码实现了菱形继承,在类 D 中试图直接访问成员变量 m_a,会出现二义性,因为类 B 和类 C 中都有从类 A 继承过来的 m_a,编译器不知道选用的是哪一个。

为了消除歧义,我们使用类名加作用域解析运算符显式指明使用来自 A --> B --> D 路径的 m_a,当然也可以使用 C::m_a。

虚继承

为了解决多继承时的命名冲突和冗余数据问题,C++ 提出了虚继承,使得在派生类中只保留一份间接基类的成员。

在继承方式前加上 virtual 关键字就是虚继承,将上面例子改为虚继承:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 间接基类 A 
class A {
protected:
int m_a;
};

// 直接基类 B
class B : virtual public A {
protected:
int m_b;
};

// 直接基类 C
class C : virtual public A {
protected:
int m_c;
};

// 派生类 D
class D : public B, public C {
public:
void seta(int a) { m_a = a; }
void setb(int b) { m_b = b; }
void setc(int c) { m_c = c; }
void setd(int d) { m_d = d; }
private:
int m_d;
};

int main() {
D d;
return 0;
}

使用虚继承实现的菱形继承,在派生类 D 中只保留了一份成员变量 m_a,直接访问就不会再有歧义了。

虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就成为虚基类(Virtual Base Class),上例中 A 就是一个虚基类。这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含一份虚基类的成员。

现在本例的继承关系为:
虚继承实现的菱形继承

观察这个新的继承体系,我们会发现虚继承的一个不太直观的特征:必须在虚派生的真实需求出现前就已经完成虚派生的操作。在上图中,当定义 D 时才出现了对虚派生的需求,但是如果 B 和 C 不是从 A 类虚派生得到的,那么 D 类还是会保留 A 类的两份成员。

也就是说,虚派生只影响从指定了虚基类的派生类中进一步派生出来的类,它不会影响派生类本身

在实际开发中,位于中间层次的基类将其继承声明为虚继承一般不会带来什么问题。通常情况下,使用虚继承的类层次是由一个人或者一个项目组一次性设计完成的。对于一个独立开发的类来说,很少需要基类中的某一个类是虚基类,况且新类的开发者也无法改变已经存在的类体系。

C++标准库中的 iostream 类就是一个虚继承的实际应用案例。iostream 从 istream 和 ostream 直接继承而来,而 istream 和 ostream 又都继承自一个共同的名为 base_ios 的类,是典型的菱形继承。此时 istream 和 ostream 必须采用虚继承,否则将导致 iostream 类中保留两份 base_ios 类的成员。

虚继承在C++标准库中的实际应用

虚继承和作用域解析运算符

虚继承和作用域解析运算符是两种解决不同问题的机制。它们在处理命名冲突问题时具有不同的应用场景和优势。虚继承主要用于解决由多继承所引起的菱形继承问题;而作用域解析运算符则用于在普通多继承中处理直接的命名冲突。

  1. 虚继承
    菱形继承可能导致在派生类中出现同一个成员的多个实例,从而引发二义性和资源浪费问题。虚继承确保在派生类中只有一个共享的基类子对象,也就是在派生类中只保留一个基类的实例。这样,派生类可以在不使用作用域解析运算符的情况下直接访问基类的成员,而不会冲突,避免二义性和资源浪费。

  2. 作用域解析运算符
    作用域解析运算符::用于在类中明确指定访问的成员来自哪一个父类,从而解决直接的命名冲突问题。即使使用了虚继承,对一个多继承关系来说,当多个基类具有相同的成员时,仍然需要使用作用域解析运算符来明确指定访问的成员。

以虚继承实现的菱形继承为例,假设 A 定义了一个 x 的成员变量,当我们在 D 中直接访问 x 时,会有以下三种可能性:

  • 如果 B 和 C 中都没有 x 的重定义,那么 x 将被解析为 A 的成员,不存在二义性;
  • 如果 B 或 C 中的一个类重定义了 x,也不会有二义性,派生类的 x 比虚基类的 x 优先级更高;
  • 如果 B 和 C 中都重定义了 x,那么直接访问 x 将产生二义性问题,此时需要使用作用域解析运算符。

同样的,对一个普通的多继承关系:D 同时继承 B、C,而 B、C 中有相同的成员定义,也是需要使用作用域解析运算符的,和上面的第三种情况相同。

可以看到,使用多继承经常会出现二义性问题,必须非常小心。

上面的例子是简单的,如果继承的层次再多一些,关系更复杂一些,程序的编写、调试和维护工作都会变得更加困难。因此不提倡在程序中使用多继承,只有在比较简单和不易出现二义性的情况或实在必要时才使用多继承,能用单继承解决的问题就不用多继承。

也是由于这个原因,C++ 之后的很多面向对象的编程语言,例如 Java、C#、PHP 等,都不支持多继承。

参考

C++虚继承和虚基类详解

ChatGPT