深入探索C++对象模型-Function语义学

发布时间:2023年12月29日

成员函数的各种调用方式

非静态成员函数

非静态成员函数和普通的函数具有相同的效率。

非静态成员函数经过编译器处理后,就会转换成普通函数相同的形式,并不会有任何额外的开销。

我们可以看到上图的两个函数都是对一个Point3d对象进行相同操作的功能的函数。只不过第一个为普通函数是通过传入一个对象指针来进行操作,另一个为成员函数,直接对本对象进行操作。

实际上,Point3d::magnitude函数经过编译器处理也会变成一个接受指针的函数。

上图中中的函数就是经过编译器处理的非静态成员函数,我们可以看到相比原型相比,多一个this指针参数,非静态成员函数体内的任何对非静态成员变量的存取操作都是通过this指针进行。

后续,编译器还会继续处理,还会将这个函数变为一个外部函数,这个外部函数的命名会经过mangling处理,使得这个外部函数的名称包含函数的原名字、参数类型、参数数量等信息。mangling处理也是能够函数重载的原理。最终函数的样子如下所示。

name mangling

一个类中的成员变量的名称也会经过mangling处理,会加上类的名称,形成一个独一无二的命名,这样也能够解决父子类之间存在相同成员变量名的情况。

如上所示,我们可以看到父子类的ival变量,经过编译器处理后,变量名中都会加入类的信息。

同理的还有函数的重载机制,我们称两个函数拥有相同的函数名,但是拥有不同的参数数量和参数类型而能够同时存在的机制为重载,而实际上,这两个函数经过编译器处理都分别会有一个独一无二的函数名。

成员函数经过mangling后,会加上类名的信息,还有一些其他信息,我们称经过mangling的函数名字为这个函数的signature.

基本上这个mangling的规则:函数的signature=函数名称+参数数量+参数类型。我们可以看到其中并没有返回值信息,因此重载并不支持两个相同名字不同返回类型的函数。

虚拟成员函数

虚函数的调用经过编译器处理,会转变为通过指针/引用的虚表的方法调用。

ptr->normalize();
(*ptr->vptr[1])(ptr);//第二个ptr表示的是this指针,因为成员函数其实都有一个this的指针参数,需要将对象的地址传入

此外虚函数的这种多态特征仅通过指针或引用才会体现,通过明确类型的对象进行调用时无法表现。此处不过多赘述了,前面的章节已经讲的非常清楚。

静态成员函数

我们先讲述一下静态成员函数的特性:

1、不能够存取类中的非静态成员变量;

2、不能声明为const、volatile、virtual;

3、无需经过object来进行调用;

首先,我们明确一点,静态成员函数最初创造出来就是作为完全不访问成员变量的成员函数。静态成员函数经过编译器处理并不会在参数列表中新增一个this的指针,因此该函数无法访问非静态成员变量。

同时,静态成员函数可以相当于就是普通函数,因此无法赋予const,volatile,virtual的属性,自然也就无需经过object来进行调用。

我们获取静态成员函数的指针的时候,我们也可以发现指针类型为普通的函数指针类型,而非成员函数指针。

虚拟成员函数

这里将根据单一继承、多重继承、虚拟继承等多重情况探究,虚函数的实现模型。

为了支持虚函数的机制,那么就需要能够对一个多态对象的指针或引用进行”runtime type resolution“,需要能够在执行期获取相关信息。

ptr->z();

针对上面这个虚函数的调用,我们在执行期需要知道两个信息才能正确调用:

1、ptr指向的对象的真实类型;

2、z()实体位置;

目前的实现是通过构建一个虚表,虚表中记载了每个虚函数的内存地址,同时每个虚函数所处的slot位置都是固定的。

从基类开始的虚函数依次放到虚表中,后续继承的子类中,如果新增了虚函数则将占用后续的slot位置,或者重写了基类的虚函数,那么对应slot位置中的函数地址自然也是不同的。

因此不同的虚函数在虚表中拥有不同的slot位置,重写的虚函数在虚表中有相同的slot位置,但是虚函数在各自的虚表中的内存地址不同。

目前看来虚表的设计针对单一继承体系而言非常好用,但是我们后续讲到多重继承和虚拟继承后,虚表的这种设计实现又会有诸多头疼的事情出现。

多重继承下的虚函数

在多重继承下的虚函数的复杂度主要在第二个及后继的base classes身上。此外,通过指针来调用虚函数时可能还需要在执行器调整this指针。

以下图的三个类之间的继承关系举例:

注意:上面这个类的代码实现无法通过VC5的编译,因为在VC5看来,派生类Derived中的重写clone方法,但是返回值却不匹配,这和重写的规则不符。但是C++标准现在已经针对这种情况做了修改。

Dervied *temp = new Derived();
Base1 *pbase1 = temp;
Base2 *pbase2 = temp;
// 实际上b2的赋值语句如下
b2 = derived ? (Base2*)((char *)derived + sizeof(Base1)) : 0;

通过前面Data的语义学的学习,我们可以知道pbase2的指向内存地址和pbase1和temp不同,将temp的内存地址赋值给父类pbase2需要进行地址的调整。通过这样的调整,pbase2指针来能够通过正确的成员变量偏移来访问到data_Base2成员变量。

下面我们讲讲为什么多重继承下,调用虚函数发生this指针调整的情况。

我们删除pbase2对象时,执行delete pbase2时

首先通过虚表来调用析构函数

(*pbase2->vptr[1])(pbase2); // vptr[1]一般就是析构函数

虚表的析构函数指向的实际上的Derived的析构函数,但是析构函数作为非静态成员函数会接受一个this指针作为参数,这个this指针指向的应当是Derived对象的起始内存地址,如果将pbase2将作为this指针传入,显然这个this指针是错误的,因此这个操作需要连带着this指针调整操作,而这个调整操作必须在执行期完成。

一种解决方法:让虚表每个slot存储的不再只是一个函数地址,还包含一个offset,变成一个聚合体。这种方法的问题在于,使得虚表的使用开销增大,在大多数情况下,其实是不会发生this指针调整的。

那么此时析构函数的调用变成下面的形式

(*pbase2->vptr[1].faddr) (pbase2 + pbase2->vptr[1].offset);

第二种方法:使用thunk,多重继承后,派生类中有n-1个额外的虚表,n表示其上一层base class的数目,n-1对应了除第一个base class以外的其他class。

我们可以看到Derived类中有两个虚表,第一个虚表中是由Derived和Base1类型指针使用的,调用Base2类的虚函数时需要进行this指针的调整,因此函数边上加了个*号表示该函数执行过程中会发生this指针的调整。

thunk技术允许虚表中的slot中的地址可以直接指向virtual function,也可以指向一个相关的thunk。那么那些无需调整this指针的虚函数就需要承担额外的负担。

此外,我们可以看到上图中Derived中的两个虚表都存在带有*号的虚函数,第一个虚表中的Base2::mumble()和第二个虚表中的Derived::~Derived()和Derived::clone()

这是因为通过Base1或Derived类型指针调用Base2的虚函数和通过Base2类型指针调用Base1和Derived的虚函数都需要进行this指针的调整。

当一个类拥有多个虚表时,传统方法就是每个虚表都使用mangling的方法来进行命名,和Derived关联的两个虚表可能就会有这样的名称

此外,还有一种特别糟心的情况需要讲述,请看下面的代码:

Base2 *pb1 = new Derived;
Base2 *pb2 = pb1->clone();

首先通过pb1进行调用clone方法,需要发生this指针的调整,但是方法产生的是Derived类的指针,但是pb2又是Base2类型的,又要对返回值进行调整后才能赋予pb2。

这种情况下,调用函数会发生两次指针的调整,针对这种情况Sun编译器提供了split function技术,直接让clone虚函数存在两份实现,分别对应不用指针调整和需要指针调整的版本,让第二个虚表中的clone函数所在的slot中指向的就是函数体内包含了对指针进行调整的全新函数。

使用g++编译后的类中成员的布局似乎已经不大一样了。如下图所示:

Derived类中存在两个虚表,但是第一个虚表中并没有Base2的虚函数。

虚拟继承下的虚函数

我们这里仅考虑单一继承下的虚拟继承,多重继承下的虚拟继承过于复杂。

由于虚拟继承也会导致对象中出现虚基表,让共享部分数据和独立部分数据分开存储,导致类型转换时也要进行指针的调整,因此虚表中也会有thunk一样的机制存在,为了应对this指针的调整。

成员函数指针

在上一章,已经学习过了非静态成员变量指针,指针的内容并非是具体的内存地址而是一个offset。

而非静态成员函数指针的内容为一个具体的内存地址,但是这个函数指针的使用也需要配合对象来调用才行。

举例:

double (Point::*pmf) ();
double (Point::*coord) () = &Point::x;
(origin.*coord) ();
(ptr->*coord) ();

虚函数指针

由于虚函数的地址在编译期间是不确定的,我们并不确定调用的虚函数到底是哪个类的。

因此虚函数指针的内容并非是具体的函数内存地址,而是指定虚函数在虚表的slot号。

&Point::~Point; // 将析构函数的地址赋予给成员函数指针后,值为1
&Point::x(); // 非虚函数,值为函数的内存地址
&Point::y(); // 非虚函数,值为函数的内存地址
float (Point::*pmf)() = &Point::z(); // 值为2
float res = (ptr->*pmf)();
// 上面这个函数调用式子经过编译器处理变成下面的形式
// 可以看到指针利用pmf记录的slot号在虚表中找到指定的函数内存地址并进行调用
float res = (ptr->vptr[(int)pmf])(ptr);

因此编译器定义成员函数指针后,要求这个指针能够存储两种数值。

在cfont2.0非正式版本中,如果一个指针除了低8位外全0,那么这个成员函数指针就是存的是slot号,反之存的就是具体内存地址。

(((int)pmf) & ~127) ? (*pmf)(ptr) : (*ptr->vptr[(int)pmf](ptr));

当然这种实现方式有个缺陷,要求slot号小于等于127,那么一个类中最多有128个虚函数。

多重继承下的成员函数指针

为了让成员函数指针还能够支持多重继承和虚拟继承,下面这个结构体被设计了出来。

由于多重继承下,虚函数的调用可能会发生this指针的调整,此外类中也存在多个虚表,

微软针对成员函数指针,提供了三种形式:

1、单一继承:非虚函数使用函数内存地址,虚函数就使用vcall thunk的地址,vcall thunk会调用相关vtbl中正确的slot;

2、多重继承、虚拟继承使用上述结构体:其中delta表示this指针的offset值,而v_offset保存的是第二个或后继base class的虚表地址

文章来源:https://blog.csdn.net/MarksSa/article/details/135233380
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。