【C++】浅析C++中的虚函数

关于虚函数

Q1:观察一个类引入虚函数后,类会发生什么变化?

首先,创建一个空类A,并实例化出A的一个对象a,计算一下这个对象占用多少字节:

#include<iostream>
using namespace std;

class A
{
	
};

int main(){
	A a;
	cout<<sizeof(a)<<endl;
	return 0;
}

输出结果:

1            //这里不要以为a占用的字节是0。声明出一个对象就会占用内存空间,哪怕是个空类也至少是1

接下来,我们在A中加入两个普通成员函数,再次观察:

#include<iostream>
using namespace std;

class A
{
	void fun1(){}
	void fun2(){}
};

int main(){
	A a;
	cout<<sizeof(a)<<endl;
	return 0;
}

输入结果:

1            //并没有变化,说明普通的类成员函数并不占用类对象的内存空间

再次向类A中添加一个虚函数,观察现象:

#include<iostream>
using namespace std;

class A
{
	void fun1(){}
	void fun2(){}
	virtual void vfun(){}
};

int main(){
	A a;
	cout<<sizeof(a)<<endl;
	return 0;
}

输出结果:

8           //这里的数值跟你的机器有关,我的是64位机器,也可能这个数在你的机器上是4,没关系道理是一样的

看到这里,我们至少可以得出结论:这个虚函数的增加引起了从1变成8的这种变化。

当一个或者多个虚函数被加入到类中之后,编译器会向类中插入一个看不见的成员变量。在类中,这个看不见的成员变量类似是下面这样的伪代码:

class A
{
	void *vptr;
	...
};

这个看不见的成员变量有一个名字叫做虚函数表指针(virtual table pointer,vptr)。这个指针的大小正好是8个字节,因此sizeof(a)的结果就变成了8。(指针占用多少字节取决于机器,一般情况下,32位机器上指针占用4个字节,64位机器上指针占用8个字节)

Q2: 虚函数表的生成时机和生成原因

由上一节的实验,我们得出了这样的结论:当一个类中的虚函数数量大于等于1时(换言之,当类中至少含有一个虚函数时),在编译期间,编译器就会为该类生成一个虚函数表(virtual table,vtbl),这个虚函数表会一直伴随着类.经过编译链接生成一个可执行文件后,这个类以及伴随着类的虚函数表都会保存在可执行文件中。当这个可执行文件执行的时候会一并载入内存。

Q3:虚函数表指针被赋值的时机

我们现在已经知道了虚函数表指针(vptr)和虚函数表(vtbl),那么这两个有什么关系呢?

扫描二维码关注公众号,回复: 15507747 查看本文章

对于上述有虚函数的类A,在编译期间,编译器会在类A的构造函数中,安插为vptr赋值的语句。

A(){
    vptr = &A::vftable;//编译器在编译期间做的
    ...
}

当创建类A对象的时候会执行类A的构造函数,因为构造函数中,有给vptr赋值的语句,从而就能够使vptr指向类A的vtbl;

当然,如果程序员没有书写自己的关于类A的构造函数的时候,编译器会自动为类A生成一个构造函数,并会自动安插给vptr赋值的语句。

这也就解释了为什么之前说构造函数不能为虚函数的原因:执行构造函数前对象尚未完成创建,虚函数表还不存在。

Q4:类对象在内存中的布局

我们为上述类A做一下小小的改变,添加一个虚函数fun2和一个虚析构函数,然后再添加两个成员变量:

class A
{
	public:
		void fun1(){}
		void fun2(){}
		virtual void vfun1(){}
		virtual void vfun2(){}
		virtual ~A(){}
	private:
		int m_a;
		int m_b;
};

这是我们再实例化一个类A的对象a:

A a;

这里可以看到在类A的内存中实际上有三个占用内存的块,除了成员变量之外,还有这个虚函数表指针vptr,并且,指向虚函数表的指针总是存在于对象实例中最前面的位置,这是为了保证取到虚函数表的有最高的性能——如果有多层继承或是多重继承的情况下。

请添加图片描述
我们刚才聊到了vptr与vtbl的关系,毫无疑问,编译器会将这个vptr指向类A的虚函数表vtbl:
请添加图片描述
这个虚函数表中包含三个指针,这三个指针分别指向三个虚函数:
请添加图片描述
然后再加上两个普通成员函数:
请添加图片描述
虚函数表以及成员函数,这些统统属于类A的组成部分,但并不占用类A对象的内存空间,也就是sizeof这个对象的时候,得到的值应该只是一个虚函数表指针的地址加上两个成员变量的指针:

#include<iostream>
using namespace std;

class A
{
	public:
		void fun1(){}
		void fun2(){}
		virtual void vfun1(){}
		virtual void vfun2(){}
		virtual ~A(){}
	private:
		int m_a;
		int m_b;
};

int main(){
	A a;
	int* aa;
	cout<<sizeof(a)<<endl;
	return 0;
}

输出结果:

16

Q5:虚函数的工作原理以及多态性的体现

C++中有两种方式实现多态,即重载和覆盖。

  • 重载:是指允许存在多个同名函数,而这些函数的参数表不同(参数个数不同、参数类型不同或者两者都不同)。这种形式实现的多态为静态多态。
  • 覆盖:是指子类重新定义父类虚函数的做法,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针拥有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来实现可变的算法,比如:模板元编程是在编译期完成的泛型技术,RTTI、虚函数则是在运行时完成的泛型技术。通过虚函数实现的多态称之为动态多态。

也就是说,动态多态必须存在虚函数,没有虚函数,绝对不可能存在动态多态。

代码实现

首先,从代码实现上:

可以看一下调用路线:是不是利用vptr找到vtbl,通过查询vtbl来找到虚函数表的入口地址,并去执行这个虚函数,如果调用这个虚函数的路线是走的这个路线,那么就是多态;如果调用这个虚函数走的不是这个路线,而是像调用普通成员函数一样直接调用,就不是多态。从这个角度来讲,就不用管什么继承关系,有没有什么子类。

例:

#include<iostream>
using namespace std;

class Base
{
	public:
		virtual void myvirfunc(){}
};

int main(){
    Base * pa = new Base();
    pa->myvirfunc();
    
    Base base;
    base.myvirfunc();
    
    Base* ybase = &base;
    ybase->myvirfunc();
	return 0;
}

利用这个方式去判定,得出中间的调用不是多态,上面和下面的都是多态。这一点其实通过汇编代码能很清晰的看出调用方式的差异。

(这里推荐一个看汇编代码的网站:Compiler Explorer (godbolt.org))
在这里插入图片描述

表现形式

其次,从表现形式上:

  • 程序中即要存在父类,也要存在子类,父类中必须包含虚函数,子类中也必须重写父类中的虚函数。
  • 父类指针要指向子类对象或者父类引用绑定子类对象。
  • 当通过父类的指针或引用,调用子类中重写的虚函数时,就能看出多态性的表现了。

也就是说,最后发现调用的是子类的虚函数。

class Derive : public Base
{
    public:
    vvirtual void myvirfunc(){}
}
//用父类指针指向子类对象
Derive derive;
Base * pbase = &derive;
pbase->myvirfunc();//Derive::myvirfunc()    
//或者
Base * pbase2 = new Derive();
pbase2->myvirfunc();//Derive::myvirfunc()

//父类引用指向子类对象
Derive derive2;
Base& yinbase = deriver2;
yinbase.myvirfunc();//Derive::myvirfunc()

这种有继承关系的内存布局:(假设基类Base有三个虚函数f,g,h,子类Derive重写了其中的g函数)
请添加图片描述
能够看到,由于子类Derive类重写了g函数,在它的虚函数表指针vtbl里,就指向了它自己的g函数,其他两个函数指向父类的f函数与h函数。

总结

虚函数(Virtual Function)是通过虚函数表(Virtual Table,简称为V-Table)来实现的。虚函数表主要存储的是指向一个类的虚函数地址的指针,通过使用虚函数表,继承、覆盖的问题都都得到了解决。假如一个类有虚函数,当我们构建这个类的实例时,将会额外分配一个指向该类虚函数表的指针,当我们用父类的指针来操作一个子类的时候,这个指向虚函数表的指针就派上用场了,它指明了此时应该使用哪个虚函数表,而虚函数表本身就像一个地图一样,为编译器指明了实际所应该调用的函数。指向虚函数表的指针是存在于对象实例中最前面的位置(这是为了保证取到虚函数表的有最高的性能——如果有多层继承或是多重继承的情况下),这就意味着理论上我们可以通过对象实例的地址得到这张虚函数表(实际上确实可以做到),然后对虚函数表进行遍历,并调用其中的函数。

#include <iostream>
#include <string>

typedef void (*Fun)(void);

class Base
{
    
    
public:
    virtual void f()
    {
    
    
        std::cout << "Base::f()" << std::endl;
    }

    virtual void g()
    {
    
    
        std::cout << "Base::g()" << std::endl;
    }

    virtual void h()
    {
    
    
        std::cout << "Base::h()" << std::endl;
    }
};

int main(int argc, char* argv[])
{
    
    
    Base base;
    Fun  fun1 = nullptr;
    Fun  fun2 = nullptr;
    Fun  fun3 = nullptr;

    std::cout << "指向虚函数表指针的地址:" << (long*)(&base) << std::endl;//输出类Base中虚函数表指针的地址(这个指针位于类内存中最前面,所以类的地址即为该指针的地址)
    std::cout << "虚函数表的地址:" << (long*)*(long*)(&base) << std::endl;//(这个指针是(long*)(&base),这个指针指向虚函数表,所以虚函数表是*(long*)(&base),因此虚函数表的地址是(long*)*(long*)(&base))

    std::cout << "offset_to_top: " << *((long*)*(long*)(&base) - 2) << std::endl;
    std::cout << "typeinfo for Base: " << (long*)*((long*)*(long*)(&base) - 1) << std::endl;

    fun1 = (Fun) * ((long*)*(long*)(&base));//前面推理出这个虚函数表地址是(long*)*(long*)(&base),表里第一个虚函数的地址是*((long*)*(long*)(&base))
    std::cout << "虚函数表中第一个函数的地址:" << (long*)fun1 << std::endl;
    fun1();

    fun2 = (Fun) * (((long*)*(long*)(&base))+1);
    std::cout << "虚函数表中第二个函数的地址:" << (long*)fun2 << std::endl;
    fun2();

    fun3 = (Fun) * ((long*)*(long*)(&base) + 2);
    std::cout << "虚函数表中第三个函数的地址:" << (long*)fun3 << std::endl;
    fun3();
}

程序运行结果:

指向虚函数表指针的地址:0x7ffca8cae9a8
虚函数表的地址:0x55cc55f1ad50
offset_to_top: 0
typeinfo for Base: 0x55cc55f1ad68
虚函数表中第一个函数的地址:0x55cc55f184c0
Base::f()
虚函数表中第二个函数的地址:0x55cc55f184fc
Base::g()
虚函数表中第三个函数的地址:0x55cc55f18538
Base::h()

本文仅对C++中的虚函数及其内存模型进行简要分析,如果有兴趣深入了解,请查看这篇文章:
一文读懂C++虚函数的内存模型

猜你喜欢

转载自blog.csdn.net/weixin_43717839/article/details/131456346