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

作者 | 祁宇

责编 | 郭芮

出品 | CSDN(ID:CSDNnews)

不断出现的C++新的标准,正在改变元编程的编程思想,新的idea和方法不断涌现,让元编程变得越来越简单,让C++变得简单也是C++未来的一个趋势。

很多人对元编程有一些误解,认为代码晦涩难懂,编译错误提示很糟糕,还会让编译时间变长,对元编程有一种厌恶感。不可否认,元编程确实有这样或那样的缺点,但是它同时也有非常鲜明的优点:

zero-overhead的编译期计算;

简洁而优雅地解决问题;

终极抽象。

在我看来元编程最大的魅力是它常常能化腐朽为神奇,帮我们写出dream code!

C++98模版元编程思想

C++98中的模版元编程通常和这些特性和方法有关:

元函数;

SFINAE;

模版递归;

递归继承;

Tag Dispatch;

模版特化/偏特化。

元函数

元函数就是编译期函数调用的类或模版类。比如下面这个例子:

addpointer就是一个元函数(模版类),元函数的调用是通过访问其sub-type实现的,比如addpointer::type就是调用add_pointer元函数了。

这里面类型T作为元函数的value,类型是元编程中的一等公民。模版元编程概念上是函数式编程,对应于一个普通函数,值作为参数传给函数,在模版元里,类型作为元函数的参数被传来传去。

SFINAE

替换失败不是错误。

在上面的例子中,调用foo('a')模版函数的时候,有一个模版实例化的过程,这个过程中会替换模版参数,如果模版参数替换失败,比如不符合编译期的某个条件,那么这个模版实例化会失败,但是这时候编译器不认为这是一个错误,还会继续寻找其他的替换方案,直到所有的都失败时才会产生编译错误,这就是SFINAE。SFINAE实际上是一种编译期的选择,不断去选择直到选择到一个合适的版本位置,其实它也可以认为是基于模板实例化的tag dispatch。

模版递归,模版特化

这是模版元编程的hello world例子,通过模版特化和模版递归实现编译期计算。

在C++98中模版元编程的集大成者的库是boost.mpl和boost.fusion,boost.mpl主要提供了编译期类型容器和算法,boost.fusion通过异构的编译期容器融合编译期和运行期计算。

上面这个例子遍历boost::fusion::vector异构容器,打印其中的string类型。

关于C++98模版元的书可以看《modern c++ design》和《c++ templates》。

Modern C++ metaprogramming编程思想

C++11中的元编程思想

Modern C++新标准对于元编程有着深刻的影响,一些新的编程思想和方法涌现,但总体趋势是元编程变得更简单了。比如C++98中的add_pointer元函数,我们需要写一个模版类:

而在C++11中我们只需要使用C++11的新特性模版别名就可以定义一个add_pointer元函数了,代码变得更简洁了。

在C++11中,元函数由模版类变为模版别名了。C++11中提供了大量元函数在type_traits库中,这样我们不用再自己写了,直接拿过来使用就行了。

C++11中另外的一个新特性variadic template可以作为一个类型容器,我们可以通过variadic templates pack访问模版参数,不需要通过模版递归和特化来访问模版参数。

通过variadic template pack让编译器帮助我们访问类型,比C++98中通过模版递归和特化来访问类型效率更高。

C++11中另外一个新特性constexpr也让我们编写元函数变得更简单了。

在C++98中:

在C++11中:

我们不再需要通过模版特化和递归来做编译期计算了,我们直接通过新的关键字constexpr来实现编译期计算,它修饰一个函数,表明这个函数是在编译期计算的,这个函数和一个普通函数看起来几乎没有分别,唯一的差别就是多了一个constexpr,比C++98的写法简单多了。

不过在C++11中constexpr的限制比较多,比如说constexpr函数中只能是个表达式,无法使用变量,循环等语句,在C++14中就去掉这个限制了,让我们可以更方便地写编译期计算的函数了。

C++14中的元编程思想

可以看到在C++14中我们写constexpr编译期计算的函数时,不必受限于表达式语句了,可以定义变量和写循环语句了,这样也不用通过递归去计算了,直接通过循环语句就可以得到编译期计算结果了,使用起来更方便了。

在C++14中除了constexpr增强之外,更重要的几个影响元编程思想的特性是constexpr, generic lambda, variable template。新标准、新特性会产生新的编程思想,在C++14里元编程的编程思想发生了重大的变化!

在2014年Louis Dionne用C++14写的一个叫Hana的元编程库横空出世,它的出现在C++社区引起震动,因为它所采用的方法不再是经典的模版元的那一套方法了,是真正意义上的函数式编程实现的。模版元在概念上是函数式编程,而Hana是第一次在写法上也变成函数式编程了,这是C++元编程思想的一个重大改变。

Boost.Hana的编程思想

通过一个例子来看Boost.Hana的编程思想:

这里我们定义了一个类型的wraper,里面只有一个子类型,接着定义这个wraper的变量模版,有了这个变量模版,我们就可以很方便的实现type-to-value和value-to-type了。

某个具体类型的变量模版就代表一个值,通过decltype这个值就能得到变量模版的类型了,有了这个变量模版,我们就可以通过Lambda写元函数了,这里的Lambda是C++14中的generic lambda,这个Lambda的参数就是一个变量模版值,在Lambda表达式中,我们可以对获取值的sub type并做转换,然后再返回变换之后的变量模版值。

这里的add_pointer元函数不再是一个模版类或者模版别名了,而是一个Lambda表达式。这里面关键的两个地方是如何把类型变为值和把值变为类型,通过C++14的变量模版就可以实现这个目标了。

Boost.Hana的目标是通过类型容器融合编译期和运行期计算,替代boost.mpl和boost.fusion!比如下面的例子:

我们既可以操作类型容器中的类型,又可以操作类型容器中的运行期的值,Hana可以帮我们很方便地融合编译期与运行期的计算。

Boost.Hana的特点:

元函数不再是类或类模版,而是lambda;

不再基于类型,而是基于值;

没有SFINAE,没有模版递归;

函数式编程;

代码更容易理解;

元编程变得更简单;

融合编译期与运行期。

以Boost.Hana为代表的元编程实现不再是经典的type level的思想了,而是以C++14新特性实现的lambda level的函数式编程思想了。

C++17元编程思想

在C++17中,元编程得到了进一步地简化,比如我们之前需要借助模版特化,SFINAE才能实现的编译期选择,现在通过if constexpr就可以很轻松的实现了。

在C++98中:

在C++17中:

这里不再需要模版特化了,也不需要拆分成多个函数了,就像普通的if-else语句一样写编译期选择的代码,简洁易懂!

在C++14中:

在C++17中:

这里不再需要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就可以实现了。

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

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

再看一个例子:

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

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

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

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

编译期探测

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

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

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

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

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

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

具体代码可以参考这里:https://github.com/qicosmos/feather。

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

编译期计算

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

类型计算;

类型推导;

类型萃取;

类型转换;

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

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

完整代码可以参考这里:https://github.com/qicosmos/cinatra。

有了这个function_traits之后就方便实现一个RPC路由了,以rest_rpc为例(https://github.com/qicosmos/rest_rpc):

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

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

编译期反射

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

序列化引擎;

ORM;

协议适配器。

以序列化引擎iguana(https://github.com/qicosmos/iguana)来举例,通过编译期反射可以很容易的将元数据映射为json、xml、msgpack或其他格式的数据。

以ORM引擎(https://github.com/qicosmos/ormpp)举例,通过编译期反射得到的元数据可以用来自动生成目标数据库的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(maketuplefrom_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库(https://github.com/qicosmos/ormpp)要做的一件事就是要屏蔽这些接口的差异,让用户可以试用统一的接口来操作数据库,完全感受不到底层数据库的差异。

元编程可以帮助我们实现这个目标,具体思路是通过可变参数模版来统一接口,通过policy-base设计和variadic templates来屏蔽数据库接口差异。

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

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

消除重复(宏)

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

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

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

再看一个宏的例子:

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

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

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

接口易用和灵活性

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

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

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

再来看另外一个例子,cinatra(https://github.com/qicosmos/cinatra)注册路由函数的例子:

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

过滤参数的代码如下:

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

总结

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

本文内容为作者自 2018 中国 C++大会演讲内容整理而来。

作者:祁宇,Modern C++开源社区purecpp.org创始人,《深入应用 C++11》作者,开源库cinatra、feather作者,热爱开源,热爱Modern C++。乐于研究和分享技术,多次在国际C++大会(cppcon)做演讲。

热 文推 荐

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

扫码关注云+社区

领取腾讯云代金券

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