深入理解 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++ 的必讀書籍。希望大家有空都可以看看,一定會讓你開卷有益、相見恨晚。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/fvgU5iBP9_0yZOsX0no_Cg