深入理解 virtual 關鍵字

来源:https://www.cnblogs.com/88223100/archive/2022/11/01/Understand-the-virtual-keyword-in-depth.html
-Advertisement-
Play Games

為什麼會寫這篇文章?主要是因為項目中的代碼大量使用了帶virtual關鍵字的類,想通過本文淺談一下。virtual並沒有什麼超能力可以化腐朽為神奇,它有其存在的理由,但濫用它是一種非常不可取的錯誤行為。本文將帶你一步一步瞭解virtual機制,為你揭開virtual的神秘面紗。 ...


 

 

引言

為什麼會寫這篇文章?主要是因為項目中的代碼大量使用了帶virtual關鍵字的類,想通過本文淺談一下。virtual並沒有什麼超能力可以化腐朽為神奇,它有其存在的理由,但濫用它是一種非常不可取的錯誤行為。本文將帶你一步一步瞭解virtual機制,為你揭開virtual的神秘面紗。

 

為什麼需要virtual

假設我們正在進行一個公共圖形化庫的設計實現,其中涉及2d和3d坐標點的列印,設計出Point2d和Point3d的實現如下:

#include <stdio.h>
class Point2d {
public:
  Point2d(int x = 0, int y = 0): _x(x), _y(y) {}
  void print() const { printf("Point2d(%d, %d)\n", _x, _y); }
protected:
  int _x;
  int _y;
};
class Point3d : public Point2d {
public:
  Point3d(int x = 0, int y = 0, int z = 0):Point2d(x, y), _z(z) {}
  void print() const { printf("Point3d(%d, %d, %d)\n", _x, _y, _z); }
protected:
  int _z;
};
int main() {
  Point2d point2d;
  Point3d point3d;
  point2d.print();        //outputs: Point2d(0, 0)
  point3d.print();        //outputs: Point3d(0, 0, 0)
  return 0;
}

完美,一切都符合預期。既然如此,我們為什麼需要virtual?讓我們提個新需求:封裝一個坐標點列印介面,輸入是坐標點實例,輸出是坐標點的值。很快,我們實現了代碼:

void print(const Point2d &point) {
  point.print();
}
int main() {
  Point2d point2d;
  Point3d point3d;
  print(point2d);       //outputs: Point2d(0, 0)
  print(point3d);       //outputs: Point2d(0, 0)
  return 0;
}

問題來了,當我們傳入3d坐標點實例時,我們的期望是列印3d坐標點的值,而實際只能列印2d坐標點的值。現在的程式分不清坐標點是2d還是3d,為了讓程式變得更聰明,需要對症下藥,而virtual正是該症的藥方。只需要更新Point2d介面print的聲明即可:

class Point2d {
public:
  virtual void print() const { printf("Point2d(%d, %d)\n", _x, _y); }
};
int main() {
  Point2d point2d;
  Point3d point3d;
  print(point2d);       //outputs: Point2d(0, 0)
  print(point3d);       //outputs: Point3d(0, 0, 0)
  return 0;
}

乾的漂亮,一切又恢復完美如初。在c++繼承關係中實現多態的威力,正是需要virtual的地方。那麼它的神奇魔力究竟從何而來呢?一切要從類數據成員記憶體佈局說起。

 

類的記憶體佈局

在c++對象模型中,非靜態數據成員被配置於每一個類對象之內,靜態數據成員則被存放在類對象之外。靜態和非靜態函數成員也被存放在類對象之外。大多數編譯器對類的記憶體佈局方式是按成員的聲明順序依次排列,本文的所有例子都是在mac環境下,使用x86_64-apple-darwin21.6.0/clang-1300.0.29.3編譯,非virtual版本的Point2d記憶體佈局:

 

 

記憶體佈局需要我們註意的是編譯器對記憶體的對齊方式,記憶體對齊一般分兩步:其一是類成員先按自身大小對齊,其二是類按最大成員大小對齊。我們在安排類成員的時候,應該遵循成員從大到小的順序聲明,這樣可以避免不必要的記憶體填充,節省記憶體占用。

派生類的記憶體佈局

在c++的繼承模型中,一個子類的記憶體大小,是其基類的數據成員加上其自己的數據成員大小的總和。大多數編譯器對子類的記憶體佈局是先安排基類的數據成員,然後是本身的數據成員。非virtual版本的Point3d的記憶體佈局:

 

 

virtual 類的記憶體佈局

當Point2d聲明瞭virtual函數後,對類對象產生了兩點重大影響:一是類將產生一系列指向virtual functions的指針,放在表格之中,這個表格被稱之為virtual table(vtbl)。二是類實例都被安插一個指針指向相關的virtual table,通常這個指針被稱為vptr。為了示例需要,我們重新設計Point2d和Point3d實現:

class Point2d {
public:
  Point2d(int x = 0, int y = 0): _x(x), _y(y) {}
  virtual void print() const { printf("Point2d(%d, %d)\n", _x, _y); }
  virtual int z() const { printf("Point2d get z: 0\n"); return 0; }
  virtual void z(int z) { printf("Point2d set z: %d\n", z); }
protected:
  int _x;
  int _y;
};
class Point3d : public Point2d {
public:
  Point3d(int x = 0, int y = 0, int z = 0):Point2d(x, y), _z(z) {}
  void print() const { printf("Point3d(%d, %d, %d)\n", _x, _y, _z); }
  int z() const { printf("Point3d get z: %d\n", _z); return _z; }
  void z(int z) { printf("Point3d set z: %d\n", z); _z = z; }
protected:
  int _z;
};

大多數編譯器把vptr安插在類實例的開始處,現在我們來看看virtual版本的Point2d和Point3d的記憶體佈局:

 

 真實記憶體佈局是否如上圖所示,很簡單,我們一驗便知:

int main() {
  typedef void (*VF1) (Point2d*);
  typedef void (*VF2) (Point2d*, int);
  Point2d point2d(11, 22);
  intptr_t *vtbl2d = (intptr_t*)*(intptr_t*)&point2d;
  ((VF1)vtbl2d[0])(&point2d);       //outputs: Point2d(11, 22)
  ((VF1)vtbl2d[1])(&point2d);       //outputs: Point2d get z: 0
  ((VF2)vtbl2d[2])(&point2d, 33);   //outputs: Point2d set z: 33
  Point3d point3d(44, 55, 66);
  intptr_t *vtbl3d = (intptr_t*)*(intptr_t*)&point3d;
  ((VF1)vtbl3d[0])(&point3d);       //outputs: Point3d(44, 55, 66)
  ((VF1)vtbl3d[1])(&point3d);       //outputs: Point3d get z: 66
  ((VF2)vtbl3d[2])(&point3d, 77);   //outputs: Point3d set z: 77
  return 0;
}

關鍵核心virtual table的獲取在第5行,其實可以看成兩步操作:intptr_t vptr2d = *(intptr_t*)&point2d;intptr_t *vtbl2d = (intptr_t*)vptr2d;第一步使vptr2d指向virtual table,第二步將指針轉換為數組首地址。然後就可以用vtbl2d逐個調用虛函數。從輸出結果看,程式確實逐個調用到對應的虛函數,virtual類的記憶體佈局和先前我們所畫結構圖一致。

另一個有趣的地方是虛函數指針的定義,有沒有讓你聯想到什麼?你沒想錯,正是c++類this指針的存在:類成員函數里的this指針,其實是編譯器將類實例的地址以第一個參數的形式傳遞進去的。和其他任何參數一樣,this指針沒有任何特別之處!

virtual 析構函數

前文中我們都沒設計析構函數,是因為要在這裡單獨講解。讓我們重新設計下繼承體系,加入Point類:

class Point {
public:
  ~Point() { printf("~Point\n"); }
};
class Point2d : public Point {
public:
  ~Point2d() { printf("~Point2d"); }
};
class Point3d : public Point2d {
public:
  ~Point3d() { printf("~Point3d"); }
};
int main() {
  Point *p1 = new Point();
  Point *p2 = new Point2d();
  Point2d *p3 = new Point2d();
  Point2d *p4 = new Point3d();
  Point3d *p5 = new Point3d();
  delete p1;      //outputs: ~Point
  delete p2;      //outputs: ~Point
  delete p3;      //outputs: ~Point2d~Point
  delete p4;      //outputs: ~Point2d~Point
  delete p5;      //outputs: ~Point3d~Point2d~Point
  return 0;
}

可以看到,非virtual析構函數版本,決定繼承體系中析構函數鏈調用的因素是指針的聲明類型:析構函數的調用從聲明指針類型的類開始,依次調用其父類析構函數。現在我們把Point的析構函數聲明為virtual,來看下同樣調用的結果:

//除Point析構聲明為virtual外,其餘均不變
int main() {
  Point *p1 = new Point();
  Point *p2 = new Point2d();
  Point2d *p3 = new Point2d();
  Point2d *p4 = new Point3d();
  Point3d *p5 = new Point3d();
  delete p1;      //outputs: ~Point
  delete p2;      //outputs: ~Point2d~Point
  delete p3;      //outputs: ~Point2d~Point
  delete p4;      //outputs: ~Point3d~Point2d~Point
  delete p5;      //outputs: ~Point3d~Point2d~Point
  return 0;
}

virtual析構函數版本,決定繼承體系中析構函數鏈調用的因素是指針的實際類型:析構函數的調用從指針指向的實際類型的類開始,依次調用其父類析構函數。

什麼時候需要 virtual

我看過項目中很多模塊的代碼,大量的類不管三七二十一都把析構函數聲明為virtual。關鍵是這樣的類既不是設計用於基類繼承,也不是設計要使用多態能力,簡直讓人哭笑不得。現在你能理解為啥濫用virtual是不對的嗎?因為在非必需的情況下,引入virtual實在不是一個明智的選擇,它會帶來兩個明顯的副作用:其一是每個類額外增加一個指針大小的記憶體占用,其二是函數調用多一層間接性。這兩個特性會帶來記憶體與性能的雙重消耗。

其中記憶體的消耗是固定的一個指針大小,似乎看起來不起眼,但在類沒有成員或者成員很少的情況下,就會帶來100%以上的記憶體膨脹。性能的消耗則更加隱蔽,virtual會帶來構造函數的強制合成,這點可能出乎很多人的意料。為何呢?因為虛表指針需要被安插妥當,因此編譯器需要在類構造的時候做好這項工作。如果我們再聲明一個虛析構函數,那將再引入一個非必要的合成函數,造成性能的雙殺。讓我們來瞧瞧這樣做的後果:

#include <stdio.h>
#include <time.h>
struct Point2d {
    int _x, _y;
};
struct VPoint2d {
    virtual ~VPoint2d() {}
    int _x, _y;
};
template <typename T>
T sum(const T &a, const T &b) {

    T result;
    result._x = a._x + b._x;
    result._y = a._y + b._y;
    return result;
}
template <typename T>
void test(int times) {
    clock_t t1 = clock();
    for (int i = 0; i < times; ++i) {
        sum(T(), T());
    }
    clock_t t2 = clock();
    printf("clocks: %lu\n", t2 - t1);
}
int main() {
    test<Point2d>(1000000);
    test<VPoint2d>(1000000);
    return 0;
}

假設將上面的代碼存為demo.cpp,用clang++ -o demo demo.cpp將代碼編譯成demo,使用nm demo|grep Point2d查看所有相關符號:

 

 

可以看到VPoint2d自動合成了構造和析構函數,以及typeinfo信息。作為對比Point2d則沒有合成任何函數,我們看下兩者的執行效率:在作者mac機器上,三次demo執行的結果取中間值是Point2d:12819,VPoint2d:21833,VPoint2d性能耗時增加了9014次clock,增幅達70.32%。

因此,一定不要隨意引入virtual,一定不要隨意引入virtual,一定不要隨意引入virtual,除非你真正需要它:

1.在繼承中使用多態能力的時候,需要使用virtual functions機制;

2.基類指針指向子類實例的時候,需要使用virtual析構函數;

任何其他時候,virtual並沒有其他你想要的任何魔力且會有反噬作用。其實還有一種情況需要virtual,就是virtual base class,由於這種情況太過於複雜,建議任何時候都不要去嘗試它(可能需要另外一篇長文來解釋為何不建議使用,本文暫且不表)。

結語

關於virtual的講解至此結束,不多不少,不知對你來說是否夠用。希望本文對你瞭解和使用virtual可以起到幫助作用。c++複雜且龐大,很多特性都有它使用的場景和限制,我們只有深入瞭解其背後的機制,才能做到"寵辱不驚,看庭前花開花落;去留無意,望天上雲卷雲舒;"。

最後,本文參考了《深度探索c++對象模型》一書。毋須多言,我覺得這是一本關於c++的必讀書籍。希望大家有空都可以看看,一定會讓你開卷有益、相見恨晚。

 

 

作 者 | 林少華(逸絕)

本文來自博客園,作者:古道輕風,轉載請註明原文鏈接:https://www.cnblogs.com/88223100/p/Understand-the-virtual-keyword-in-depth.html


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 1.封裝函數,可以判斷一個數字是否為偶數 def func(n): if n%2==0: print("%d是偶數"%n) else: print("%d是奇數"%n) func(11) # 11是奇數 2.封裝函數,可以實現1-n之間所有偶數的列印 def func(n): for i in ra ...
  • 這不光棍節快到了,表弟準備寫一封情書給他的女神,想在光棍節之前脫單。 為了提高成功率,於是跑來找我給他參謀參謀,本來我是不想理他的,不過誰讓他是我表弟呢(請我洗jio),於是教給他程式員的終極浪漫絕招,先假裝給女神拍照,然後再把情書寫到她的照片上列印出來送給她,嘿嘿~ 實現步驟 想要實現把情書寫在像 ...
  • 前言 大家好,我是棧長。 最近,棧長又參加了騰訊雲小伙伴邀請的Techo Day 技術開放日 2.0的線上活動,這一期又是乾貨滿滿,主要是雲原生和微服務方面的,比如:雲原生網關、容器、安全、雲監控、灰度發佈等等,這些內容都與我們現有的微服務系統息息相關。 令棧長印象最深刻的就是微服務灰度發佈這個主題 ...
  • 在分散式系統盛行的今天,緩存充當著扛壓屏障的作用,一旦緩存出現問題,對系統影響也是致命的。本文我們一起聊聊如何安全且可靠的使用緩存,聊聊緩存擊穿、緩存雪崩、緩存穿透以及數據一致性、熱點數據淘汰機制等。 ...
  • 1.遍歷/匹配(foreach/find/match) Stream也是支持類似集合的遍歷和匹配元素的,只是Stream中的元素是以Optional類型存在的。Stream的遍歷、匹配非常簡單。 List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 7, ...
  • 1,JDK和JRE有什麼區別? JRE:Java Runtime Environment( java 運行時環境)。即java程式的運行時環境,包含了 java 虛擬機,java基礎類庫。 JDK:Java Development Kit( java 開發工具包)。即java語言編寫的程式所需的開發 ...
  • 作者:牛牛碼特 鏈接:https://juejin.cn/post/6844903929281511438 背景 緩存是軟體開發中一個非常有用的概念,資料庫緩存更是在項目中必然會遇到的場景。而緩存一致性的保證,更是在面試中被反覆問到,這裡進行一下總結,針對不同的要求,選擇恰到好處的一致性方案。 緩存 ...
  • 責任鏈模式(Chain of Responsibility Pattern)是將鏈中每一個節點看作是一個對象,每個節點處理的請求均不同,且內部自動維護一個下一節點對象。 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...