概述
“虚函数” 是 C++面向对象三最:最基础、最重要、最关键的知识点。我们从网上搜索到来自腾讯与字节公司招聘C++新人(主要是校招)的题集中,选择出现多次的七道题:
- 虚函数的作用?
- 虚函数在什么情况下发挥作用?
- 纯虚函数是什么?
- 关键字 override 的作用?
- 析构函数可以是虚函数吗,“虚析构”函数有什么关键作用?
- 构造函数可以是虚函数吗?为什么?
- 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本身是虚的,不同的“飞行目标”会有不同的逃避子弹方法,而在它的实现中,子弹的一些行为也是“虚”的,于是同一种飞行目标,面对不同子弹时,也理应有不同的表现……
如果对本辑话题有兴趣,请关注本辑课程后面的六节课。