大厂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

相关文章

java中反射Reflection的4个作用详解

《java中反射Reflection的4个作用详解》反射Reflection是Java等编程语言中的一个重要特性,它允许程序在运行时进行自我检查和对内部成员(如字段、方法、类等)的操作,本文将详细介绍... 目录作用1、在运行时判断任意一个对象所属的类作用2、在运行时构造任意一个类的对象作用3、在运行时判断

MySQL 中的 CAST 函数详解及常见用法

《MySQL中的CAST函数详解及常见用法》CAST函数是MySQL中用于数据类型转换的重要函数,它允许你将一个值从一种数据类型转换为另一种数据类型,本文给大家介绍MySQL中的CAST... 目录mysql 中的 CAST 函数详解一、基本语法二、支持的数据类型三、常见用法示例1. 字符串转数字2. 数字

Python内置函数之classmethod函数使用详解

《Python内置函数之classmethod函数使用详解》:本文主要介绍Python内置函数之classmethod函数使用方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地... 目录1. 类方法定义与基本语法2. 类方法 vs 实例方法 vs 静态方法3. 核心特性与用法(1编程客

Python函数作用域示例详解

《Python函数作用域示例详解》本文介绍了Python中的LEGB作用域规则,详细解析了变量查找的四个层级,通过具体代码示例,展示了各层级的变量访问规则和特性,对python函数作用域相关知识感兴趣... 目录一、LEGB 规则二、作用域实例2.1 局部作用域(Local)2.2 闭包作用域(Enclos

从入门到精通C++11 <chrono> 库特性

《从入门到精通C++11<chrono>库特性》chrono库是C++11中一个非常强大和实用的库,它为时间处理提供了丰富的功能和类型安全的接口,通过本文的介绍,我们了解了chrono库的基本概念... 目录一、引言1.1 为什么需要<chrono>库1.2<chrono>库的基本概念二、时间段(Durat

MySQL count()聚合函数详解

《MySQLcount()聚合函数详解》MySQL中的COUNT()函数,它是SQL中最常用的聚合函数之一,用于计算表中符合特定条件的行数,本文给大家介绍MySQLcount()聚合函数,感兴趣的朋... 目录核心功能语法形式重要特性与行为如何选择使用哪种形式?总结深入剖析一下 mysql 中的 COUNT

C++20管道运算符的实现示例

《C++20管道运算符的实现示例》本文简要介绍C++20管道运算符的使用与实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧... 目录标准库的管道运算符使用自己实现类似的管道运算符我们不打算介绍太多,因为它实际属于c++20最为重要的

Visual Studio 2022 编译C++20代码的图文步骤

《VisualStudio2022编译C++20代码的图文步骤》在VisualStudio中启用C++20import功能,需设置语言标准为ISOC++20,开启扫描源查找模块依赖及实验性标... 默认创建Visual Studio桌面控制台项目代码包含C++20的import方法。右键项目的属性:

python常用的正则表达式及作用

《python常用的正则表达式及作用》正则表达式是处理字符串的强大工具,Python通过re模块提供正则表达式支持,本文给大家介绍python常用的正则表达式及作用详解,感兴趣的朋友跟随小编一起看看吧... 目录python常用正则表达式及作用基本匹配模式常用正则表达式示例常用量词边界匹配分组和捕获常用re

MySQL 中 ROW_NUMBER() 函数最佳实践

《MySQL中ROW_NUMBER()函数最佳实践》MySQL中ROW_NUMBER()函数,作为窗口函数为每行分配唯一连续序号,区别于RANK()和DENSE_RANK(),特别适合分页、去重... 目录mysql 中 ROW_NUMBER() 函数详解一、基础语法二、核心特点三、典型应用场景1. 数据分