Modern C+元编程应用(二)

编译期检查

元编程的一个典型应用就是编译期检查,这也是元编程最简单的一个应用,简单到用一行代码就可以实现编译期检查。比如我们需要检查程序运行的系统是32位的还是64位的,通过一个简单的assert就可以实现了。

当系统为32位时就会产生一个编译期错误并且编译器会告诉你错误的原因。

这种编译期检查比通过#if define宏定义来检查系统是32位还是64位好得多,因为宏定义可能存在忘记写的问题,并不能在编译期就检查到错误,要到运行期才能发现问题,这时候就太晚了。

再看一个例子:

在这个例子中,这个Matrix是非常安全的,完全不用担心定义Matrix时行和列的值写错了,因为编译器会在编译期提醒你哪里写错了,而不是等到运行期才发现错误。

除了经常用static_assert做编译期检查之外,我们还可以使用enable_if来做编译期检查。

比如这个代码,我们通过std::enable_if_t来限定输入参数的类型必须为非成员函数,如果传入了成员函数则会出现一个编译期错误。

元编程可以让我们的代码更安全,帮助我们尽可能早地、在程序运行之前的编译期就发现bug,让编译器而不是人来帮助我们发现bug。

编译期探测

元编程可以帮助我们在编译期探测一个成员函数或者成员变量是否存在。

我们借助C++17的void_t,就可以轻松实现编译期探测功能了,这里实际上是利用了SFINAE特性,当decltype(std::declval().foo())成功了就表明存在foo成员函数,否则就不存在。

通过编译期探测我们可以很容易实现一个AOP(Aspect Oriented Programming)功能,AOP可以通过一系列的切面帮我们把核心逻辑和非核心逻辑分离。

上面这段代码的核心逻辑就是返回一个hello world,非核心逻辑就是检查输入参数和记录日志,把非核心逻辑分离出来放到两个切面中,不仅仅可以让我们的核心逻辑保持简洁,还可以让我们可以更专注于核心逻辑。

实现AOP的思路很简单,通过编译期探测,探测切面中是否存在before或者after成员函数,存在就调用。

为了让编译期探测的代码能复用,并且支持可变模版参数,我们可以写一个通用的编译期探测的代码:

具体代码可以参考这里。

注:这段宏代码可以用c++20的std::is_detected替代。也可以写一个C++14/17的代码来替代这个宏:

编译期计算

编译期计算包含了较多内容,限于篇幅,我们重点说一下类型萃取的应用。

类型计算

类型推导

类型萃取

类型转换

数值计算:表达式模版,Xtensor, Eigen, Mshadow

我们可以通过一个function_traits来萃取可调用对象的类型,参数类型,参数个数等类型信息。

完整代码可以参考这里。

有了这个function_traits之后就方便实现一个rpc路由了,以rest_rpc为例:

rpc_server注册了两个服务函数add和translate,客户端发起rpc调用,会传rpc函数的实际参数,这里需要把网络传过来的字节映射到一个函数并调用,这里就需要一个rpc路由来做这个事情。下面是rest_rpc路由的实现:

rpc_server注册rpc服务函数的时候,函数类型会保存在invoker中,后面收到网络字节的时候,我们通过function_traits萃取出函数参数对应的tuple类型,反序列化得到一个实例化的tuple之后就可以借助c++17的std::apply实现函数调用了。详细代码可以参考rest_rpc。

编译期反射

通过编译期反射,我们可以得到类型的元数据,有了这个元数据之后我们就可以用它做很多有趣的事情了。可以用编译期反射实现:

序列化引擎

ORM

协议适配器

以序列化引擎iguana来举例,通过编译期反射可以很容易的将元数据映射为json,xml,msgpack或其他格式的数据。

以ORM引擎举例,通过编译期反射得到的元数据可以用来自动生成目标数据库的sql语句:

反射将进入C++23标准,未来的C++标准中的反射将更强大和易用。

融合编译期和运行期

运行期和编译期存在一个巨大的鸿沟,而在实际应用中我需要融合编译期与运行期,这时候就需要一个桥梁来连接编译期与运行期,编译器和运行期从概念上可以简单地认为分别代表了type和value, 融合的关键就是如何实现type to value以及value to type.

modern c++已经给我们提供了便利,比如下面这个例子:

我们可以很方便地将一个值变为一个类型,然后由通过类型获得一个值。接下来我们来看一个具体的例子:如何根据一个运行时的值调用一个编译期模版函数?

这个代码似乎很好地解决了这个问题,可以实现从运行期数值到编译期模版函数调用。但是如果这个运行期数值越来越大的时候,我们这个switch就会越来越长,还存在写错的可能,比如调用了foo(100),那这时候真的需要写100个switch-case吗?所以这个写法并不完美。

我们可以借助tuple来比较完美地解决这个问题:

通过一个tuple_switch就可以通过运行期的值调用编译期模版函数了,不用switch-case了。关于之前需要写很长的switch-case语句的问题,也可以借助元编程来解决:

这里的decltype(make_tuple_from_sequence())会自动生成一个有100个int的tuple辅助类型,有了这个辅助类型,我们完全不必要去写长长的switch-case语句了。有人也许会担心,这里这么长的tuple会不会生成100个lambda实例化代码,这里其实不用担心,依赖编译器可以做优化,优化的情况下只会生成一次lambda实例化的代码,而且实际场景中不可能存在100个分支的代码。

接口的泛化与统一

元编程可以帮助我们融合底层异构的子系统、屏蔽接口或系统的差异、提供统一的接口。

以ORM为例:

Mysql connect

Postgresql connect

Sqlite connect

ORM unified connect interface

不同的数据库的c connector相同功能的接口是完全不同的,ormpp库要做的一件事就是要屏蔽这些接口的差异,让用户可以试用统一的接口来操作数据库,完全感受不到底层数据库的差异。元编程可以帮助我们实现这个目标,具体思路是通过可变参数模版来统一接口,通过policy-base设计和variadic templates来屏蔽数据库接口差异。

这里通过connect(Args... args)统一连接数据库的接口,然后再connect内部通过if constexpr和变参来选择不同的分支。if constexpr加variadic templates等于静态多态,这是C++17给我们提供的一种新的实现静态多态方法。

这样的好处是可以通过增加参数或修改参数类型方式来扩展接口,没有继承,没有SFINAE,没有模版特化,简单直接。

消除重复(宏)

很多人喜欢用宏来减少手写重复的代码,比如下马这个例子,如果对每个枚举类型都写一个写到输出流里的代码段,是重复而繁琐的,于是就通过一个宏来消除这些重复代码(事实上,这些重复代码仍然会生成,只不过由编译器帮助生成了)。

这看似是使用宏的合适场景,但是宏最大的问题是代码无法调试,代码的易读性差,但是用元编程,我们不用写这个宏了,也不用去写宏定义了。

元编程比宏更好地解决了问题。

再看一个宏的例子:

这也是宏使用的一个典型场景--复用代码段。当很多代码段都是类似的时候,只有一点点代码不同,那么就可以通过宏来避免手写这些重复代码。上面这个宏把不同的代码段func1(root_path),func2(temp_path)作为参数传进来,从而复用这个代码段。

我们可以通过一个泛型函数来替换这个宏:

事实上大部分宏能做的,元编程能做得更好,更完美!

接口易用和灵活性

还是以rest_rpc为例,我们可以注册任意类型的rpc函数,不管参数个数和类型是否相同,返回类型是否相同,这让我们的注册接口非常易用和灵活。

这里我们使用元编程帮我们擦除了函数类型:

typename Function做了类型擦除,typename function_traits::args_tuple帮我们还原了类型。

再来看另外一个例子,cinatra注册路由函数的例子:

这个例子中,用户可以增加任意切面,还可以增加缓存参数,切面和缓存参数的顺序可以是任意的,这样完全消除了用户使用接口时需需要注意参数顺序的负担,完全是自由灵活的。这里并没有使用多个重载函数做这个事情,而是借助元编程,把缓存参数过滤出来,这样就可以无视外面传入参数的顺序了。过滤参数的代码如下:

这里通过C++17的std::disjunction来判断是否存在某个类型,通过if constexpr实现编译期选择。

总结

C++新标准给元编程带来了巨大的改变,不仅仅让元编程变得简单好写了,还让它变得更加强大了,帮助我们优雅地解决了很多实际的问题。文中列举到的元编程应用仅仅是冰山一角,还有很多其他方面的应用。

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20190106G07XP700?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。

同媒体快讯

扫码关注云+社区

领取腾讯云代金券

年度创作总结 领取年终奖励