實現一個 Variant

twoon發表於2015-10-26

很多時候我們希望能夠用一個變數來儲存和操作不同型別的資料(比如解析文字建立 AST 時儲存不同型別的結點),這種需求可以通過繼承來滿足,但繼承意味著得使用指標或引用,除了麻煩和可能引起的效率問題,該做法最大的不便還在語義上,指標和引用都不是值型別。於是我們想到 union,union 對簡單型別來說是很好的解決思路,它的出現本身也是為了解決這個問題,只是它到底是 C 語言世界裡的東西,在 C++ 裡面它沒法很好的支援使用者自定義的型別,主要原因是它不能方便和智慧地呼叫自定義型別的構造和解構函式,即使是到了 c++11 也沒法很好解決。

所以,如果我們能設計出這樣一種類似 union 的東西,它繼承了 union 的所有優點,並且還可以型別安全(因此可以存放任意型別的值,當然前提是可以 copyable & movable),從而不用擔心構造和析構的問題,那世界將會變得多麼美好。。。這個美好的世界其實已經存在了,它就是 boost 裡的 Variant,出於對它實現的好奇,我找到了 Andrei Alexandrescu 的這篇文章,推薦讀者們也讀一讀。

當然只說不練是不夠的,Andrei 的實現是基於年代久遠的 c++ 98/03,很多東西實現起來很不方便,而現在我們有了 c++11,到了可以用新武器來解決舊問題的時候了(正好標準庫裡又沒這個東西)。

使用場景

我的實現希望能全面模仿 boost 裡的 Variant,因此它的使用要求其實非常的簡單:

  1. 可以支援任意數量的型別,並且能像簡單型別一樣對其賦值,而且值是不同的型別。
  2. 通過 variant::get<type>() 這樣的方式來獲取儲存在裡面的值。
  3. 除此,還需要支援獲取指標(從而型別錯誤時不用拋異常),以及支援 emplace_set()(類似 vector 裡的 emplace_back()).
  4. 支援隱式構造,支援 copy 和 move 語義。

總結起來,就是要能滿足如下一些簡單的使用用例:

// 構造
Variant<int, double, string> v1(32);
Variant<int, double, string> v2 = string("www");
Variant<int, double, string> v3(v2);
Variant<int, string> v4("abc");

int k = v1.GetRef<int>();
assert(k == 32);

string& s = v2.GetRef<string>();
assert(s == "www");
assert(v3.GetRef<string>() == "www");
assert(2, v4.GetType());
assert(v4.GetRef<string>() == "abc");

// 賦值
v1 = 23;
assert(v1.GetRef<int>() == 23);
v1 = "eee";
assert(v1.GetRef<string>() == "eee");

v1.emplace_set<string>(4, 'a'); 
assert(v1.GetRef<string>() == "aaaa");

// 拷貝
v1 = v2;
assert(v1.GetRef<string>() == "www");
assert(v2.GetRef<string>() == "www");

// move
v2 = std::move(v1);
assert(v2.GetRef<string>() == "www");
assert(v1.Get<string>() == nullptr);
Variant<int, double, string> v5(std::move(v2));
assert(v5.GetRef<string>() == "www");
assert(v2.Get<string>() == nullptr);

支援任意數量的型別

在模板中支援任意數量的型別曾經是個很麻煩的問題,但到了 c++11,變長引數模板(variadic template)的出現直接解決了這個問題,good bye typelist。除此還剩幾個問題待解決。

記憶體與對齊

因為 Variant 中各型別的大小通常不一樣,對齊也不一樣,怎麼用同一塊記憶體來儲存這些不同型別的值呢?最直接最省事的想法是 Variant 內部還是用一個 union 作為儲存,但是因為要支援任意數量的模板引數,這個方法變得不可行:編譯時雖可以獲得全部的模板引數,但怎麼在 union 中定義各個型別的變數呢?這裡巨集都不一定有用,變長引數的逐個展開必須用到遞迴,也許用繼承可以把各個型別的變數嵌入到繼承的體系中,總之我沒想出來具體的解法。Andrei 的做法是劃出一塊足夠大的公共記憶體然後使用 placement new.

    template <typename ...TS> struct TypeMaxSize;

    template <>
    struct TypeMaxSize<>
    {
        static constexpr std::size_t value = 0;
        static constexpr std::size_t align = 0;
    };

    template <typename T, typename ...TS>
    struct TypeMaxSize<T, TS...>
    {
        static constexpr std::size_t cur = sizeof(T);
        static constexpr std::size_t next = TypeMaxSize<TS...>::value;
        static constexpr std::size_t value = cur > next? cur : next;

        static constexpr std::size_t cur_align = alignof(T);
        static constexpr std::size_t next_align = TypeMaxSize<TS...>::value;
        static constexpr std::size_t align = cur_align > next_align? cur_align : next_align;
    };

   template<class ...TS>
   struct variant_t 
   {
     private:
        constexpr static size_t Alignment() { return TypeMaxSize<TS...>::align; }
        constexpr static size_t TypeSize() { return TypeMaxSize<TS...>::value; }

     private:
        alignas(Alignment()) unsigned char data_[TypeSize()];
   };

如上,TypeMaxSize 這個結構體用來在各型別的 size/alignment 中分別找出最大的兩個,引數的展開是常規的遞迴,值得注意的是 alignofalignas 這兩個新關鍵字,前者用來獲取型別 alignment 的大小,後者用於按指定的值來對齊它所修飾的變數,至此,Andrei 論文裡提到的處理 alignment 的各式複雜的 trick 就完全用不上了。

標記型別

型別的設定是在編譯時完成的,但 Variant 支援在執行時切換不同型別的值,因此我們需要設定一種方式來動態的標記當前儲存的是哪種型別的資料,從而可以析構當前值,再儲存新的值。Andrei 用 typeid() 來作為型別的 tag,這樣的好處之一是模板的引數順序就變得不重要了,甚至型別重複也影響不大,但我覺得 Variant 的定義應該嚴格一些,比如, Variant<int, double> 就不能寫成 Variant<double, int>(畢竟本來這兩種寫法就表示不同的型別了),型別的順序要固定,因此實際上我們可以利用型別在模板引數列表中的位置作為該型別在 Variant 中的 id,這樣做的好處是非常直觀簡單。如下程式碼用來檢查某個型別是否存在於模板的變長引數列表中,如果存在,順便計算它的位置(從 1 開始),注意,這些都是編譯時的計算。

    // check if a type exists in the variadic type list
    template <typename T, typename ...TS> struct TypeExist;

    template <typename T>
    struct TypeExist<T>
    {
        enum { exist = 0 };
        static constexpr std::size_t id = 0;
    };

    template <typename T, typename T2, typename ...TS>
    struct TypeExist<T, T2, TS...>
    {
        enum { exist = std::is_same<T, T2>::value || TypeExist<T, TS...>::exist };
        static constexpr std::size_t id = std::is_same<T, T2>::value? 1 : 1 + TypeExist<T, TS...>::id;
    };

有了上面的程式碼,我們可以嘗試寫一下 Variant 的建構函式:

   template<class ...TS>
   struct variant_t 
   {
     template<class T>
     variant_t(T&& v): type_(TypeExist<T, TS...>::id
     {
        static_assert(TypeExist<T,TS...>::exist, "invalid type for Variant.");
        // placement new to construct an object of T.
        new(data_) typename std::remove_reference<T>::type(std::forward<T>(v));
     }

     private:
        constexpr static size_t Alignment() { return TypeMaxSize<TS...>::value; }

     private:
        size_t type_ = 0;
        alignas(Alignment()) unsigned char data_[Alignment()];
   };

很簡潔,建構函式是個模板,從而可以接受不同型別的值,並就地構造,那麼怎麼銷燬呢?構造時我們知道型別,但析構時,我們卻只有一個整型的數字,不知道相對應的型別,因此我們需要一種特殊的反射。

動態選擇相應型別的解構函式拷貝函式

雖然在迫切需要型別時,我們只有型別的編號,但這個編號是和型別一一對應的,而針對每個型別的解構函式的呼叫方式其實是一樣的(畢竟解構函式的簽名都是一樣的),比如,對於任意型別 T, 手動呼叫它的解構函式,肯定是寫成這樣:reinterpret_cast<T*>(obj)->~T();,這不赤裸裸暗示我們可以把析構物件的過程寫成一個模板函式嗎,而且當前 Variant 所需要處理的型別在模板例項化的時候就已經確定了,我們顯然可以在例項化模板時,就把各個型別對應的解構函式給例項化一下。

template<class T>
void destroy(unsigned char* data)
{
  reinterpret_cast<T*>(data)->~T();
}

現在的問題是何時何地去例項化和呼叫上面的模板函式呢? 顯然,模板函式的例項化是肯定要在編譯時完成的,因此要在合適的時候把 Variant 的變長引數列表展開,將裡面的型別逐個傳給 template<class T> void destroy,這不難,但怎麼把型別的編號和這些相應的函式對應起來呢?有兩種方式,一種是在執行時根據型別的 id 來搜尋:

template<class ...TS>
struct call
{
  static void call_(size_t, unsigned char*)
  {
     assert(0);
  }
};

template<class T, class ...TS>
struct call<T, TS...>
{
   static void call_(size_t k, unsigned char* data)
   {
      if (k == 0) return;

      if (k == 1) return destroy<T>(data);
      
      call<TS...>::call_(k-1, data);
   }
};

注意上面的程式碼是怎麼把變長型別列表的展開和具體型別的 id 對應起來的,混合了編譯時與執行時的程式碼,可能不是那麼直觀明瞭,但它是能正確工作的,只是它的問題也明顯: 引入了沒必要的執行時開銷。那麼,怎麼改進呢?一個非常直接的想法是把各個型別對應的 destroy<> 函式在編譯時放到一個陣列裡,執行時只需要根據型別 id 取出相應的函式即可。那麼現在的問題變成了,我們能在編譯時建立一個陣列嗎?答案是可以的,而且相當簡單。

   template<class ...TS>
   struct variant_t 
   {
     // other definition.
     private:
       using destroy_func_t = void(*)(unsigned char*);

       // 只是宣告,需在結構體外再定義。
       constexpr static destroy_func_t fun[] = {destroy<TS>...};
   };

   // 定義 constexpr 陣列。
   template<class ...TS>
   constexpr variant_t<TS...>::destroy_func_t variant_t<TS...>::fun[];

編譯時的陣列其實在 c++11 以前也是支援的,只是再加上支援變長模板引數型別的話,寫起來比較麻煩罷了。有了如上定義的一個陣列,在執行時,我們只根據一個型別 id,就能直接呼叫相應的解構函式了。

   template<class ...TS>
   struct variant_t 
   {
      // other definition....
     ~variant_t()
      {
        Release();
      }

     // other definition....
     private:
      void Release()
      {
        if (type_ == 0) return;

        destroy_[type_ - 1](data_);
      }

     private:
      size_t type_ = 0;
      using destroy_func_t = void(*)(unsigned char*);

      // 只是宣告,需在結構體外再定義。
      constexpr static destroy_func_t destroy_[] = {destroy<TS>...};

      alignas(Alignment()) unsigned char data_[Alignment()];
   };
   // other definition....

根據型別的 id 來呼叫相應的拷貝建構函式與 move 建構函式也是同樣的做法,這裡就不重複了。

隱式構造與型別轉換[10.29 更新]

模板建構函式使得我們可以支援使用者使用任意型別的值來構造一個 Variant, 但顯然我們並不需要支援任意型別,也做不到支援任意型別,事實上我們需要支援的只是兩類:

  1. Variant 模板引數中指定的型別。
  2. 能夠隱式轉換為 Variant 模板引數中的型別的型別,具體來說,就是要使得 Variant<int, string> v("abc"); 是合法的。

其中第一種型別的引數我們已經支援了,現在得處理的是第二種型別,所以我們需要一個能轉換型別的東西,它能根據建構函式的模板引數 T,從 Variant 的模板引數列表中選擇一個型別 CT,使得 T 能隱式地轉換為 CT.

    template<class T, class ...TS>
    struct SelectType
    {
       using type = typename std::conditional<TypeExist<T, TS...>::exist, T,
               typename SelectConvertible<T, TS...>::type>::type;
    };

參看如上所示 template<> SelectType,第一步是判斷 T 是否已經存在於型別引數列表中了,如果是則直接使用 T,否則的話,我們就要遍歷 TS,從中找出一個型別 CT, 使得 T 能隱式地轉換為 CT,判斷一個型別是否能隱式地轉換為另一種型別需要一些特別的技巧,比較常見的做法是 Andrei 在 Modern c++ deisgn 裡介紹的那種通過函式過載,並判斷返回型別來實現型別的選擇。

template<class S, class D>
struct is_convertible
{
   struct big { char d[2]; };
   typedef char small;

   static S get_src_type();

   static big foo(D);
   static small foo(...);

   enum { value = sizeof(foo(get_src_type())) == sizeof(big) };
};

判斷一個型別是否可轉換為另一個型別實在是太常見了,因此 c++11 裡內建了一個功能相同的結構:std::is_convertible<>,正好幫我省一些程式碼,剩下要做的就只是遍歷變長引數列表了。

    template<class T, class ...TS>
    struct SelectConvertible
    {
        enum { exist = false };
        using type = void;
    };

    template<class T, class T1, class ...TS>
    struct SelectConvertible<T, T1, TS...>
    {
        enum { exist = std::is_convertible<T, T1>::value || SelectConvertible<T, TS...>::exist };

        using type = typename std::conditional<std::is_convertible<T, T1>::value,
                T1, typename SelectConvertible<T, TS...>::type>::type ;
    };

拷貝構造和 Move Semantic

經過前面的介紹,一個具備基本功能的 Variant 已經差不多完成了,但我們還沒有定義 Variant 本身的 copy 和 move 語義,這個兩個功能事關易用性與效能,其實是非常關鍵的,當然了,實現起來其實就是四個函式:

   template<class ...TS>
   struct variant_t 
   {
      variant_t(variant<TS...>&& v);
      variant_t(const variant_t<TS...>& v);
      variant_t& operator=(variant_t<TS...>&& v);
      variant_t& operator=(const variant_t<TS...>& v);
   }

後面兩賦值操作符過載與前面兩個建構函式實現上大同小異,這兒只說一說前兩個怎麼實現。首先注意到,我們前面已經定義了一個模板建構函式用來接受不同型別的值,現在再定義引數型別為 variant_t 的建構函式會和它衝突(當引數是非 const 的左傳引用),因此我們必須想辦法使得前面的模板建構函式不接受 variant_t<> 這種型別作為模板引數,嗯,這顯然就得依賴 SFINAE 了。

   template<class ...TS>
   struct variant_t 
   {
     template<class T, class D = typename std::enable_if<
            !std::is_same<typename std::remove_reference<T>::type, Variant<TS...>>::value>::type>
     variant_t(T&& v): type_(TypeExist<T, TS...>::id
     {
        static_assert(TypeExist<T,TS...>::exist, "invalid type for Variant.");

        // placement new to construct an object of T.
        new(data_) typename std::remove_reference<T>::type(std::forward<T>(v));
     }
     
     // other definition....
   };

這樣一來模板建構函式就有兩個模板引數了,但是實際上這對使用者並沒有影響,因為建構函式的模板引數是沒法由使用者顯式去指定的(因為建構函式沒法直接呼叫),它們只能由編譯器推導,而這裡第二個引數是由我們自己定義的,因此使用者也完全沒辦法影響它的推導,當然了,問題還是有的,介面變得有些嚇人了,雖然本質沒變。有了如上定義,我們就可以順利地寫出如下程式碼:

   template<class ...TS>
   struct variant_t 
   {   
     // other definition....
     variant_t(variant_t<TS...>&& other)
     {
        // TODO, check if other is movable.
        if (other.type_ == 0) return;

        move_[other.type_ - 1](other.data_, data_);
        type_ = other.type_;
     }

     variant_t(const variant_t<TS...>& other)
     {
        // TODO, check if other is copyable.
        if (other.type_ == 0) return;

        copy_[other.type_ - 1](other.data_, data_);
        type_ = other.type_;
     }
   };

上面的 move_ 與 copy_ 都是函式指標陣列,和前面介紹的各型別的解構函式陣列一樣,都是在編譯時建立的,只通過型別的 id 就能獲取該型別對應的處理函式,非常方便高效。對於拷貝賦值(copy assignment)與移動賦值(move assignment),實現上類似,但有些細節需要考慮:

  1. 當前 variant 儲存的物件的型別與引數 variant 儲存的型別一樣時,需要執行的操作是 copy assignment 及 move assignment.
  2. 當前 variant 儲存的物件的型別與引數 variant 儲存的型別不同時,需要先析構當前儲存的物件,然後再 copy/move construct.
  3. 如果 copy/move 丟擲了異常,需要確保當前 variant 仍處於一個合法的狀態:空或者保持原來的值。不同的選擇只是實現上的取捨,前者好實現些,後者則比較麻煩。

完整的程式碼請參看這裡

優化 copy 和 move 的實現[10.29 更新]

前面提到,copy_ 和 move_ 的實現可以完全照搬 destroy_,但那樣做會引入一個可大可小的問題,我們強制例項化了 Variant 模板引數列表中每一個型別所對應的 copy 和 move 函式,這就使得使用者在使用 Variant 時,必須保證其所使用的全部型別都是 copyable 和 movable,這個要求可以說是很嚴格的,因此很大程度限制了 Variant 的使用範圍,那麼我們是否可以優化一下呢?使得 Variant 能像 vector 一樣,只對可以 move 和可以 copy 的型別定義那些相應的 copy 函式和 move 函式,而不是一律死板地要求全部型別都必須 movable 和 copyable?答案顯然是可行的。

為實現這個功能,我們需要增加一些輔助性的結構,首先是怎麼判斷一個型別是否可 copy 或可 move,這可以通過檢查該型別是否定義了 copy constructor 和 move constructor 來達到這個目的,具體做法參考 modern c++ design,這裡我使用了 c++11 自帶的 std::is_copy_constructiblestd::is_move_constructible,然後我們還需要定義一個模板的 copy/move 函式,並對這些函式進行一個特化,這個特化是專門給不能 copy/move 的型別用的,當使用者企圖 copy/move 一個不能 copy/move 的型別時,就呼叫這個特化的函式。

    template<class T>
    void CopyConstruct(const unsigned char* f, unsigned char* t)
    {
        new(t) T(*reinterpret_cast<const T*>(f));
    }

    template<>
    void CopyConstruct<void>(const unsigned char*, unsigned char*)
    {
        throw "try to copy Variant object containing non-copyable type.";
    }

接下來就和之前處理 destroy 函式一樣,得把它們填充到函式陣列裡了,因為需要特殊處理那些不能 copy/move 的型別,這裡需要藉助 std::conditional 來轉換一下型別從而選擇合適的 copy/move 函式。

constexpr static VariantHelper::copy_func_t copy_[] = {CopyConstruct<typename std::conditional<std::is_copy_constructible<TS>::value, TS, void>::type>...};

如上所示,我們終於把 copy_ 和 move_ 重新定義好了,其中特化的 CopyConstruct<void> 什麼也沒做只是拋了一個異常。至此,似乎該做的功能都差不多完成了,但等等,我們還有些手尾要處理:雖然我們不再例項化那些不能 copy 或不能 move 的型別的 copy 函式和 move 函式,轉而在陣列裡填了一個什麼事也沒做只會拋異常的空函式,但我們並沒阻止使用者去做錯誤的事情,使用者還是可以把一個不能 copy/move construct 的物件用傳左值引用的方式去構造一個 Variant。

NonCopyable nc;

// 以下可以通過編譯,但在執行時會拋異常。
variant_t<int, NonCopyable> v(nc);

這顯然還不夠友好,事實上我們可以對 Variant 的拷貝建構函式在編譯時進行檢查,如果發現使用者以左值引用的方式傳入一個不支援 copy 的引數就報個錯,對 move 同理。注意到 Variant 的建構函式是轉發型別的模板函式(template<class T> variant_t(T&&)),它既能接受左值引用,也能接受右值引用,因此我們需要定義一個簡單的結構來判斷當前的引數是 lvalue reference 還是 rvalue reference,並對不同型別的引用進行檢查。

    template<bool lvalue, class T>
    struct CheckConstructible
    {
        enum { value = std::is_copy_constructible<T>::value };
    };

    template<class T>
    struct CheckConstructible<false, T>
    {
        enum { value = std::is_move_constructible<T>::value };
    };

判斷一個型別是左值引用還是右值引用可以使用 std::is_lvalue_reference<>, std::is_rvalue_reference<>,於是我們可以在 Variant 的建構函式裡再加一個 static_assert<>。

    template <typename T, typename ...other...>
    variant_t(T&& v)
    {
        static_assert(VariantHelper::TypeExist<T, TS...>::exist,
                     "invalid type for invariant.");

        static_assert(VariantHelper::CheckConstructible<std::is_lvalue_reference<T>::value, T>::value,
                     "try to copy or move an object that is not copyable or moveable.");

        // 其它的程式碼省略
    }

好了,到現在我們已經可以在 Variant 的模板建構函式與模板賦值函式裡對型別的 copy 和 move 語義進行編譯時檢查,但對 Variant 本身的 copy 與 move 語義,我們卻束手無策了。

    variant_t(const variant_t<TS...>& other)
    {
        if (other.type_ == 0) return;

        copy_[other.type_ - 1](other.data_, data_);
        type_ = other.type_;
    }

因為 Variant 中當前儲存的型別 type_ 只有在執行時才能知道,因此如果使用者將一個儲存了 non-copyable 物件的 Variant 物件賦值給另一個相同型別的 Variant,此時執行的將會是一個假的拷貝函式,一個執行時的異常將會丟擲。

剩下的問題[10.29更新,下面提到的問題已全部解決]

至此,一個簡單的 Variant 就算完成了,基本的功能都差不多具備,完整的程式碼讀者有興趣的話可以參看這裡,相應的單元測試在,除此還剩下一些比較麻煩的工作沒完成,[10.29 更新,已經支援隱式構造] 首先是隱式構造,現在的建構函式接受的引數的型別必須是模板引數列表中之一,否則會報錯,因此Variant<string, int> v("www")會編譯不過,必須改成 Variant<string, int> v(string("www"));。隱式構造雖然看起來功能簡單,但是做起來卻很麻煩,主要的問題是怎麼判斷使用者想構造哪種型別的值呢?因此需要在實現上一個型別一個型別地去檢查,因此複雜麻煩。另外一個做得不是很好的問題是型別檢查,現在拷貝構造,賦值構造,move 構造對型別檢查不是很嚴格,如果對應的型別不支援 copy 或 move 的話,出錯資訊比較難看。最後一個也算是比較大的問題是,現在的實現要求 Variant 所能儲存的值必須是 copyable & moveable,哪怕使用者從始至終都沒有用到其中的 copy 或 move,特別是 copy, 其實使用的場景非常少,大部分情況下 move 就夠了,因此實現上最好能像 vector 一樣,基本功能只要求 movable,copyable 不應該強制。

參考:

An Implementation of Discriminated Unions in C++

相關文章