Function语义学——Virtual Member Function

前言

 
我们已经初步了解了virtual function的实现原理:vtbl与vptr。在本节中,我们将根据一个实例,分析单一继承、多重继承、虚拟继承等各种情况,从细节上探究virtual function模型。


运行期类型判断

 
为了完成virtual function机制,我们能够在执行期判断出当前pointer或reference的动态类型,如此才能找到并调用相应的函数实体。

方法一

考虑下述语句:

1
ptr->z();

最直接了当的做法就是把必要的信息加上ptr身上,如此一来,pointer或者reference至少包含两个信息:

  1. 内容(它所指向的object的地址)
  2. 对象的动态类型

这个方法有两个缺点:

  1. 增加了空间负担,即使程序并不体现出多态性
  2. 破坏了与C的兼容性

方法二

如果不能在pointer或erference中加入信息,那么势必只能在对象中添加信息。显然并非所有的对象都应该被加入信息,我们应该在“可能会运行期多态”的class中加入RTTI信息。识别一个class是否支持多态,只需要去看他是否具备任何virtual function即可。


单一继承下virtual function的实现

 
在明确了需要有信息插入class object之后,我们需要明确:哪些信息需要被插入class?仍以下述语句为例:

1
ptr->z();

我们需要了解:

  1. ptr的动态类型,以方便调用正确的z()实体。
  2. z()实体位置

落实到具体实现,我们需要在每一个具备多态的class object身上增加两个members:

  1. 一个用以表示动态类型的字符串或者数字
  2. 一个指向某个table的指针,该table内部存放着virtual functions的执行期地址

上述工作均由编译器完成,执行期只是按图索骥而已。

一个class只会有一个vtbl,每一个vtbl内部含有所有virtual functions函数实体的地址,这些virtual functions包括:

  • 该class定义的函数实体,它可能会overriding一个可能存在的base class virtual function函数实体。
  • 继承自base class的函数实体,这是在derived class不覆写时发生的情况。
  • 一个pure_virtual_called函数实体,它既可以扮演pure virtual function的空间保卫者,也可以当做执行期异常处理函数。

具体实例

考虑下述定义:

1
2
3
4
5
6
7
8
9
class Point{
public:
virtual ~Point();
virtual Point& mult(float)=0;
float x() const;
virtual float y() const;
virtual float z() const;
...
}

现有Point2d derived from Point:
1
2
3
4
5
6
class Point2d:public Point{
public:
...
Point2d& mult(float);//override
float y() const;//override
}

并有Point3d derived from Point2d:
1
2
3
4
5
class Point3d:public Point2d{
...
Point2d& mult(float);//override
float y() const;//override
}

其具体的vtbl如下所示:
image_1cdha351o116i11o21cr67nojva9.png-369.9kB
当一个class派生自另一个class,一共具备三种可能性:

  1. 继承声明与实现
    在该情况下virtual functions的地址将会被拷贝到derived class的vtbl中,且保持原有的slot。
  2. 只继承声明
    此时函数实体的地址被置于对应的slot中。
  3. 增加新的virtual function
    此时derived class的vtbl会被扩充,新的virtual function会对应于新的slot。

当我们遇到

1
ptr->z();

如何完成运行期virtual function的调用呢?

  1. 尽管我们并不了解ptr的动态类型,但我们了解其vptr的位置,并由vptr找到vtbl
  2. 尽管并不明确哪一个z()实体会被调用,但我们明确了解z()必然位于slot4

因此,上述语句被转化为:

1
(*ptr->vptr[4])(ptr);

尽管该语句的效果在执行期才能被确定(调用哪个z实体),但至少在编译期我们完成了正确的转化。

在单一继承体系中,virtual function运转良好,但在多继承和虚继承中实际情况却较为麻烦。


多重继承下的virtual function

 
多重继承下支持virtual function的难点在于后继base class,以及在执行期调整this指针。
以下述继承体系为例:
image_1cdhf6elj1btn1ocfv1n19tgjucm.png-15.9kB

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
class Base1{
public:
Base1();
virtual ~Base1();
virtual void speakClearly();
virtual Base1 *clone() const;
protected:
float data_Base1;//抽象工厂
};
class Base2{
public:
Base2();
virtual ~Base2();
virtual void mumble();
virtual Base2 *clone() const;
protected:
float data_Base2;
};
class Derivedpublic Base1,public Base2{
public:
Derived();
virtual ~Derived();
virtual Derived *clone() const;
protected:
float data_Derived;
};

Derived支持virtual function的难点统统落在Base2 subobject上,其中大致有三个问题:

  1. virtual destructor
  2. 被继承的Base2::mumble()
  3. 一组clone()函数实体

解决方案

首先,我们将一个从heap中配置得到的Derived对象的地址指定给一个Base2指针:

1
Base2 *pbase2 = new Derived;

新的Derived对象的地址必须调整,以指向Base2 subobject。在编译期间会有如下代码产生:
1
2
Derived *temp = new Derived;
Base2* pbase2 = temp?temp+sizeof(Base1):0;

当开发者需要释放pbase2所指向的对象时:
1
delete pbase2;

指针必须被再一次调整,以指向Derived对象的起始处。但在这里我们并不能像绑定操作那样在编译期完成offset添加,因为我们只有在运行期才能明确对象的真正类型。

如果我们经由类型为后继base class的指针或者引用来调用virtual function,该调用所连带的this指针调整操作必须在执行期完成,也就是说,编译器所做的关于扩充代码的操作,必须在某个地方插入。关键问题便是在哪里插入。

方案一

cfront编译器的做法是将vtbl增大,使它容纳此处所需要的this指针。每一个virtual table slot不再是一个指针,而是一个struct,内含可能的offset以及地址。于是调用操作由:

1
*(pbase2->vptr[1])(pbase2);

转变为:
1
*(pbase2->vptr[1].faddr)(pbase2+pbase2->vptr[1].offset)

这方法的缺点是增加了间接性,无论是否需要offset都需要去执行上述语句,并且vtbl的大小也扩大了。

方案二

比较有效率的解决方案是使用thunk。所谓thunk是一小段assembly码,用来完成以下两个任务:

  1. 以适当的offset调整this指针
  2. 跳转至virtual function

举例而言,经由一个Base2指针调用Derived destructor,其相关的thunk大致如下:

1
2
3
pbase2_dtor_thunk:
this+=sizeof(base1);
Derived::~Derived(this);

thunk技术保证了vtbl继续内含一个简单的指针,因此多重继承不再需要任何空间上的额外负担。slots中的地址可以指向virtual function,也可以指向一个相关的thunk(如果需要调整this指针)。

调整this指针的第二个额外负担是,函数的调用由于两种不同的可能:

  1. 经由derived class或第一base class调用
  2. 经由后继base class调用

同一函数需要在vtbl中存放多笔对应的slots,例如:

1
2
3
4
5
Base1* pbase1 = new Derived;
Base2* pbase2 = new Derived;

delete pbase1;
delete pbase2;

虽然两个delete操作导致相同的derived destructor,但它们需要两个不同的vtbl slots:

  1. pbase1不需要调整this指针,其vtbl放置的是真正的destructor地址
  2. pbase2需要调整指针,其vtbl slot需要放置相关的thunk地址。

在多重继承下,一个derived class内含n-1个额外的vtbl,n表示上一层base classes的数目。对于实例中的Derived,它会有两个vtbl:

  1. 一个主要实体,与Base1共享
  2. 一个次要实体,与Base2有关

针对每一个vtbl,derived对象中有相应的vptr。具体内存结构如下所示:
image_1cdhlsdfr1406v33m71ipd1pej13.png-379.6kB


后继base class对virtual function的影响

在三种情况下后继base class会影响对virtual function的支持:

  • 通过指向后继base class的指针来调用derived virtual function

    1
    2
    3
    Base2 *ptr = new Derived;
    //ptr必须向后调整sizeof(Base1)
    delete ptr;

    显然,ptr初始情况下指向了base2 subobject,为了能够正确执行,ptr必须调整指向Derived对象的起始处。

  • 通过一个指向derived class的指针调用继承自后继base class的virtual function

    1
    2
    3
    Derived *pder = new Derived;
    //pder必须向前调整sizeof(Base1)
    pder->mumble();

    此时,指针必须再次调整。

  • 返回值不相同

    具体来说就是clone的实现原理。


虚继承下的virtual functions

 
考虑下述继承体系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Point2d{
public:
...
virtual ~Point2d();
virtual void mumble();
virtual float z();
...
}

class Point3d:public virtual Point2d{
public:
~Point3d();
float z();
};

Point3d和Point2d的起始部分并不和“非虚拟的单一继承”一致。由于Point2d和Point3d的对象不再相符,两者之间的转换也就需要调整this指针。它们的内存布局:
image_1cdhu6f6t1uoo1qge1sc61avn1to1m.png-283.1kB
(这个图似乎有点问题,右下角发生了错误)