引言
為什么會寫這篇文章?主要是因為專案中的代碼大量使用了帶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
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/524905.html
標籤:其他
上一篇:支持JDK19虛擬執行緒的web框架,之二:完整開發一個支持虛擬執行緒的quarkus應用
下一篇:聊一聊責任鏈模式
