一、虚函数的常见应用场景;
二、发挥虚函数作用的语法;
三、虚函数的实现机制;
四、虚函数的性能影响。
题2-虚函数的常见应用场景
上一节我们讲了虚函数的作用,同时也演示了虚函数发挥作用的路径之一:
- 一个派生类对象(设为o),调用一个基类方法(设为m);
- 该基类方法内,调用了一个虚函数 (设为v);
- 该虚函数,在派生类中有 override 实现 (设为 ov)。
这种应用场景,通常被称为 “框架式基类”,常用于“做一件事的基本步骤是确定的,只是每一个小步骤,不同类型(就是派生类)的实体做起来会有一些变化”这样的场景。
比如《白话C++》里的那道面向对象作业题:三个工厂计算员工月薪,都走以下框架:
- 先计算员工的基本月薪
- 再统计员工的本月请假扣薪数;
- 用第1步结果减第2步结果,得到本月应发薪水
这个过程,就可以在基类里,写一个非虚的成员函数,里面调用 “计算基本月薪 ()” 和 “统计员工请假扣薪 ()” 即可。而后两者,就必须使用虚函数;因为三个类型的工厂,对于如何计算员工基本月薪,以及如何统计员工本月请假扣薪总数,存在很大的不同:
- 以“计算基本月薪”为例,甲工厂是固定金额,乙工厂是计件金额,丙工厂是依据本厂本月销售总额,动态算出每个人的基本月薪……
- 以“统计员工请假扣薪”为例,甲工厂员工每月两天带薪病假,乙工厂可能是一天带薪病假,一天带薪事假,丙工厂则是全年允许十五天带薪假……
本课作业提供了这道题,大家可以练习下。
虚函数发挥作用的另一个路径是:
- 一个类型为基类的 指针(或引用)……
- 实质指向或绑定到一个派生类对象上;
- 该对象直接或间接调用了一个虚函数;
- 该虚函数,在派生类中有 override 实现。
还是以“射击游戏” 为例,这回我们让需求更简单一些:用一个数据容器,存储一堆各种各样的“会飞的东西” (未来的射击目标) ,然后让它们一个个飞出来“秀”一下。
#include <iostream>
using namespace std;
class 会飞的东西
{
public:
virtual ~会飞的东西 () {};
virtual void 飞() = 0; // 纯虚函数
};
class 鸭子 : public 会飞的东西
{
public:
void 飞() override { cout << "我拍着翅膀飞呀飞..." << endl; }
};
class 喷气机 : public 会飞的东西
{
public:
void 飞() override { cout << "我屁股着火地飞呀飞..." << endl; }
};
class UFO : public 会飞的东西
{
public:
void 飞() override { cout << "我无头苍蝇似地窜着飞..." << endl; }
};
然后,我们想把不同的“会飞的东西”,放进同一容器,比如一个 std::list<T>;此时,T得是基类的指针,才方便放不同的派生类对象。
#include <iostream>
#include <list>
using namespace std;
/* 上面的 类定义 */
int main()
{
list<会飞的东西*> lst =
{
new 鸭子, new 喷气机, new UFO
};
for (会飞的东西* p : lst)
{
p->飞();
}
}
注意 for 的循环变量 p ,它的类型是 “会飞的东西 *” ,但一旦调用(直接或间接)到虚函数,此处是直接调用 “ 飞() ”,则要优先走派生的版本(如果有),因此得到运行结果是:
我拍着翅膀飞呀飞...
我屁股着火地飞呀飞...
我无头苍蝇似地窜着飞...
这种现象(或效果),在语义上就被称为“多态”。直面含义是:看起来是普通的基类指针(或引用),但是在运行期间,依据它实质指向(绑定)到哪个派生类,就展现出哪个派生类的“形态”。
题3-发挥虚函数作用的语法
如果对象本来就是派生类类型,那么,此时它展现出派生类的形态,那是天经地义的,不被视为多态的表现,比如:
auto ufo1 = new UFO;
ufo1->飞(); // 我无头苍蝇似地窜着飞...
// 或者
UFO ufo2;
ufo2.飞(); // 也不算多态
此时,“飞()” 是不是虚的,都不影响 ufo1 或 ufo2 调用 class UFO 自己的实现版本。
除了基类指针指向派生类对象,基类引用绑定到派生类对象,同样可实现多态效果:
UFO ufo;
会飞的东西& ur = ufo;
ur.飞(); // 我无头苍蝇似地窜着飞...
引用形式的多态,通常用在函数参数传递上:
void 体检(会飞的东西& f) // f 是一个引用
{
cout << "为证明体能,请一口气飞三分钟!" << endl;
while( 时间不够三分钟 )
{
f.飞(); // 虚函数发挥作用
}
}
都2023年了,面试官怎么会放过这一老一新的问题结合?问:智能指针会影响虚函数的作用吗?
答:智能指针(unique_ptr、shared_ptr……)包装并不影响虚函数作用发挥:
auto p = make_unique<会飞的东西>(new UFO);
p->飞(); // 走的是 UFO 的 “飞()”
现象是清楚的,原理是简单的:智能指针最终调用成员函数,无论是虚函数还是非虚函数,都还是要经由存储在智能指针内部的裸指针发起。
题4-虚函数的实现机制
C++ 标准并没有规定虚函数在底层应该如何实现。同时,我个人也不认为不了解C++虚函数的底层实现,对现实编程会有多大的影响。
学C++需要去深究它的底层实现,这大概是当年编程行业“开卷”的最早表现之一。
有一些面试官挺爱这些问题,所以我们也讲讲;并且我们不要人云亦云真的去谈太多特定的实现细节,我们从C语言的函数指针说起。
C++当年起步,就基于C语言。C语言中有很多优秀的惯用法,C++继承并且直接在语言层面加以实现——这样就可以让更多的程序员享用来自C语言的优秀惯用法。
C语言需不需要多态?当然也需要。C语言采用 结构+函数指针来实现多态;C++看到了这一点,并且干脆通过“虚函数”的语法点,从而实现,我们只需写一个“virtual”,就能让编译器帮我们在底层用C的方法加以提供虚函数的机制。
我们给一个超简化版的,纯C语言的多态实现例子:
#include <stdio.h>
#include <stdlib.h>
typedef void (* PFlyFunc) ();
typedef struct Flyable
{
PFlyFunc fly;
} Flyable;
void UFOFly()
{
printf("我无头飞!");
}
typedef struct UFO
{
PFlyFunc fly;
} UFO;
UFO* MakeUFO()
{
UFO *ufo = malloc(sizeof(UFO));
ufo->fly = UFOFly; // 手工指定函数指针的指向
return ufo;
}
int main()
{
Flyable *pf =(Flyable *) MakeUFO();
pf->fly();
free(pf);
return 0;
}
注意,“基类” Flyable 和 “派生类” UFO,都有一个数据成员:fly,这是一个函数指针,类型都是 “PFlyFunc”;并且,该成员都是结构体中的第一个数据成员(类内偏移一致)。基类 Flyable 的 fly 未初始化,可理解为指向为空,这就类似于 C++ 中基类的纯虚函数;而派生类 UFO 则借助 MakeUFO 函数,程序员手工将它的 fly 指向函数:UFOFly();这就相当于派生类提供了自己的“fly”实现。
这个版本的C模拟多态实现,至少有两个缺点:
- 缺点1:必须由程序员手工设置好各个类(结构)中的函数指针的指向。
- 缺点2:不管基类派生类,由它们创建出来的每个对象,都不得不拥有 fly 这个函数指针。这显然是在浪费内存。以派生类 UFO 为例。理论上,由 UFO 实例化出一万个对象,它们的 fly 都需要指向同一个函数(例中的 UFOFly)才对。当前,我们只举了一个“虚函数”为例,如果一个类(结构)需要有更多个虚函数,那么内存浪费的问题就会加剧。
C++的虚函数,就是让C++编译器来帮我们写代码,编译器不会忘记写代码,缺点1一举解决。
缺点2怎么解决呢?也好办。为每个有“多态”需求的类型(基类、派生类)额外生成一张函数地址表,再将对应用虚函数地址,一个个放进这张表;当需要调用时,先到这张表查询出所要调用函数的地址,再依据函数地址(相当于函数指针)调用实际函数。
比如,上面纯C例子中的 UFO 结构,它需要有一个“虚函数” fly,实质指向 UFOFly这个函数,那么我们就将后者的地址,存放到为 UFO struct 专门生成 一张数据表的第一行;之后需要调用 fly 方法时,通过该表查出 UFOFly 的地址,然后调用它。
假设例中的 Flyable 和 UFO 后面又多了一个“虚函数”,叫 “show”;并且,UFO 中的 show 函数需指向 UFOShow() 函数,那么,UFO 的 “ 虚函数 ”表,大概长这样子:
编号 | 函数地址 |
---|---|
#1 | &UFOFly |
#2 | &UFOShow |
编号是依据 函数在类(结构)中的声明次序决定的,fly 排第一,show 排第二。当我们代码写 ufo->fly()时,你大可以理解为:在编译器眼里,它将变成:“ufo 的1号虚函数调用……”,然后它就去找表中的第一行的第二列,找到 &UFOFly——实际肯定是一个数字,表示函数地址。
注意,整张表是对应 UFO 这个结构的;也就是说,所有 UFO 实例化出的对象,都拥有共同的一张虚函数表。这就解决了上面说的缺点2:内存浪费。当然,所有的UFO对象要如何才能知道这张表在哪里呢?这就迫使所有UFO对象仍然必须至少拥有一个指针,用于指向UFO类的虚函数表。
显然,假设整个类只有一个虚函数,那么,为这个类的额外生成虚函数表就只存放了一个函数地址;而这个类的所有对象,又都必须拥有一个指针用于记录函数表的地址……这时候,从节约的内存的角度考虑,还不如直接让这个指针指向唯一的虚函数地址。但是,如果一个类拥有比较多(比如三、四个)虚函数,大多数C++编译器所采用的,基于虚函数表的方案(再次强调,这并不是标准),就显得有利可图了。
题5-虚函数的性能影响
当然,有利可图指的是内存节约,在性能上,要调用一个函数,需要先找到虚函数表,再从虚函数中查出对应的函数地址,然后才能开始调用……尽管前面两个步骤复杂度都是O(1),但两次地址跳转,难免损耗了一点性能——在非极端应用下,完全可以忽略。另外,这样的性能损耗,仅发生成上面的说的多态语义成立的情况下;比如,下面的代码就不存在虚函数带来的性能损耗:
UFO ufo;
ufo.飞(); // 性能相当于非虚函数调用
按理说,ufo 要调用 飞() 的方法,一样要借助虚函数以查到“飞()”函数地址,一样会有两次跳转,为什么能够放心地说,这个过程不损耗性能呢?这是因为编译器不傻,它发现 ufo 就是 UFO,所以遇上这类非多态的虚函数调用,它会在编译其内,就直接查出函数地址。