随笔- 54  评论- 178  文章- 0 

实现一个 Variant

很多时候我们希望能够用一个变量来保存和操作不同类型的数据(比如解析文本创建 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++

posted on 2015-10-26 18:02  twoon  阅读(...)  评论(...编辑  收藏