这篇文章记述了关于虚函数/纯虚函数的知识点。
C++的多态分为静态多态和动态多态。
静态多态是在编译时通过函数重载、模板来实现;
动态多态则是通过虚函数来实现。
虚函数的内存分布
https://jacktang816.github.io/post/virtualfunction/
1 | class A { |
如以上代码所示,在C++中定义一个对象 A,那么在内存中的分布大概是如下图这个样子。
- 首先在主函数的栈帧上有一个 A 类型的指针
A* obj
指向堆里面分配好的对象 A 实例。 - 对象 A 实例的头部是一个 vtable 指针,紧接着是 A 对象按照声明顺序排列的成员变量。(当我们创建一个对象时,便可以通过实例对象的地址,得到该实例的虚函数表,从而获取其函数指针。)
- vtable 指针指向的是代码段中的 A 类型的虚函数表中的第一个虚函数起始地址。
- 虚函数表的结构其实是有一个头部的,叫做 vtable_prefix ,紧接着是按照声明顺序排列的虚函数。
- 注意到这里有两个虚析构函数,因为对象有两种构造方式,栈构造和堆构造,所以对应的,对象会有两种析构方式,其中堆上对象的析构和栈上对象的析构不同之处在于,栈内存的析构不需要执行 delete 函数,会自动被回收。
- typeinfo 存储着 A 的类基础信息,包括父类与类名称,C++关键字 typeid 返回的就是这个对象。
type_info
对象描述了类的简要信息(如 name, hash_code 等),用以支持RTTI(runtime type identification)
。我们使用的typeid
运算符,应该就是通过访问type_info
对象实现的。 - typeinfo 也是一个类,对于没有父类的 A 来说,当前 tinfo 是 class_type_info 类型的,从虚函数指针指向的vtable 起始位置可以看出。
多继承中的虚函数表
- 一般继承时,子类的虚函数表中先将父类虚函数放在前,再放自己的虚函数指针。
- 如果子类覆盖了父类的虚函数,将被放到了虚表中原来父类虚函数的位置。
- 在多继承的情况下,每个父类都有自己的虚表,子类的成员函数被放到了第一个父类的表中。,也就是说当类在多重继承中时,其实例对象的内存结构并不只记录一个虚函数表指针。基类中有几个存在虚函数,则子类就会保存几个虚函数表指针
1 | class A{ |
虚函数的应用注意事项
- 内联函数 (inline)
虚函数用于实现运行时的多态,或者称为晚绑定或动态绑定。而内联函数用于提高效率。内联函数的原理是,在编译期间,对调用内联函数的地方的代码替换成函数代码。内联函数对于程序中需要频繁使用和调用的小函数非常有用。默认地,类中定义的所有函数,除了虚函数之外,会隐式地或自动地当成内联函数(注意:内联只是对于编译器的一个建议,编译器可以自己决定是否进行内联).
无论何时,**使用基类指针或引用来调用虚函数,它都不能为内联函数(因为调用发生在运行时)**。但是,无论何时,使用类的对象(不是指针或引用)来调用时,可以当做是内联,因为编译器在编译时确切知道对象是哪个类的。 - 静态成员函数 (static)
static成员不属于任何类对象或类实例,所以即使给此函数加上virutal也是没有任何意义的。此外静态与非静态成员函数之间有一个主要的区别,那就是静态成员函数没有this指针,从而导致两者调用方式不同。虚函数依靠vptr和vtable来处理。vptr是一个指针,在类的构造函数中创建生成,并且只能用this指针来访问它,因为它是类的一个成员,并且vptr指向保存虚函数地址的vtable。虚函数的调用关系:this -> vptr -> vtable ->virtual function,对于静态成员函数,它没有this指针,所以无法访问vptr. 这就是为何static函数不能为virtual。 - 构造函数 (constructor)
虚函数基于虚表vtable(内存空间),构造函数 (constructor) 如果是virtual的,调用时也需要根据vtable寻找,但是constructor是virtual的情况下是找不到的,因为constructor自己本身都不存在了,创建不到class的实例,没有实例class的成员(除了public static/protected static for friend class/functions,其余无论是否virtual)都不能被访问了。此外构造函数不仅不能是虚函数。而且在构造函数中调用虚函数,实际执行的是父类的对应函数,因为自己还没有构造好,多态是被disable的。 - 析构函数 (deconstructor)
对于可能作为基类的类的析构函数要求就是virtual的。因为如果不是virtual的,派生类析构的时候调用的是基类的析构函数,而基类的析构函数只要对基类部分进行析构,从而可能导致派生类部分出现内存泄漏问题。 - 纯虚函数
析构函数可以是纯虚的,但纯虚析构函数必须有定义体,因为析构函数的调用是在子类中隐含的。
定义一个函数为虚函数,不代表函数为不被实现的函数。
定义他为虚函数是为了允许用基类的指针来调用子类的这个函数。
定义一个函数为纯虚函数,才代表函数没有被实现。
定义纯虚函数是为了实现一个接口,起到一个规范的作用,规范继承这个类的程序员必须实现这个函数。
虚函数与纯虚函数
虚表是类层面上的,而不是对象层面上的。即同一个类的不同对象,共享同一个虚表。
- 子类可自主选择是否要提供一份属于自己的个性化虚函数实现。
- 子类必须提供一份属于自己的个性化纯虚函数实现。
C++的编译器一旦发现一个类型中有虚函数,会为该类型生成虚函数表(vtbl),并在该类型的每一个实例中添加一个指向虚函数表的指针(vptr)。
通常,编译器处理虚函数的方法是:
给每个对象添加一个隐藏成员;隐藏成员中保存了一个指向函数地址数组的指针。
其实这里的函数地址数组指的就是虚函数表(virtual function table),vtbl。
虚函数表中存储了为类对象进行声明的虚函数的地址。
例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指针。
如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址,如果派生类没有B::重新定义虚函数,该vtbl将保存函数原始版本的地址。
(这句话解释了上图中为什么B在继承A后,B::vfunc2()函数没有出现在B的虚函数表中,这是因为B只是重新定义了vfunc1,所以B的虚函数表中保存了A::vfunc2的地址)
如果派生类定义了新的虚函数,则该函数的地址也将被添加到vtbl中,注意,无论类中包含的虚函数是一个还是多个,都只需要在对象中添加一个地址成员,只是表的大小不同。
- 每个对象都将增大,增大量为存储地址的空间
- 对每个类,编译器都创建一个虚函数的地址表
- 每个函数调用都需要执行一步额外的操作,即到表中查找地址
纯虚函数
虚函数的声明以=0
结束,便可将它声明为纯虚函数。包含纯虚函数的类不允许实例化,称为抽象类。 事实上纯虚函数提供了面向对象中接口的功能。当然,这样的接口是以继承的方式实现的。
纯虚函数的意义,让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”
虚函数的意义,就在于定义了一个从最早的父类,到最后的子类,都必须具备的一个功能(函数),只是在不断的进化(继承)中,这个功能会略微发生改变。通过虚函数,我们在调用不同的衍生类的时候,可以拥有不同的功能。然后我会说:这么麻烦,干脆每个继承类都重写命名一个函数么算了,只要知道重命名的函数有这个功能就行了不是?理论上来说,完全可以,在一个父类和其继承类不多的项目中,这么做完全可以,只要你自己能熟记或者找到这个重命名函数是干嘛用的;但是在大一点的项目中,由于类中的函数成百上千,恐怕你就会为此疯狂。另外还有一点,是重命名函数无法做到的,这一点我会在纯虚函数中一并解释。
纯虚函数,就是虚函数了以后,末尾还要加=0的那一类函数。我一直没想通的是,既然这个函数完全没有实现方法,那么定义这个函数有个蛋用啊?我也曾经试着在网上搜索过纯虚函数的意义和作用,回答大多千篇一律照本宣科。于是我渐渐的也就无视这个纯虚函数了。直到现在我开始写一个PSO算法的时候,才发现天哪这居然是一个完全不可或缺的东西!如果说虚函数还可以用重命名作为另外一种解决方法,那么纯虚函数则是没有第二种可以替代的方法。我可以拿一个非常简单的代码说明一下:
1 | class test{ |
上面声明了一个非常简单的类,它只有两个函数,其中一个是虚函数:打印,另外一个是纯虚函数:排序。其中打印函数的定义如下:
1 | void test::print(){ |
在这个打印函数中,调用了order函数对array进行了排序,然后输出结果。问题是:我根本不知道order函数是什么算法,或者说order函数因人而异,所以无法确定!于是网上照本宣科的内容就出来:当函数没有实现方法或者需要子类来定义实现方法的时候,可以在父类中定义纯虚函数。就是这么简单!于是当不同的子类继承这个父类的时候,定义不同的实现方法,那么实例化这个子类的时候,这个纯虚函数就有了不同的方法。这也解释了为什么包含纯虚函数的抽象类为什么不能实例化,因为它中间有函数根本不知道是怎么个实现!当然我们可以用其他方法避免使用纯虚函数,比方说在子类中重写print方法,但是这样一来等于除了order函数代码以外所有的代码都要重新复制一遍,当继承类越来越多的时候,要修改print等于这一堆继承类都要修改,会疯的!所以说纯虚函数是一个很神奇的用法,也是简化了编程使得面向对象的方法更加灵活。
至于接口,这是一个只有JAVA中才用到的概念,C++中不存在接口,与接口相似的是:抽象类。因为JAVA不允许多重继承类,但可以继承多个接口。关于接口,在我编写JAVA SERVLET的时候,碰到过一个httpservlet,用户需要为doget和dopost等函数编写实现方法。而这些函数就可以看成是纯虚函数,它在HTTPservlet也类似于上述代码的order函数,有着在局部函数中的作用。
面向对象编程确实很有意思,虽然从某种程度上来说,和面向过程也差不多,但是灵活多变的设计方法,也许也是C++(面向对象)比C(面向过程)强大的地方.
虚函数总结0409
基类如果有函数前面加了virtual,那么构造器会为每个基类的实例增加一个隐藏的虚函数表指针,指向一张虚函数表,虚函数表记录着基类的虚函数信息。当派生类继承基类的时候,也会把基类的虚函数表指针继承下来,就不生成新的指针了。该指针就指向派生类自己的虚函数表,如果派生类选择重写基类的虚函数,那么就会在自己的虚函数表里新增一个自己的虚函数记录!如果选择不重写,那么派生类也会有一个指向基类虚函数的指针!
当我们new一个派生类对象并且把它的引用赋值给基类引用时,它的虚函数表指针指向的还是自己的虚函数,所以就可以实现通过基类的引用或指针来调用派生类的方法从而实现运行时多态了……
由于函数只是对数据的加工,编译的时候会放到代码段,所以内存模型里不会出现函数……
一:虚函数表指针(vptr)创建时机
vptr跟着对象走,所以对象什么时候创建出来,vptr就什么时候创建出来,也就是运行的时候。
当程序在编译期间,编译器会为构造函数中增加为vptr赋值的代码(这是编译器的行为),当程序在运行时,遇到创建对象的代码,执行对象的构造函数,那么这个构造函数里有为这个对象的vptr赋值的语句。
二:虚函数表创建时机
虚函数表创建时机是在编译期间。编译期间编译器就为每个类确定好了对应的虚函数表里的内容。
所以在程序运行时,编译器会把虚函数表的首地址赋值给虚函数表指针,所以,这个虚函数表指针就有值了。
版权声明:本文为CSDN博主「酸菜。」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/qq_38158479/article/details/106970213
/d1 reportSingleClassLayout
构造函数里面能调用虚函数吗?
https://blog.csdn.net/hxz_qlh/article/details/14089895
我想以重复本文的主题开篇:不要在类的构造或者析构函数中调用虚函数,因为这种调用不会如你所愿,即使成功一点,最后还会使你沮丧不已。如果你以前是一个Java或者C#程序员,请密切注意本节的内容-这正是C++与其它语言的大区别之一。