## C++17 中那些值得关注的特性(中) 文/祁宇 >上期我们介绍了 C++ 17 的 fold expression、constexpr if、constexpr lambda、string _ view,除此之外还有一些很棒的特性,本文将介绍 structured binding、std::invoke、std::apply、std::void _ t 和 inline variable。 ### structured binding structured binding 是 C++ 17 中引人注目的新特性,它不仅仅能方便解包 tuple 、pair 之类,还具备一定反射功能,比如可以将对象的字段也解包出来,因此很多人认为它会有助于实现 C++ 的编译期反射。 #### 基本用法 ``` auto tp = std::make_tuple(1, 2.5, 'c'); int a; double b; char c; std::tie(a, b, c) = tp; ``` structured bindings 让我们能通过 tuple、std::pair 或是没有静态数据成员的结构体来初始化变量。在 C++ 17 之前如果要解包一个结构体,我们需要借助 tie,像这样: ``` auto tp = std::make_tuple(1, 2.5, 'c'); auto [a, b, c] = tp; ``` 现在有了 C++ 17 的 structured bindings,我们可以很方便地解包 tuple 了。遍历 map 时也不用像以前一样写 pair 然后 it->first,it->second 了,而是用更加简洁的方式: ``` std::map mp = { {1, 2}, {3, 4} }; for(auto&& [k, v] : mp) std::cout<<"key: "< ``` 更酷的是你可以用来解包一个结构体。 ``` struct Foo { int i; char c; std::tuple d; std::map e; }; std::tuple tp(2.3); Foo f { 1, 'a', tp, {{1,2}} }; auto& [i,c,d, e] = f; std::cout< ``` 需要注意的是,必须提供和结构体或 tuple 字段个数相同的变量,否则会出现编译错误,而且不能像 std::ignore 一样忽略某些字段,必须全部解包出来。解包时可以选择引用或拷贝,auto& 就是引用方式解包。 #### 特殊用法 我们可以借助这个特性来做一点有趣的事,比如把一个结构体变成一个 tuple。 ``` template decltype(void(T{std::declval()...}), std::true_type{}) test_is_braces_constructible(int); template std::false_type test_is_braces_constructible(...); template using is_braces_constructible = decltype(test_is_braces_constructible(0)); struct any_type { template constexpr operator T(); // non explicit }; template auto to_tuple(T&& object) noexcept { using type = std::decay_t; if constexpr(is_braces_constructible{}) { auto&& [p1, p2, p3, p4] = object; return std::make_tuple(p1, p2, p3, p4); } else if constexpr(is_braces_constructible{}) { auto&& [p1, p2, p3] = object; return std::make_tuple(p1, p2, p3); } else if constexpr(is_braces_constructible{}) { auto&& [p1, p2] = object; return std::make_tuple(p1, p2); } else if constexpr(is_braces_constructible{}) { auto&& [p1] = object; return std::make_tuple(p1); } else { return std::make_tuple(); } } //test code struct s { int p1; double p2; int mp3[2]; std::map v4; }; std::map v = {{"a", 2}}; auto t = to_tuple(s{1, 2.0, {2,3}, v}); ``` 实现的思路很巧妙,先判断某个类型是否由指定个数的参数构造,is _ braces _ constructible{} 就是判断 type 是否能由一个参数构造;接着通过 constexpr if 在编译期选择有效的分支,其他的分支将会被丢弃;最后通过 structured bindings 来获取结构体字段的值,并将这些值重新构造一个 tuple,这样就可以实现将结构体转换为 tuple 了。上面的例子中由于有四个字段,所以会走第一个分支。需要注意的是,这个方法只是一个示例,还没解决构造函数被 delete 的问题,还有隐式转换的问题,如果你想看更完整的解决方案可以看这里:http://playfulprogramming.blogspot.hk/2016/12/serializing-structs-with-c17-structured.html。 structured bindings 具备解包结构体的能力,虽然限制条件也不少,但至少展现了部分编译期反射的功能,它的用法值得深入探索。 #### std::invoke C++ 11 中引入了 callable 的概念,callable 是具有函数调用符的对象,下面这些对象就是 callable: - 普通函数 - 函数对象 - lambda - std::function - std::bind - std::result _ of - std::thread::thread - std::call _ once - std::async - std::packaged _ task - std::reference _ wrapper 可以看到 callable 的种类很多,有时候希望有一个统一调用 callable 的方法。C++ 17 的 invoke 就是用来统一调用 callable 对象的,它使我们可以以统一的方法来调用 callable,下面是一个例子: ``` #include int fun(int i){ return i+1; } struct A{ int fun(int i){ return i+1; } int member=2; }; void test_invoke(){ std::cout< a_sp = std::make_shared(); std::unique_ptr a_up = std::make_unique(); std::cout< fn = std::bind(fun, std::placeholders::_1); std::cout< ``` 可以看到 std::invoke 十分强大,不管是普通对象的成员函数还是指针或普通指针,都可以实现调用,还可以访问成员变量。 std::invoke 在调用 callable 对象上提供了统一的方法,可以让我们写出更加泛化的代码,是对 C++ 11/14 的一个小改进。 #### std::apply std::apply 让我们可以通过 tuple 来实现一个 callable 的调用,像这样: ``` std::cout< ``` 也许有人会问,既然已经有了 std::invoke 可以以统一的方式调用 callable,为什么还需要一个 apply 呢?其实 std::apply 就是通过 std::invoke 来实现的,下面是 std::apply 的实现: ``` namespace detail { template constexpr decltype(auto) apply_impl(F&& f, Tuple&& t, std::index_sequence){ return std::invoke(std::forward(f), std::get(std::forward(t))...); } } template constexpr decltype(auto) apply(F&& f, Tuple&& t){ return detail::apply_impl( std::forward(f), std::forward(t), std::make_index_sequence>>{}); } ``` 如果在 C++ 17 之前要实现 std::apply 需要写很多代码,有了 std::invoke,实现 std::apply 就变得很简单了,将 tuple 通过 index _ sequence 还原为参数,再通过 std::invoke 实现调用。为什么还需要 std::apply,是因为它可以帮助我们实现延迟调用,这很重要,因为有很多时候我们需要将参数暂时保存起来或者逐步“组装”起来,在后面需要的时候再调用。modern C++ 中 tuple 是最好的保存参数的对象,因此很有必要实现一个直接通过 tuple 实现函数调用的 apply。 ``` std::void_t ``` C++ 17 中的 std::void _ t 实际上是一个比较特殊的变参别名模版,它的定义如下: ``` template using void_t = void; ``` 这个别名模版的特殊之处在哪里呢,就是在变参里,这个变参所代表的模版参数实际上在任何时候都不会被用到。在 C++ 11 中,对于模版别名中没有用到的模版参数是不保证参与 SFINAE 的,也就是说在 C++ 11 中这个 void _ t 是没有作用的。 但是到了 C++ 14,情况发生了改变,C++ 14 中模版别名中的模版参数都会参与 SFINAE,但是 C++ 14 中并没有 std::void _ t,虽然可以很容易定义一个 void _ t。 std::void _ t 的主要作用什么呢?说简单一点就是它为了方便探测类型、成员和表达式的。通过它我们可以很方便地探测某个成员是否存在,某个方法是否存在。通过一个例子我们来看看 void _ t 的作用: ``` template< class T, class = void > struct has_member : std::false_type { }; template< class T > struct has_member< T , void_t< decltype( T::member ) > > : std::true_type { }; class A { public: int member; }; static_assert( has_member< A >::value , "A" ); static_assert( has_member< int >::value , "error" ); ``` 上面的例子中 void _ t 用来探测是否存在某一个成员。第二个 static _ assert 会发生编译期断言错误,因为 int 没有一个叫 member 的成员。让我们来分析一下这个过程。 首先我们定义了一个基本的 has _ member,我们称其为基础版本,它有两个模版参数,需要注意的是它的第二个模版参数,是一个默认模版参数。当实例化 `has_member<A>` 时,由于只指定了第一个模版参数,第二个模版参数会使用默认的模版参数 void,这时第二个模版参数就确定了,会生成一个特化的 `has_member>`,由于 A 存在 member,所以这个类型推导是成功的,等价于 `has_member`,它比基础版本的 `has_member` 更加特化,所以 `has_member>` 被选择了。反之如果没有成员 member 的时候,这个模版实例化就失败了,编译器会选择基础版本。 需要注意的是,如果你把基础版本的 has _ member 中的默认模版参数改成int,会导致 `has_member>` 偏特化失败,因为第二个模版参数在实例化 `has_member<A>` 就已经确定了,偏特化的 has _ member 的第二个模版参数必须是 void,否则就不是 `has_member` 的一个特化版本了,会被丢弃,从而会选择基础版本的 has _ member。 我们还可以用 void _ t 来探测某个类型是否存在,思路是一样的: ``` template< class T, class = void > struct has_type_member : std::false_type { }; template< class T > struct has_type_member< T , void_t< typename T::type > > : std::true_type { }; class A { public: using type = int; }; static_assert( has_member< A >::value , "A" ); static_assert( has_member< int >::value , "error" ); ``` 再来看一个借助 void _ t 来探测是否存在某个成员函数的例子: ``` class AA { public: int func(){return 0;} }; template< class T, class U = void> struct has_func : std::false_type { }; template< class T > struct has_func< T , std::void_t< decltype(std::declval().func()) > > : std::true_type { }; static_assert(has_func::value, "error"); ``` 由于 void _ t 是一个可变模版参数的别名模版,所以它支持任意个类型,我们还可以增加更多的类型推导到 void _ t 中。 ``` template< class T, class U = void> struct has_types : std::false_type { }; template< class T > struct has_types< T , std::void_t< AA::type, AA::ref_type > > : std::true_type { }; static_assert(has_types::value, "error"); ``` 这么棒的一个特性如果想在 C++ 11 中使用该怎么做呢,其实也比较简单,想办法让别名模版中的可变模版参数参与类型推导就行了,像这样: ``` template struct make_void { typedef void type;}; template using void_t = typename make_void::type; ``` void _ t 简化了 enbale _ if,相比 C++ 98/03 可以让我们少写很多代码,比如上面的例子中探测某个类是否存在 func 方法,在 C++ 98/03 中你不得不这样写: ``` template struct has_member_foo { template struct SFINAE {}; template static char check(SFINAE*); template static int check(...); static const bool value = sizeof(check(0)) == sizeof(char); }; ``` 这个实现和 void _ t 实现的版本相比较不仅仅代码更多,不直观,还存在诸多限制,比如限定了返回类型,而 void _ t 的版本因为借助了 decltype 不会限定返回类型,更加灵活。 #### inline variable C++ 17 中的 inline variable 算是一个不错的改进,我们知道 C++ 中有 inline function,在一个头文件中定义一个全局的 inline function,这个头文件被多个 cpp 文件包含的时候,这个内联函数会被每一个 cpp 文件拷贝一份。然而 C++ 17 之前只有 inline 函数的概念却没有 inline 变量的概念,这导致了我们使用全局变量的时候有一些限制,比如不能让多个 cpp 文件包含一个全局变量,会导致编译错误。现在 inline variable 弥补了这一不足,可以像定义 inline 函数一样定义 inline 变量了,语义是相似的。有了 inline variable 可以让我们放心地写 header only 代码了,不必再担心头文件中全局变量的问题了。 除此之外,inline variable 还能帮助我们简化静态成员变量的写法。在 C++ 17 之前我们写一个类的静态成员变量要像这样: ``` struct person{ static int val; }; int person::val = 0; ``` 静态成员变量还必须在类之外去定义一次,有了 inline variable 就不必在这样做了。 ``` struct person{ static inline int val; }; ``` 无需再在类外定义一次,代码更简洁了。 ### 总结 本文介绍了 C++ 17 的 structured binding、std::invoke、std::apply、std::void _ t 和 inline variable。structured binding 方便我们解包 tuple、pair 和类成员还具备一定的反射能力,是一个不错的改进;std::invoke 提供了一个通用地调用可调用对象的方法,而 std::apply 则可以通过 tuple 实现函数调用,便于实现延迟调用;std::void _ t 则简化了 sfinae,让我们探测对象的成员(成员变量、成员函数和类型)变得更简单,并且具备良好的扩展性;inline variable 则解决了全局变量被多个 cpp 文件包含的问题,方便我们写 header only 的库。