第4章 Function 语意学2: Virtual Member Functions

发布于:2025-03-07 ⋅ 阅读:(18) ⋅ 点赞:(0)

virtual function 的一般实现模型是:

每一个 class 有一个virtual table,内含该 class 之中有作用的 virtual function 的地址;
然后每个 object有一个 vptr,指向 virtual table 的所在。

问题一:怎么判断对象有多态特性

识别一个 class 是否支持多态,唯一适当的方法就是看看它是否有任何 virtual function。只要 class 拥有一个 virtual function,在编译时期进行改写,并在它执行期进行动态链接。(也叫做“执行期类型判断法(runtime type resolution)”)

ptr->z();

问题二:需要存储什么额外信息

回到ptr->z(),我们需要知道以下信息才能帮助我们在执行期找到正确的实体:

  • ptr 所指对象的真实类型。这可使我们选择正确的 z() 实体;

  • z()实体位置,以便我能够调用它

在实现上,首先我可以在每一个多态的 class object 身上增加两个 members:

  1. 一个字符串或数字,表示 class 的类型;

  2. 一个指针,指向某表格,表格中带有程序的 virtual functions 的执行期地址。

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

然而,执行期备妥那些函数地址,只是解答的一半而已。另一半解答是找到那些地址。以下两个步骤可以完成这项任务:

  • 为了找到表格,每一个 class object 被安插上一个由编译器内部产生的指针,指向该表格

  • 为了找到函数地址,每一个 virtual function 被指派一个表格索引值

这些工作都由编译器完成。执行期要做的,只是在特定的 virtual table slot(记录着 virtual function 的地址)中激活 virtual function。

单一继承下的的Virtual Functions

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

  • pure virtual called函数实体。

  • 继承自 base class 的函数实体。derived class 不改写virtual function 时会出现的情况

  • 本 class 所定义的函数实体。会改写 (overriding) base class 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 被赋 slot1,而 mult被赋值 slot2,此例并没有 mult()的函数定义(译注:为它是一个 pure virtualfunction),所以pure virtual called 的函数地址会被放在 slot2 中如果该函数意外地被调用,通常的操作是结束掉这个程序。

y0 被赋值 slot3 而 z0 被赋值 slot4。x的 slot 是多少?答案是没有,因为x0并非 virtual function。图4-1表示Point 的内存布局和其 virtual table。

class Point2d : public Point {
public:
    Point2d( float x = 0.0,float y = 0.0 ): Point( x ),_y( y.) { }
    ~Point2d();
    // 改写 base class virtual functions
    Point2ds mult( float );
    float y() const { return  y; )
    //其它操作 0....
protected;
    float y;
}

派生类构建虚表时可能会出现的情况:

  1. 继承 。将base 中的函数实体地址会被拷贝到 derived class 的 virtual table 相对应的 slot 之中;

  2. 改写。使用自己的函数实体(override),这表示它自己的函数实体地址必须放在对应的 slot 之中

  3. 新增。声明新的 virtual function,这时候 virtual table 的尺寸会增大一个 slot,而新的函数实体地址会被放进该 slot 之中。

Point2d的 virtual table 在 slot 1 中指出 destructor,而在 slot 2中指出mult0(取代 pure virtual function)。它自己的y0函数实体地址放在 slot3,继承自 Point的 z0函数实体地址则放在 slot4。

class Point3d : public Point2d {
public:
    Point3d( float x= 0.0,float y=0.0,float z= 0.0): Point2d( x,y ),_z( z ) { }
    ~Point3d();
    // 改写 base class virtual functions
    Point3d& mult( float );
    float z() const { return _z;} //..····其它操作
protected:
    float _z;
}

其 virtual table 中的 slot 1 放置 Point3d 的 destructor,slot 2放置Point3d::mult0函数地址。slot3 放置继承自 Point2d 的 y0函数地址,slot4 放置自己的z0函数地址。图4.1 显示 Point3d 的对象布局和其 virtualtable。

现在,如果我有这样的式子:

ptr->z();

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

  • 虽然不知道 ptr 所指对象的真正类型,但可以经由 ptr存取到该对象的 virtual table。

  • 虽然我不知道哪一个 z()函数实体会被调用,但 每一个 z() 函数地址都被放在 slot 4。

这些信息使得编译器可以将该调用转化为:

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

在这个转化中,vptr 示编译器所安的指针,指向 virtual table;4 表示z)被赋值的 slot 编号(关联到 Point 体系的 virtual table)唯一一个在执行期才能知道的东西是:slot4 所指的到底是哪一个 z函数实体?

在一个单一继承体系中,virtual function 机制的行为十分良好,不但有效率而且很容易塑造出模型来。但是在多重继承和虚拟继承之中,对 virtualfunctions 的支持就没有那么美好了。

多重继承下的Virtual Functions

// class 体系,用来描述多重继承(MI)情况下支持 virtual function 时的复杂度
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;
};

Derived 支持 virtual functions的困难度,统统落在 Base2 subobject 身上。有三个问题需要解决:

(1)通过一个“指向第二个 base class”的指针,调用 derived class virtual function。例如virtual destructor,

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

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

(2) 通过一个“指向 derived class”的指针,调用第二个 base class 中一个继承而来的 virtual function。例如,被继承下来的 Base2::mumble:

Derived *pder = new Derived;
// 调用 Base2::mumble()
// pier 必须被向前调整 sizeof( Basel ) 个 bytes
pder->muble();

在此情况下,derived class指针必须再次调整,以指向第二个 base subobject。

(3)允许一个 virtual function 的返回值类型有所变化,可能是 base type ,也可能是 publicly derived type。例如, Derive::clone函数。它的 Derived 版本传回一个Derived class 指针,默默地改写了它的两个 base class 函数实体。当我们通过“指向第二个 baseclass”的指针来调用 clone时,this 指针的 offset 问题于是诞生:

Base2 *pbl = new Derived
// 调用 Derived* Derived::clone()// 返回值必须被调整,以指向 Base2 subobject
Base2 *pb2 = pb1->clone();

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

问题一:

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

Base2 *pbase2 = new Derived;

新的 Derived 对象的地址必须调整,以指向其 Base2 subobiect。编译时期会产生以下的码:

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

一般规则是,经由指向“第二或后继之 base class的指针(或 reference)来调用 derived class virtual function。译注,就像本例的,

Base2 *pbase2 = new Derived;
delete pbase2; // invoke derived class's destructor (virtual)

该调用操作所连带的“必要的 this 指针调整”操作,必须在执行期完成。也就是说,offset 的大小,以及把 offset 加到 this 指针上头的那一小段程序代码必须由编译器在某个地方插入。

解决方式一 Bjarne :

将 virtual table 加大,使它容纳此处所需的 this 指针。每一个 virtual table slot,不再只是一个指针,而是一个聚合体,内含可能的 offset 以及地址。于是 virtual function 的调用。其中faddr内含 virtual function 地址,offset 内含 this 指针调整值

操作由:
*pbase2->vptr[1])( pbase2 );
改变为:
( *pbase2->vptr[1].faddr )( pbase2 + pbase2->vptr[1].offset );

这个做法的缺点是,它相当于连带处罚了所有的 virtual function 调用操作不管它们是否需要 offset 的调整。我所谓的处罚,包括 offset 的额外存取及其加法,以及每一个 virtual table slot 的大小改变。

解决方式二 Thunk:

引入一小段 assebly 码,用来(1)以适当的offset 值调整 this 指针,(2)跳到 virtual function 去。例如,经由一个 Base? 指针调用 Derived destructor,其相关的 thunk 可能看起来是这个样子:

//虚拟 C++码
pbase2 dtor thunk:
    this += sizeof( basel );
    Derived::~Derived( this );

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

解决了单个vtable内的存取效率,但是在多个vtable的动态链接上,仍然存在问题。

例如,同一函数在 virtual table 中可能需要多笔对应的 slots。

Basel *pbasel = new Derived;
Base2 *pbase2 = new Derived;
delete pbasel;
delete pbase2;

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

在多重继承之下,一个 derived class 内含 n-1 个额外的 virtual tables,n 表示其上一层 base classes 的数目(因此单一继承将不会有额外的 virtual tables对于本例之 Derived 而言,会有两个 vitualtables 被编译器产生出来:

1.一个主要实体,与Base1(最左端 base class)共享

2.一个次要实体,与 Base2(第二个 base class)有关

针对每一个 virtualtables,Derived 对象中有对应的 ptr。图4-2说明了这点。vptrs将在 constructor(s)中被设立初值(经由编译器所产生出来的码)。

Sun 编译器将多个 virtual tables 合并为一个。指向次要表格的指针,可由主要表格名称加上一个 offset 获得在这样的策略下,每一个 class 只有一个具名的 virtual table。

上述三种情况,详见P166

虚拟继承下的virtual function

没有具体的讨论

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;
);

函数的效能

参考: https://zhuanlan.zhihu.com/p/657688995

Part 1: All About Virtual Keyword in C++: How Does Virtual Function Works Internally? | Vishal Chovatiya