為什麼會寫這篇文章?主要是因為項目中的代碼大量使用了帶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