深度解读《深度探索C++对象模型》之C++虚函数实现分析(一)

接下来我将持续更新"深度解读《深度探索C++对象模型》"系列,敬请期待,欢迎关注!也可以关注公众号:iShare爱分享,自动获得推文和全部的文章列表。

假如有这样的一段代码,代码中定义了一个Object类,类中有一个成员函数print,通过以下的两种调用方式调用:

c++ 复制代码
Object b;
Object* p = new Object;
b.print();
p->print();

请问这两种方式有什么区别吗?在效率上一样吗?答案是不确定。因为得看成员函数print的声明方式,它可能是静态的,可能是非静态的,也可能是一个虚函数。还得看Object类的具体定义,它可能是独立的类,也有可能是经过多重继承来的类,或者继承的父类中有一个虚基类。

静态成员函数和非虚成员函数比较简单,我们在下一小节简单介绍一下即可,本文重点讲解虚函数的实现及其效率。

成员函数种类

  • 非静态成员函数

非静态成员函数和普通的非成员函数是一样的,它也是被编译器放置在代码段中,且可以像普通函数那样可以获取到它的地址。和普通非成员函数的区别是它的调用必须得经由一个对象或者对象的指针来调用,而且可以直接访问类中非公开的数据成员。下面的代码打印出函数的地址:

c++ 复制代码
#include <cstdio>

class Object {
public:
    void print() {
        printf("a=%d, b=%d\n", a, b);
    }
    int a = 1;
    int b = 2;
};

void printObject(Object* obj) {
    printf("a=%d, b=%d\n", obj->a, obj->b);
}

int main() {
    printf("Object::print = %p\n", &Object::print);
    printf("printObject = %p\n", &printObject);

    return 0;
}

程序的输出结果如下,从打印结果来看,两者的地址比较相近,说明它们都是一起放在代码段中的,从生成的汇编代码也可以看出来。

text 复制代码
Object::print = 0x1007b3f30
printObject = 0x1007b3e70

非静态成员函数和普通非成员函数的运行效率上也是一样的,普通非成员函数的实现上,对类中成员的访问看起来像是要经过指针的间接访问,如obj->a,非静态成员函数的访问看起来更直接一点,直接可以对类中的成员进行存取,好像是非静态成员函数的效率更高一些,其实不然,非静态成员函数的调用,编译器会隐式的把它转换成另一种形式:

c++ 复制代码
Object obj;
obj.print();
// 转换成:
print(&obj);
// print的定义转换成:
print(Object* const this) {
    printf("a=%d, b=%d\n", this->a, this->b);
}

两者在本质上是一样的,查看生成的汇编代码也是一样的。另外也说明了为什么非静态成员函数要经由一个对象或对象的指针来调用。

  • 静态成员函数

上面提到的非静态成员函数的调用,必须要经由类的对象来调用,是因为需要将对象的地址作为函数的参数,也就是隐式的this指针,这样在函数中访问类的非静态数据成员时将绑定到此地址上,也就是将此地址作为基地址,经过偏移得到数据成员的地址。但是如果函数中不需要访问非静态数据成员的话,是不需要this指针的,但目前的编译器并不区分这种情况。静态成员函数不能访问类中的非静态数据成员,所以是不需要this指针的,如Object类中定义了静态成员函数static int static_func(),通过对象调用:

Object obj;

obj.static_func();

或者通过对象的指针调用:

Object* pobj = new Object;

pobj->static_func();

最终都会转换成员如下的形式:

Object::static_func();

通过对象或者对象的指针来调用只是语法上的便利而已,它并不需要对象的地址作为参数(this指针)。

那么静态成员函数存在的意义是什么?静态成员函数在C++诞生之初是不支持的,是在后面的版本中增加进去的。假设不支持静态成员函数时,类中有一个非公开的静态数据成员,如果外面的代码需要访问这个静态数据,那么就需要写一个非静态成员函数来存取它,而非静态成员函数需要经由对象来调用,但有时候在这个时间点没有创建一个对象或者没有必要创建一个对象,那么就有了以下的变通做法:

c++ 复制代码
// 假设定义了get_static_var函数用于返回静态数据成员
((Object*) 0))->get_static_var();
// 编译器会转换成:
get_static_var((Object*) 0));

上面的代码把0强制转换为Object类型的指针,然后经由它来调用非静态成员函数,编译器会把0作为对象的地址传递给函数,但函数中不会使用这个0,所以不会出现问题。由于有这些需求的存在,C++标准委员会增加了支持静态成员函数,静态成员函数可以访问类中的非公开的静态数据成员,且不需要经由类的对象来调用。

静态成员函数和非静态成员函数、普通函数一样都是存储在代码段中的,也可以获取到它的地址,它是一个实际的内存的地址,是一个数据,如上面定义的static_func函数,它的类型为int (*)(),就是一个普通的函数类型。而非静态成员函数,返回的是一个"指向类成员函数的指针",如上面定义的print函数,返回的类型是:

void (Object::*) ();

静态成员函数基本上等同于普通函数,所以和C语言结合编程时,可以作为回调函数传递给C语言写的函数。

总结一下,静态成员函数具有以下的特性:

    • 静态成员函数不能存取类中的非静态数据成员。
    • 静态成员函数不能被声明为const、volatile或者是virtual。
    • 静态成员不需要经由类的对象来调用。
  • 虚函数

虚函数是否也可以像非虚函数那样获取到它的地址呢?我们写个程序来测试一下。

c++ 复制代码
#include <cstdio>

class Object {
public:
    virtual void virtual_func1() {
        printf("this is virtual function 1\n");
    }
    virtual void virtual_func2() {
        printf("this is virtual function 2\n");
    }
};

int main() {
    printf("Object::virtual_func1 = %p\n", &Object::virtual_func1);
    printf("Object::virtual_func2 = %p\n", &Object::virtual_func2);
    return 0;
}

上面程序的输出:

text 复制代码
Object::virtual_func1 = 0x0
Object::virtual_func2 = 0x8

程序的输出结果并不是一个内存地址,而是一个数字,其实这是一个偏移值,对应的是这个虚函数在虚函数表中的位置,一个位置占用8字节大小,第一个是0,第二个是8,以此类推,每多一个虚函数,就在这个表中占用一个位置。看起来像是无法获取到虚函数的地址,其实不然,虚函数的地址就存放在虚函数表中,只是我们无法直接获取到它,但是我们记得,如果有虚函数时,对象的前面会被编译器插入一个虚函数表指针,这个指针就是指向类的虚函数表,我们可以通过它来获取到虚函数的地址,下面演示一下通过非常规手段来调用虚函数的做法:

c++ 复制代码
#include <cstdio>

class Object {
public:
    virtual void virtual_func1() {
        printf("this is virtual function 1\n");
    }
    virtual void virtual_func2() {
        printf("this is virtual function 2\n");
    }
};

int main() {
    Object* pobj = new Object;
    using Fun = void (*)(void);
    Fun** ptr = (Fun**)pobj;
    printf("vptr = %p\n", *ptr);
    for (auto i = 0; i < 2; ++i) {
        Fun fp = *(*ptr + i);	//取得虚函数的内存地址
        printf("vptr[%d] = %p\n", i, fp);
        fp();	//此行调用虚函数
    }
    delete pobj;

    return 0;
}

程序的输出结果:

text 复制代码
vptr = 0x100264030
vptr[0] = 0x100263ea4
this is virtual function 1
vptr[1] = 0x100263ecc
this is virtual function 2

可以看到,虚函数的地址不光可以获取得到,而且还可以直接调用它,调用它的前提是函数中没有访问类的非静态数据成员,不然就会出现运行错误。vptr就是写入到对象前面的虚函数表指针,它的值就是虚函数表在内存中的地址,虚函数表中记录了两项内容,对应了两个虚函数的地址,即vptr[0]是虚函数virtual_func1的地址,vptr[1]是虚函数virtual_func2的地址。把他们强制转换成普通函数的类型指针,然后可以直接调用他们,所以这里是没有对象的this指针的,也就不能访问类中的非静态数据成员了。

虚函数的实现

从上一小节中我们已经窥探到虚函数的一般实现模型,每一个类有一个虚函数表,虚函数表中包含类中每个虚函数的地址,然后每个对象的前面会被编译器插入一个指向虚函数表的指针,同一个类的所有对象都共享同一个虚函数表。接下来的内容中将详细分析虚函数的实现细节,包括单一继承、多重继承和虚继承的情况。

多态是C++中最重要的特性之一,也是组成面向对象编程范式的基石,虚函数则是为多态而生。那么何为多态?多态是在基类中定义一组接口,根据不同的业务场景派生出不同的子类,在子类中实现接口,上层代码根据业务逻辑调用接口,不关心接口的具体实现。在代码中,一般是声明一个基类的指针,此指针在运行期间可能指向不同的派生类,然后通过基类的指针调用一个接口,这个接口在不同的派生类中有不同的实现,所以根据基类的指针指向哪个具体的派生类,调用的就是这个派生类的实例。假设有一个名称为print的接口,p是基类类型的指针,那么下面的调用:

p->print();

是如何识别出要实施多态的行为?以及如何调用到具体哪个派生类中的print?如果是在指针类型上增加信息,以指明具体所指对象的类型,那么会改变指针原有的语义,造成和C语言的不兼容,而且也不是每个类型都需要这个信息,这会造成不必要的空间浪费。如果是在每个类对象中增加信息,那么在不需要多态的对象中也需要存放这些信息,也会造成空间上的浪费。因此增加了一个关键字virtual,用于修饰那些需要多态的函数,这样的函数就叫做虚函数,所以识别一个类是否支持多态,就看这个类中是否声明了虚函数。只要类中有虚函数,就说明需要在类对象中存储运行期的信息。

那么在对象中要存储哪些信息才能够保证保证上面代码中print的调用是调用到正确的派生类中的实例呢?要调用到正确的print实例,我们需要知道:

  • p指向具体的对象类型,让我们知道要调用哪个print;
  • print的位置,以便我们可以正确调用它。

要如何实现它,不同的编译器可能有不同的实现方法,通常是使用虚函数表的做法。编译器在编译的过程中,收集到哪些是虚函数,然后将这些虚函数的地址存放一个表格中,这些虚函数的地址在编译期间确定的,运行期间是不会改变的,虚函数的个数也是固定的,在程序的执行期间不能删除或者增加,所以表格的大小也是固定的,这个过程由编译器在编译期间完成。表格中虚函数的位置按照类中声明的顺序,位置是固定不变的,我们在上节中通过虚函数名称打印出来的值就是虚函数在虚函数表中的位置,即相对于表格首地址的偏移值。

有了这个表格,那么如何寻址到这个表格呢?方法就是编译器根据类中是否有虚函数,如果有虚函数,就在类的构造函数里插入一些汇编代码,在构造对象时,在对象的前面插入一个指针,这个指针指向这个虚函数表,所以这个指针也叫做虚函数表指针。下面以具体的代码来看看虚函数是怎么调用的,把上面的例子main函数修改如下,其它地方不变:

c++ 复制代码
int main() {
    Object* pobj = new Object;
    pobj->virtual_func1();
    pobj->virtual_func2();
    delete pobj;

    return 0;
}

我们来看下生成的汇编代码,首先来看看虚函数表长什么样:

text 复制代码
vtable for Object:
    .quad   0
    .quad   typeinfo for Object
    .quad   Object::virtual_func1()
    .quad   Object::virtual_func2()

它是汇编中定义在数据段的一组数据,"vtable for Object"是它的标签,代表了这个数据区的起始地址,每一行定义一条数据,第一列.quad表示数据的大小,占用8字节,第二列表示数据的值,可以是数字,也可以是标签,标签是地址的引用。其实这个完整的表叫做虚表,它包含了虚函数表、RTTI信息和虚继承相关的信息,Clang和Gcc编译器是把它们合在一起了,其它编译器可能是分开的。第一行是虚继承中用到,之前已经讲过了,第二行是RTTI信息,这个以后再讲。第三、四行是两个虚函数的地址。

接着看看Object类的默认构造函数的代码:

text 复制代码
Object::Object() [base object constructor]: 	# @Object::Object() [base object constructor]
    # 略...
    lea     rcx, [rip + vtable for Object]
    add     rcx, 16
    mov     qword ptr [rax], rcx
    # 略...

之前已经讲过,有虚函数时编译器会为类生成默认构造函数,在默认构造函数里在类对象的前面设置了虚函数表指针。在这个默认构造函数里,主要的代码就是上面这三行,首先获取虚表(将上面)的起始地址存放在rcx寄存器,然后加上16的偏移值跳过第一、二行,这时指向第三行数据,也就是第一个虚函数的位置,然后将这个地址赋值给[rax],rax是存放的对象的首地址,这就完成了给对象设置虚函数表指针。

接着看main函数中对虚函数的调用:

text 复制代码
main:								# @main
    # 略...
  	# 调用构造函数
    mov     rdi, rax
    mov     qword ptr [rbp - 32], rdi       # 8-byte Spill
    call    Object::Object() [base object constructor]
    mov     rax, qword ptr [rbp - 32]       # 8-byte Reload
    mov     qword ptr [rbp - 16], rax
		# 调用第一个虚函数
    mov     rdi, qword ptr [rbp - 16]
    mov     rax, qword ptr [rdi]
    call    qword ptr [rax]
  	# 调用第二个虚函数
    mov     rdi, qword ptr [rbp - 16]
    mov     rax, qword ptr [rdi]
    call    qword ptr [rax + 8]
    # 略...

上面汇编代码中的第4行rax是调用new函数后返回来的地址,也就是pobj指针,把它存放到rdi寄存器中作为参数,同时也保存到栈空间rbp - 32中,然后调用构造函数,构造完成之后再拷贝这个地址到栈空间rbp - 16中。接下来的第10到12行是第一个虚函数的调用,将对象的首地址加载到rdi寄存器中,然后对其取内容,也就是是相当于指针的解引用,即 (*pobj),取得的内容即是构造函数中设置的虚函数表的地址,它是一个指向第一个虚函数的地址,然后第12行对其取内容,也即是对这个地址解引用,取得第一个虚函数的地址,然后以rdi寄存器(即对象的首地址)为第一个参数调用它,相当于:virtual_func1(pobj)。第14到16行是对第二个虚函数的调用,流程和第一个基本一样,区别在于将虚函数表的地址加上8的偏移量以指向第二个虚函数。

如果在一个虚函数中调用另一个虚函数又会怎样?第一个虚函数已经决议出是调用哪个对象的实例了,那么在其中调用其它虚函数还需要再动态决议吗?把main函数中对第二个虚函数的调用去掉,在第一个虚函数中增加以下代码:

c++ 复制代码
virtual_func2();
Object::virtual_func2();

来看下对应生成的汇编代码,其它代码都差不多,主要看virtual_func1函数的代码:

text 复制代码
Object::virtual_func1():            # @Object::virtual_func1()
    # 略...
    mov     rdi, qword ptr [rbp - 16]       # 8-byte Reload
    mov     rax, qword ptr [rdi]
    call    qword ptr [rax + 8]
    mov     rdi, qword ptr [rbp - 16]       # 8-byte Reload
    call    Object::virtual_func2()
    # 略...

rbp - 16保存的是对象的首地址,第3到5行对应的是上面C++代码中第一句的调用,看起来在虚函数中调用另一个虚函数,用的还是动态决议的方法,这里编译器没有识别出已经决议出具体的对象了。从汇编代码的第6、7行看到,通过前面加类名的限定符,是直接调用到这个函数,如果你明确调用的是哪个函数的话,可以直接在函数的前面加上类名,这样就不需要用多态的方式去调用了。

如果不是通过指针类型来调用虚函数,而是通过对象来调用,结果是什么情况?把main函数改成如下:

c++ 复制代码
int main() {
    Object obj;
    obj.virtual_func1();
    obj.virtual_func2();

    return 0;
}

查看main函数对应的汇编代码:

text 复制代码
main:                           # @main
    # 略...
    lea     rdi, [rbp - 16]
    call    Object::virtual_func1()
    lea     rdi, [rbp - 16]
    call    Object::virtual_func2()
    # 略...

可以看到通过对象来调用虚函数,是直接调用到这个对象的函数实例的,没有使用多态的方式,所以通过对象的方式调用是没有多态的行为的,只有通过类的指针或者引用类型来调用虚函数,才会有多态的行为。

单一继承下的虚函数

假设有以下的类定义及继承关系:

c++ 复制代码
class Point {
public:
    Point(int x = 0) { _x = x; }
    virtual ~Point() = default;
    virtual void drawLine() = 0;
    int x() { return _x; }
    virtual int y() { return 0; }
    virtual int z() { return 0; }
private:
    int _x;
};
class Point2d: public Point {
public:
    Point2d(int x = 0, int y = 0): Point(x) { _y = y; }
    virtual ~Point2d() = default;
    void drawLine() override { }
    virtual void rotate() { }
    int y() override { return _y; }
private:
    int _y;
};
class Point3d: public Point2d {
public:
    Point3d(int x = 0, int y = 0, int z = 0): Point2d(x, y) { _z = z; }
    virtual ~Point3d() = default;
    void drawLine() override { }
    void rotate() override { }
    int z() override { return _z; }
private:
    int _z;
};

int main() {
    Point* p = new Point3d(1, 1, 1);
    printf("z = %d\n", p->z());
    delete p;
    return 0;
}

先来看看生成的汇编代码中的虚函数表:

text 复制代码
vtable for Point:
    .quad   0
    .quad   typeinfo for Point
    .quad   Point::~Point() [base object destructor]
    .quad   Point::~Point() [deleting destructor]
    .quad   __cxa_pure_virtual
    .quad   Point::y()
    .quad   Point::z()

vtable for Point2d:
    .quad   0
    .quad   typeinfo for Point2d
    .quad   Point2d::~Point2d() [base object destructor]
    .quad   Point2d::~Point2d() [deleting destructor]
    .quad   Point2d::drawLine()
    .quad   Point2d::y()
    .quad   Point::z()
    .quad   Point2d::rotate()

vtable for Point3d:
    .quad   0
    .quad   typeinfo for Point3d
    .quad   Point3d::~Point3d() [base object destructor]
    .quad   Point3d::~Point3d() [deleting destructor]
    .quad   Point3d::drawLine()
    .quad   Point2d::y()
    .quad   Point3d::z()
    .quad   Point3d::rotate()

每个类都有一个对应的虚函数表,虚函数表中的内容主要来自于三方面:

  • 改写基类中对应的虚函数,用自己实现的虚函数的地址写入到对应表格中的位置;
  • 从基类中继承而来的虚函数,直接拷贝基类虚函数的地址添加到虚函数表中;
  • 新增的虚函数,基类中没有,子类的虚函数表会增加一行容纳新条目;

基类和子类使用各自的虚函数表,互不干扰,即使子类中没有改写基类的虚函数,也没有新增虚函数,编译器也会为子类新建一个虚函数表,内容从基类中拷贝过来,内容和基类完全一样。

虚函数表中的虚函数的排列顺序是固定的,一般是按照在类中的声明顺序,如C++代码中的这行代码:

p->z();

要寻址到正确的z函数实例的地址,我们首先需要知道p指针所指向的具体对象,然后需要知道z函数在表格中的位置,如上例中,z函数在第5个条目,也就是说虚函数表的起始地址加上32的偏移量就可以寻址到它,这个位置保持不变,无论p指针指向哪个对象,都能找到正确的z函数。如果子类中有新增的虚函数,新增的虚函数声明的位置插在从基类中继承来的虚函数中间,编译器会做调整,把它安排在后面,在原有的顺序上再递增,如上例中的rotate函数。

如果您感兴趣这方面的内容,请在微信上搜索公众号iShare爱分享并关注,以便在内容更新时直接向您推送。

相关推荐
染指11102 小时前
50.第二阶段x86游戏实战2-lua获取本地寻路,跨地图寻路和获取当前地图id
c++·windows·lua·游戏安全·反游戏外挂·游戏逆向·luastudio
Code out the future2 小时前
【C++——临时对象,const T&】
开发语言·c++
sam-zy3 小时前
MFC用List Control 和Picture控件实现界面切换效果
c++·mfc
aaasssdddd963 小时前
C++的封装(十四):《设计模式》这本书
数据结构·c++·设计模式
发呆小天才O.oᯅ3 小时前
YOLOv8目标检测——详细记录使用OpenCV的DNN模块进行推理部署C++实现
c++·图像处理·人工智能·opencv·yolo·目标检测·dnn
qincjun4 小时前
文件I/O操作:C++
开发语言·c++
星语心愿.4 小时前
D4——贪心练习
c++·算法·贪心算法
汉克老师4 小时前
2023年厦门市第30届小学生C++信息学竞赛复赛上机操作题(三、2023C. 太空旅行(travel))
开发语言·c++
single5945 小时前
【c++笔试强训】(第四十一篇)
java·c++·算法·深度优先·图论·牛客
yuanbenshidiaos5 小时前
C++-----函数与库
开发语言·c++·算法