C++中的traits和SFINAE,了解一下?

同样的内容已发布到Project 1论坛。https://rpg.blue/thread-406374-1-1.html

大扎好,我系喵^3,今天为大家介绍一下【今天】的C++造轮子时与编译器玩耍用到的两个小技巧。)咳
C++是静态类型的语言,所有对象的类型要在编译期决定。这给造轮子带来了一些麻烦,比如,在编写泛型方法的时候有时需要判断目标类型是否支持对应的操作
但是,C++这语言有些问题:缺少Metaclass,语言自省能力不足,再加上编译期必须推导出所有变量的类型,导致这种“判断”不好实现。(C++的所有高级抽象原则都是在尽量不造成太大开销的情况下实现的,这也是为什么C++这么快的原因之一)
不过由于C++的模板推导能力很强大,还是可以使用一些很hack的方式实现这类要求的。
标准库提供了大量的helper设施来判断某个类型的“特性”,比如是否可平凡析构、是否是数值类型,等等。
这里我没有用到它们,而是尝试自己推导了一组,虽然实现未必漂亮但过编译还是没问题的……233

首先我们定义两个类型,Foo和Bar。

CxxTraitsSFINAE1

Foo有两个double类型的成员taroxd和kuerlulu,而Bar只有一个taroxd,还是int类型的。

CxxTraitsSFINAE2

然后写一个函数fun,并分别用Foo和Bar类型对象作为参数调用它,经过一些蜜汁操作之后,我们可以区分fun中的抽象类型Ty是不是Foo类型,以及它有没有kuerlulu这个值(通过给Foo打了一个has_kuerlulu实现的,其实可以修改一下,直接去判断它有没有这个成员)

运行输出如下

CxxTraitsSFINAE7


看懂了嘛?懂了可以Ctrl+W了,不懂的话不着急,冷静分析.webp
为了实现fun的效果,我们需要在编译的时候计算出Ty的具体类型、以及它有没有kuerlulu这个成员,并调用正确的重载函数。如果不这么做,对于Bar这个类型,是不存在Bar::kuerlulu的,因此fun过不了编译。
因为一切的判断都是静态的,是充分利用编译器类型推导的结果,所以思维方式要转变一下,这里的“C++”其实并不是跑起来的C++,更像是一个函数式语言…
(顺便一提,利用模板类型推导可以进行计算,并且被证明是图灵完全的,感兴趣的可以写个编译期阶乘之类的~小心不要把编译器给爆栈了哦)

首先做两个辅助类型

CxxTraitsSFINAE3

其实就是两个空的结构体,里面的using是在结构体内部的可见域里定义类型的别名(例如using uint = unsigned int;),并不是成员啦。
还有一个括号运算符重载,用来获得对应的布尔值true/false
注意到static_false里没有is_true,static_true里没有is_false,具体有什么用后面再说。

CxxTraitsSFINAE4

然后做一个is_foo类型来判断。这里利用了C++的模板特化
假设有一个模板结构类型is_foo<T>,然后我们特化了一个版本叫is_foo<Foo>,那么编译器在模板实例化时会选择“更完美匹配”的版本,这里的效果就是其他类型会匹配到第一个,Foo类型会匹配到第二个。
自然,如果用int实例化,is_foo<int>里面的value就定义为static_false类型了,所以在编译期【is_foo<int>::value】这个类型就是static_false
大概就是这种思路,我们用类型定义来代替运行期的“变量”的概念

现在已经搞定了fun前半部分的is_foo操作了。由于is_foo<int>::value是一个类型,is_foo<int>::value()就是默认构造了一个is_foo<int>::value类型的对象的意思,is_foo<int>::value()()就是调用了这个对象的operator(),返回一个bool值。根据这个进行if选择就好了。
我们在写泛型轮子的时候,就可以利用这类手段去针对特定类型执行对应操作。

那教练…有没有更给力的?

我们永远不会知道我们的用户会塞进来什么样的类型呀,指明类型的名字显然不是一个好主意。更好的做法是判断一个类型是否具有某种“特性”,这就是traits,可以理解为特性萃取
事实上标准库已经这么做了。比如算法库会根据你传进来的迭代器类型,查询iterator_traits里的信息,比如你这迭代器是否支持随机访问,迭代器解引用之后的类型是什么…然后选择效率最高的算法实现。没错,都是编译期决定的。

所以我们也来写一个吧。

CxxTraitsSFINAE5

foo_traits_base类型是用来保存某个信息对应的两个traits默认值的。value_type定义为Ty类型里的tag_value_type的别名,而has_kuerlulu定义为static_false类型的别名。
普通版的foo_traits<Ty>直接继承了base,而Foo这个类型可以定义自己的foo_traits<Foo>特化(通常这个定义和你的Foo类是一块提供的,我是为了更清楚的叙述逻辑才放到这里的)
还是和is_foo类似的思路,foo_traits<Bar>::has_kuerlulu就是static_false类型的别名。只有Foo这个类型的has_kuerlulu是static_true的别名。这就相当于你提供一个类型Ty,然后foo_traits这个类型就可以把Ty的一些“特性”提取出来。
而value_type我们希望它表示Ty里面的成员的类型(Foo是double而Bar是int),它是从这两个结构定义里的tag_value_type获得的。可以翻上去看看

接下来就是上面这堆东西的真正用法了。

CxxTraitsSFINAE6

这两个版本的get_kuerlulu_if是在fun里面调用的函数重载候选。我们需要根据给fun的Ty类型里有没有kuerlulu来决定调用哪个版本。所谓的SFINAE就是“替换失败不是错误”,意思是编译器用实际类型X在匹配重载的模板候选P时,如果将模板参数Ty替换为X时会导致病式(ill-formed),那么不会产生编译错误,而是将候选P从重载候选中移除。我们就利用这个规则,让编译器在没有kuerlulu时拒绝候选2,从而接受候选1,什么都不做;当Ty拥有kuerlulu时,拒绝候选1而接受候选2。

利用SFINAE有很多种形式,比如在函数参数里加一个类型,让它在特定条件下为病式,但是这样就多了一个参数,看起来很不好(编译器会不会把它优化掉另说…)
所以这里我们选择从返回值做文章。这两个版本的返回值都是void。
Sb在实例化的时候我们放上了foo_traits<Ty>::has_kuerlulu。再强调一遍,它是个类型别名,可能是static_false<>或者static_true<> (我把它们写成了模板,默认参数其实有一个Ty=void)
为了帮助理解,我们分类讨论。
假设Sb的类型是static_false<void>:
那么根据定义,对第一个版本,static_false<void>::is_false就是void的别名,函数返回值就被替换为void,完美。
对第二个版本,由于static_false<void>中不存在is_true这个定义,因此为病式,这个版本被拒绝。
假设Sb的类型是static_true<void>,类似地,第一个版本被拒绝,第二个版本匹配上了。

到此为止,就有了fun中的那套神奇操作。


我的看法:
有效吗?有效。
为什么写法这么丑,就像做数学证明题?因为这套操作并不是语言有意设计的,纯属这个语言的类型推导过于牛逼。
有更好的写法吗?比如给C++增加更多的语言特性,编译期反射啊元类啊Concepts啊…这个就要等待语言的发展了,目前C++是一个过于复杂的语言,拥有巨量的语言特性(和坑),导致这个语言极难掌握、极难精通。←“精通C++”已经成为一个梗了,类似“PHP是最好的语言”一样的存在。作为萌新的我自己连“熟练”都不敢说呢…听说有人想21天?)雾
所以再增加新特性的话必须慎之又慎(不考虑兼容性的话甚至还要砍吧),期待那群语言律师在C++20(如果不鸽的话)能拿出更棒的设计来,让这个语言写起来更靠谱、更友好。

 

发表评论