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 的“接口”只能綁定一個特定派生類,缺乏動態擴展能力;