4.2 Virtual Member Functions 虚拟成员函数

virtual function 一般实现模型: 每一个 class 有一个 virtual table,内含该 class 中有作用的 virtual function 地址, 然后每个 object 有一个 vptr, 指向 virtual table。
在这一节中, 根据单一继承, 多重继承和虚拟继承的多种情况, 从细节上来探究这个模型。
virtual function 机制, 必须能够对于多态对象有某种形式的“执行期类型判断法”,以下的调用操作将需要 ptr 在执行期的某些相关信息:

ptr->Z();	// 找到并调用 Z() 的适当实体
cl [filename].cpp /d1reportSingleClassLayout[className]

最直截但成本最高的方法是把必要的信息加在 ptr 身上:指针(或 reference) 含有两项信息:

  1. 它所参考到的对象地址
  2. 对象类型的某种编码, 或是某个结构(用以决议出 Z() 函数实例的地址)
    问题:
  3. 明显增加了空间负担
    2.打断了与 C 程序间的兼容性.

如果这额外的信息不能够和指针放在一起, 下一个可以考虑的地方就是把它放在对象本身, 但是哪一个对象需要这些信息呢? 把这些信息放在可能被继承的每一个聚合体上吗? 或许吧? 但考虑如下 C struct 声明:

struct Date { int m, d, y; };

严格地说, 这符合上述规范, 然而事实上它并不需要那些信息, 加上那些信息将使 C struct 膨胀并且打破连接兼容性, 却没有任何明显的补偿利益
只对那些明确使用了 class 关键词的声明. 才应该加上那些额外的执行器信息吗? 还有个例子:

//符合要求但不需要那份信息
class date {public: int m, d, y;};
//需要信息但不合要求
struct geom {public: virtual ~geom();...};

//////////////////// 下面才是正文 //////////////////////////////////////////
我们需要的是一个 "以 class 的使用为基础, 而不在乎关键词是 class 或 struct 的规范"。 如果 class 需要那份信息, 它就会存在, 反之则不存在。到底什么时候需要那份信息? 在必须支持某种形式的“执行期多态”的时候。
在 C++ 中,** 多态表示以一个 public base class 的指针(或 reference), 寻址出一个 derived class object 的意思**, 例如以下声明:

Point *ptr;
ptr = new Point2d;	// ptr 以寻址出一个 Point2d 对象
ptr = new Point3d;	//ptr 以寻址出一个 Point3d 对象

ptr 的多态机能主要扮演一个输送机制(transport mechanism) 的角色, 经由它,可以在程序的任何地方采用一组 public derived 类型. 这种多态形式被称为消极的(passive) , 可以在编译时期完成--virtual base class 的情况除外。
当被指出的对象真正被使用时, 多态也就变成积极的了, 下面对于 virtual function 的调用:

//积极多态的常见例子
ptr->Z();

在 runtime type identification(RTTI) 性质于 1993 年被引入 C++ 语言之前, C++ 对积极多态的唯一支持就是对于 virtual function call 的决议操作. 有了 RTTI, 就能够在执行期查询一个多态的 pointer 或多态的 reference 了。

//积极多态的第二个例子
if(Point3d *p3d = dynamic_cast<Point3d*>(ptr))
    return p3d->_z;

鉴定哪些 classes 展现多态特性, 需要额外的执行期信息, 因此识别一个 class 是否支持多态, 唯一适当的方法就是看看它是否有任何 virtual function. 只要class 拥有一个 virtual function, 它就需要这份额外的执行期信息.

下一个问题是, 要储存哪些额外信息? 也就是说, 如果我有这样的调用:

ptr->Z();

其中 Z() 是一个 virtual function, 什么信息才能在执行期调用正确的 Z() 实体?
1. ptr 所指向对象的真实类型, 以便选择正确的 Z() 实体
2. Z() 的实体位置, 以便我们能调用它。

在实现上, 可以在每一个多态的 class object 身上增加两个 members:
1. 一个字符串或数字, 表示class 类型
2.一个指针, 指向某表格, 表格中带有程序的 virtual functions 的执行期地址

那么表格中的 virtual functions 地址如何被建构起来?
在 C++ 中, virtual functions 可以在编译时期被获知, 此外, 这一组地址是固定不变的, 执行期不可能新增或替换之. 由于程序执行时, 表格的大小和内容都不会改变, 所以其建构和存取由编译器掌握, 不需要执行期的任何介入。

如何找到那些地址:

  1. 为了找到表, 每一个 class object 被安插上一个由编译器内部产生的指针, 指向该表格(vptr)。
  2. 为了找到函数地址, 每一个 virtual function 被指派一个表格索引值(slot)
    这些工作都由编译器完成, 执行期间要做的, 只是在特定的 virtual table slot 中激活virtual function。

一个 class 只会有一个 virtual table, 每一个 table 内含对应的 class object 中所有 active virtual functions 函数实体的地址. 这些 active virtual functions 包括:

  1. 这个 class 所定义的函数实体,它会改写一个可能存在的 base class virtual function 函数实体。(重定义)
  2. 继承自 base class 的函数实体, 这是在 derived class 决定不改写 virtual function 时才会出现的情况。(继承不改写)
  3. 一个 pure_virtual_called() 函数实体, 它既可以扮演 pure virtual function 的空间保卫者角色, 也可以当作执行期异常处理函数.
    每一个 virtual function 都被指派一个固定的索引值, 这个索引在整个继承体系中保持与特定的 virtual function 关联, 例如在 Point class 体系中:
class Point
{
public:
    virtual ~Point();
    virtual Point& mult(float) = 0;
    //其他操作
    float X() const { return _x; }
    virtual float Y() const { return 0; }
    virtual float Z() const { return 0; }
    //...
protected:
    Point(float x = 0.0);
    float _x;
};

virtual destructor 被赋值 slot 1, 而 mult() 被赋值 slot 2。 此例中并没有 mult() 的函数定义, 所以 pure_virtual_called() 的函数地址会被放在 slot 2 中, 如果该函数意外地被调用,通常的结果是结束掉这个程序(我用的编译器, 处理方式是编译时报错). Y(), Z() 为 slot 3, 4。
单一继承下的 virtual table 布局如图所示:
image

当一个 class 派生自 Point 时, 会发生什么事? 例如 class Point2d 继承 Point 时, 有三种可能性:

  1. 它可以继承 base class 所声明的 virtual functions 的函数实体。正确地说, 是该函数实体的地址会被拷贝到 derived class 的 virtual table 的 相应的 slot 中.
  2. 它可以使用自己的函数实体, 这表示它自己的函数实体地址必须放在对应的 slot 中
  3. 它可以加入一个新的 virtual function, 这时候 virtual table 是尺寸会增大一个slot, 而新的函数地址则放在 slot 4.

好了现在有一个这样的式子:

ptr->Z();

如何有足够的知识在编译时期设定 virtual function 的调用呢?

  1. 一般并不知道 ptr 所指对象的真正类型, 然而经由 ptr 可以存取到该对象的 virtual table.
  2. 虽然不知道哪一个 Z() 函数实体会被调用, 但每一个 Z() 函数地址都放在 slot 4.
    这些信息使得编译器可以将调用转化为:
(*ptr->vptr[4])(ptr);

其中唯一在执行期才能知道的是, slot 4 所指的到底是哪一个函数实体。

多重继承下 Virtual Functions

多重继承支持 virtual function, 其复杂度围绕在第二个及后继的 base classes 上, 以及必须在“执行期调整 this 指针”这一点。
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 Derived: public Base1, public Base2
{
public:
    Derived();
    virtual ~Derived();
    virtual Derived* clone() const;
protected:
    float data_derived;
};

image
image
正常情况下:重写函数的类型:包括函数名、参数、返回值类型,都必须和被改写函数相同。

函数的重载:函数名一致
//参数:类型不同、 个数不同、 顺序不同
//notice: 函数的返回值不可作为函数的重载条件

这一规则对协变返回类型却有所放松,子类函数的返回值类型可以是父类函数返回值类型的子类的指针或者引用类型,语义上要求子类与父类能够构成一种is-a关系。

在以上的体系中, Derived 支持 virtual functions 的困难度统统落在 Base2 subobject 身上. 共有三个问题需要解决:

  1. virtual destructor---------------虚析构函数
  2. 被继承下来的 Base2::mumble()------继承的虚函数
  3. 一组 clone() 实体。---------------重写的虚函数

首先, 把从 heap 中配置的 Derived 对象的地址, 指定给一个Base2 指针:

Base2 *pbase2 = new Derived;

新的 Derived 对象地址必须调整, 以指向其 Base2 subobject,** 编译时**产生以下代码:

//转移地址以支持第二个 base class
Derived *temp = new Derived;
Base2 *pbase2 = temp ? (temp + sizeof(Base1)) : 0;

如果没有这样的调整, 指针的任何非多态运用都将失败, 如下:

//pbase2 被指定一个Deirved 对象,数据成员没问题
pbase2->data_base2;

// 当程序员要删除 pbase2 所指的对象时:
// 必须首先调用正确的 virtual destructor 函数实体,然后施行 delete 运算符
// pbase2 可能需要被再一次调整, 指出完整对象的起点
delete pbase2;

指针必须被再一次调整, 以求再一次指向 Derived 对象的起始处, 然而上述的 offset 加法却不能在编译时期直接确认, 因为 pbase2 所指的对象只有在执行期才能确定
一般规则是, 通过指向第二或后继 base class 的指针(或 reference) 来调用 derived class virtual function时:

Base2 *pbase2 = new Derived;
delete pbase2;	// 调用派生类的析构函数

指向自己的类型!

其所连带的必要的 this 指针调整操作必须在执行期完成, 也就是说, offset 的大小, 以及把 offset 加到 this 指针上头的那一小段代码必须由编译器在某个地方插入, 问题是插哪个地方?
cfront编译器中的方法是将 virtual table 扩大, 使它容纳此处所需的 this 指针, 调整相关事物。每一个 virtual table slot 不再只是一个指针, 而是一个聚合体, 内含可能的 offset 及地址。于是 virtual function 的调用操作由:

(*pbase2->vptr[1])(pbase2);
改变为:
(取出的是虚函数地址)(取出的是相应对象的offset,运算得使指针指向对应的对象)
(*pbase2->vptr[1].faddr)(pbase2 + pbase2->vptr[1].offset);
// faddr 内含 virtual function地址,offset内含this 指针调整值

这个方法的缺点就是连带惩罚了所有的 virtual function 调用操作,不管他们是否需要 offset 的调整. 所说的处罚包括 offset 的额外存取及加法, 以及每个 virtual table slot 大小的改变。

thunk 技术就是一小段 assembly 码(汇编语言), 用来:

  1. 以适当的 offset 值调整 this 指针
  2. 跳到 virtual function 去
    例如, 经由一个Base2 指针调用 Derived destructor, 其相关的 thunk 可能看起来是这个样子:
//虚拟 C++ 码
pbase2_dtor_thunk:
    this += sizeof(basel);
    Derived::~Derived(this);

Thunk 技术允许 virtual table slot 继续内含一个简单的指针, 因此多重继承不需要任何空间上的额外负担, slot 中的地址可以直接指向 virtual function, 也可以指向一个相关的thunk(如果需要调整 this指针的话). 于是, 对于那些不需要调整 this 指针的virtual function 而言, 也就不需要承载效率上的额外负担.
调整 this 指针的第二个额外负担就是, 由于两种不同的可能:

  1. 经由 derived class 或第一个 base class 调用
  2. 经由第二个或后继的 base class 调用
    同一函数在 virtual table 可能需要多笔对应的 slots, 例如:
Base1 *pbase1 = new Derived;
Base2 *pbase2 = new Derived;
delete pbase1;
delete pbase2;

虽然两个 delete 操作导致相同的 Derived destructor, 但他们需要不同的 virtual table slots:

  1. pbase1 不需要调整 this 指针(因为 Base1 是最左端的 base class 之故, 它已指向 Derived 对象的起始处). 其 virtual table slot 需放置真正的 destructor 地址。
  2. pbase2 需要调整 this 指针。其 virtual table slot 需要相关的 thunk 地址。
    在多重继承下,一个 derived class 内含 n - 1 个额外的 virtual tables, n 表示其上一层 base classes的数目(所以单一继承不会有额外的 virtual tables). 对于本例而言, 会有两个 virtual tables 被编译器产生出来:
  3. 一个主要实体, 与 Base1(最左端 base class) 共享。
  4. 一个次要实体, 与 Bsae2(第二个 base class) 有关。
    针对每一个 virtual tables, Derived 对象中有对应的 vptr. 下图说明了这一点, vptrs 将在 constructor 中被设立初值(经由编译器所产生的码).
    image

用来支持一个class 拥有多个 virtual tables 的传统方法是, 将每一个 tables 以外部对象的形式产生出来并给与独一无二的名称, 例如 Derived 所关联的两个 tables 可能有这样的名称:

vtbl__Derived;        //主要表格
vtbl__Base2__Derived; //次要表格

于是当你把一个Derived 对象地址指定给一个 Base1 指针或 Derived 指针时, 被处理的 virtual table 是主要表格 vtbl__Derived;
而当你将一个 Derived 对象地址指定给一个 Base2 指针时, 被处理的 virtual table 是次要表格 vtbl__Base2_Derived。
//bzf:当把一个new对象的地址给基类指针时候,编译器首先直接通过指针调整,找到相应的基类对象地址,然后通过虚函数表进行相应虚函数的激活操作。

由于执行期链接器(支持动态共享函数库),链接符号名称的连接非常缓慢。Sun编译器:将多个virtual table是连锁为一个,只想次要表格的指针,可以有主要表格名称加上一个offset获得。

有三种情况, 第二或后继的 base class 会影响对 virtual function 的支持。

1.第一种情况是通过一个指向第二个 base class 的指针, 调用 derived class virtual function如:

Base2 *ptr = new Derived;
//调用 Derived::~Derived
//ptr 必须被向后调整sizeof(Base1) 个 bytes
delete ptr;

从上图中可以看到这个调用操作的重点: ptr 指向 Derived 对象中的 Base2 subobject; 为了能够正确执行, ptr 必须调整指向 Derived 对象的起始处。

2.第二种情况是第一种情况的变化, 通过一个指向 derived class 的指针, 调用第二个 base class 中继承来的 virtual function。在此情况之下, derived class 指针必须再次调整, 以指向第二个 base subobject, 如:

Derived *pder = new Derived;
//调用 Base2::mumble(), pder 必须向前调整 sizeof(Base1) 个 bytes
pder->mumble();	// mumble() 被继承下来,没有改写。第二个及之后base放在base表中

3.第三种情况发生在一个语言的扩充性质之下:** 允许一个 virtual function 的返回值类型有所变化, 可以是 base type,也可以是 publicly derived type**。 这一点可以通过 Derived::clone()函数实体来说明。 clone 函数的 Derived 版本传回一个 Derived class 指针, 默默地改写了它的两个 base class 函数实体。当我们通过指向第二个 base class 的指针来调用 clone() 时, this 指针的 offset 问题于是产生:

Base2 *pb1 = new Derived;	// 编译期间pb1只想改变
//调用 Derived* Derived::clone()
//返回值必须被调整, 以指向 Base2 subobject
Base2 *pb2 = pb1->clone();

pb1编译期间会最先被调整之向Base2结构,由于对象类型是Derived,所以调用方法时侯,调用的是new出来的Derived的虚函数,会被调整指向 Derived 对象的起始地址。变量呢?

当进行 pb1->clone() 时, pb1 会被调整指向 Derived 对象的起始地址,于是Derived 版的 clone() 会被调用;它会传回一个指针, 指向新的 Derived 对象; 该对象在被指定给 pb2 之前, 必须经过调整,指向 Base2 subobject.

虚继承下的 Virtual Functions

考虑下面的 virtual base class 派生体系:

class Point2d
{
public:
    Point2d(float = 0.0, float = 0.0);
    virtual ~Point2d();

    virtual void Mumble();
    virtual float Z();
    //...
protected:
    float _x, _y;
};

class Point3d:public virtual Point2d
{
public:
    Point3d(float = 0.0, float = 0.0, float = 0.0);
    ~Point3d();

    float Z();
protected:
    float _z;
};

虽然 Point3d 有唯一的(同时也是最左边的) base class, 也就是 Point2d, 但 Point3d 和 Point2d 的起始部分并不像非虚拟的单一继承情况那样一致, 这种情况具体如下图,。
由于 point2d 和 Point3d 的对象不再相符, 两者之间的转换也就需要调整 this 指针, 至于在虚拟继承的情况下要消除 thunks, 已经被证明是一项高难度技术。

image
image

当一个 virtual base class 从另一个 virtual base class 派生而来, 并且两者都支持 virtual functions 和 nonstatic data members 时, 编译器对于 virtual base class 的支持简直像是进了迷宫. 所以只需记住: 不要在一个 virtual base class 中声明 nonstatic data members。

posted @ 2021-04-20 22:59  点|滴  阅读(15)  评论(0)    收藏  举报