介绍虚函数的一些功能和使用。

虚函数的作用

虚函数的作用在于帮助使用基类指针去操作派生类对象时,基类指针不会因为自己有基类背景,而将就选用基类内部的成员函数,而有所灵性地去调整自己使用出派生类对象旗下的成员函数。

通俗地介绍一下:

虚函数主要是用来实现多态和多重继承的,虽然没有虚函数理论上也可以实现多态,但是太麻烦了,没有虚函数清晰,故而虚函数主要是在多态上使用。

多态是什么呢:多态就是一个指针指向子类对象,那么他调用的函数就是子类的对象的。 这就实现了什么?实现了一个函数会根据传入参数的不同有不同的功能。也就是函数有多个状态,就是多态。

例如:

1
void fuck (Animal *a);

如果没有多态,那么 这个函数只能fuck Animal。想要fuck多种 就要用重载,写多个同名函数 参数不同。

但是有了多态 他可以fuck所有Animal的子类 比如牛、羊、耗子什么的。

根据传进来的参数不同,fuck不同的动物。这样这个函数就是 fuckAnimalYouWant 函数了,这就多态了。

比如现在你想fuck狗,要做的只是:

  1. 创建狗类继承Animal
  2. 传入到fuck函数中

此时,并不需要该代码,即使传入到fuck函数中,也可以是通过配置文件这种形式改变传入参数,并没有改变代码。

如果没有多态呢?

  1. 创建狗对象继承Animal
  2. 在类中增加一个fuck函数 参数是Dog

这就改变代码了。也就违反了设计模式开闭原则:对扩展开放,对修改关闭

实现多态的方式是什么? 子类有和父类 同名同参数(重写)的函数。

所以,对于多态的状态来说,一定会有两个同名同参数函数,分别定义在子类中和父类中
那么当用指针调用一个函数的时候,究竟是调用子类的还是父类的:

对于一个类成员函数的调用:

1
2
3
Animal *a = new Dog()

a->bark()

表面上看来是在一个对象内部去调用函数,好像是这个函数是存在于对象内部的感觉。

但是实际上不是,实际上所有的函数都存放在单独的一个代码区,而函数对象里面只有成员变量

也就是说

1
a->bark()

实际上被编译器变成了 Animal::bark(a) ,把对象a传进去 才告诉了编译器具体是哪个对象调用的这个函数。

但是对于多态来说:
Animal::bark() 有,Dog::bark()也一样有,究竟选哪个函数执行呢?

此时还没运行,还是在编译阶段,也就是内存中什么 Dog 啊 Animal 的对象还没有存在。
所以只能根据a这个指针的类型 来决定使用哪个。也就是 Animal::bark()

所以,无论a指向什么子类,他调用的bark函数 都是Animal版本的父类版本的bark函数。
所以,现在没有办法达到多态的效果,这个在编译时决定函数是哪个类的函数的方式就叫做静态联编

a->bark(),现在希望调用的是Dog::bark(a),也就是希望调用的是Dog类的。
所以就不能使用静态联编,静态联编在编译的时候决定函数是哪个。

为什么知道调用的是Dog::bark(a) 而不是 Cat::bark(a) 呢?
因为a指向的对象是一个Dog 而不是一个Cat,所以必须在a指向的对象创建出来后才能决定究竟调用哪个函数,这就是动态联编

怎么把静态联编改成动态联编呢??

编译器在静态联编时做了这样的转换:a->bark ---> Animal::bark(a)。当bark函数被设置为虚函数时,就不会进行那个转换了,而是转化为 a->bark ----》 (a->vtpl[1])(a)

即:先通过a指针找到对象,再找到对象中的虚表,再在虚表里面找到该调用的那个函数的函数指针。
因为必须要在a指向的对象里面找,所以 必须等到a被创建出来,所以必须是运行时,这就是动态联编(运行时决定调用哪个函数)

而这个vtpl就是虚表指针,当类中出现一个virtual指针时编译器就自动在类的成员变量里加入一个指针成员。这个指针成员指向这个类的虚表指针。

当子类重写父类方法时 同时也是把继承自父类的虚表中对应函数的索引的函数指针从父类函数改成了自己的函数。这就造成了子类和父类对象里面的虚表内容不同。所以动态联编时 去子类 或者父类里面找虚表,调用的函数不同,就形成了多态的效果。

这个过程其实自己写逻辑也能写出来,但是官方作出了virtual就是为了释放这个工作量,当你写了virtual这个关键字 ,以上的创建虚表 继承 等一系列工作就都自动执行了,实现了面向对象的思想。

举个具体场景的栗子

首先想象一个场景,你要为开发一款游戏,这款游戏里面有好几个角色,比如战士,法师,牧师,刺客等等。不管怎么样,战士,法师,牧师,刺客这些都算是一种游戏角色。回归本题,这些职业都可以派生于一个游戏角色的积累,并且每个角色会有自己的看家本领,比如战士warrior会有旋风斩,刺客会有shadow step暗影步…..

为了开发,于是你首先建立了一下基类GameCharacter:

1
2
3
4
5
6
7
8
class GameCharacter
{
public:
std::string UseAbility()
{
return "Using a generic ability...";
}
};

然后你写好了战士Warrior,继承自基类:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Warrior : public GameCharacter
{
private:
std::string skill_name;

public:
Warrior(const std::string &skill_name)
: skill_name(skill_name) {}
std::string UseAbility()
{
return skill_name;
}
};

然后是法师、猎人等等。最终你会需要一个函数,来让这些角色中二地喊出它们的技能名。
这需要能够一个东西来操控这些角色背后代表的类的对象。

由于基类指针可以指向任何派生类的对象,于是在函数中使用基类指针去接收,从而避免为每个类单独去写一个专门的UseAbility:

1
2
3
4
void UseAbility(GameCharacter *character)
{
std::cout << character->UseAbility() << std::endl;
}

然后你写了段简单的代码对函数的使用来进行试验:

1
2
3
4
5
6
7
8
9
10
int main()
{
GameCharacter *character = new GameCharacter();
UseAbility(character);

Warrior *warrior = new Warrior("⚔️ Using 'Whirlwind Attack'! ️");
UseAbility(warrior);

return 0;
}

却惊奇地发现,输出是

1
2
Using a generic ability...
Using a generic ability...

并没有让你的战士使出旋风斩⚔️ Using 'Whirlwind Attack'! ️
这是因为如果通过基类指针调用时,只会调用基类的实现,而不会调用派生类的实现。于是这就需要virtual来进行帮助,告知编译器去注意去不要简单调用基类的实现。

这里先对virtual的实现进行一点说明:
首先在基类的要修改的函数中前面加上virtual

1
2
3
4
5
6
7
8
class GameCharacter
{
public:
virtual std::string UseAbility()
{
return "Using a generic ability...";
}
};

其次在派生类对应的函数后加上override。其中override表示对基类同名函数的重写:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Warrior : public GameCharacter
{
private:
std::string skill_name;

public:
Warrior(const std::string &skill_name)
: skill_name(skill_name) {}
std::string UseAbility() override
{
return skill_name;
}
};

此时再运行,就会发现输出了战士的技能:

1
2
Using a generic ability...
⚔️ Using 'Whirlwind Attack'!

这是由于virtual的魅力,如果成员函数被virtual进行绑定上,编译器就会更加谨慎,当通过基类指针去使用到有virtual修饰的成员函数时,会有一张称为虚表(virtual-table)的表来提供查询,从而在多个派生类中查到对应的重写的成员函数。

纯虚函数

由于 Using a generic ability... 这种基类的输出在游戏中并不能够体现任何角色,并没有很大的意义。
有的时候就需要取消这个基类虚函数,让它不会产生实际效果,让它成为一种基类自身不去完成的未完成状态,保留存在,但不去实现,因而要强迫要求所有派生于它的基类都需要去override这个基类虚函数,否则该派生类无法创建实例。同时,由于基类存在纯虚函数,它就无法创建实例。

对于纯虚函数的实现,只需要将虚函数的函数体去掉,让它=0即可。即由virtual来实现虚,由=0来实现纯。实际代码如下:

1
2
3
4
5
class GameCharacter
{
public:
virtual std::string UseAbility() = 0;
};

此外,纯虚函数也可以称为接口

虚函数表指针和虚函数表创建时机和存放位置

  1. 虚函数表指针(vptr)创建时机:
    vptr是跟着对象走的,所以对象什么时候创建出来,vptr就什么时候创建出来,也就是运行的时候。

  2. vptr初始化时机:
    《Inside the c++ Object model》中指出vptr初始化的时间为:在所有基类构造函数之后,但又在自身构造函数或初始化列表之前。 当程序在编译期间,编译器会为构造函数中增加为vptr赋值的代码(这是编译器的行为),当程序在运行时,遇到创建对象的代码,执行对象的构造函数,那么这个构造函数里有为这个对象的vptr赋值的语句。

  3. 虚函数表的创建时机:
    虚函数表创建时机是在编译期间。 编译期间编译器发现virtual关键字,就为每个类确定好了对应的虚函数表里的内容。所以在程序运行时,编译器会把虚函数表的首地址赋值给虚函数表指针,所以,这个虚函数表指针就有值了。

  4. 虚函数表和虚函数的位置:
    虚函数表是全局共享的,只有一个,类似于静态变量。测试结果显示:虚函数表vtable在Linux/Unix中存放在可执行文件的只读数据段中(rodata),这与微软的编译器将虚函 数表存放在常量段存在一些差别。而虚函数也算函数,自然放在代码段。

总结一下就是:

C++中 1. 虚函数则位于代码段(.text),也就是C++内存模型中的代码区; 2. 虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区; 3. 虚表指针与对象存储的位置相同(堆或者栈)

最后注意一下:

虚函数不是无额外开销的,使用虚函数的两种运行成本:

  • 需要额外的内存来存储v表,这样就可以分配到正确的函数
  • 调用虚函数时,需要遍历v表,来确定要映射到哪个函数