C++面试冲刺笔记1:虚函数的基本工作原理
C++面试冲刺笔记1:虚函数的基本工作原理
前言
笔者最近开始投简历,出于应对之后快速的面试流程需求,这里准备的是将常见的C++八股文进行总结,从而方便自己进行学习,检查和评估。
什么是虚函数
虚函数,本质上还是函数,为什么是虚的呢?虚函数本质上是由 virtual
修饰的成员函数,但“虚”的真正含义指的是:它的调用行为并非在编译阶段决定,而是在运行阶段通过动态绑定机制决定。举一个例子感受一下:
class BaseClass {
public:virtual void aVirtualFunction() = 0; // 这是一个最极端的纯虚函数
};class DerivedClass : public BaseClass {
public:void aVirtualFunction() override; // 这是子类的一个实现
};
如果你熟悉,你就会说:很简单,当我们写上代码:
BaseClass* pSon = new DerivedClass();
pSon->aVirtualFunction();
的时候,我们就会高兴的发现,尽管我们在书写的时候写的是BaseClass*,但是实际上,由于我们的赋值对象是BaseClass的派生类DerivedClass,这个时候,aVirtualFunction被瞧瞧的替换成了子类的实现而不是父类的实现。**这就是一种运行时的多态!**大类中的子类行为可以被共同表达为一个抽象的行为,而子类则各自为政,具象化这个抽象的父类行为(或者是覆盖父类的行为,这就是类的覆盖了,我们后面讨论类的时候慢慢聊)。
虚函数的实现本质
我们现在聊一聊虚函数的运作本质是如何支持的。很简单,写过嵌入式C比较大型项目的朋友都知道,我们经常搞函数回调,也就是说,在运行的时候动态的跳转道给定的地址执行代码。在C++中,我们可以联想到——上述的这些本质上可以看作是类中的一个动态的存储着函数指针的成员变量,发生继承的时候,我们就把子类的指针赋值给我们的对象上,这样,发生调用的时候,我们就会直接调用这个函数指针指向的函数。很好!实际上大致的确如此,我们把这些潜在的虚函数排列成一张表格。也就是经典的虚函数表。虚函数表是用一个虚函数表指针指向的。所以说,指向虚函数表成员的大小就是一个指针的大小,这是毋庸置疑的。**这就是你调试一个带有虚函数的类的时候,你看到调试栏中的_vptr
或者_vtable
**对象了。
免责声明:
_vptr
是编译器生成的,名称和布局是实现相关的(如 MSVC 和 GCC 不一样),也就是说,不同的编译器对此的实现不一致,所以别乱搞Hack把代码的可移植性搞丢了- 不是标准定义的,因此依赖它做 portable 编程是不可取的,但了解它对理解原理很重要。
一些其他重要的事情
-
派生类中重写时,可以省略
virtual
,虚性自动继承。 -
可以用
override
强制编译器检查函数签名是否匹配,避免拼写或参数错误 -
纯虚函数(
=0
)表示“子类必须重写”,容许定义抽象类,也就是无法实例化的接口 。这个小trick可以用在库设计中,强迫客户程序员重写父类的代码 -
虚析构函数用来确保通过基类指针删除子类对象时,析构链能正确触发。所以,任何带有虚函数的类,都最好将自己的析构函数写上大大的
virtual
,否则会翻车。struct A {~A() { std::cout << "A析构\n"; } }; struct B : A {~B() { std::cout << "B析构\n"; } };A* p = new B(); delete p; // 只会调用A的析构!
这个时候你需要做的是:
struct A {virtual ~A() { std::cout << "A析构\n"; } };
-
静态函数不能是虚函数:它不属于实例,没
this
指针,无法参与动态绑定
到这里就结束?No No No
那可太没意思了兄弟们,大伙都知道我这个人的性子,写着点是没有啥值得说的,否则就成了博客灌水了。我们还有很多其他的话题。
多重继承下的虚函数表结构(主次 vtable)
我们知道,C++是一个允许多重继承的语言,请看下图:
class HolyShitComplexClass : public BaseA, // is BaseApublic BaseB, // is BaseBpublic BaseC, // is Also BaseC
{...
};
这个所谓的HolyShitComplexClass是三个类的子集,我们这个时候会问,欸!这么多的父类,我们的虚函数表怎么排列呢?答案是这样的,按照我们继承的顺序,依次排放我们的虚函数表指针,这也就是说,在多重继承中,每个基类子对象通常都有独立的 vptr
和对应的 vtable(主表与次表),使得各继承路径可独立动态绑定
[vptr_A][A_fields][vptr_B][B_fields][vptr_C][C_fields][Derived_fields]
你看,这样排列的,我要是重写了A中的一个虚函数的行为,咱们就改A函数表中对应的函数指针指向,其他的如法炮制!
虚函数与非虚函数混合时的内存布局
更多的时候,我们往往是类中同时含有虚函数和非虚数据成员,笔者的CCIMXDesktop项目中就有大量的这样的例子:
#ifndef APPCARDWIDGET_H
#define APPCARDWIDGET_H#include <QWidget>class DesktopToast;
class QLabel;
namespace Ui {
class AppCardWidget;
}/*** @brief AppCardWidget is a lightweight widget used to post messages to a DesktopToast.** This is an abstract base class representing an application card UI component.* It is responsible for handling pre-launch work and posting messages via toast notifications.*/
class AppCardWidget : public QWidget {Q_OBJECTpublic:Q_DISABLE_COPY(AppCardWidget);AppCardWidget() = delete;/*** @brief Constructs an AppCardWidget.* @param toast Pointer to the DesktopToast object used to show messages.* @param parent Optional parent widget.*/explicit AppCardWidget(DesktopToast* toast, QWidget* parent = nullptr);~AppCardWidget();/*** @brief Set the current icon for the app card.** This function allows derived classes to customize the app card icon* by providing a QPixmap.** @param icons The pixmap to be used as the icon.*/virtual void setCurrentIcon(const QPixmap& icons);/*** @brief Abstract method to invoke pre-launch operations.** Derived classes should implement this to perform necessary* preparations before the system starts or the app card becomes active.*/virtual void invoke_preLaunch_work() = 0;/*** @brief operate_comment_label*/virtual void operate_comment_label() = 0;/*** @brief invoke_textlabel_stylefresh*/void invoke_textlabel_stylefresh();protected:/*** @brief Abstract method to post messages to the bound DesktopToast.** Derived classes implement this to send notifications or status updates* through the toast system.*/virtual void postAppCardWidget() = 0;/*** @brief setHelperFunction: plainly set the text for shown* @param what*/virtual void setHelperFunction(const QString& what);virtual void setupSelfTextLabelStyle(QLabel* selfTextLabel) = 0;DesktopToast* binding_toast; ///< Pointer to the toast widget used for posting messages.Ui::AppCardWidget* ui; ///< UI object generated from the Qt Designer form.public:/*** @brief Event filter to handle user interaction events.* @param watched The QObject being watched.* @param event The event being filtered.* @return true if the event was handled, otherwise false.*/bool eventFilter(QObject* watched, QEvent* event) override;
};
#endif // APPCARDWIDGET_H
你看,这个类中,我们就混合了虚函数和非虚函数,那么问题来了,我们的编译器有没有比较具体的排列呢?有的。
- 通常
vptr
放在对象首部(首成员); - 紧接其后是非虚成员,保持自然对齐;
- 所有虚函数都在单一 vtable 中按声明顺序排列 。
+---------------------+--------------------------------------------+
| 内存区域 | 内容描述 |
+=====================+============================================+
| 虚表指针 | 指向 AppCardWidget 的虚函数表 |
+---------------------+--------------------------------------------+
| | |
| 成员变量 | DesktopToast* binding_toast |
| | (绑定的 toast 对象指针) |
| +--------------------------------------------+
| | Ui::AppCardWidget* ui |
| | (UI 组件指针) |
+---------------------+--------------------------------------------+
| 继承部分 | QWidget 基类的所有成员数据 |
+---------------------+--------------------------------------------+
| Qt 特性 | Q_OBJECT 宏添加的元对象系统数据 |
| +--------------------------------------------+
| | Q_DISABLE_COPY 宏禁用拷贝功能 |
+---------------------+--------------------------------------------+虚表指针指向的是->
+-----+---------------------------------------------+-----------+
| 偏移 | 函数签名 | 类型 |
+=====+=============================================+===========+
| 0 | ~AppCardWidget() | 析构函数 |
+-----+---------------------------------------------+-----------+
| 1 | setCurrentIcon(const QPixmap&) | 虚函数 |
+-----+---------------------------------------------+-----------+
| 2 | invoke_preLaunch_work() | 纯虚函数 |
+-----+---------------------------------------------+-----------+
| 3 | operate_comment_label() | 纯虚函数 |
+-----+---------------------------------------------+-----------+
| 4 | postAppCardWidget() | 纯虚函数 |
+-----+---------------------------------------------+-----------+
| 5 | setHelperFunction(const QString&) | 虚函数 |
+-----+---------------------------------------------+-----------+
| 6 | setupSelfTextLabelStyle(QLabel*) | 纯虚函数 |
+-----+---------------------------------------------+-----------+
| 7 | eventFilter(QObject*, QEvent*) | 虚函数 |
+-----+---------------------------------------------+-----------+
CRTP 静态多态
这个就谈不上虚函数的内容了,但是放在这里原因很简单,我们谈论到运行时多态的时候,必然要跟CRTP 静态多态做做对比。
在很多时候,我们可能实际上,不太需要的是运行时多态,什么意思?
BaseClass* pSon = new DerivedClass();
pSon->aVirtualFunction();
这个代码显然我们并不需要运行时多态,理由非常简单,因为我们知道pSon在这个逻辑流中指向的是一个具体的子类DerivedClass,而不是其他奇奇怪怪的东西。基于这个理念,我们发现一些场景中完全不需要所谓的运行时多态,我们在写代码的时候就预料到这里一定不会出现运行才能裁决的事情,啥叫运行时裁决呢?
void DesktopMainWindow::invoke_appcards_init() {/* sequencely invoke the work */showToast("AppCards Are Initing...");// each_app_cards是一个父类,这个父类表达这个对象是一个AppCards,但是具体是啥,如何提前派发初始化的工作,由子类自己的invoke_preLaunch_work裁决。for (const auto& each_app_cards : std::as_const(this->app_cards)) {each_app_cards->invoke_preLaunch_work();}showToast("AppCards Init Finished!");
}
这个卡片可能实从用户提供的插件动态库,将来的我创建的,但是现在我完全不知道他们的具体的行为,但是我可以保证他们肯定至少是AppCards,这个时候就要到运行的时候检索类对象的元信息找出她到底是谁,然后调用具体的类对invoke_preLaunch_work的实现
所以,CRTP是啥呢?答案是:Curiously Recurring Template Pattern,奇异递归模板模式。这个玩意的基本起手框架是这样的。。。
template <typename Derived>
class Base {
public:void interface() {// 调用派生类实现的方法static_cast<Derived*>(this)->implementation();}// 可提供默认实现(可选)void implementation() {std::cout << "Base implementation\n";}
};class Derived : public Base<Derived> {
public:void implementation() {std::cout << "Derived implementation\n";}
};
这很有意思,我们实际上利用模板,在编译的时候就转发给了我们指定的编译器确定的Derived类。这就是CRTP。我们可以看到,这里在语法上,Base和Derived可以说是毫无关系,我们在代码编写的时候才会耦合到一起。
- 这个可是和传统虚函数的动态多态不同,CRTP 在 编译期就完成了多态行为的分发(通过
static_cast
强转为派生类),没有虚函数表、没有间接跳转; - 不会因为虚函数造成 cache miss 或影响内联优化;
- 可以实现“接口继承”或“行为注入”。
所以CRTP在现代C++中还是有不少的身影的(我没用到,因为我评估我的项目是否可以有潜在的提升的时候发现真用不上)。
感谢GPT,他给了我一个总结表格:
特性 | CRTP(静态多态) | 虚函数(动态多态) |
---|---|---|
多态分发时机 | 编译期 | 运行时 |
是否使用 vtable | 否 | 是 |
性能开销 | 无额外开销,可内联优化 | 存在虚函数调用开销 |
类型灵活性 | 编译期固定,类型必须已知 | 支持运行时多态 |
扩展性 | 模板层级难以抽象出接口 | 更适合框架级接口 |
🧱 CRTP 限制和注意事项
CRTP是存在问题的,我们一个一个了解:
- 编译复杂度增加:模板膨胀、编译错误难排查,我想大家都被该死的模板报错折磨过(好像新clang对模板的报错稍微友善了点?但是排查模板的报错属于是非常的不友好了)
- 不可跨 DLL 边界使用:CRTP 依赖编译期展开,这就是因为它本身就是一个依赖模板的静态多态技术,属于是代价了。
- 无法通过指针存储基类对象:除非用
Base<Derived>*
这样的具体类型; - 类型绑定固定:CRTP 的“接口”只能绑定一个特定派生类,缺乏动态扩展能力;