《C++0x漫谈》系列之:Concept, Concept!
By 刘未鹏(pongba)
C++的罗浮宫(http://blog.csdn.net/pongba)
《C++0x漫谈》系列导言
这个系列其实早就想写了,断断续续关注C++0x也大约有两年余了,其间看着各个重要proposals一路review过来:rvalue-references,concepts,memory-model,variadic-templates,template-aliases,auto/decltype,GC,initializer-lists…
总的来说C++09跟C++98相比的变化是极其重大的。这个变化体现在三个方面,一个是形式上的变化,即在编码形式层面的支持,也就是对应我们所谓的编程范式(paradigm)。C++09不会引入新的编程范式,但在对泛型编程(GP)这个范式的支持上会得到质的提高:concepts,variadic-templates,auto/decltype,template-aliases,initializer-lists皆属于这类特性。另一个是内在的变化,即并非代码组织表达方面的,memory-model,GC属于这一类。最后一个是既有形式又有内在的,r-value references属于这类。
这个系列如果能够写下去,会陆续将C++09的新特性介绍出来。鉴于已经有许多牛人写了很多很好的tutor(这里,这里,还有C++标准主页上的一些introductive的proposals,如这里,此外C++社群中老当益壮的Lawrence Crowl也在google做了非常漂亮的talk)。所以我就不作重复劳动了:),我会尽量从一个宏观的层面,如特性引入的动机,特性引入过程中经历的修改,特性本身的最具代表性的使用场景,特性对编程范式的影响等方面进行介绍。至于细节,大家可以见每篇介绍末尾的延伸阅读。
Concept
好吧好吧,我承认我跳票了,上次说这次要写variadic templates的。但g9老大写了一篇精彩的散记,让我觉得concept应该先写,因为这实在是个有意思的特性,比variadic templates有意思多了。
我和Concept不得不说的事
事儿#1
看看下面这坨代码有什么问题:
std::list<int> li;
std::sort(li.begin(), li.end());
如果对人肉编译不在行的话,可以用你手头的编译器试一下。你会发现,你的编译器一碰到这简单而无辜的两行代码便会一反常态,跟个长舌妇似的吐出一大堆&#$@*^,令人牙酸的错误信息来。在使用C++模板库时这种编译错误井喷是家常便饭,动辄喷出令人应接不暇的4K字节的错误信息出来。你还以为不是编译器井喷,而是你自己RP井喷了,于是一脸无辜地跑去问模板达人,后者抬了抬眼皮,告诉你说“把list改成vector因为list的iterator不是random的而std::sort需要random的iterator”,你一边在脑子里给这句话分词加标点符号一边想弄明白他是怎么从一堆毛线似的字符里抽象出这么个结论的。
实际上,这个问题比你想像得严重,其根本问题在于降低工作效率,你得在你本不需要花工夫的地方(人肉解析编译错误)花工夫;这个问题比你想像得普遍,乃至于居然有人把“能够独立地解决所有的编译与链接问题”也列在了“有实际开发工作经验”要求里面;这个问题比你想像得影响恶劣,因为你可以想像可怜的新手在两行貌似无辜的代码面前哭丧脸的模样——C++编译器就这样把一个可怜的潜在C++用户给扼杀了。你也可以想像为什么有那么多人不喜欢C++模板——其实语法只是其一个非主要的方面。
实际上你请教的那个达人并没有什么火星抽象能力,只不过是吃过的桥比你走过的盐还多而已。而这,还预示着另一个问题,就是能人肉解析模板编译错误居然也成为了衡量C++达人与否的一个标准…不信你去各个坛子上转一转看看有多少帖子是询问关于编译错误的问题的,其中又有多少是关于模板编译错误的。
更小概率的是居然还存在一个专门解析STL相关错误信息的“STL错误解码器”——STLFilt。这玩意帮你把编译错误转换成人能识别的自然语言,不错是不错。可惜STLFilt有了,BoostFilt呢?ACEFilt呢?我自己写的模板库呢?…
其实,造成这个问题的直接原因是C++的类型系统的抽象层次太低。C++的静态强(也有人说C++的类型系统其实是弱类型系统,anyway)类型系统所处的抽象层面是在基本类型(int、double、char…)层面的。一方面,C++虽然拥有对自定义类型的上乘支持(比如,支持将自定义类型的接口装扮得跟内建类型几乎毫无二致——vector vs. build-in array),然而另一方面,C++的类型系统却对于像vector这样的抽象从语意上毫不知情。直接的后果就是,一个高层的类型错误往往以相差了十万八千里的底层类型错误表现出来,结果就是你得充当一次福尔摩斯,从底层错误一直往上回溯最终找到问题的发生点。譬如一开始给出的那个例子:std::sort(li.begin(), li.end());的错误,如果C++类型系统的抽象层能高一些的话(所谓抽象层次高,就是知道高层抽象概念(Concept)的存在,如“随机迭代器”这个概念),给出的错误无非就是:“list的迭代器不满足随机迭代器这个概念(concept)的要求(requirements)”。然而由于C++并不知道所谓concept的存在,所以问题到它眼里就变成了“找不到匹配的operator+…”一堆nonsense。
事儿#2
大二上学期的时候我们上一门计算方法的课程,期末考试要写一些矩阵算法。地球上的程序员大抵都知道矩阵算法不用Matlab算基本等于没事找抽,一大堆accidental complexities在那恭候着,一个index错误能让你debug到抓狂。当时我C++用得半斤八两,模板七窍也差不多通了六窍;为了到上机考试的时候节省点时间,就事先写了一个简单的矩阵库,封装了一些基本的操作和像高斯消元这种基本算法。
那个时候你能指望我知道TDD?还是XP?或者STLLint?于是呢?写了一个简单的程序,简单使用了一下写好的库,发现编译通过后就兴冲冲地告诉哥们说:大家不用怕,有我这Matrix库罩着,写算法跟写伪码差不到哪去!
两天后上机考试,程序不同了,等于测试用例不同了,结果原来没有出现的编译错误一下统统跑出来了。原来为什么不出现?一个原因是原来有些成员函数就没用到,C++说,在一个模板类里面,没用到的成员函数是不予编译的。那不予编译就代表不予纠错吗?不予类型检查吗?令人悲伤的是,的确如此。或者把置信度提高一点说,几乎如此。为什么?看看下面的代码:
template<typename T>
void f(T& t)
{
t.m();
}
你说编译器看着这个函数,它怎么做类型检查?它怎么知道t上面有没有成员函数m?它连t的类型都不知道。“很久很久以前,模板就是这样破坏模块式错误检查的…”
实际上,C++98那会,为了能够尽早尽量检查模板代码中的隐患,以响应“防范胜于救灾,隐患重于明火”的号召,C++甚至将模板上下文中的代码中的名字生生分成了两类,一类叫dependent names,一类叫non-dependent names。举个例子,上面那段代码中的m成员函数就是dependent的,因为它的隐含this参数t的类型是dependent的;对于dependent name,不作类型检查——原因刚才讲过,因为类型信息根本就没有。剩下的就是non-dependent names了,比如:
void g(double); // #1
template<typename T>
void f()
{
g(1);
}
void g(int); // #2
int main()
{
f<int>();
}
这里f里面调用的g绑定到哪呢?答案是#1。因为g是个non-dependent name(虽然它位于模板函数(上下文)里面)。而对于non-dependent name,还是赶紧进行类型检查和名字绑定吧,有错误的话也能早点暴露出来,于是g便在它的使用点“g(1)”处被查找绑定了——尽管#2处的g(int)是一个更好的匹配,但在g(1)处只有g(double)是可见的,所以g(double)被编译器看中了,可怜的g(int)只能感叹“既生g(int),何生g(double)…”。
这,便是臭名昭著的腰斩…sorry…是二段式名字查找,C++著名的复杂性来源之一。说它臭名昭著还有一个原因——在众多编译器支持良莠不齐的C++复杂特性中,它基本可以说是位居第一(第二估计要留给友元声明了),VC挣扎到8.0还是没有实现二段式名字查找,而是把所有的工作留到模板实例化点上进行,结果就是上面的例子中会选中#2。
D&E中对此亦有详细介绍。
实际上,这个二段式名字查找的种种问题正从一个侧面证明了早期类型检查是何等重要,动态语言的老大们在Ruby翻出来的旧瓶新酒Duck Typing上吵翻了天其实说的也是这个问题(sorry,要加上“之一”)。
事儿#3
在一个无聊的午后,我在敲打一坨代码,这是一个算法,算法要用到一个容器,算法是用模板来实现的:
template<typename ContainerT>
void XXXAlgo(ContainerT cont)
{
… cont.
在我敲打出“cont”加点号“.”之后,我习惯性地心理期待着“智能”的IDE能够告诉我cont上面有哪些成员函数,正如我们每次敲打出“std::cout.”之后一样。习惯成自然,你能说我不对么?难道你金山糍粑用久了不也一样在读影印版纸书遇到不认识单词的时候想着把手指头伸过去指着那个单词等着跳出个词条窗口来?难道只是我?咳咳…
问题是,我知道XXXAlgo的那个模板参数ContainerT是应当符合STL的Container概念(concept)的,我当然希望编译器也能知道,从而根据Container概念所规定它必须具有的成员函数来给我一个成员函数列表提示(begin,end,size…),难道这样的要求很过分吗?它没有道理很过分啊,觉得它很过分我会说的啊,不可能它明明不过分我偏要说它很过分,他很过分我偏要说它不过分啊…你觉得这要求过分你就说嘛…咳…乱敲键盘是不好滴,键帽掉下来砸到花花草草也不好啊…你看,“.”键又给你磨平了…
一方面,程序员一脸无辜地认为IDE应该能够看到代码里面的ContainerT暗示着这是一个符合STL的Container概念的类型。而另一方面IDE厂商却也是理直气壮:写个ContainerT就了不起啊,万一遇到个C过来的,写成ContT我怎么办?写成CntnrT哪?是不是要我实现一个spell checker?再说你觉得ContainerT是对应STL的Container概念的,别人还用这个单词来对应线程池呢怎么办捏?什么?他不知道“poor”怎么写管我啥事嘞?我身为一个IDE,根据既有的信息,作出这样的假设,既合情,也合理…
事儿#4(此事纯虚虚构,如有巧合,算你运气背)
一天,PM跑过来告诉你说:“嘿,猜怎么着,你写的那坨模板代码,隔壁部门人用了说很不错,希望你能把代码和文档完善一下,做成一个内部使用的库,给大家用,如何?”你心头一阵花枝乱颤:“靠!来部门这么久了,C++手段终于可以展露一下了。”于是废寝忘食地按照STL文档标准,遵照C++先贤们的教诲,写了一个漂漂亮亮的文档出来。里面Concept井井有条,Requirements一丝不苟…
动态语言的老大们常挂在嘴边的话是什么?——需求总是在变的。又一天,你发现某个Concept需要revise了,比如原来的代码是这样的:
template<typename XXX>
void f(XXX a)
{
…
a.m1();
}
本来XXX所属的那个Concept只要求有m1成员函数。后来因需求变更,XXX上需要一个新的成员函数m2。于是你的代码变成了:
template<typename XXX>
void f(XXX a)
{
…
a.m1();
…
a.m2();
}
但仅改代码是不行的,文档里面关于XXX所属的那个Concept的描述也要同步修改…可惜天色已晚,良宵苦短,你准备睡一觉明天再说…结果第二天一早你就被boss叫去商量一个新的项目(因为你最近表现不错),于是你把这事给忘了。于是跟代码不一致的文档就留在那里了…
这种文档和代码不一致的情况太常见了,根本原因是因为代码和文档是物理上分离的,代码不能说谎,因为要运行,但文档呢?什么东西能验证文档精确反映了代码呢?除了往往忽视文档的程序员们之外没有其他人。这个问题是如此广泛和严重以至于程序员们干脆就近乎鸵鸟式地倡导“代码就是文档”了,这句话与其说是一个陈述句,不如说是一个美好的愿景(远景?)。
好吧,好吧,你记性好,这点小事你不会忘掉,第二天一早你就把文档给改了,你真是劳模。可惜过一天,需求居然又改变了(你心说是哪个家伙负责客户需求分析的?!),这下你需要修改Concept继承体系了…
你看,可能造成文档和代码脱节的因素太多了,一般一段时间以后,能说得上话的也就剩代码,文档只能拿来看看“系统应该是什么样子的”,只有代码才陈述了“系统实际是什么样子的”。
然而,如果文档就在代码当中呢?不,我不是说注释,你又不是不知道要写出合格的注释比写出合格的小说还要难。我是说,代码就是文档文档就是代码…
此外,把Concept约束写在代码里面还有一个好处就是能够使得被调用函数和调用方之间的契约很明显,Concept的作用就像门神,告诉每一个来调用该函数的人:“你要进去的话必须满足以下条件…”。Ruby的Duck Typing被诟病的原因之一就是它的Concept在代码里面是隐式的,取决于对象上的哪些方法被使用到了。
事儿#5
重构重不重要?Martin Fowler叔叔笑了…
原来我抽屉里有这么一段代码:
template<typename XXXConcept>
void foo(XXXConcept t)
{
…
t.m1(); // #1
…
}
template<typename XXXConcept>
void bar(XXXConcept t)
{
…
t.m1(); // #2
…
}
现在我想对代码作一种最简单的重构——改名。m1这个名字不好听,我想改成mem。于是我指望编译器能替我完成这个简单的任务,我把鼠标指到#1处,在m1上右击,然后重命名m1为mem。同时很显然我期望“智能”的编译器能够帮我把#2处也改过来,因为它们用的是同一个concept上的成员函数。
但编译器不干,原因见事儿#3。或者见这篇blog,后者举了一个类似的例子——如果我们重命名实现了那个XXXConcept的类上的m1方法,那么#1和#2处的调用能被自动重命名吗?Ruby Refactoring Browser的作者笑了…
事儿#6
很久很久以前…我写了一个容器类。这个容器类里面该有的功能都有了…唯一的问题是,当时我还不知道STL(准确地说是就算知道也没用),结果呢?这个各方面功能都完备的容器类的使用界面(接口)并不符合STL容器的规范。比如我把begin()叫做start(),把end()叫做…还是叫做end()(不然总不能叫finish()吧?)。我还把empty()叫做isEmpty()了…而另一方面我的empty()实际却做的是clear()的工作…
后来,我又写了一个算法,这个算法是针对STL容器的,你问我干嘛不针对迭代器编程?很简单,因为我要用到empty、front/back、clear等成员函数。基于迭代器编写也…