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

相关文章

C++统计函数执行时间的最佳实践

《C++统计函数执行时间的最佳实践》在软件开发过程中,性能分析是优化程序的重要环节,了解函数的执行时间分布对于识别性能瓶颈至关重要,本文将分享一个C++函数执行时间统计工具,希望对大家有所帮助... 目录前言工具特性核心设计1. 数据结构设计2. 单例模式管理器3. RAII自动计时使用方法基本用法高级用法

GO语言中函数命名返回值的使用

《GO语言中函数命名返回值的使用》在Go语言中,函数可以为其返回值指定名称,这被称为命名返回值或命名返回参数,这种特性可以使代码更清晰,特别是在返回多个值时,感兴趣的可以了解一下... 目录基本语法函数命名返回特点代码示例命名特点基本语法func functionName(parameters) (nam

深入解析C++ 中std::map内存管理

《深入解析C++中std::map内存管理》文章详解C++std::map内存管理,指出clear()仅删除元素可能不释放底层内存,建议用swap()与空map交换以彻底释放,针对指针类型需手动de... 目录1️、基本清空std::map2️、使用 swap 彻底释放内存3️、map 中存储指针类型的对象

Python Counter 函数使用案例

《PythonCounter函数使用案例》Counter是collections模块中的一个类,专门用于对可迭代对象中的元素进行计数,接下来通过本文给大家介绍PythonCounter函数使用案例... 目录一、Counter函数概述二、基本使用案例(一)列表元素计数(二)字符串字符计数(三)元组计数三、C

C++ STL-string类底层实现过程

《C++STL-string类底层实现过程》本文实现了一个简易的string类,涵盖动态数组存储、深拷贝机制、迭代器支持、容量调整、字符串修改、运算符重载等功能,模拟标准string核心特性,重点强... 目录实现框架一、默认成员函数1.默认构造函数2.构造函数3.拷贝构造函数(重点)4.赋值运算符重载函数

Python中的filter() 函数的工作原理及应用技巧

《Python中的filter()函数的工作原理及应用技巧》Python的filter()函数用于筛选序列元素,返回迭代器,适合函数式编程,相比列表推导式,内存更优,尤其适用于大数据集,结合lamb... 目录前言一、基本概念基本语法二、使用方式1. 使用 lambda 函数2. 使用普通函数3. 使用 N

MySQL中REPLACE函数与语句举例详解

《MySQL中REPLACE函数与语句举例详解》在MySQL中REPLACE函数是一个用于处理字符串的强大工具,它的主要功能是替换字符串中的某些子字符串,:本文主要介绍MySQL中REPLACE函... 目录一、REPLACE()函数语法:参数说明:功能说明:示例:二、REPLACE INTO语句语法:参数

C++ vector越界问题的完整解决方案

《C++vector越界问题的完整解决方案》在C++开发中,std::vector作为最常用的动态数组容器,其便捷性与性能优势使其成为处理可变长度数据的首选,然而,数组越界访问始终是威胁程序稳定性的... 目录引言一、vector越界的底层原理与危害1.1 越界访问的本质原因1.2 越界访问的实际危害二、基

python中update()函数的用法和一些例子

《python中update()函数的用法和一些例子》update()方法是字典对象的方法,用于将一个字典中的键值对更新到另一个字典中,:本文主要介绍python中update()函数的用法和一些... 目录前言用法注意事项示例示例 1: 使用另一个字典来更新示例 2: 使用可迭代对象来更新示例 3: 使用

c++日志库log4cplus快速入门小结

《c++日志库log4cplus快速入门小结》文章浏览阅读1.1w次,点赞9次,收藏44次。本文介绍Log4cplus,一种适用于C++的线程安全日志记录API,提供灵活的日志管理和配置控制。文章涵盖... 目录简介日志等级配置文件使用关于初始化使用示例总结参考资料简介log4j 用于Java,log4c