多重继承和虚基类
多重继承是面向对象编程(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
的成员。
下面问题来了:如果B
和C
都继承A
,情况变得复杂起来:
+-------------+
|+-----+-----+|
||+---+|+---+||
||| A ||| A |||
||+---+|+---+||
|| B | C ||
|+-----+-----+|
| D |
+-------------+
type B struct {
A
}
type C struct {
A
}
从继承关系的角度,应该是如下的菱形结构:
A
/ \
B C
\ /
D
但是从存储的角度,一个D
类对象中含有两个A
类的实例。这会引发一些问题。为了让B
和C
共享同一个基类对象,需要引入虚基类的机制。
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()
}
因为B
和C
共用同一个基类对象,所以它们的方法中所读写的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
但并非以虚基类的方式,那么就很难构造出同时继承X
和B
的子类。在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个机器字,那么:
sizeof (A)
= ?sizeof (B)
= ?sizeof (D)
= ?