从 C++98 到 C++17,元编程是如何演进的?

论坛 期权论坛 期权     
CPP开发者   2019-7-7 23:11   2970   0
(给CPP开发者加星标,提升C/C++技能)
作者:CSDN / 祁宇 (id: CSDNnews)
不断出现的C++新的标准,正在改变元编程的编程思想,新的idea和方法不断涌现,让元编程变得越来越简单,让C++变得简单也是C++未来的一个趋势。
很多人对元编程有一些误解,认为代码晦涩难懂,编译错误提示很糟糕,还会让编译时间变长,对元编程有一种厌恶感。不可否认,元编程确实有这样或那样的缺点,但是它同时也有非常鲜明的优点:
  • zero-overhead的编译期计算;
  • 简洁而优雅地解决问题;
  • 终极抽象。
在我看来元编程最大的魅力是它常常能化腐朽为神奇,帮我们写出dream code!
C++98模版元编程思想

C++98中的模版元编程通常和这些特性和方法有关:
  • 元函数;
  • SFINAE;
  • 模版递归;
  • 递归继承;
  • Tag Dispatch;
  • 模版特化/偏特化。
元函数
元函数就是编译期函数调用的类或模版类。比如下面这个例子:
  1. template
  2. struct add_pointer { typedef T* type; };
  3. typedef typename add_pointer::type int_pointer;
复制代码
addpointer就是一个元函数(模版类),元函数的调用是通过访问其sub-type实现的,比如addpointer::type就是调用add_pointer元函数了。
这里面类型T作为元函数的value,类型是元编程中的一等公民。模版元编程概念上是函数式编程,对应于一个普通函数,值作为参数传给函数,在模版元里,类型作为元函数的参数被传来传去。
SFINAE
替换失败不是错误。
  1. template
  2. struct enable_if {};
  3. template
  4. struct enable_if { typedef T type; };
  5. template
  6. typename enable_if::type
  7. foo(T  t) {}
  8. foo(1);  //ok
  9. foo('a'); //compile error
复制代码
在上面的例子中,调用foo('a')模版函数的时候,有一个模版实例化的过程,这个过程中会替换模版参数,如果模版参数替换失败,比如不符合编译期的某个条件,那么这个模版实例化会失败,但是这时候编译器不认为这是一个错误,还会继续寻找其他的替换方案,直到所有的都失败时才会产生编译错误,这就是SFINAE。SFINAE实际上是一种编译期的选择,不断去选择直到选择到一个合适的版本位置,其实它也可以认为是基于模板实例化的tag dispatch。
模版递归,模版特化
  1. template  struct fact98 {
  2.   static const int value = n * fact98::value;
  3. };
  4. template  struct fact98 {
  5.   static const int value = 1;
  6. };
  7. std::cout string> to_string(T t){
  8.     return std::to_string(t);
  9. }
复制代码
在C++17中:
  1. template
  2. std::string to_string(T t){
  3.     if constexpr(std::is_same_v)
  4.         return t;
  5.     else
  6.         return std::to_string(t);
  7. }
复制代码
这里不再需要SFINAE了,同样可以实现编译期选择,代码更加简洁。
C++元编程的库以这些库为代表,这些库代表了C++元编程思想不断演进的一个趋势:
  • C++98:boost.mpl,boost.fusion
  • C++11:boost.mp11,meta,brigand
  • C++14:boost.hana
从C++98到Modern C++,C++新标准新特性产生新的idea,让元编程变得更简单更强大,Newer is Better!

Modern C++元编程应用

编译期检查
元编程的一个典型应用就是编译期检查,这也是元编程最简单的一个应用,简单到用一行代码就可以实现编译期检查。比如我们需要检查程序运行的系统是32位的还是64位的,通过一个简单的assert就可以实现了。
  1. static_assert(sizeof(void *) == 8, "expected 64-bit platform");
复制代码
当系统为32位时就会产生一个编译期错误并且编译器会告诉你错误的原因。

这种编译期检查比通过#if define宏定义来检查系统是32位还是64位好得多,因为宏定义可能存在忘记写的问题,并不能在编译期就检查到错误,要到运行期才能发现问题,这时候就太晚了。
再看一个例子:
  1. template
  2. struct Matrix {
  3.     static_assert(Row >= 0, "Row number must be positive.");
  4.     static_assert(Column >= 0, "Column number must be positive.");
  5.     static_assert(Row + Column > 0, "Row and Column must be greater than 0.");
  6. };
复制代码
在这个例子中,这个Matrix是非常安全的,完全不用担心定义Matrix时行和列的值写错了,因为编译器会在编译期提醒你哪里写错了,而不是等到运行期才发现错误。
除了经常用staticassert做编译期检查之外,我们还可以使用enableif来做编译期检查。
  1. struct A {
  2. void foo(){}
  3.     int member;
  4. };
  5. template
  6. std::enable_if_tstd::is_member_function_pointer_v> foo(Function&& f) {
  7. }
  8. foo([] {});  //ok
  9. foo(&A::foo);  //compile error: no matching function for call to 'foo(void (A::*)())'
复制代码
比如这个代码,我们通过std::enableift来限定输入参数的类型必须为非成员函数,如果传入了成员函数则会出现一个编译期错误。
元编程可以让我们的代码更安全,帮助我们尽可能早地、在程序运行之前的编译期就发现bug,让编译器而不是人来帮助我们发现bug。
编译期探测
元编程可以帮助我们在编译期探测一个成员函数或者成员变量是否存在。
  1. template< class, class = void >
  2. struct has_foo : std::false_type {};
  3. template< class T >
  4. struct has_foo< T, std::void_t > :
  5.     std::true_type {};
  6. template< class, class = void >
  7. struct has_member : std::false_type {};
  8. template< class T >
  9. struct has_member< T, std::void_t > :
  10.     std::true_type {};
  11. struct A {
  12.     void foo(){}
  13.     int member;
  14. };
  15. static_assert(has_foo< A >::value);
  16. static_assert(has_member< A >::value);
复制代码
我们借助C++17的void_t,就可以轻松实现编译期探测功能了,这里实际上是利用了SFINAE特性,当decltype(std::declval().foo())成功了就表明存在foo成员函数,否则就不存在。
通过编译期探测我们可以很容易实现一个AOP(Aspect Oriented Programming)功能,AOP可以通过一系列的切面帮我们把核心逻辑和非核心逻辑分离。
  1. server.set_http_handler("/aspect", [](request& req, response& res) {
  2.     res.render_string("hello world");
  3. }, check{}, log_t{});
复制代码
上面这段代码的核心逻辑就是返回一个hello world,非核心逻辑就是检查输入参数和记录日志,把非核心逻辑分离出来放到两个切面中,不仅仅可以让我们的核心逻辑保持简洁,还可以让我们可以更专注于核心逻辑。
实现AOP的思路很简单,通过编译期探测,探测切面中是否存在before或者after成员函数,存在就调用。
  1. constexpr bool has_befor_mtd = has_before::value;
  2. if constexpr (has_befor_mtd)
  3.     r = item.before(req, res);
  4. constexpr bool has_after_mtd = has_after::value;
  5. if constexpr (has_after_mtd)
  6.     r = item.after(req, res);
复制代码
为了让编译期探测的代码能复用,并且支持可变模版参数,我们可以写一个通用的编译期探测的代码:
  1. #define HAS_MEMBER(member)\
  2. template\
  3. struct has_##member\
  4. {\
  5. private:\
  6.       template static auto Check(int) -> decltype(std::declval().member(std::declval()...),                  std::true_type()); \
  7.    template static std::false_type Check(...);\
  8. public:\
  9.    enum{value = std::is_same::value};\
  10. };
  11. HAS_MEMBER(before)
  12. HAS_MEMBER(after)
复制代码
具体代码可以参考这里:https://github.com/qicosmos/feather。
注:这段宏代码可以用c++20的std::is_detected替代,也可以写一个C++14/17的代码来替代这个宏:
  1. namespace {
  2.     struct nonesuch {
  3.         nonesuch() = delete;
  4.         ~nonesuch() = delete;
  5.         nonesuch(const nonesuch&) = delete;
  6.         void operator=(const nonesuch&) = delete;
  7.     };
  8.     template
  9.     struct detector {
  10.         using value_t = std::false_type;
  11.         using type = Default;
  12.     };
  13.     template
  14.     struct detector {
  15.         using value_t = std::true_type;
  16.         using type = Op;
  17.     };
  18.     template
  19.     using is_detected = typename detector::value_t;
  20.     template
  21.     using detected_t = typename detector::type;
  22.     template
  23.     using has_before_t = decltype(std::declval().before(std::declval()...));
  24.     template
  25.     using has_after_t = decltype(std::declval().after(std::declval()...));
  26. }
  27. template
  28. using has_before = is_detected;
  29. template
  30. using has_after = is_detected;
复制代码
编译期计算


编译期计算包含了较多内容,限于篇幅,我们重点说一下类型萃取的应用:
  • 类型计算;
  • 类型推导;
  • 类型萃取;
  • 类型转换;
  • 数值计算:表达式模版,Xtensor,Eigen,Mshadow。
我们可以通过一个function_traits来萃取可调用对象的类型、参数类型、参数个数等类型信息。
  1. template
  2. struct function_traits_impl{
  3. public:
  4.     enum { arity = sizeof...(Args) };
  5.     typedef Ret function_type(Args...);
  6.     typedef Ret result_type;
  7.     using stl_function_type = std::function;
  8.     typedef Ret(*pointer)(Args...);
  9.     template
  10.     struct args{
  11.     static_assert(I < arity, "index is out of range, index must less than sizeof Args");
  12.         using type = typename std::tuple_element::type;
  13.          };
  14.     typedef std::tuple tuple_type;
  15.     using args_type_t = std::tuple;
  16. };
复制代码
完整代码可以参考这里:https://github.com/qicosmos/cinatra。
有了这个function_traits之后就方便实现一个RPC路由了,以rest_rpc为例(https://github.com/qicosmos/rest_rpc):
  1. struct rpc_service {
  2.     int add(int a, int b) { return a + b; }
  3.     std::string translate(const std::string& orignal) {
  4.         std::string temp = orignal;
  5.         for (auto& c : temp) c = toupper(c);
  6.         return temp;
  7.     }
  8. };
  9. rpc_server server;
  10. server.register_handler("add", &rpc_service::add, &rpc_srv);
  11. server.register_handler("translate", &rpc_service::translate, &rpc_srv);
  12. auto result = client.call("add", 1, 2);
  13. auto result = client.call("translate", "hello");
复制代码
RPCServer注册了两个服务函数add和translate,客户端发起RPC调用,会传RPC函数的实际参数,这里需要把网络传过来的字节映射到一个函数并调用,这里就需要一个RPC路由来做这个事情。下面是RestRPC路由的实现:
  1. template
  2. void register_nonmember_func(std::string const& name, const Function& f) {
  3.     this->map_invokers_[name] = { std::bind(&invoker::apply, f,   std::placeholders::_1,   std::placeholders::_2, std::placeholders::_3) };
  4. }
  5. template
  6. struct invoker {
  7.     static void apply(const Function& func, const char* data, size_t size,
  8. std::string& result) {
  9.     using args_tuple = typename function_traits::args_tuple;
  10.          msgpack_codec codec;
  11.     auto tp = codec.unpack(data, size);
  12.          call(func, result, tp);
  13.     }
  14. };
复制代码
RPCServer注册RPC服务函数的时候,函数类型会保存在invoker中,后面收到网络字节的时候,我们通过functiontraits萃取出函数参数对应的tuple类型,反序列化得到一个实例化的tuple之后就可以借助C++17的std::apply实现函数调用了。详细代码可以参考rest_rpc。

编译期反射

通过编译期反射,我们可以得到类型的元数据,有了这个元数据之后我们就可以用它做很多有趣的事情了。可以用编译期反射实现:
  • 序列化引擎;
  • ORM;
  • 协议适配器。
以序列化引擎iguana(https://github.com/qicosmos/iguana)来举例,通过编译期反射可以很容易的将元数据映射为json、xml、msgpack或其他格式的数据。
  1. struct person{
  2.   std::string  name;
  3.   int          age;
  4. };
  5. REFLECTION(person, name, age)
  6. person p = {"tom", 20};
  7. iguana::string_stream ss;
  8. to_xml(ss, p);
  9. to_json(ss, p);
  10. to_msgpack(ss, p);
  11. to_protobuf(ss, p);
复制代码
以ORM引擎(https://github.com/qicosmos/ormpp)举例,通过编译期反射得到的元数据可以用来自动生成目标数据库的SQL语句:
  1. ormpp::dbng mysql;
  2. ormpp::dbng sqlite;
  3. ormpp::dbng postgres;
  4. mysql.create_datatable();
  5. sqlite.create_datatable();
  6. postgres.create_datatable();
复制代码
反射将进入C++23标准,未来的C++标准中的反射将更强大和易用。

融合编译期和运行期

运行期和编译期存在一个巨大的鸿沟,而在实际应用中我需要融合编译期与运行期,这时候就需要一个桥梁来连接编译期与运行期。编译期和运行期从概念上可以简单地认为分别代表了type和value,融合的关键就是如何实现type to value以及value to type。
Modern C++已经给我们提供了便利,比如下面这个例子:
  1. auto val = std::integral_constant{};
  2. using int_type = decltype(val);
  3. auto v = decltype(val)::value;
复制代码
我们可以很方便地将一个值变为一个类型,然后由通过类型获得一个值。接下来我们来看一个具体的例子:如何根据一个运行时的值调用一个编译期模版函数?
  1. template
  2. void fun() {}
  3. void foo(int n) {
  4.     switch (n){
  5.     case 0:
  6.              fun();
  7.       break;
  8.     case 1:
  9.              fun();
  10.       break;
  11.     case 2:
  12.              fun();
  13.       break;
  14.     default:
  15.       break;
  16.         }
  17. }
复制代码
这个代码似乎很好地解决了这个问题,可以实现从运行期数值到编译期模版函数调用。但是如果这个运行期数值越来越大的时候,我们这个switch就会越来越长,还存在写错的可能,比如调用了foo(100),那这时候真的需要写100个switch-case吗?所以这个写法并不完美。
我们可以借助tuple来比较完美地解决这个问题:
  1. namespace detail {
  2.     template
  3.     void tuple_switch(const std::size_t i, Tuple&& t, F&& f, std::index_sequence) {
  4.         (void)std::initializer_list {
  5.                 (i == Is && (
  6.                         (void)std::forward(f)(std::integral_constant{}), 0))...
  7.         };
  8.     }
  9. } // namespace detail
  10. template
  11. inline void tuple_switch(const std::size_t i, Tuple&& t, F&& f) {
  12.     constexpr auto N =
  13.             std::tuple_size::value;
  14.     detail::tuple_switch(i, std::forward(t), std::forward(f),
  15.                          std::make_index_sequence{});
  16. }
  17. void foo(int n) {
  18.     std::tuple tp;
  19.     tuple_switch(n, tp, [](auto item) {
  20.     constexpr auto I = decltype(item)::value;
  21.          fun();
  22.     });
  23. }
  24. foo(1);
  25. foo(2);
复制代码
通过一个tuple_switch就可以通过运行期的值调用编译期模版函数了,不用switch-case了。关于之前需要写很长的switch-case语句的问题,也可以借助元编程来解决:
  1. template
  2. auto make_tuple_from_sequence(std::index_sequence)->decltype(std::make_tuple(Is...)) {
  3.         std::make_tuple(Is...);
  4. }
  5. template
  6. constexpr auto make_tuple_from_sequence()->decltype(make_tuple_from_sequence(std::make_index_sequence{})) {
  7.         return make_tuple_from_sequence(std::make_index_sequence{});
  8. }
  9. void foo(int n) {
  10.         decltype(make_tuple_from_sequence()) tp;  //std::tuple
  11.         tuple_switch(n, tp, [](auto item) {
  12.         constexpr auto I = decltype(item)::value;
  13.                  fun();
  14.         });
  15. }
  16. foo(98);
  17. foo(99);
复制代码
这里的decltype(maketuplefrom_sequence())会自动生成一个有100个int的tuple辅助类型,有了这个辅助类型,我们完全不必要去写长长的switch-case语句了。
有人也许会担心,这里这么长的tuple会不会生成100个Lambda实例化代码?这里其实不用担心,因为编译器可以做优化,优化的情况下只会生成一次Lambda实例化的代码,而且实际场景中不可能存在100个分支的代码。

接口的泛化与统一

元编程可以帮助我们融合底层异构的子系统、屏蔽接口或系统的差异、提供统一的接口。
以ORM为例:
MySQL connect
  1. mysql_real_connect(handle, "127.0.0.1", "feather", "2018", "testdb", 0, nullptr, 0);
复制代码
PostgreSQL connect
  1. PQconnectdb("host=localhost user=127.0.0.1 password=2018 dbname=testdb");
复制代码
Sqlite connect
  1. sqlite3_open("testdb", handle);
复制代码
ORM unified connect interface
  1. ORM::mysql.connect("127.0.0.1", “feather", “2018", "testdb");
  2. ORM::postgres.connect("127.0.0.1", “feather", “2018", "testdb");
  3. ORM::sqlite.connect("testdb");
复制代码
不同的数据库的C connector相同功能的接口是完全不同的,ormpp库(https://github.com/qicosmos/ormpp)要做的一件事就是要屏蔽这些接口的差异,让用户可以试用统一的接口来操作数据库,完全感受不到底层数据库的差异。
元编程可以帮助我们实现这个目标,具体思路是通过可变参数模版来统一接口,通过policy-base设计和variadic templates来屏蔽数据库接口差异。
  1. template
  2. class dbng{
  3. template
  4. bool connect(Args&&... args){
  5.     return  db_.connect(std::forward(args)...);
  6. }
  7. template
  8. bool connect(Args... args) {
  9. if constexpr (sizeof...(Args)==5) {
  10.     return std::apply(&mysql_real_connect, std::make_tuple(args...);
  11. }
  12. else if constexpr (sizeof...(Args) == 4) {//postgresql}
  13. else if constexpr (sizeof...(Args) == 2) {//sqlite}
  14. }
复制代码
这里通过connect(Args... args)统一连接数据库的接口,然后再connect内部通过if constexpr和变参来选择不同的分支。if constexpr加variadic templates等于静态多态,这是C++17给我们提供的一种新的实现静态多态方法。
这样的好处是可以通过增加参数或修改参数类型方式来扩展接口,没有继承,没有SFINAE,没有模版特化,简单直接。

消除重复(宏)

很多人喜欢用宏来减少手写重复的代码,比如下面这个例子,如果对每个枚举类型都写一个写到输出流里的代码段,是重复而繁琐的,于是就通过一个宏来消除这些重复代码(事实上,这些重复代码仍然会生成,只不过由编译器帮助生成了)。
[code]#define ENUM_TO_OSTREAM_FUNC(EnumType)  \
      std::ostream& operator
分享到 :
0 人收藏
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

积分:110
帖子:22
精华:0
期权论坛 期权论坛
发布
内容

下载期权论坛手机APP