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

2023-10-18 13:20

本文主要是介绍大厂C++题第1辑——虚函数七题精讲之1:虚函数的作用,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

“虚函数的作用” 是面向对象的C++编程最基础也最核心的知识点,如果不能无法正确回答本题,则只此一题,不管大厂还是小厂,都铁定无缘了。

概述

“虚函数” 是 C++面向对象三最:最基础、最重要、最关键的知识点。我们从网上搜索到来自腾讯与字节公司招聘C++新人(主要是校招)的题集中,选择出现多次的七道题:

  1. 虚函数的作用?
  2. 虚函数在什么情况下发挥作用?
  3. 纯虚函数是什么?
  4. 关键字 override 的作用?
  5. 析构函数可以是虚函数吗,“虚析构”函数有什么关键作用?
  6. 构造函数可以是虚函数吗?为什么?
  7. C++中,如果不使用虚函数,还有哪些方式可以实现类似效果?

本辑大厂C++面试题,提供和问题紧密相关的知识点的全面精讲,在实际面试中可按需回答。

题1-虚函数的作用

题目评价: 虚函数的作用,是面向对象的C++编程最基础也最核心的知识点,如果不能无法正确回答本题,则只此一题,不管大厂还是小厂,都铁定无缘了。

参考阅读: 这么重要的问题,自然问的人很多,站长(南郁)也曾在外部平台回答过多次。其中2018年在知乎的回答,自回答后就一直获该问题的榜首推荐。我们也将该回答收录到本站(d2school)课程《站长技术问答精选》 下的第10课:《C++中虚函数相比非虚函数的优势》。建议可以先阅读该文章,并完成其内作业。

一个类的(非静态)成员函数,加上 “virtual” 修饰,就得到一个“虚函数”。假设它被作为基类,有另一个新类派生自它,那么,派生类既可以重新定义基类的“虚函数”,也可以重新定义基类的“非虚函数”。前者的行为称为 “override / 覆盖”,后者则属于 “overwrite / 重写”中的一种。

“覆盖”和“重写”的共同点是:基类可以用基类的实现(如果确实该成员函数有提供实现),派生类则可以用基类的,也可以用派生类自己的实现。

到这里都还很好理解:一个功能,基类用基类的,派生类如果重新实现了,就可用自己的。举个例子:假设有个“坦克”作为基类,提供一个功能叫“前行”:

// 普通坦克
class Tank
{
pubic:void Forward(){cout << "我用履带在陆地上前行";}
};

 接下来,有个“水陆两用坦克”,它派生类上面的坦克,它提供了新的前行方法:

// 水陆两用坦克
class AmphibiousTank : public Tank
{
public:void Forward(){if (/* 在水中 */){cout << "我用螺旋桨在水中前行";} else{Tank::Forward(); // 使用基类的功能 }}
};

有派生类,自然有基类,因此,在派生类的扩展实现中,可以不用,也可以使用基类的原有实现,这很好理解。但是,“虚函数” 的作用,却是要让基类可以用上派生类对该虚函数重新定义的功能。要知道,在有基类的时候不一定有派生类,并且,一个基类未来可以有许多个派生类,所以,更严谨的说法应该是:虚函数让用基类可以“预定”派生类的功能。

从基类的角度来理解,会直观一些:当一个基类(的设计者)将它的一个成员函数,定义为“虚”函数时,目的就是为了让基类可以“预定”派生类对该函数引入的变化。

这就是 “覆盖”和“重写”的不同点:“覆盖/overide” 可以让基类的代码有机会用到派生类的功能,简单的“重写/overwrite”则无法实现。

不使用虚函数实现基类调用派生类的定制功能的话,可利用 “CRTP”方式实现。

希望基类的代码可真实调用派生类定制功能,这种基类可被称为“框架式基类” (见《白话C++》之练功8.6.7小节)。我们也给个例子(同样来自《白话C++》)——

假设有个射击类游戏,游戏中有个“会飞的目标”是基类。在写基类的阶段,我们就很知道游戏的主干逻辑:

  • 第1步:目标飞呀飞呀飞……
  • 第2步:目标检查一下周边50米内是否有逼近的子弹?
  • 第3步:如果没有子弹,回第1步;
  • 第4步:如果有子弹,目标尝试逃避子弹……
  • 第5步:逃避成功,回第1步;
  • 第6步:逃避失败,目标做最后的演出。

目标可以是鸭子、战机、UFO、美国超人。在游戏的第一个版本,为了极大简单化问题,我们原准备让它们从第1步到第6步,都完全一个模样……但甲方爸爸跳起来了:这游戏还有什么可玩性?!

好吧,我们决定让鸭子、战机、UFO、美国超人在最后一步,也就是“最后的演出”上略有不同。

整个主干逻辑,都可以在基类的“飞/Fly”方法上实现:

// 射击目标的基类
class 会飞的目标
{
public:/* 飞翔函数返回 true 表示可以继续飞,返回 false 表示已挂,不能再飞了 */void Fly() {cout << "我自由自在地飞呀飞呀飞……\n";/* 话外音: 然而,这世上哪有无限的自由!*/cout << "好吧,让我检查一下边上有没有可恨的子弹……\n";// 检查周围飞来的子弹auto bullet = this->inspectBulletAround(); if (!bullet) {cout << "世界是和平的!\n";return true;}// 居然有子弹!尝试逃避!if (this->tryEscape(bullet)){cout << "哈哈哈,我可真厉害!\n";return true;}// 完蛋,没躲开,做最后挣扎吧!return this->lastShow();}   
private:// 检查周围子弹Bullet* inspectBulletAround() { ... }   // 尝试逃避子弹,基类觉得自己永远躲不开bool tryEscape(Bullet* ) { return false; } 
};

如上所说,怎么检查和怎么逃避子弹(事实上还有怎么自由地飞呀飞),无论什么目标,都是相同的,因此该基类提供了 “inspectBulletAround()” 和 “tryEscape()” 的实现,它们是非虚的。

但是!还有个“最后的表演”,“万恶”的甲方爸爸说,这是底线了,一定要让鸭子、战机、UFO、美国超人中弹后的最后表演,各有不同、异彩纷呈。

尽管上面的 Fly() 明显是基类的一个方法,但是,确实可以让它“预定”派生类的方法。这就是“虚函数”的作用。

在本例中,我们只需要将 lastShow() 定义为虚函数。注意,这正是基类在设计上的职责:确定哪些成函数为虚函数,哪些不是——这是面向对象设计中的一个难点,也是一个痛点。

“难点”的意思是:很难,但必须努力去做好。“痛点”的意思是:这件事不仅难,而且,就算是你努力也不一定做得好。

在基类中的 lastShow()是虚函数这一基础上,它还有两种选择。一是提供默认的实现,比如:

class 会飞的目标
{
public:bool Fly() { ... }...
private:// 基类提供的“最后表演”的默认实现 (注意有 virtual 修饰)virtual bool lastShow() {// 默认表演:什么都不做, 直接返回 false,表示 认命而死return false;}   
};

此时,派生类可以依据自己的实际情况,提供或不提供定制的 lastShow 实现,在提供的情况下,还可以在必要,调用基类的默认实现。

如果基类不提供默认实现,此时称 lastShow 为纯虚函数 (pure-virutal),表示强制要求每个具体的派生类,都要提供自己定制实现的 lastShow 行为,本辑第3点将进一步详解“纯虚函数”。

接下来,我们定义一个派生类:鸭子,它几乎什么都不用做,除了提供定制的 “lastShow”:

class 鸭子 : public 会飞的目标
{
private:    // 鸭子版本的最后表演:bool lastShow() override{std::cout << "嘎~嘎~嘎~,我这一死,真是轻如鸿毛!\n";return false; }
};

鸭子类继承了来自基类的 “Fly”。如果此时我们定义出一只鸭子,并调用Fly,会怎样?

鸭子 唐小鸭;
唐小鸭.Fly();  // 调用来自基类的 Fly

会进入基类的Fly函数,如果中弹,会调用 lastShow()。那么,真正的问题来了,此时调用的是基类的lastShow,还是派生类的 lastShow?答:会调用派生类的,尽管这段代码当时是写在基类里的。

作为对比,如果我们让鸭子类也提供自己的 “tryEscape / 逃避方法”,于是有:

class 鸭子 : public 会飞的目标
{
private:// 鸭子觉得,自己这么灵活,可以躲开子弹:bool tryEscape(Bullet* ) { return true;  } // overwrite 重写// 鸭子版本的最后表演:bool lastShow() override // 覆盖{std::cout << "嘎~嘎~嘎~,我这一死,真是轻如鸿毛!\n";return false; }
};

看 tryEscape 的实现与注释:愚蠢的鸭子觉得自己可以恒定躲开子弹,然而,tryEscape 不是虚函数,这意味着基类并没有“预定”派生类对它的定制实现,所以在基类的 “Fly” 方法中,执行的那个 tryEscape,仍然是基类的……

这就是虚与非虚的区别:基类是否可以预定派生类对该函数的定制实现。针对本例,还有一些细节,你需要特别关注到:

  • 我们明确定义了一个派生类的对象,然后调用继承自基类的某个方法(本例中的Fly),这个方法中调用了一个虚函数(本例中的 lastShow),这是虚函数发挥作用的方式之一。下面第2点,我们就会详解虚函数发挥作用的另一种方式。
  • 继续第1点,请注意:例中调用的基类方法 Fly,并不是虚的;这是一种常用的虚函数使用方法:在基类的非虚函数中,调用一个虚函数。(《白话C++》中称之为“框架型基类”);
  • 注意:lastShow() 是一个私有方法,但这并不影响因为它是“虚”的,所以假设有人问你:基类的代码有办法调用派生类的某个私有方法吗?请回答:“可以”;
  • 派生类在重定义虚函数 lastShow 时,用到了 override,请注意它的出现位置。它的作用在本辑第5点能找到答案。
  • 如要作为真实应用,本例中的 “inspectBulletAround()”、“tryEscape()” 等方法显然也应“虚”化。
  • 如要作为真实应用,检查得到的子弹 “Bullet ”,也应该是一个基类,然后子弹类也可以提供不少虚函数,并有各种种样的派生类子弹,这样 tryEscape(Bullet) 的实现与调用,就会出现所谓的 “双重分派 / double dispatch”,意思是:tryEscape本身是虚的,不同的“飞行目标”会有不同的逃避子弹方法,而在它的实现中,子弹的一些行为也是“虚”的,于是同一种飞行目标,面对不同子弹时,也理应有不同的表现……

如果对本辑话题有兴趣,请关注本辑课程后面的六节课。也欢迎参与本课堂练习(小测),通过检验强化自己的学习成果。

这篇关于大厂C++题第1辑——虚函数七题精讲之1:虚函数的作用的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/232823

相关文章

Pandas中统计汇总可视化函数plot()的使用

《Pandas中统计汇总可视化函数plot()的使用》Pandas提供了许多强大的数据处理和分析功能,其中plot()函数就是其可视化功能的一个重要组成部分,本文主要介绍了Pandas中统计汇总可视化... 目录一、plot()函数简介二、plot()函数的基本用法三、plot()函数的参数详解四、使用pl

C#如何调用C++库

《C#如何调用C++库》:本文主要介绍C#如何调用C++库方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录方法一:使用P/Invoke1. 导出C++函数2. 定义P/Invoke签名3. 调用C++函数方法二:使用C++/CLI作为桥接1. 创建C++/CL

Python的time模块一些常用功能(各种与时间相关的函数)

《Python的time模块一些常用功能(各种与时间相关的函数)》Python的time模块提供了各种与时间相关的函数,包括获取当前时间、处理时间间隔、执行时间测量等,:本文主要介绍Python的... 目录1. 获取当前时间2. 时间格式化3. 延时执行4. 时间戳运算5. 计算代码执行时间6. 转换为指

Python正则表达式语法及re模块中的常用函数详解

《Python正则表达式语法及re模块中的常用函数详解》这篇文章主要给大家介绍了关于Python正则表达式语法及re模块中常用函数的相关资料,正则表达式是一种强大的字符串处理工具,可以用于匹配、切分、... 目录概念、作用和步骤语法re模块中的常用函数总结 概念、作用和步骤概念: 本身也是一个字符串,其中

C++如何通过Qt反射机制实现数据类序列化

《C++如何通过Qt反射机制实现数据类序列化》在C++工程中经常需要使用数据类,并对数据类进行存储、打印、调试等操作,所以本文就来聊聊C++如何通过Qt反射机制实现数据类序列化吧... 目录设计预期设计思路代码实现使用方法在 C++ 工程中经常需要使用数据类,并对数据类进行存储、打印、调试等操作。由于数据类

Linux下如何使用C++获取硬件信息

《Linux下如何使用C++获取硬件信息》这篇文章主要为大家详细介绍了如何使用C++实现获取CPU,主板,磁盘,BIOS信息等硬件信息,文中的示例代码讲解详细,感兴趣的小伙伴可以了解下... 目录方法获取CPU信息:读取"/proc/cpuinfo"文件获取磁盘信息:读取"/proc/diskstats"文

C++使用printf语句实现进制转换的示例代码

《C++使用printf语句实现进制转换的示例代码》在C语言中,printf函数可以直接实现部分进制转换功能,通过格式说明符(formatspecifier)快速输出不同进制的数值,下面给大家分享C+... 目录一、printf 原生支持的进制转换1. 十进制、八进制、十六进制转换2. 显示进制前缀3. 指

C++中初始化二维数组的几种常见方法

《C++中初始化二维数组的几种常见方法》本文详细介绍了在C++中初始化二维数组的不同方式,包括静态初始化、循环、全部为零、部分初始化、std::array和std::vector,以及std::vec... 目录1. 静态初始化2. 使用循环初始化3. 全部初始化为零4. 部分初始化5. 使用 std::a

shell编程之函数与数组的使用详解

《shell编程之函数与数组的使用详解》:本文主要介绍shell编程之函数与数组的使用,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录shell函数函数的用法俩个数求和系统资源监控并报警函数函数变量的作用范围函数的参数递归函数shell数组获取数组的长度读取某下的

MySQL高级查询之JOIN、子查询、窗口函数实际案例

《MySQL高级查询之JOIN、子查询、窗口函数实际案例》:本文主要介绍MySQL高级查询之JOIN、子查询、窗口函数实际案例的相关资料,JOIN用于多表关联查询,子查询用于数据筛选和过滤,窗口函... 目录前言1. JOIN(连接查询)1.1 内连接(INNER JOIN)1.2 左连接(LEFT JOI