多重继承和虚基类

多重继承是面向对象编程(Object Oriented Programming, OOP)中的一个有争议的话题。并非所有OOP语言都支持多重继承(Java和C#均不支持)。C++作为万能语言是支持多重继承的典型例子,即便如此,一些C++的开发框架,例如MFC,也从不使用多重继承特性。Go虽然很少用到复杂的继承,但是它本身是支持多重继承的。

我想从对象的存储方式角度来分析多重继承和虚基类的问题。C++在这方面有些故弄玄虚,一个virtual关键字弄得让人有些摸不着头脑。Go的设计在提供多重继承功能的同时,也非常明确地指出了对象在内存中的存储形式,理解起来也容易许多。

所谓继承,在内存中不过是一个父类的对象被嵌入了子类对象中。然后在语法层面,子类可以直接调用父类的方法。假设类D继承类C,那么D的对象在内存中形式如下:

+-----+
|+---+|
|| C ||
|+---+|
|  D  |
+-----+
type D struct {
	C
}

如果类D同时又继承类B,则构成多重继承。D的对象在内存中可能是这样的:

+---------+
|+---+---+|
|| B | C ||
|+---+---+|
|    D    |
+---------+
type D struct {
	B
	C
}

这时D的对象既包含B的成员,又包含C的成员。

下面问题来了:如果BC都继承A,情况变得复杂起来:

+-------------+
|+-----+-----+|
||+---+|+---+||
||| A ||| A |||
||+---+|+---+||
||  B  |  C  ||
|+-----+-----+|
|      D      |
+-------------+
type B struct {
	A
}
type C struct {
	A
}

从继承关系的角度,应该是如下的菱形结构:

  A
 / \
B   C
 \ /
  D

但是从存储的角度,一个D类对象中含有两个A类的实例。这会引发一些问题。为了让BC共享同一个基类对象,需要引入虚基类的机制。

type B {
	*A
}
type C {
	*A
}
type D {
	*A
	B
	C
}
func NewD() *D {
	var o struct { A; D }
	o.B.A = &o.A
	o.C.A = &o.A
	o.D.A = &o.A
	return &o.D
}

使用NewD构造出的对象在内存中是这样的:

+---------+
|    A    <-
+--^---^--+ \
+--|---|--+ |
|+-|-+-|-+| |
|| | | | || |
||---|---|| |
|| B | C || |
|+---+---+| |
|    -------/
|---------|
|    D    |
+---------+

从Go语言的描述中可以很清楚地看到,在B, C, D中,不保存A的实例,而是保存一个指向A对象的指针。这样它们就可以共享同一个对象。在NewD中可以看到显式地为指针赋值的代码。在C++中这是由编译器自动生成的。还可以看到,A的实例是在构造D的对象的时候构造的。这就是为什么C++要求虚基类的构造函数需要由最后的子类调用。

C++的虚基类机制要求B, C, D必须共用同一个基类对象。据我猜测,按照C++无限压榨机器性能的风格,在这种情况下,D中无需保存A的指针,借用B或者C中的指针就可以了,这样可以节省一个机器字的空间。(思考:为什么不直接使用A对象在D中的偏移量?)

作为一个可以实际运行的例子,考虑给上述各类增加成员:

import "fmt"
type A struct {
	msg string
}
func (o *B) SetMsg() {
	o.msg = "hello, world"
}
func (o *C) PrintMsg() {
	fmt.Println(o.msg)
}

func main() {
	o := NewD()
	o.SetMsg()
	o.PrintMsg()
}

因为BC共用同一个基类对象,所以它们的方法中所读写的msg成员是同一个变量。这个程序可以正确地打印出相应的消息。如果不使用虚基类,则会有问题。

使用C++编写的等价程序如下:

#include <iostream>
#include <string>

class A {
public:
	std::string msg;
};

class B : virtual public A {
public:
	void SetMsg() { msg = "hello, world"; }
};

class C : virtual public A {
public:
	void PrintMsg() { std::cout << msg << std::endl; }
};

class D : virtual public A, public B, public C {
};

int main()
{
	D o;
	o.SetMsg();
	o.PrintMsg();
	return 0;
}

C++把和指针相关的细节都隐藏起来了,抽象程度更高。但是从灵活性上讲,却不如Go。假如有一个类X继承了A但并非以虚基类的方式,那么就很难构造出同时继承XB的子类。在Go中却很容易:

type X struct {
	A
}
type Y struct {
	X
	B
	*A
}
func NewY() *Y {
	var o Y
	o.A = &o.X.A
	o.B.A = o.A
}

使用NewY构造出的对象在内存中是这样的:

  +-----------+
  |+-----+---+|
  ||+---+|   ||
 ---> A <--- ||
/ ||+---+|---||
| ||  X  | B ||
\ |+-----+---+|
 --------     |
  |-----------|
  |     Y     |
  +-----------+

文中的程序纯粹为演示目的而编写,没有实际价值。多重继承和虚基类是实际程序中比较少见的技术。Go语言在设计时也有意弱化继承层次的重要性,所以也很难见到上述写法在现实中的应用。

思考题:在那个C++程序中,假设一个机器字为8字节,一个指针需要1个机器字,一个std::string对象需要4个机器字,那么:

  1. sizeof (A) = ?
  2. sizeof (B) = ?
  3. sizeof (D) = ?

分享