實現一個 Variant

不少時候咱們但願可以用一個變量來保存和操做不一樣類型的數據(好比解析文本建立 AST 時保存不一樣類型的結點),這種需求能夠經過繼承來知足,但繼承意味着得使用指針或引用,除了麻煩和可能引發的效率問題,該作法最大的不便還在語義上,指針和引用都不是值類型。因而咱們想到 union,union 對簡單類型來講是很好的解決思路,它的出現自己也是爲了解決這個問題,只是它究竟是 C 語言世界裏的東西,在 C++ 裏面它無法很好的支持用戶自定義的類型,主要緣由是它不能方便和智能地調用自定義類型的構造和析構函數,即便是到了 c++11 也無法很好解決。html

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

固然只說不練是不夠的,Andrei 的實現是基於年代久遠的 c++ 98/03,不少東西實現起來很不方便,而如今咱們有了 c++11,到了能夠用新武器來解決舊問題的時候了(正好標準庫裏又沒這個東西)。git

使用場景

個人實現但願能全面模仿 boost 裏的 Variant,所以它的使用要求其實很是的簡單:github

  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++

相關文章
相關標籤/搜索