本文通过5种方案实现了Pretty Printer框架。借助SFINAE非常完美的满足了Pretty Printer提出的的四点需求。即做到了全类型制霸,做到了无侵入,不需要改变用户对象的内存布局,不会触发隐式转换,不会惊吓到用户。
演讲嘉宾简介:
陶云峰(花名:陶大),阿里云高级技术专家,上海交通大学理论计算机科学博士,专注数据存储、分布式系统与计算等领域,写了20多年程序。2000年参加ACM/ICPC大赛,实现亚洲队伍进World Final前十的突破。
以下内容根据演讲嘉宾视频分享以及PPT整理而成。
本次的分享主要围绕以下四个方面:
一、SFINAE基本概念
二、Pretty Printer需求
三、实现一个Pretty Printer
四、总结
一、SFINAE基本概念
下图中一段英文是从Wikipedia上抄的SFINAE的基本概念,看起来很长,简单来说Substitution failure is nor error(SFINAE)的意思是在模版参数替换的过程中间,如果有多个候选的模版替换方案,如果当前方案不行,则替换其它候选替换方案,编译器不会报错,这是SFINAE的一个特性(feature),不是它的优化。SFINAE这个词语本身的意思就是替换失败不被认为是个错误,所以不会报错,只有当所有的替换方案都不可行时,编译器才会报错。
下面介绍一个SFINAE的简单例子,下图中的代码实现两个版本的f, #1的f会看参数中会不会有foo,是以T类型里面的子类型foo做参数,则#2的f直接拿T做参数。在主函数中调用f<Test>(10);实际上匹配的是#1,Test作为#1中的T替换进来,Test有foo,foo的类型又是int,刚好括号中10也是int,所以match的是#1。反过来看#2的T用Test替换掉后,括号中是Test,而f<Test>(10)中的10是int,是不匹配的。
在主函数中调用f<int>(10);我们首先看#1,将#1中T替换为int,那么int是否有子类型foo?没有,所以#1不匹配。将#2中T替换为int,括号里参数是int,10是int类型,所以f<int>(10);实际上调用的是#2。
二、Pretty Printer需求
首先,制定Pretty Printer的需求。我们想要实现的是无论什么对象x丢过来,只需要写prettyPrint(x),不需要其它内容,就可以得到string s, s是人们能够看懂的x对象的表示。
所以,希望Pretty Printer能够达到四点要求。第一点,通用:无论是用户自己的对象,系统的原生对象,甚至是基础类型,第三方库的对象,统统可以丢进来以同样方式转成std::string。其次,还需要Pretty Printer易扩展:意思是指用户扩展到自己的对象时,只需要修改自己的代码部分,不需要修改Pretty Printer框架的代码就可以了。第三点,要求无侵入:是指用户使用了Pretty Printer框架之后,不限制用户对象的内存布局,即不需要限制到特定的场景才可以使用框架。最后一点,无惊吓:是指用户不会因为意料之外的转换而获得奇怪的结果。
三、实现一个Pretty Printer
下面分别介绍五种实现Pretty Printer的方案。
方案一:函数重载
了解完Pretty Printer的需要,大家可能会想在标准库中ostream不是可以将所有对象转换为string类型吗?为何还要实现Pretty Printer呢?
首先,ostream的方案实际上等同于函数重载的方案。在下图代码中实现两个类A和B,并各实现A和B的prettyPrint,之后有两个对象a和b,丢到prettyPrint(a)和prettyPrint(b),打印出了A和B,结果很好。
但是这个函数重载这个方案的问题在于如果忘记实现A的prettyPrint,编译器不会报错,运行时也不会报错,唯一的变化是倒数第二行的输出结果变了,变成了B。这是因为用户不小心在struct B中写了B(const A&) {},换句话说A可以隐式转换成B,所以在倒数第二行中的a被隐式转换成了B类,然后运行了prettyPrint(const B&),所以打印出了B。
根据上面的需求,这种方案就不符合第四点,给用户造成了惊吓,即因为意料之外的转换而获得奇怪的结果。想象一下,这个Pretty Printer被用在输出错误日志上会如何?我们都知道错误日志是很难在测试时被覆盖到,因为错误往往是种意外。测试时没有错误,结果很好,等到真的发生错误时,输出的结果不对,被隐式转换成了另外的东西,如果用户顺着错误的B这个结果调查,一定会晕头转向。这便是隐式转换带来的问题。函数重载不能避免隐式转换,无论运行期还是编译期都做不了。函数重载会惊吓用户,所以需要否决掉这一方案。
方案二:虚函数
既然否决掉了标准库中的ostream这种方式,接下来我们来思考一下Pretty Printer的本质是什么?其实是一个多态的问题。因为prettyPrint函数是个多态函数,对于不同的对象有不同的行为。那么对于多态问题,OO的做法就是弄接口。对于C++来说,需要一个接口类PrettyPrinter,还需要一个成员方法prettyPrint()。然后每个实现的类都需要实现成员方法prettyPrinter()。然后prettyPrint这个函数将接口const PrettyPrinter& x传进来,不需要A本身,然后调用x.prettyPrint()这个程序方法。
这个方案是可以的,但依然存在两个问题。第一个问题就是会改变用户的内存布局,本来是一个Plain Old Data (POD)类型的对象或者是trival的对象,加了虚函数之后,必须带一个虚表,整个内存布局就有所变化,所以说对用户用侵入性。第二个问题,这个方案对已有的对象类型,比如系统原生的类型,第三方库或标准库中的类型显然不可能配合做虚函数的改动,需要做代理对象,使用起来比较复杂。总体来说,这个方案限制比较多,要特定的场景,会改变用户的内存布局,所以虚函数该方案也不可行,需要否决掉。
方案三:模版函数特化
模版函数特化是首先给一个函数声明 template<class T> string prettyPrint(const T&);
针对如string这种特殊的类型对模版进行一个特化:
template<>
inline string prettyPrint<string>(const string& x){
return x;
}
这个方案虽然看起来可行,但是模版函数特化也有自身的问题,对于某些类型的优化无法实现。如下图,字符串"abc"对应的类型是char x[n],但是n无法提前知道,这是会得到编译错误的结果。这里必须要把<4>写进去,虽然可以实现,但是这样会破坏使用界面的一致性,使得使用变得复杂。这个问题的根本在于模版函数不支持局部特化,如下图中第二段是局部特化了<int n>,在使用时也要写<4>。
所以,模版函数特化也是不可行的,但是模版函数特化方案有一个好处是会帮助用户推导模版函数里的类型,可以是下一个改进方案的基础。
方案四:模版函数+特化模版类
那么基于上面的模版函数特化方案,改进的方案就是模版函数+特化模版类。因为模版函数没有办法做局部特化,但可以通过模版类实现局部特化。
首先声明一个PrettyPrinter的模版类,对PrettyPrinter做一个<char[n]>的特化,接着用一个工具函数pp将char[n]转成string类型。然后prettyPrint方法对所有类型都是通用的,那模版函数推导出来的T去特化PrettyPrinter类,调用类中的pp方法。输出字符串常量"abc"的结果是对的。
尽管模版函数+特化模版类的方案已经能够满足上面提出的四个需求,但依然存在一些小缺陷。这个方案有时候会比较啰嗦,比方说我们知道标准库提供了vector,deque,set等一系列表示集合的类,它们的访问接口各有特色。但是Pretty Printer 对这三个类型的操作都是一样的,从头到尾拿一遍,a0,a1,…,an,的形式,那么就想这样的操作如果只做一次,不弄三次是不是可以?但是vector,deque,set这三个都不是类型,只有完全特化之后,比如 vector<int>之后才是类型。那在下图第三行实现时就需要特化T,且在PrettyPrinter后面要加<T>。
但是实现的和声明的第二行一模一样,编译器识别不出来,会报错。所以这个问题就需要借助SFINAE解决。
方案五:模版函数+特化模版类+SFINAE
SFINAE方案中做了三个改动。第一,引入了一个工具类VoidIfExists;第二,在声明PrettyPrinter时引入了一个带默认值的模版参数class E=void;最后,在特化版本的PrettyPrinter<>中做了一堆事情。下面分别来看看这三处改动都有什么效果。
第一个,在声明PrettyPrinter中,提供了带默认值的模版参数class E=void。那么在下面的prettyPrint这个函数中用PrettyPrinter做构造对象p,实际上p的类型是PrettyPrinter<T,void>,因为第二个参数void不写。
下面来看特化版本,下图中VoidIfExists<>中填的是T 的const_iterator作为参数。如果T=vector<int>,带入到参数里VoidIfExists<vector<int>::const_iterator>::Type,那么vector<int>其实是有const_iterator的,所以跟模版类VoidIfExists匹配上了,Type就定义成了void,所以整个VoidIfExists<vector<int>::const_iterator>::Type最后就变成了void。
但如果T=int,因为int没有const_iterator这个子类型,所以不匹配,根据SFINAE的定义,不匹配的话忽略掉就可以。
大家可能会问对于原始的数据类型构造Pretty Printer该怎么做?其实原理一样,甚至更加方便。而且标准库中<type_traits>也会提供各种各样的工具,比方说对int构造Pretty Printer,该如何做?<type_traits>提供了叫做is_integral的工具类,如果T是整型int,后面的value就是true,否则是false。而enable_if的第一个参数是true,则它的type就是void,如果第一个参数是false,那么整个类型就不match。
通过类型检查VoidIfExists的方法,可以为第三方库构造Pretty Printer。另外通过<type_traits>
里面提供的enable_if,is_xxxx就可以为原生类型(int,double...)造Pretty Printer。
用户自己定义的类,也需要给每个弄一个特化的Pretty Printer吗?
其实不必,用户可以很简单的在类里面实现一个prettyPrint()成员函数,至于成员函数需不需要virtual,其实不重要。
用户给了一个类T,如果其中有成员函数prettyPrint(),且这个成员函数是无参数的,返回string,是const的话则匹配下图中IsPrettyPrintable,Type会变成void。调用PrettyPrinter的pp时,只需要调用x.prettyPrint()即可。对于用户来讲,只需要在自己定义的类中实现成员函数prettyPrint()就好了。总体来说,借助方案五(模版函数+特化模版类+SFINAE)完美的解决了Pretty Printer的四种需求。
四、总结
通过5种方案实现了Pretty Printer框架,借助SFINAE我们做到了全类型制霸(原生类型,三方类型,标准库类型,用户自己定义的类型等),而且做到了无侵入,不需要改变用户对象的内存布局,不会触发隐式转换,不会惊吓到用户。实际上,Pretty Printer函数是个多态函数,但不同于用虚表的方式,称之为编译期多态。因为是编译器在类型决策时的多态,所以不会有运行期的代价,具体来讲,省掉了虚表的跳转,速度更快,而且可以兼容各种类型。但缺点是碰到不同的类型,都会生成一份不同的代码,造成代码膨胀。通常来讲,编译期多态如果用的好,还是可以在很多场景(如Pretty Printer)下是非常合适的。很多时候代码膨胀不是一个大问题,而性能,适用性是用户更加关注的问题。
本文由云栖志愿小组董黎明整理,编辑百见