如何為您的類別階層設定 LLVM 風格 RTTI

背景

LLVM 避免使用 C++ 內建的 RTTI。相反地,它廣泛使用自己手動打造的 RTTI 形式,這種形式更有效率且更具彈性,儘管它需要類別作者付出更多努力。

程式設計師手冊中描述了從客戶端角度如何使用 LLVM 風格 RTTI。相較之下,本文檔討論了作為類別階層作者,您需要採取哪些步驟來讓您的客戶端可以使用 LLVM 風格 RTTI。

在深入探討之前,請確保您熟悉物件導向程式設計的「是一種 (is-a)」概念。

基本設定

本節描述如何設定最基本形式的 LLVM 風格 RTTI(這足以應付 99.9% 的情況)。我們將為這個類別階層設定 LLVM 風格 RTTI

class Shape {
public:
  Shape() {}
  virtual double computeArea() = 0;
};

class Square : public Shape {
  double SideLength;
public:
  Square(double S) : SideLength(S) {}
  double computeArea() override;
};

class Circle : public Shape {
  double Radius;
public:
  Circle(double R) : Radius(R) {}
  double computeArea() override;
};

最基本可運作的 LLVM 風格 RTTI 設定需要以下步驟

  1. 在您宣告 Shape 的標頭檔中,您會想要 #include "llvm/Support/Casting.h",它宣告了 LLVM 的 RTTI 樣板。這樣您的客戶端甚至不必考慮它。

    #include "llvm/Support/Casting.h"
    
  2. 在基底類別中,引入一個枚舉,區分階層中所有不同的具體類別,並將枚舉值存放在基底類別的某個地方。

    以下是引入此變更後的程式碼

     class Shape {
     public:
    +  /// Discriminator for LLVM-style RTTI (dyn_cast<> et al.)
    +  enum ShapeKind {
    +    SK_Square,
    +    SK_Circle
    +  };
    +private:
    +  const ShapeKind Kind;
    +public:
    +  ShapeKind getKind() const { return Kind; }
    +
       Shape() {}
       virtual double computeArea() = 0;
     };
    

    您通常會希望保持 Kind 成員的封裝性和私有性,但讓枚舉 ShapeKind 與提供 getKind() 方法一起公開。這對客戶端來說很方便,他們可以對枚舉執行 switch

    一個常見的命名慣例是這些枚舉是「kind」類型,以避免與「type」或「class」等詞彙產生歧義,這些詞彙在 LLVM 內的許多上下文中都有過於多重的含義。有時會有一個自然的名稱,例如「opcode」。不要在這個問題上鑽牛角尖;當有疑問時,使用 Kind

    您可能會想知道為什麼 Kind 枚舉沒有 Shape 的條目。原因是,由於 Shape 是抽象的(computeArea() = 0;),您永遠不會真正擁有完全該類別的非衍生實例(只有子類別)。請參閱 具體基底類別與更深的階層,以獲取有關如何處理非抽象基底類別的資訊。值得在此提及的是,與 dynamic_cast<> 不同,LLVM 風格 RTTI 可以用於(且經常被用於)沒有 v-table 的類別。

  3. 接下來,您需要確保 Kind 初始化為對應於類別動態型別的值。通常,您會希望它成為基底類別建構子的參數,然後從子類別建構子中傳入各自的 XXXKind

    以下是該變更後的程式碼

     class Shape {
     public:
       /// Discriminator for LLVM-style RTTI (dyn_cast<> et al.)
       enum ShapeKind {
         SK_Square,
         SK_Circle
       };
     private:
       const ShapeKind Kind;
     public:
       ShapeKind getKind() const { return Kind; }
    
    -  Shape() {}
    +  Shape(ShapeKind K) : Kind(K) {}
       virtual double computeArea() = 0;
     };
    
     class Square : public Shape {
       double SideLength;
     public:
    -  Square(double S) : SideLength(S) {}
    +  Square(double S) : Shape(SK_Square), SideLength(S) {}
       double computeArea() override;
     };
    
     class Circle : public Shape {
       double Radius;
     public:
    -  Circle(double R) : Radius(R) {}
    +  Circle(double R) : Shape(SK_Circle), Radius(R) {}
       double computeArea() override;
     };
    
  4. 最後,您需要告知 LLVM 的 RTTI 樣板如何動態地判斷類別的型別(即 isa<>/dyn_cast<> 是否應該成功)。預設的「99.9% 使用案例」方法是透過一個小的靜態成員函式 classof 來完成。為了讓解釋有適當的上下文,我們將首先顯示此程式碼,然後在下面描述每個部分

     class Shape {
     public:
       /// Discriminator for LLVM-style RTTI (dyn_cast<> et al.)
       enum ShapeKind {
         SK_Square,
         SK_Circle
       };
     private:
       const ShapeKind Kind;
     public:
       ShapeKind getKind() const { return Kind; }
    
       Shape(ShapeKind K) : Kind(K) {}
       virtual double computeArea() = 0;
     };
    
     class Square : public Shape {
       double SideLength;
     public:
       Square(double S) : Shape(SK_Square), SideLength(S) {}
       double computeArea() override;
    +
    +  static bool classof(const Shape *S) {
    +    return S->getKind() == SK_Square;
    +  }
     };
    
     class Circle : public Shape {
       double Radius;
     public:
       Circle(double R) : Shape(SK_Circle), Radius(R) {}
       double computeArea() override;
    +
    +  static bool classof(const Shape *S) {
    +    return S->getKind() == SK_Circle;
    +  }
     };
    

    classof 的工作是動態判斷基底類別的物件是否實際上是特定的衍生類別。為了將型別 Base 向下轉型為型別 Derived,需要在 Derived 中有一個 classof,它將接受型別為 Base 的物件。

    具體來說,請考慮以下程式碼

    Shape *S = ...;
    if (isa<Circle>(S)) {
      /* do something ... */
    }
    

    此程式碼中 isa<> 測試的程式碼最終會歸結為——在樣板實例化和其他機制之後——大致類似於 Circle::classof(S) 的檢查。有關更多資訊,請參閱 `classof` 的契約

    classof 的參數應始終是一個祖先類別,因為實作具有邏輯來自動允許和最佳化向上轉型/向上 isa<>。就好像每個類別 Foo 都自動具有類似以下的 classof

    class Foo {
      [...]
      template <class T>
      static bool classof(const T *,
                          ::std::enable_if<
                            ::std::is_base_of<Foo, T>::value
                          >::type* = 0) { return true; }
      [...]
    };
    

    請注意,這就是我們不需要在 Shape 中引入 classof 的原因:所有相關類別都衍生自 Shape,並且 Shape 本身是抽象的(在 Kind 枚舉中沒有條目),因此這個概念上推斷的 classof 是我們所需要的全部。請參閱 具體基底類別與更深的階層,以獲取有關如何將此範例擴展到更一般階層的更多資訊。

儘管對於這個小範例,設定 LLVM 風格 RTTI 似乎有很多「樣板程式碼」,但如果您的類別正在執行任何有趣的事情,那麼這最終將只佔程式碼的一小部分。

具體基底類別與更深的階層

對於具體基底類別(即繼承樹狀結構的非抽象內部節點),classof 內部的 Kind 檢查需要稍微複雜一些。這種情況與上面的範例不同,因為

  • 由於該類別是具體的,因此它本身必須在 Kind 枚舉中具有一個條目,因為有可能擁有以該類別作為動態型別的物件。

  • 由於該類別有子類別,因此 classof 內部的檢查必須將它們考慮在內。

假設 SpecialSquareOtherSpecialSquare 衍生自 Square,因此 ShapeKind 變為

 enum ShapeKind {
   SK_Square,
+  SK_SpecialSquare,
+  SK_OtherSpecialSquare,
   SK_Circle
 }

然後在 Square 中,我們需要像這樣修改 classof

-  static bool classof(const Shape *S) {
-    return S->getKind() == SK_Square;
-  }
+  static bool classof(const Shape *S) {
+    return S->getKind() >= SK_Square &&
+           S->getKind() <= SK_OtherSpecialSquare;
+  }

我們需要測試像這樣的範圍而不是僅僅相等的原因是,SpecialSquareOtherSpecialSquare 都是「是一種 (is-a)」 Square,因此 classof 需要為它們返回 true

這種方法可以擴展到任意深度的階層。訣竅在於您排列枚舉值,使其對應於類別階層樹狀結構的前序遍歷。透過這種排列,所有子類別測試都可以透過兩個比較來完成,如上所示。如果您只是像項目符號列表一樣列出類別階層,您將獲得正確的順序

| Shape
  | Square
    | SpecialSquare
    | OtherSpecialSquare
  | Circle

應注意的錯誤

剛剛給出的範例開啟了錯誤之門,當向階層新增(或從階層中移除)類別時,classof 沒有更新以匹配 Kind 枚舉。

繼續上面的範例,假設我們新增一個 SomewhatSpecialSquare 作為 Square 的子類別,並更新 ShapeKind 枚舉,如下所示

 enum ShapeKind {
   SK_Square,
   SK_SpecialSquare,
   SK_OtherSpecialSquare,
+  SK_SomewhatSpecialSquare,
   SK_Circle
 }

現在,假設我們忘記更新 Square::classof(),所以它仍然看起來像

static bool classof(const Shape *S) {
  // BUG: Returns false when S->getKind() == SK_SomewhatSpecialSquare,
  // even though SomewhatSpecialSquare "is a" Square.
  return S->getKind() >= SK_Square &&
         S->getKind() <= SK_OtherSpecialSquare;
}

正如註解所示,此程式碼包含一個錯誤。避免這種情況的一種直接且不聰明的方法是在新增第一個子類別時,在枚舉中引入一個明確的 SK_LastSquare 條目。例如,我們可以將 具體基底類別與更深的階層 開頭的範例重寫為

 enum ShapeKind {
   SK_Square,
+  SK_SpecialSquare,
+  SK_OtherSpecialSquare,
+  SK_LastSquare,
   SK_Circle
 }
...
// Square::classof()
-  static bool classof(const Shape *S) {
-    return S->getKind() == SK_Square;
-  }
+  static bool classof(const Shape *S) {
+    return S->getKind() >= SK_Square &&
+           S->getKind() <= SK_LastSquare;
+  }

然後,新增新的子類別很容易

 enum ShapeKind {
   SK_Square,
   SK_SpecialSquare,
   SK_OtherSpecialSquare,
+  SK_SomewhatSpecialSquare,
   SK_LastSquare,
   SK_Circle
 }

請注意,Square::classof 不需要更改。

classof 的契約

更精確地說,讓 classof 位於類別 C 內。然後 classof 的契約是「如果參數的動態型別是 C 的一種 (is-a) 型別,則返回 true」。只要您的實作滿足此契約,您就可以根據需要盡可能多地調整和最佳化它。

例如,透過定義適當的 classof,LLVM 風格 RTTI 可以在多重繼承的情況下正常運作。在實務中,Decl 與 Clang 內部的 DeclContext 就是一個範例。Decl 階層的完成方式與本教學中示範的範例設定非常相似。關鍵部分是如何整合 DeclContext:所有需要的都在 bool DeclContext::classof(const Decl *) 中,它會詢問「給定一個 Decl,我如何判斷它是否是 DeclContext 的一種 (is-a) 型別?」。它透過對 Decl「種類」集合進行簡單的 switch 語句來回答這個問題,並對於已知是 DeclContext 的種類返回 true。

經驗法則

  1. Kind 枚舉應該為每個具體類別都有一個條目,並根據繼承樹狀結構的前序遍歷排序。

  2. classof 的參數應該是 const Base *,其中 Base 是繼承階層中的某個祖先。參數絕不應該是衍生類別或類別本身:isa<> 的樣板機制已經處理了這種情況並對其進行了最佳化。

  3. 對於階層中沒有子類別的每個類別,實作一個僅針對其 Kind 進行檢查的 classof

  4. 對於階層中具有子類別的每個類別,實作一個檢查第一個子類別的 Kind 和最後一個子類別的 Kind 範圍的 classof

開放類別階層的 RTTI

有時不可能預先知道階層中的所有型別。例如,在上面描述的形狀階層中,作者可能希望他們的代码也能適用於使用者定義的形狀。為了支援需要開放階層的使用案例,LLVM 提供了 RTTIRootRTTIExtends 工具。

RTTIRoot 類別描述了執行 RTTI 檢查的介面。RTTIExtends 類別樣板為衍生自 RTTIRoot 的類別提供了此介面的實作。RTTIExtends 使用「奇特的遞迴範本模式 (Curiously Recurring Template Idiom)」,將正在定義的類別作為其第一個樣板參數,將父類別作為第二個參數。任何使用 RTTIExtends 的類別都必須定義一個 static char ID 成員,其位址將用於識別型別。

只有在您的使用案例需要時才應使用這種開放階層 RTTI 支援。否則,應優先使用標準 LLVM RTTI 系統。

例如:

class Shape : public RTTIExtends<Shape, RTTIRoot> {
public:
  static char ID;
  virtual double computeArea() = 0;
};

class Square : public RTTIExtends<Square, Shape> {
  double SideLength;
public:
  static char ID;

  Square(double S) : SideLength(S) {}
  double computeArea() override;
};

class Circle : public RTTIExtends<Circle, Shape> {
  double Radius;
public:
  static char ID;

  Circle(double R) : Radius(R) {}
  double computeArea() override;
};

char Shape::ID = 0;
char Square::ID = 0;
char Circle::ID = 0;

進階使用案例

isa/cast/dyn_cast 的底層實作都由一個名為 CastInfo 的結構控制。CastInfo 提供了 4 個方法:isPossibledoCastcastFaileddoCastIfPossible。這些方法依序用於 isacastdyn_cast。您可以透過建立 CastInfo 結構的特化(針對您想要的型別),來控制轉型的執行方式,該特化提供與基本 CastInfo 結構相同的靜態方法。

這可能有很多樣板程式碼,因此我們也有所謂的 Cast Traits (轉型特徵)。這些是提供上述一個或多個方法的結構,因此您可以分解專案中的常見轉型模式。我們在標頭檔中提供了一些可供使用的轉型特徵,我們將展示一些範例來說明其用途。這些範例並不詳盡,新增新的轉型特徵很容易,因此使用者應隨時將它們新增到他們的專案中,或者如果它們特別有用,則貢獻它們!

值到值的轉型

在這種情況下,我們有一個稱為「可為空的 (nullable)」結構——即,它可以從 nullptr 建構,並且會產生一個您可以判斷為無效的值。

class SomeValue {
public:
  SomeValue(void *ptr) : ptr(ptr) {}
  void *getPointer() const { return ptr; }
  bool isValid() const { return ptr != nullptr; }
private:
  void *ptr;
};

給定類似這樣的東西,我們想要透過值傳遞這個物件,並且我們希望從這種型別的物件轉型到某些其他物件集合。目前,我們假設我們想要轉型的所有型別都提供 classof。因此,我們可以像這樣使用一些提供的轉型特徵

template <typename T>
struct CastInfo<T, SomeValue>
  : CastIsPossible<T, SomeValue>, NullableValueCastFailed<T>,
    DefaultDoCastIfPossible<T, SomeValue, CastInfo<T, SomeValue>> {
  static T doCast(SomeValue v) {
    return T(v.getPointer());
  }
};

指標到值的轉型

現在給定上面的值 SomeValue,也許我們希望能夠從 char 指標型別轉型為該型別。因此,在這種情況下,我們會做的是

template <typename T>
struct CastInfo<SomeValue, T *>
  : NullableValueCastFailed<SomeValue>,
    DefaultDoCastIfPossible<SomeValue, T *, CastInfo<SomeValue, T *>> {
  static bool isPossible(const T *t) {
    return std::is_same<T, char>::value;
  }
  static SomeValue doCast(const T *t) {
    return SomeValue((void *)t);
  }
};

如果我們想從 char * 轉型為 SomeValue,這將使我們能夠做到。

選用值轉型

當您的型別無法從 nullptr 建構,或者沒有簡單的方法判斷物件何時無效時,您可能想要使用 std::optional。在這些情況下,您可能想要類似這樣的東西

template <typename T>
struct CastInfo<T, SomeValue> : OptionalValueCast<T, SomeValue> {};

該轉型特徵要求 T 可以從 const SomeValue & 建構,但它啟用了像這樣的轉型

SomeValue someVal = ...;
std::optional<AnotherValue> valOr = dyn_cast<AnotherValue>(someVal);

使用 _if_present 變體,您甚至可以執行像這樣的選用鏈結

std::optional<SomeValue> someVal = ...;
std::optional<AnotherValue> valOr = dyn_cast_if_present<AnotherValue>(someVal);

如果 someVal 無法轉換 someVal 也是 std::nullopt,則 valOr 將為 std::nullopt