虚函数

首先,什么是多态,什么又是虚函数呢?先来看看维基百科对多态的解释:

“多态(英语:polymorphism),是指计算机程序运行时,相同的消息可能会送给多个不同的类别之对象,而系统可依据对象所属类别,引发对应类别的方法,而有不同的行为。简单来说,所谓多态意指相同的消息给予不同的对象会引发不同的动作称之。”其实更简单地来说,就是“在用父类指针调用函数时,实际调用的是指针指向的实际类型(子类)的成员函数”。多态性使得程序调用的函数是在运行时动态确定的,而不是在编译时静态确定的。而虚函数则是加了virtual修饰词的类的成员函数。

1. Example

下面我们来看一个虚函数的例子:

Image

在上述例子中,我们首先定义了一个基类base,基类有一个名为vir_func的虚函数,和一个名为func的普通成员函数。而类A,B都是由类base派生的子类,并且都对成员函数进行了重载。然后我们定义三个base类型的指针Base、a、b分别指向类base、A、B。可以看到,当使用这三个指针调用func函数时,调用的都是基类base的函数。而使用这三个指针调用虚函数vir_func时,调用的是指针指向的实际类型的函数。最后,我们将指针b做强制类型转换,转换为A类型,然后分别调用func和vir_func函数,发现普通函数调用的是类A的函数,而虚函数调用的是类B的函数。以上,我们可以得出结论“当使用类的指针调用成员函数时,普通函数由指针类型决定,而虚函数由指针指向的实际类型决定”。那么,虚函数又是怎么实现的呢?

2. 内存布局

在这里先做个简单地介绍,后续再专门写一篇详细介绍对象内存布局的文章(这也是一个面试必问题)。对于一个只包含非静态成员变量和普通成员函数的类,如“class C {void fun_a();void fun_b();int var};”,其内存布局如下图:

Image

其中成员函数放在代码区,为该类的所有对象公有,即不管新建多少个该类的对象,所对应的都是同一个函数存储区的函数。而成员变量则为各个对象所私有,即每新建一个对象都会新建一块内存区用来存储var值。在调用成员函数时,程序会根据类的类型,找到对应代码区所对应的函数并进行调用。在文章开头的例子中,Base、a、b都是base类型的指针。调用普通函数时,程序根据指针的类型到类base所对应的代码区找到所对应的函数,所以都调用了类base的func函数,即指针的类型决定了普通函数的调用。那么虚函数又是怎么实现的呢?对于“class D{void func_a();virtul void func_b();int var}”这个类,其内存布局为:

Image

这时如果sizeof一个类D的对象,会发现比类C的对象大4个字节。多出来的这4个字节就是实现虚函数的关键—-虚函数表指针vptr。这个指针指向一张名为“虚函数表”(vtbl)的表,而表中的数据则为函数指针,存储了虚函数fun_b()具体实现所对应的位置。注意,普通函数、虚函数、虚函数表都是同一个类的所有对象公有的,只有成员变量和虚函数表指针是每个对象私有的,sizeof的值也只包括vptr和var所占内存的大小(也是个常出现的问题),并且vptr通常会在对象内存的最起始位置。另外,当类有多个虚函数时,仍然只有一个虚函数表指针vptr,而此时的虚函数表vtbl中会有多个函数指针,分别指向对应的虚函数实现区域。在重复一遍虚函数实现的过程:**通过对象内存中的vptr找到虚函数表vtbl,接着通过vtbl找到对应虚函数的实现区域并进行调用。**如开头例子中,当调用vir_func函数时,分别通过Base、a、b指针找到对应的vptr,然后找到各自的vtbl,最后通过vtbl找到各自虚函数的具体实现。所以虚函数的调用时由指针所指向内存块的具体类型决定的。

虚函数表中只存有一个虚函数的指针地址,不存放普通函数或是构造函数的指针地址。只要有虚函数,C++类都会存在这样的一张虚函数表,不管是普通虚函数亦或是纯虚函数,亦或是派生类中隐式声明的这些虚函数都会生成这张虚函数表。

  虚函数表创建的时间:在一个类构造的时候,创建这张虚函数表,而这个虚函数表是供整个类所共有的。虚函数表存储在对象最开始的位置。虚函数表其实就是函数指针的地址。函数调用的时候,通过函数指针所指向的函数来调用函数。

1、无继承情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
using namespace std;

class Base
{
public:
Base(){cout<<"Base construct"<<endl;}
virtual void f() {cout<<"Base::f()"<<endl;}
virtual void g() {cout<<"Base::g()"<<endl;}
virtual void h() {cout<<"Base::h()"<<endl;}
virtual ~Base(){}
};

int main()
{
typedef void (*Fun)(); //定义一个函数指针类型变量类型 Fun
Base *b = new Base();
//虚函数表存储在对象最开始的位置
//将对象的首地址输出
cout<<"首地址:"<<*(int*)(&b)<<endl;

Fun funf = (Fun)(*(int*)*(int*)b);
Fun fung = (Fun)(*((int*)*(int*)b+1));//地址内的值 即为函数指针的地址,将函数指针的地址存储在了虚函数表中了
Fun funh = (Fun)(*((int *)*(int *)b+2));

funf();
fung();
funh();

cout<<(Fun)(*((int*)*(int*)b+4))<<endl; //最后一个位置为0 表明虚函数表结束 +4是因为定义了一个 虚析构函数

delete b;
return 0;
}

image-20221007210425620

2、单继承情况(无虚函数覆盖)

  假设有如下所示的一个继承关系:

在这里插入图片描述
  请注意,在这个继承关系中,子类没有重载任何父类的函数。那么,在派生类的实例中,其虚函数表如下所示:
在这里插入图片描述

  • 虚函数按照其声明顺序放于表中。

  • 父类的虚函数在子类的虚函数前面。

3、单继承情况(有虚函数覆盖)

  覆盖父类的虚函数是很显然的事情,不然,虚函数就变得毫无意义。下面,我们来看一下,如果子类中有虚函数重载了父类的虚函数,会是一个什么样子?假设,我们有下面这样的一个继承关系。
在这里插入图片描述
  为了让大家看到被继承过后的效果,在这个类的设计中,我只覆盖了父类的一个函数:f()。那么,对于派生类的实例,其虚函数表会是下面的一个样子:
在这里插入图片描述
【Note】:

  • 覆盖的f()函数被放到了虚表中原来父类虚函数的位置。
  • 没有被覆盖的函数依旧在原来的位置。

这样,我们就可以看到对于下面这样的程序,

1
2
3
Base *b = new Derive();
b->f();
12

由b所指的内存中的虚函数表的f()的位置已经被Derive::f()函数地址所取代,于是在实际调用发生时,是Derive::f()被调用了。这就实现了多态。

4、多重继承情况(无虚函数覆盖)

  下面,再让我们来看看多重继承中的情况,假设有下面这样一个类的继承关系。注意:子类并没有覆盖父类的函数。
在这里插入图片描述
对于子类实例中的虚函数表,是下面这个样子:

在这里插入图片描述
【Note】:

  • 每个父类都有自己的虚表(有几个基类就有几个虚函数表)。
  • 子类的成员函数被放到了第一个父类的表中。(所谓的第一个父类是按照声明顺序来判断的)。

5、多重继承情况(有虚函数覆盖)

  下面我们再来看看,如果发生虚函数覆盖的情况。下图中,我们在子类中覆盖了父类的f()函数。
在这里插入图片描述
下面是对于子类实例中的虚函数表的图:
在这里插入图片描述
  我们可以看见,三个父类虚函数表中的f()的位置被替换成了子类的函数指针。这样,我们就可以任一静态类型的父类来指向子类,并调用子类的f()了。如:

1
2
3
4
5
6
7
8
9
10
Derive d;
Base1 *b1 = &d;
Base2 *b2 = &d;
Base3 *b3 = &d;
b1->f(); //Derive::f()
b2->f(); //Derive::f()
b3->f(); //Derive::f()
b1->g(); //Base1::g()
b2->g(); //Base2::g()
b3->g(); //Base3::g()

3. 构造函数和析构函数可以是虚函数吗?

答案是构造函数不能是虚函数,析构函数可以是虚函数且推荐最好设置为虚函数。

  1. 我们已经知道虚函数的实现则是通过对象内存中的vptr来实现的。而构造函数是用来实例化一个对象的,通俗来讲就是为对象内存中的值做初始化操作。那么在构造函数完成之前,vptr是没有值的,也就无法通过vptr找到作为虚函数的构造函数所在的代码区,所以构造函数只能作为普通函数存放在类所指定的代码区中。
  2. 那么为什么析构函数推荐最好设置为虚函数呢?如文章开头的例子中,当我们delete(a)的时候,如果析构函数不是虚函数,那么调用的将会是基类base的析构函数。而当继承的时候,通常派生类会在基类的基础上定义自己的成员,此时我们当时是希望可以调用派生类的析构函数对新定义的成员也进行析构啦。
  3. 对于构造函数和析构函数与虚函数的关系我们也可以从另一方面来理解。通常是如例子中base *a=new(A)这样创建一个对象时调用构造函数,此时我们是在new函数中直接指定类名字的,当然会直接调用对应类的构造函数,不会出现基类类型实际指向子类的情况出现。而调用析构函数通常是针对对象的操作,如delete(a),此时我们当然希望可以调用到a实际指向的类型(类A)的析构函数,故需要设置为虚函数。
  4. 执行子类构造函数之前一定会执行父类的构造函数;同理,执行子类的析构函数后,一定会执行父类的析构函数

4. 纯虚函数

和普通的虚函数不一样,一个纯虚函数无须定义。我们通过在函数体的位置(即在声明语句的分号之前)书写=0就可以将一个虚函数说明为纯虚函数。其中,=0只能出现在类内部的虚函数声明语句处:

image-20221007220935070

含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类(abstract base class)。抽象基类负责定义接口,而后续的其他类可以覆盖该接口。我们不能(直接)创建一个抽象基类的对象。