大厂C++题第1辑——虚函数七题精讲之2:虚函数的作用机制

一、虚函数的常见应用场景;
二、发挥虚函数作用的语法;
三、虚函数的实现机制;
四、虚函数的性能影响。

题2-虚函数的常见应用场景

上一节我们讲了虚函数的作用,同时也演示了虚函数发挥作用的路径之一:

  1. 一个派生类对象(设为o),调用一个基类方法(设为m);
  2. 该基类方法内,调用了一个虚函数 (设为v);
  3. 该虚函数,在派生类中有 override 实现 (设为 ov)。

这种应用场景,通常被称为 “框架式基类”,常用于“做一件事的基本步骤是确定的,只是每一个小步骤,不同类型(就是派生类)的实体做起来会有一些变化”这样的场景。

比如《白话C++》里的那道面向对象作业题:三个工厂计算员工月薪,都走以下框架:

  1. 先计算员工的基本月薪
  2. 再统计员工的本月请假扣薪数;
  3. 用第1步结果减第2步结果,得到本月应发薪水

这个过程,就可以在基类里,写一个非虚的成员函数,里面调用 “计算基本月薪 ()” 和 “统计员工请假扣薪 ()” 即可。而后两者,就必须使用虚函数;因为三个类型的工厂,对于如何计算员工基本月薪,以及如何统计员工本月请假扣薪总数,存在很大的不同:

  • 以“计算基本月薪”为例,甲工厂是固定金额,乙工厂是计件金额,丙工厂是依据本厂本月销售总额,动态算出每个人的基本月薪……
  • 以“统计员工请假扣薪”为例,甲工厂员工每月两天带薪病假,乙工厂可能是一天带薪病假,一天带薪事假,丙工厂则是全年允许十五天带薪假……

本课作业提供了这道题,大家可以练习下。

虚函数发挥作用的另一个路径是:

  1. 一个类型为基类的 指针(或引用)……
  2. 实质指向或绑定到一个派生类对象上;
  3. 该对象直接或间接调用了一个虚函数;
  4. 该虚函数,在派生类中有 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,所以遇上这类非多态的虚函数调用,它会在编译其内,就直接查出函数地址。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

南郁

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值