多态与虚函数

引子

首先看如下的代码:

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
#include <QDebug>

class Animal{
public:
virtual void bark(){
qDebug() << "Animal bark!" << endl;
}
};

class Dog: public Animal{
public:
void bark() override{
qDebug() << "Dog bark!" << endl;
}
};

int main()
{
Animal* e = new Animal();
e->bark();

//注意下面这一句
Animal* p = new Dog();
p->bark();

return 0;
}

输出结果是:

别的先不管,首先注意代码中Animal* p = new Dog();这一句,其中new Dog()这一句我们知道是一个构造函数,返回的应该是一个指向Dog类型的指针(Dog*)。然而我们又注意到p实际上是Animal*类型的,那么第一个问题就来了——

Question1:=两边类型不同,那么为什么这里编译器没有报错呢?

答案是,这种机制叫做多态。

什么是多态?

C++允许使用基类指针或引用来调用子类的重写方法,从而使得同一接口可以表现不同的行为,即“一个接口,多种实现”,而这种机制就被称为多态。

由于多态的核心是“一个接口,多种实现”,因此在 C++ 里,子类指针可以自动转换成父类指针,这叫做 *向上转型(upcasting)。*在上面的例子中,由于Dog类继承了Animal类,因此作为Animal类的子类,指向Dog类的指针可以被自动转换为指向Animal类的指针。

当然读者大概也能猜到,与向上转型(upcasting)相对的还有向下转型(downcasting),与前者相比,这是一种不安全的语法。在此我们先不过多纠缠这个问题。

然而,即便我们已经了解了多态的设计目标,但技术层面的问题依然存在——

Question2:既然p是一个指向Animal类的指针,那么p->bark();为什么会输出Dog bark!而不是Animal bark!呢?

这就好像,你以为你拿着的是动物签的合约,但实际上来上班的是一只狗。

而这就引入了第二个概念,虚函数。

什么是虚函数?

虚函数是C++中用来实现多态的工具,它允许通过基类指针或引用调用派生类中同名的重写函数。没有虚函数,多态所谓“一个接口,多种实现”的目标就无法达成。

让我们回到Question2,实际上这个问题答案非常简单。因为指针的“静态类型”和“真实类型”并不是一回事。Animal* p 只是告诉编译器:“p 这个指针,按语法 来说可以当成 Animal 来用”,但是我们知道,new Dog() 产生的对象本体(也就是在堆内实际被分配的内存数据),其真身是 Dog

当我们在Animal类中的bark()函数前加入Virtual关键字时,就是在声明bark()是一个虚函数。而这实际上是在告诉编译器,我要创建一种动态绑定(Dynamic Binding)关系,也就是说,直到程序运行时,才根据对象的真实类型确定函数调用的具体实现。这就区别于不使用虚函数时的静态调用。

如果看不懂上面的说明也没有关系。我们依然拿和动物的合约来举一个例子,想象你是《疯狂动物城》里的大先生,你现在正坐在谈判桌上,谈判正进行得如火如荼,此时你需要手下的人吼一声来威吓对手。作为老板,你只关心此时手下有人能应时来一嗓子,至于手底下谁来,吠叫的时候用什么声调搭配什么姿势,这些细枝末节的事都不是你关心的东西。

在上面的例子中,大先生作为虚函数的调用者,“给我叫!”(bark())这一命令是永恒不变的。具体负责吠叫的手下作为实际的对象本体,具体要怎么执行大先生的命令,则取决于手下个人对于这条指令的理解(即对虚函数的重写)。而到底让哪个动物来执行大先生的命令,则取决于你,即程序的编写者,实际让Animal* p指向哪种类型的动物。

从上面的代码中,我们还可以得知这些语法知识:

  1. 关键字virtual用于标识基类(Animal类)中某个函数为虚函数,override关键字用于在派生类(Dog类)中标识某个函数为基类中虚函数的重写。
  2. 关键字virtual是必需的,但override关键字不是必需的,只是用作函数覆盖的标识,对于机器来说不写也完全没有问题。但从人类的角度出发,出于代码可读性的考虑还是推荐加上。

补充内容

好了,到这里你已经很好地理解了多态和虚函数的概念。

然而,在编译器读到Virtual关键字后,此时站在编译的角度,到底发生了什么呢?也就是说,我们想知道的是——

Question3:从编译的角度出发,虚函数具体是如何被调用的呢?

注意:

以下内容可能理解起来有一定难度,哪怕第一时间没看懂也没关系,留个印象就可以。

每当创建一个包含有虚函数的类或从包含有虚函数的类派生一个类时,编译器就会为这个类创建一个虚函数表(也叫Vtable)保存该类所有虚函数的地址;而对于每个覆写了基类虚函数的对象,其内部都具备一个被称为vptr的指针,指向自己对应的真实类型所对应的虚函数表。

那么对于编译器来说,虚函数表是如何构造的呢?

让我们再次复习一下我们已经熟知的概念:

内存中的存储位置 存储内容
vtbl(virtual table,虚函数表) 只读数据区(rodata) 每个具有虚函数的类都对应一个vtbl,每个vtbl以表格形式存储自己对应类中所有虚函数的地址(虚函数与其它函数一起位于代码段)
vptr 所属对象内部,通常位于对象的开头 自己所属对象的真实类型所对应的 vtable的地址
对象 存在于栈(Stack)或堆(Heap)中,若在栈(Stack)上则由编译器自动管理生命周期,若在堆(Heap)上则由程序员手动new和delete 一种具有特定可控制生命周期的(构造,删除等),可以绑定函数以及属性的可复用的数据结构
不存在 属性(成员变量)、方法(成员函数)

因此回到先前的话题,从编译的角度出发,虚函数具体是如何被调用的呢?

在程序运行时,首先通过对象中的vptr找到其真实类型对应的vtable,通过vtable查找代码段中对应的虚函数地址,然后加以调用。