C++成员函数调用方式用法详解

作者:袖梨 2022-06-25

前言

C++的成员函数分为静态函数、非静态函数和虚函数三种,在本系列文章中,多处提到static和non-static不影响对象占用的内存,而虚函数需要引入虚指针,所以需要调整对象的内存布局。既然已经解决了数据,函数等在内存中的布局问题,下一个需要考虑的就是如何调用,上述提到的三种函数的调用机制都不一样,其间的差异正是本篇博客需要讨论的。

非静态成员函数

C++的设计准则之一就是:非静态成员函数至少必须和一般的非成员函数有相同的效率。要达到这一点,成员函数的成员属性不会给其带来额外的负担。考虑以下两种函数调用:

int getAge(Animal *_this);//非成员函数
int Animal::getAge();//成员函数
//getNum函数定义如下:
int getAge(){
 return age;
}
前者需要传入一个类指针,属于非成员函数调用,后者直接指明Animal类的函数调用。本质上,这两个函数是一样的,因为编译器会将后者转换为前者,其转换步骤如下:

1.改写函数的原型,使得其接受一个额外的参数,这个额外的参数就是函数的this指针:

int Animal::getNum(Animal *this);//在函数内安插一个this指针
2.将每一个对非静态成员变量的存取操作改为经由this指针来存取:

{
 return this.age;
}
3.将成员函数重写成一个外部函数,函数名称经过“mangling”处理,使它在程序中称为独一无二的语汇,如上述函数可能被处理为:getNum_AnimalFv(p),这里需要保证名字不会有冲突!

这里引申一下,extern “C”操作会抑制函数名称的“mangling”效果,用于在C++中调用C函数。

所以,将一个成员函数改写成一个非成员函数的关键在于两点:一是能够提供给函数一个读写成员变量的通道,二是解决好有可能带来的名字冲突。前者通过传递一个this指针可以很好的解决,后者则通过一定的名字转换规则来确保名字的独一无二性。

虚拟成员函数

我们来回想一下如果一个类中存在虚函数,编译器会做以下三件事:

1.为该类分配一个虚函数表,它存有虚函数在执行器的地址

2. 在该类中安插一个虚指针,指向该类的虚表

3.将每一个虚函数的入口地址存放在虚函数表中相应的slot

所以,要想正确调用虚函数,只需找到该虚函数在虚函数表的相应位置即可,于是,考虑到以下示例。

class Animal{
public:
 char name[10];//动物名字
 int weight;//体重
 virtual void eat(){}
};
Animal *animal;
animal->eat();
当调用虚函数eat的时候,编译器会自动转换成以下代码:

//vptr为指向虚函数表的指针,eat存放在虚函数的第一位,
//由于是成员函数,所以函数还必须传入一个this指针参数
(* animal->vptr[0])(animal);
只有在指针和引用才能展现出多态的形式,如果我们显示调用或者直接用类对象来调用的话会是什么样呢?

//显示调用eat函数
Animal::eat();
//直接用对象来调用
Animal animal;
animal.eat();
在上述两种调用中,前者会抑制掉虚拟机制,直接将eat()作为非静态成员函数一样调用。对于后者,假设编译器将其转换成如下形式:

(* animal.vptr[0])(&animal);
这样虽然在语意上正确,但是完全没有必要这样做,所以编译器会直接当成Animal::eat()显示调用来处理。

单一继承下的虚函数调用

当一个类继承自一个基类时,其中的虚函数可能发生如下三种情况:

1.子类中的虚函数会重写父类的虚函数

2. 继承自基类的虚函数实体,也就是基类中存在,子类中没有重写

3. 一个纯虚函数,用来在虚函数表中“占座”,也可以当做执行器异常处理函数

针对如上三种情况,子类在构建自己的虚函数表时,会做如下处理:

1.当重写了父类的虚函数时,就将虚函数表中对应的slot改写成子类的虚函数入口地址

2. 当继承基类的虚函数实例时,只需要将实例的地址拷贝到子类的虚函数表中即可

3.子类可以定义自己的虚函数实例,存档在虚表的slot中,虚表的尺寸会增大

这里还是引用之前博文中讲过的一个实例来说明一下,考虑到如下继承关系:

其内存布局如下:

可见在子类中重新改写了虚函数表,那么,针对这类继承,虚函数时怎么调用的呢?我们可以观察到父类的虚函数表中函数的相对位置在子类中是没有发生变化的,,针对于如下调用:

void Fun(Dog *dog){
 dog->eat();
}
Dog* dog = new Dog();
Animal* animal = new Dog();
Fun(dog);//第一种调用方式,直接传入一个dog指针
Fun(animal);//第二种调用方式,传入一个animal指针
如果传入的是一个Dog类的对象指针,那么直接利用上一小节的方法即可,如果传入的是一个Animal类的对象指针,我们可以看到,还是一如既往的可以采用上一小节中的方法,因为eat()在虚函数表中的位置并没有发生变化,唯一在执行期才能知道的是:哪个的eat()函数被调用。

多重继承下的虚函数调用

有了上述的了解之后,我们知道虚函数的调用无非是需要满足一下两点:

1.需要知道虚函数表的地址

2.需要知道该虚函数在虚函数表中的位置

但是,在多重继承中,这就变得有些复杂了,多重继承中存在多个虚表,如下面这样的继承关系和内存布局:

其内存布局如下:

还是以上面的Fun函数为例,考虑下面几个调用方式:

Dog *dog = new Dog();
dog->eat();//第一种调用方式,直接传入一个dog指针
dog->sleep();//第二种调用方式,传入一个animal指针
dog->jump();//第三种调用方式,传入一个canidae指针
针对前两种调用方式,其调用给方式与上一小节中基本相似,不需要改变this指针,因为第一顺位继承类的起点与子类对象的起点一致。对于第三种调用方式来说的话,就显得有些复杂了。如果继续传入一个没有经过调整的this指针的话,就难以获取Canidae的虚表地址了。这里首先来介绍一种Thunk方法。thunk的作用在于:

1.以适当的offset来调整this指针

2.跳到对应的虚函数中 按照thunk的思想,再调用jump()函数时,其this指针需要做如下调整:

thunk:
 this+=sizeof(Animal);
 Dog::eat(this);
好,我们的问题就变成多重继承关系中,除继承顺序的第一位外,其他位的类实现虚函数调用都需要做一些调整。这种调整发生在以下两种情况:

//一、将一个基类指针指向一个子类,当然是继承顺序第一位以后的基类
Canidae *canidae = new Dog();
//二、使用子类指针来调用基类的函数,当然是继承顺序第一位以后的基类函数
Dog dog = new Dog();
dog->jump();
前一种情况中,需要将canidae指针向后调整sizeof(Animal)位,指向子类中对应的基类部分。

第二种情况,需要调整dog指针向后sizeof(Animal)位,指向dog中Canidae基类部分。

这样一来,对于多重继承下的虚函数调用就比较容易理解了,你理解了吗?

虚拟继承下的虚函数调用

针对于虚继承来说,其虚基类的地址在内存布局中存放的位置对于不同的编译器来说都不一样,书中直接说像进了迷宫一样。好吧,我是怀着向探究本源的目的来的,被作者的这一句话着实给吓到了。

在虚拟继承下的虚函数调用中,其复杂点依旧在于如何调整this指针,虚拟继承在多重继承上又多了一个虚基类指针,这使得情况就变得复杂多变了。

作者最后给了一个定义:不要在虚基类中定义非静态成员成员变量,想来也是怕这些会影响虚基类指针在内存中的布局位置,从而增加了决定适当的offset的复杂度。

静态成员函数

静态成员函数相比于其他成员函数来说,最大的不同就是它没有this指针,其主要特性是:

1.它不能够直接存取其class中的非静态成员变量

2.它不能被声明为const、volatile或virtual

3.它不需要经由类对象才被调用

所以,对于静态成员函数的调用就几乎等同于非成员函数调用了。当然,为了指明他是一个类成员函数,在命名调整上必然会加上类的信息,如下:

nimal::getAge();//假设getAge是一个静态成员函数
//其经过命名调整后如下:
getAge_AnimalSFv();//SFv表示他是一个静态成员函数,static member Function,其拥有一个空白的参数列表(void)
总结

本篇博客讲解了三类成员函数(非静态、静态、虚函数)的底层调用机制,以及C++对函数命名,this指针的调整规则等。我们可以知道,C++在成员函数调用上,对于静态,非静态成员函数在函数调用效率上基本等同于非成员函数,而虚函数的调用上为了满足多态性,需要调整this指针,找到虚表地址等等操作,影响了其函数调用效率,不过这些也是值得的!

相关文章

精彩推荐