2

多重继承和虚基类

 2 years ago
source link: https://z-rui.github.io/post/2016/08/virtual-base/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

多重继承和虚基类

Thu Aug 11, 2016

多重继承是面向对象编程(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) = ?


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK