C++ 虚函数、纯虚函数

转载于C++ 虚函数、纯虚函数

多态(polymorphism)是面向对象编程语言的一大特点,而虚函数是实现多态的机制。其核心理念就是通过基类访问派生类定义的函数。多态性使得程序调用的函数是在运行时动态确定的,而不是在编译时静态确定的。使用一个基类类型的指针或者引用,来指向子类对象,进而调用由子类复写的个性化的虚函数,这是C++实现多态性的一个最经典的场景。

  • 虚函数,在类成员方法的声明(不是定义)语句前加“virtual”, 如 virtual void func()
  • 纯虚函数,在虚函数后加“=0”,如 virtual void func()=0
  • 对于虚函数,子类可以(也可以不)重新定义基类的虚函数,该行为称之为复写Override。
  • 对于纯虚函数,子类必须提供纯虚函数的个性化实现。

在派生子类中对虚函数和纯虚函数的个性化实现,都体现了“多态”特性。但区别是:

  • 子类如果不提供虚函数的实现,将会自动调用基类的缺省虚函数实现,作为备选方案;
  • 子类如果不提供纯虚函数的实现,编译将会失败。尽管在基类中可以给出纯虚函数的实现,但无法通过指向子类对象的基类类型指针来调用该纯虚函数,也即不能作为子类相应纯虚函数的备选方案。(纯虚函数在基类中的实现跟多态性无关,它只是提供了一种语法上的便利,在变化多端的应用场景中留有后路。)

虚函数

![preview](C++ 虚函数、纯虚函数/v2-469a76704128dbfd3837542bb1201934_r.jpg)

在上述例子中,我们首先定义了一个基类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的函数。以上,我们可以得出结论“当使用类的指针调用成员函数时,普通函数由指针类型决定,而虚函数由指针指向的实际类型决定”。

对于一个只包含非静态成员变量和普通成员函数的类,如

1
2
3
4
5
class C {
void fun_a();
void fun_b();
int var;
};

其内存布局如下图:

![img](C++ 虚函数、纯虚函数/v2-2fbe21ff4350dad5b4d2832571836fa5_720w.jpg)

其中成员函数放在代码区,为该类的所有对象公有,即不管新建多少个该类的对象,所对应的都是同一个函数存储区的函数。而成员变量则为各个对象所私有,即每新建一个对象都会新建一块内存区用来存储var值。在调用成员函数时,程序会根据类的类型,找到对应代码区所对应的函数并进行调用。

在上面例子中,Base、a、b都是base类型的指针。调用普通函数时,程序根据指针的类型到类base所对应的代码区找到所对应的函数,所以都调用了类base的func函数,即指针的类型决定了普通函数的调用。

那么包含虚函数的类又是怎样的呢?

1
2
3
4
5
class D{
void func_a();
virtual void func_b();
int var;
};

其内存布局为:

![img](C++ 虚函数、纯虚函数/v2-cbb25357580cb5c7f46bcacb5918f28f_720w.jpg)

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

纯虚函数

在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。而针对每种动物的方法又有所不同,此时需要使用多态特性,也就需要在基类中定义虚函数。

纯虚函数是在基类中声明的虚函数,它要求任何派生类都要定义自己的实现方法,以实现多态性。实现了纯虚函数的子类,该纯虚函数在子类中就变成了虚函数。

定义纯虚函数是为了实现一个接口,用来规范派生类的行为,也即规范继承这个类的程序员必须实现这个函数。派生类仅仅只是继承函数的接口。纯虚函数的意义在于,让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但基类无法为纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。

含有纯虚函数的类称之为抽象类,它不能生成对象(创建实例),只能创建它的派生类的实例。抽象类是一种特殊的类,它是为了抽象和设计的目的为建立的,它处于继承层次结构的较上层。抽象类的主要作用是将有关的操作作为结果接口组织在一个继承层次结构中,由它来为派生类提供一个公共的根,派生类将具体实现在其基类中作为接口的操作。

抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类。

虚函数 vs 纯虚函数,如何选用?

  1. 当基类中的某个成员方法,在大多数情形下都应该由子类提供个性化实现,但基类也可以提供缺省备选方案的时候,该方法应该设计为虚函数。
  2. 当基类中的某个成员方法,必须由子类提供个性化实现的时候,应该设计为纯虚函数。

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

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

首先,我们已经知道虚函数的实现则是通过对象内存中的vptr来实现的。而构造函数是用来实例化一个对象的,通俗来讲就是为对象内存中的值做初始化操作。那么在构造函数完成之前,也即还没有进行初始化,此时vptr是没有值的,也就无法通过vptr找到作为构造函数和虚函数所在的代码区,所以构造函数只能以普通函数的形式存放在类所指定的代码区中。

而对于析构函数,当我们delete(a)的时候,如果析构函数不是虚函数,那么调用的将会是基类base的析构函数。而当继承的时候,通常派生类会在基类的基础上定义自己的成员,此时我们当然希望可以调用派生类的析构函数对新定义的成员也进行析构。