10月 25, 2012

文/DoNews著名作者 刘未鹏

Joel Spolsky曾经感叹:招聘难,难于上青天(此处笔者稍加演绎:))。他有两个辛辣但不乏洞察力的断言:真正的牛人也许一辈子就投大概4次简历,这些家伙一毕业就被好公司抢走了,并且他们的雇主会给他们不赖的待遇,所以他们也不想挪窝。(刚刚去世的Dennis Ritchie就是这样一个人)而“人才”市场上能找到的大多都不是什么人才。招到这帮人轻则费钱重则把你公司搞挂。

诚然,也许没有哪个行业像IT行业这样,无形资产占据公司的绝大多数资产。拒坊间传言比尔·盖茨就曾经说过类似这样的话:只要允许我带走100个人我可以再造一个微软。这话没搜到原版出处,但是从一个侧面反映了IT公司当中智力资产所占的比例之重。

所以一个自然的推论就是,招聘也许是一个公司决策当中最最重要的一个环节。Joel Spolsky把他在这方面的观察,体会和洞见集结成了一本小册子《Smart and Gets Things Done》,开篇就挑战“产品是公司成败的关键”这个传统观念,他认为创造最适合工程师生活的环境,留下最优秀的人才才是最先最重要的一步,接下来好的产品是水到渠成的事情。国内iapp4me.com创始人郝培强正是这个理念,所以他在微博上说:

我们是小公司,工资开的不高,也不招太多的人,但是电脑都是iMac27,iMac21,Macbook pro15,基本上比很多大公司都好多了。软件没盗版,刚才photoshop的正版我也收了。中午管饭,公司备伞。哈哈。节日假正常放,从不加班,早晨11点上班,下午6点下班。我是有资格说某些大公司的员工苦逼的。

事实上,米国找个人尚且难成这样,搞得Joel还费心费力写本书语重心长地劝企业们要善待好工程师,国内找个人更是难上加难,国内高质量问答社区知乎创始人周源就曾经在知乎上分享他呕心沥血的招人历程,看完真是让人慨叹这年头找个靠谱的人多不容易(这条知乎问答还有很多精彩的跟帖):

其实从 08 年到现在,我一直想这事能不能有点窍门,或者是实用的方法,结论是几乎没有。我用过的大家都用的方法:

在水木上发贴子(有点效果)

在蓝色理想上发贴子(无效)

在技术邮件组里发贴子(无效)

买 51job/智联 最便宜的服务(有点效果)

给所有可以想到的人打电话,请他们推荐(无效)

给所有和你讨论过创业,喝过点小酒的人打电话(无效)

约前同事私下谈(有效)

我用过的大家可能没有用的方法:

上 twitter,看 XXX 的 follower,一个一个看,看他们的 twitter,博客,Google Reader 分享,想办法搞到邮件,联系,半夜电话骚扰。

上豆瓣,前端后端挑几本重量级的书,去找想看,看过,正在看这本书的人,一个一个看,看他们的活动,博客,Google Reader 分享,想办法搞到邮件,联系,半夜电话骚扰。

找同事,问他们都看什么技术博客,想办法搞到邮件,联系,半夜电话骚扰。

正是这样的不容易,才有不少公司走内部培养的办法,这里的逻辑是:一上来就招到靠谱的人太难了,但找一块靠谱的璞玉然后雕琢雕琢相对就简单很多。这倒是个办法,但这样做的人难免就陷入了纠结:培养好了,人跑了怎么办。这也不能怪招聘的公司,的确是人之常情。其实解决的办法也很简单,培养的时候进行适当引导,让员工发挥自己的主动学习能力,这样不但人得到更多成长,公司也不会觉得投入太多患得患失。所谓师傅领进门修行在个人。

但是,这仍然还是没有解决根本的问题,就是招聘真的很困难。应聘者固然觉得自己是在“海投”,大海捞针一般。而招聘者何尝不也是这种大海捞针的感觉。这就好比两个人谈恋爱,都想和对方好上,但是偏偏就聊不到一块去。

招聘真的很困难。以至于招聘者每年需要绞尽脑汁出新笔试题,以免往年的笔试题早就被人背熟了。出题很费脑子,要出的不太简单也不太难,能够滤掉绝大多数滥竽充数的但又要保证不因题目不公平而滤掉真正有能力的,要考虑审题人的时间成本就只能大多数用选择题,而选择题又是可以猜答案的(极少有人会在选了答案之后还敢在空白的地方写为什么选某答案的原因的)。更悲催的是,有些题目出的连公司的员工们自己都会做错(真的是员工们做错了吗?还是题目本身就出错了?)

笔试完了之后如果还没有被鄙视就要进入面试环节,姑且不说笔试题的种种弊端,就说面试环节,短短几个小时的面试(大多数公司也许连几个小时的面试时间都没有),既需要全面考察基本知识,又要考察编程素养,还要考察(也许最重要的)性格心态。再然后还有一项根本没法考察但却占据程序员相当一部分工作时间的:debug能力。面试官不但得找准问题,不因对方一题答对而妄下结论,也不因一题打错而就扼杀机会,还要以管窥豹,从一朵花看到整个世界,从面试人的举止言谈,分析问题的方式,甚至写程序的笔迹来观察这个人的性格,做事的方式和心态,简直是要面试官具备心理分析师的水准才行。

这厢要招人的雇主苦不堪言,那边找工作的人也是一团乱麻。绝大多数应届生直到毕业也不清楚他们想要去的公司到底需要什么样的能力,或者说,他们到底需要具备什么样的能力才能在应聘季节拥有自己的选择权。中国虽然本科教育环境差,但是同样有很多的人在本科希望整点东西出来,他们有一腔的激情和抱负,有强大的动力,但就是不知道自己需要掌握哪些技能才能满足雇主的要求,求告无门,整年整年苦闷的像没头苍蝇一样乱撞(我就收到过很多次这样的来信,他们往往很想学点东西,但又不知道哪些重要哪些不重要,到底该学到什么程度,不知道导致不确定,不确定导致决策瘫痪,干脆嘛也不动,荒废时间)。

什么叫熟练?什么又叫精通?那么扎实呢?两年的YY经验又意味着什么?能这么简单的量化吗?同样是两年的“实践”有的人能真的学到点东西,有的人也许近似一无所得。那么实习呢?很多人都一定要在简历上弄个实习经验,这个又能说明多少问题呢?大作业呢?得奖呢?有一次我面试一位同学,据简历说编译原理课的大作业得了一等奖,可我一问什么是递归下降,就傻眼了。

这个现实的结果就是,现在绝大多数应届简历而言,也许最具信息量的部分不是“精通XXX,熟悉YYY,掌握ZZZ”,不是“在UUU实习过”,也不是这个项目那个作业,反倒是越来越被认为不重要的一项:毕业学校。毕业学校本不应该是最具信息量的,它之所以最具信息量只是源于一个悲剧的事实:简历上其他条目实在信息量太少了。所以靠谱的面试者往往学会了无视简历上华而不实的内容,只相信面试的时候亲眼所见,扫两眼简历也就罢了,最后还得自己捋起袖子慢慢面。而应聘者也许也知道招聘的也不会细细纠简历上的条目,所以什么词也都敢往上捅,反正先过了HR筛简历这关再说。从经济学角度来讲,应聘者的这种策略是正确的,没有代价(因为目前似乎没有公司会去给已经申请过的人做一个诚信数据库),但至少有可能会带来巨大的收益。应聘成了博彩。而博彩式的应聘给招聘公司带来了巨大的筛选压力。简历成了摆设。

那么招聘这个关系里面的第三者——学校——所处的位置呢?学校更关心的是毕业率和就业率,这似乎是件好事,有这个为目标,那么老师们似乎应该努力让自己的学生多学点东西。可惜就业的质量似乎不是最重要的指标,此其一。其二老师本身大多数没有丰富的业界经验,根本不知道企业整整需要的人才是什么样的,可能花了精力,但却培养不出雇主真正需要的人。另一方面,老师所起的作用很多时候甚至是一个负面的作用,例如布置大作业表面上看上去是培养学生的能力,我们姑且不说抄袭,假设每个人都做了,那么大作业本身能够衡量多少东西呢?能否衡量代码质量,能否衡量团队协作能力?能否衡量交流能力?考虑到大作业用到的东西往往都是书里面现成的,大作业甚至不能衡量学习能力。而学习能力简直算是这个行业最重要的能力没有之一了。

所以,简而言之,如果把人才培养/招聘这件事情本身类比做一个项目,那么这整个项目迄今为止就是一个巨大的失败。为什么这么说呢:

1.和需求严重脱节:作为人才需求方的雇主的需求到底是什么?绝大多数应聘者都没搞清。更严重的是,这却一点都不是应聘者的错。因为雇主是stakeholder,是雇主自己的责任得去说清楚需求是什么。结果应聘者实现的不是雇主想要的,雇主想要的应聘者没有实现。

2.应聘者雇来培训自己的人根本不管事:学生交了学费,就相当于雇老师来培训自己,可培训者根本也不了解(或不关心)他的客户们的需求。这里,学生是需求方,老师则是实现方。弄清需求的职责在后者,可后者也弄不清。

3.学生自己也弄不清:学生自己既是需求方(需要特定技能),也是实现方。可他们自己也弄不清需求到底是什么。

以上三点还不是最严重的,最严重的在下面:

明白需求是什么的也不知道怎么实现:怎么去培养现代IT企业真正需要的人才?特别地,实战能力怎么培养?代码素养怎么培养?协作沟通能力怎么培养?学习能力怎么培养?就算这些都知道怎么培养,又怎么给在象牙塔里头,离催命之日还遥遥无期的学生提供足够的动力呢?而学生自己就算知道该学哪些技能,又怎么知道具体怎么着手?什么是最有效率的学习方法?又如何让自己保持学习的热情?

以上这些问题,就是当下人才培养/招聘的惨淡现状。简而言之,在雇主和学生之间,横梗着一条巨大的鸿沟,两头都很着急,两头都有动力,但就是没有方法,君住长江头妾住长江尾。像微软谷歌这样的,干脆和高校合作,直接插手本科或硕士的教育,从而保证到时有足够强的候选,某种程度上,这的确是根本解决之道,可一来这代价太大了,非一般企业承受得起,二来这影响面也太小了。

这一切,也许将在未来的5年发生根本的变化。

《Switch: How to Change Things When Change Is Hard》(中译《瞬变》)里面指出,表面上看来非常困难的改变,也许是因为根本就没有抓住要害。在书中作者通过大量案例分析和心理学研究,雄辩地指出以下几点促成改变的关键之处:

触动内心的大象:要改变的人必须要有情感层面的动力。有一些特定的方法能够比另一些方法更能对人的情感产生触动。
给出清晰、明确的目标:目标一定不能含糊,模棱两口的目标让人无所适从,导致决策瘫痪。例如最近我们组在招实习生,我在微博上发了一条招聘信息,其中提到“扎实”的系统底层知识,有同学就写信来问,怎么叫“扎实”。我傻眼了。比尔·盖茨就以目标清晰明确著称,不仅在战略制定上,“每个人桌面上都有一台PC”,而且居然还体现在招聘上——“如果你读完了TAOCP,那么就给我投简历吧”。多么清晰,明确的目标啊——虽然高了点,也许这就是比尔·盖茨至今还没被应聘邮件淹没的原因:)
给前进的道路扫清障碍:人是懒惰的,只要有借口就会不想往前。如果既有明确的目标,同时道路又直直指向目标,一览无余,只等你开始往前走,那么便没有借口,一往无前。

那么让我们对照上面看看,可以做什么?

首先,内心的大象不需要触动,中国有足够多的人足够早就开始焦虑就业的事情,只是不知道往哪使劲,这部分人如果把劲头用到正确的事情上面也许足以满足现在的IT企业人才饥渴了。至于其他人,好吧,也许身边的人开始动起来他们也会被触动。

然后是清晰、明确的目标。这一点上目前雇主们的做法可谓好坏参半,好的一点是大家都强调要有实践经验,要有团队协作精神,坏的一点就在基础知识和技能的要求方面,可谓再含糊不过了:“精通XX语言”,“扎实的XX功底”,“熟悉XX技术”,甚至看上去最具量化感的描述“X年YY经验”其实都根本说明不了多少东西,在信息量方面还不如我家门口菜市场上一家卖酥油饼的店门口挂的横幅——“三天不硬、至少六层!”。

很多朋友也许注意到一个现象,现在企业对招聘者简历的要求也在变得越来越灵活变通,例如ThoughtWorks在招聘的时候就希望招聘者能给出自己的博客地址,博客对IT行业的意义也许胜过其他所有行业,一个积累多年的技术博客比任何简历都更能说明问题。台湾的郭安定也说“为什么写技术博客对新人如此重要”。可惜这个做法也有一个弊端:并不是所有技术牛人都写博客,有人就是只干不说型的,而就算写博客,乃至动手写过一阵子的,写一个常年的博客,也远比你想象的更为困难,因为很多时候,写(说)得靠谱比做得靠谱更难。所以这个过滤器很多时候用不上。

但是这的确表明了一个思考的方向,就是寻找更具鉴别力的过滤器,Stackoverflow Careers 2.0之所以强大,是因为Joel Spolsky和Jeff Atwood这两位常年混社区的资深博主创造性地将一个人在社区的活动历史浓缩成为一系列的量化数值,由于这个历史很长期,所以鉴别力非常高。但它同样也有问题,就是对于应聘者来讲相当花费时间,而且并不是花时间(在Stackoverflow上回答问题)就一定能花到点子上。

到底什么特征才是既通用,又能够有效地鉴别高低应聘者的特征呢?这个特征必须不像博客那样难以实现,同时又必须有足够的区分度。

有的地方在要求填写简历的时候必须填上平时都访问哪些技术网站。恩,很不错的尝试,可区分度仍然还是不够,因为上网站上查东西毕竟只占现阶段大多数应届生的少数信息来源,特别是当我们看重得更多的是应届应聘者的系统性的知识基础的时候,网上的东西虽然丰富,但属于提高班,也更为琐碎,什么是更系统的知识来源呢?答案其实大家都知道——书。

我一向认为,很多时候,是否好好看完一本好书,对一个人的提升往往能达到质的区别。就算不好好看完一本好书,马马虎虎看完,只要书是真的好书,也肯定会有很大的提高。我在面试的时候就经常询问对方看过哪些技术书籍,经常上哪些网站,订哪些博客。这里头尤其数书籍这一项的区分度最高。此外,好书和坏书的差别,从本质上,就是学习效率和大方向的差别。一本烂书可以浪费你半年的时间,但一本好书却可以为你带来真正扎实的基础和开阔的视野。人们常常用“内功”来形容扎实的基础,认为学好了内功以后学什么都快,其实一点没错,好的“内功”书不仅讲清楚深刻的原理,而且指明技术的本质,刻画领域的地图。好的书抓住不变量,让人能够触类旁通。好的书不仅介绍知识,而且阐释原则,介绍那些万变不离其宗的东西。读烂书浪费时间,但读好书却节省时间。

象牙塔内的学生受到视野的限制,往往择书不慎,事倍功半,烂书不仅浪费时间,还会打击人的积极性,让人对知识心生恐惧,认为很难掌握,殊不知只是作者没有讲好(或者没有翻译好)。因此,为招聘头疼的公司完全可以给出“应聘俺们公司前必读的十本书”,也不一定要每个公司都不一样,在某个技术子领域有影响力的人,或者创始人们,可以来定义具有代表性的书单。

我们姑且把这个计划叫做“书单计划”,容易看到“书单计划”具备以下几个卓越的优点:

清晰、明确。完全可度量。

防伪:读没读过,随便一问便知。而正因为应聘者也知道这事不像实习经验可以忽悠,所以也不敢乱往简历上捅词。
不在乎是否“泄题”:书单完全公开的,无所谓,本来就是要你去读的。想背题?背书吧。真能背下来说明认真看了。
管你用心不用心读,只要读了,读完了,就有区别。真正的好书,你想不被吸引都难。据我观察很多人就是不知道该去读什么书。

不存在“怎么做”的障碍:所有人都知道怎么读书——一页一页读。

不需要招聘者投入精力:书单在此,就这么简单,您看着办。

评估的负担很大程度转移到了应聘者的身上:是不是认真看完了,有没有心得体会,您自己掂量。没看完别来找我们。
“书单计划”能很大程度上起到强鉴别器的作用,看了就是看了,必然能学到东西,没看就是没看。知道和不知道,区别是本质的。其实很多企业内部培训,根本上其实还不就是叫员工去看之前没看过的书或者资料嘛。最后,除了鉴别作用之外,它还是一个清晰促进的目标,是完全不花精力的培养。

当然,“书单计划”的背后是另一个悲剧的现实,如果不是因为这个现实,这个计划也完全没有必要,那就是,中国IT大学教育当中要求要学的书,和企业真正需要你去读的书相比,不是完全不够用,就是写的不够好,或者更悲剧的就是根本用不上,所以在这个大背景下出来的牛人都是自己淘书自己学的。微软高级开发测试工程师,《Windows用户态程序高效排错》作者熊力就在微博上说过:“我当年毕业的时候总结了一个公式:第一份工作的月薪=大学四年买过的技术书籍价格的总和。”

但是光有“书单计划”还不够,因为书籍只能管基础知识这一块,一些更难以量化衡量的实战“能力”又怎么办呢?至少目前为止,除了“练”之外好像还没有特别好的办法。可是在象牙塔里面做的项目,或大作业,真的能起到练的作用吗?前面说了,学生会知道自己最终要交差的不是雇主,而是老师,于是就以老师能够评判的标准来默认要求自己了,老师能够评判编码素养?代码风格?文档?设计?协作?甚至连著名的Joel 12条的第一条“是否用源代码管理系统”都没法通过。所以大多数时候,大作业能起到的作用近乎0。

但是如果这一切是由雇主来评判的,这个“作业”是由雇主来给出的,就完全不一样了。一想到作业是要作为简历的一部分的,能不紧张嘛。能不好好做嘛。能不学到点东西嘛?

可是这事儿能实现吗?雇主能给学生出大作业吗?也许一两个关系好的高校可以,可是中国那么多学生呢?

为什么不能呢?如果像书单那样,列出各个技术领域“推荐在学校期间尝试的项目”,至于动不动手做,那是学生自己的问题。做的,自然能够得到锻炼,面试的时候自然能得到更大的优势。

可问题是,面试的人又怎么来评估呢?这不又回到了没法有效评估的怪圈了吗?答案很简单,但这个答案,直到最近几年,才真正成为现实——

GitHub

GitHub诞生于08年春天,第一年便产生了4万6千个公共项目,大约一年半之后用户就已经达到10万用户之巨。而到今年九月份,GitHub已经迎来了百万级用户。Host超过两百万个项目。

增长的太快了!就像Twitter一样。这样疯了一般的增长只能说明一个事实——人们等待这个产品太久了。

Social CodingI

真实的项目,真实的流程,真实的人名,一切代码review, check-in, test, build, document, 甚至讨论,计划,brianstorming,流程,一切的一切,都是项目历史的一部分,都可以像棋局那样复盘。有经验的面试者只要稍稍扫两眼一个人的GitHub历史,挑出几个check-in历史看一看,便完全能够迅速判断这个人是否满足他的要求。不再需要费劲心机地去想题目,去观察,去揣测,去花费大量的时间的同时还只能采样到几个极为有限的点。

不像象牙塔里面大作业,这里有源代码管理系统,自动化build,有check-in,有review,有分工,有合作,最重要的是——这是一个集市,一个超出象牙塔的集市,牛人相互吸引,你可以在互联网上找到和自己拥有共同兴趣的一帮人,真正做起一点事情,而不是交差,不需要受限于几十个人的一个小班级。Here Comes Everybody。

为什么我这么有信心?因为这事儿已经发生了。这个想法也完全不是我原创的。

正如很多事情一样,现在在国内发生的事情,往往是美国那头的历史。今年7月中旬,纽约一家公司的工程师老大发了一篇博客文章:Github is Your New Resume。指出一个惊人但再合理不过的事实:越来越多的IT公司在招聘的时候要求应聘者给出GitHub账号。甚至已经有人为GitHub写了根据GitHub上的历史自动生成简历的工具。

仔细想想,这是必然的趋势,没有比这个再合理的事情了,既然StackOverflow的历史能够作为简历,GitHub的历史不本该就是更好的简历吗:你想要具有实战经验,懂check-in懂review懂test和代码质量的重要性,懂交流和沟通的重要性,你本就应该在一个真实的项目当中去锻炼这些东西,而这些在目前已经完全可以办到。正如邹欣老师所说,你的工作就是最好的面试。

这件事情放在早几年,是完全没法做到的,因为我们那时候还没有GitHub。正如没有Twitter,没有微博之前,很多事情都不会成为可能一样,你有千钧之力,缺乏一个合适的支点,也没法撬动一整个社群。无组织中的组织,具有强大的杠杆效应。

这个事情里面,我唯一提出的东西就是:在目前国内这个现状下,苦闷的招聘者应该主动行动,给出一些建议项目,正如前面提到的书单计划一样,招聘者需要给出的只是引导和清晰明确的目标,剩下的事情,应聘者自然会去完成,这些项目可以是实验项目,也可以是完全能做出点卖钱的东西的项目(如果好好做的话),唯一的不可或缺的前提是,项目不能太小,单人就能完成的项目不理想,一两个月就能完成的项目不理想,最好足够大到能够锻炼到方方面面,偏大一点倒是无所谓的,因为一个尚未完成的项目完全可以作为简历。当然,可以想见的是,真到了那个时候,学生们肯定又是不会满足于仅去做那些已经有许多人做过的项目了。所以这里企业们一开始所建议的项目只是一个《Nudge》,是滚雪球之前需要的一点初始动能。后面的事情,他们自己会完成。

“GitHub计划”同样有一些明显的、甚至不可替代的优点:

清晰、明确,完全可度量。
防伪:同样不担心“泄题”。你伪造不了GitHub历史,伪造不了check-in历史,review comments,文档,交流记录…
它不但是招聘,也是不花精力的培养。善哉善哉。
评估的责任很大程度上交给了应聘者自己。
从你的GitHub旅程开始,你就已经一脚踏进了真正的企业,而企业的面试也已经开始。

书单+GitHub,就相当于一个两年左右的面试。

没有什么面试比持续两年的面试更具有信息量。

书单,加上项目,已经基本上覆盖了所需的全部技能。最妙的是,有太多的人在焦急的等待着他们未来的雇主给出明确的信号,他们想投入精力,去学习和实践,去成为企业需要的人,但是他们就是不知道往什么方向走,所谓有动力没方向。所以,雇主给出了清晰明确的要求,相信对于很多人来说反倒是一个解脱:“终于知道该干什么了”。《编程之美》为什么常居畅销榜?因为它透露了雇主眼中的需求,明确、清晰的需求,可以实现,并且知道怎么去实现的需求。

你提前两年就开始面试和培养未来的候选者,而且还不需要你花出一分精力,而且人家还很乐意,没有比这更完美的面试了。

想一想,以后那些没见过世面的公司看见你拿出GitHub账号给他看,该是多么惊讶同时又觉得多么合理。

而这一切,只是因为两个小小的改变:

由需求方(雇主)给出了清晰、明确的目标。
GitHub这样的平台。
那么,学校/老师在这个事情当中的位置呢?说实话我不知道。没有哪个行业像IT行业这样特殊:没有什么东西不能够(应该)在互联网上学到的。自组织的力量完全大过传统的教育方式。而且,既然雇主都当了领路人了,我不知道还有中间开发商什么事儿。(注:这里说的是软件开发,并非计算机科学研究,后者另当别论)

那么,这个改变会发生吗?多久会发生呢?当然,它在国外已经发生了,所以问这个问题多少有点无趣。但我还是预计很快就会在国内发生,毕竟,不是已经有人要求出示博客,和经常浏览的网站了吗?也许5年左右(4年本科和6年硕士的中间值?))就会深刻改变整个人才培养/招聘的格局。当然,我并不是预言家,所以不要把我的时间估计当真,我能肯定的是,这种方式是必然的大势所趋。

刚才我就收到一位同学邀请我上知乎回答一个问题“找工作的首要原则是什么?”,当然,这个问题的答案是:“弄清雇主的需求到底是什么”。

======带个自私自利的小AD=========

欢迎向DoNews投递关于互联网业界的热点类、观点类、趣点类、分析类、爆料类稿件。地址:tougao@donews.com

转载请注明DoNews著名作者/刘未鹏

Tags: ,,.
09月 21, 2012

文/DoNews著名作者 刘未鹏

过去的一年我在微软亚洲研究院做输入法,我们的产品叫“英库拼音输入法” (下载Beta版),如果你用过“英库词典”(现已更名为必应词典),应该知道“英库”这个名字(实际上我们的核心开发团队也有很大一部分来源于英库团队的老成员)。整个项目是微软亚洲研究院的自然语言处理组、互联网搜索与挖掘组和我们创新工程中心,以及微软中国Office商务软件部(MODC)多组合作的结果。至于我们的输入法有哪些创新的feature,以及这些feature背后的种种有趣故事… 本文暂不讨论。虽然整个过程中我也参与了很多feature的设想和设计,但90%的职责还是开发,所以作为client端的核心开发人员之一,我想跟大家分享这一年来在项目中全面使用C++11以及现代C++风格(Elements of Modern C++ Style)来做开发的种种经验。

我们用的开发环境是VS2010 SP1,该版本已经支持了相当多的C++11的特性:lambda表达式,右值引用,auto类型推导,static_assert,decltype,nullptr,exception_ptr等等。C++曾经饱受“学院派”标签的困扰,不过这个标签着实被贴得挺冤,C++11的新feature没有一个是从学院派角度出发来设计的,以上提到的所有这些feature都在我们的项目中得到了适得其所的运用,并且带来了很大的收益。尤其是lambda表达式。

说起来我跟C++也算是有相当大的缘分,03年还在读本科的时候,第一篇发表在程序员上面的文章就是Boost库的源码剖析,那个时候Boost库在国内还真是相当的阳春白雪,至今已经快十年了,Boost库如今已经是写C++代码不可或缺的库,被誉为“准标准库”,C++的TR1基本就脱胎于Boost的一系列子库,而TR2同样也大量从Boost库中取材。之后有好几年,我在CSDN上的博客几乎纯粹是C++的前沿技术文章,包括从06年就开始写的“C++0x漫谈”系列。(后来写技术文章写得少了,也就把博客从CSDN博客独立了出来,便是现在的mindhacks.cn)。自从独立博客了之后我就没有再写过C++相关的文章(不过仍然一直对C++的发展保持了一定的关注),一方面我喜欢关注前沿的进展,写完了Boost源码剖析系列和C++0x漫谈系列之后我觉得这一波的前沿进展从大方面来说也都写得差不多了,所以不想再费时间。另一方面的原因也是我虽然对C++关注较深,但实践经验却始终绝大多数都是“替代经验”,即从别人那儿看来的,并非自己第一手的。而过去一年来深度参与的英库输入法项目弥补了这个缺憾,所以我就决定重新开始写一点C++11的实践经验。算是对努力一年的项目发布第一版的一个小结。

09年入职微软亚洲研究院之后,前两年跟C++基本没沾边,第一个项目倒是用C++的,不过是工作在既有代码基上,时间也相对较短。第二个项目为Bing Image Search用javascript写前端,第三个项目则给Visual Studio 2012写Code Clone Detection,用C#和WPF。直到一年前英库输入法这个项目,是我在研究院的第四个项目了,也是最大的一个,一年来我很开心,因为又回到了C++。

这个项目我们从零开始,,而client端的核心开发人员也很紧凑,只有3个。这个项目有很多特殊之处,对高效的快速迭代开发提出了很大的挑战(研究院所倡导的“以实践为驱动的研究(Deployment-Driven-Research)”要求我们迅速对用户的需求作出响应):

  1. 长期时间压力:从零开始到发布,只有一年时间,我们既要在主要feature上能和主流的输入法相较,还需要实现我们自己独特的创新feature,从而能够和其他输入法产品区分开来。
  2. 短期时间压力:输入法在中国是一个非常成熟的市场,谁也没法保证闷着头搞一年搞出来的东西就一炮而红,所以我们从第一天起就进入demo驱动的准迭代式开发,整个过程中必须不断有阶段性输出,抬头看路好过闷头走路。但工程师最头疼的二难问题之一恐怕就是短期与长远的矛盾:要持续不断出短期的成果,就必须经常在某些地方赶工,赶工的结果则可能导致在设计和代码质量上面的折衷,这些折衷也被称为Technical Debt(技术债)。没有任何项目没有技术债,只是多少,以及偿还的方式的区别。我们的目的不是消除技术债,而是通过不断持续改进代码质量,阻止技术债的滚雪球式积累。
  3. C++是一门不容易用好的语言:错误的使用方式会给代码基的质量带来很大的损伤。而C++的误用方式又特别多。
  4. 输入法是个很特殊的应用程序,在Windows下面,输入法是加载到目标进程空间当中的dll,所以,输入法对质量的要求极高,别的软件出了错误崩溃了大不了重启一下,而输入法如果崩溃就会造成整个目标进程崩溃,如果用户的文档未保存就可能会丢失宝贵的用户数据,所以输入法最容不得崩溃。可是只要是人写的代码怎么可能没有bug呢?所以关键在于如何减少bug及其产生的影响和如何能尽快响应并修复bug。所以我们的做法分为三步:1). 使用现代C++技术减少bug产生的机会。2). 即便bug产生了,也尽量减少对用户产生的影响。3). 完善的bug汇报系统使开发人员能够第一时间拥有足够的信息修复bug。

至于为什么要用C++而不是C呢?对于我们来说理由很现实:时间紧任务重,用C的话需要发明的轮子太多了,C++的抽象层次高,代码量少,bug相对就会更少,现代C++的内存管理完全自动,以至于从头到尾我根本不记得曾遇到过什么内存管理相关的bug,现代C++的错误处理机制也非常适合快速开发的同时不用担心bug乱飞,另外有了C++11的强大支持更是如虎添翼,当然,这一切都必须建立在核心团队必须善用C++的大前提上,而这对于我们这个紧凑的小团队来说这不是问题,因为大家都有较好的C++背景,没有陡峭的学习曲线要爬。(至于C++在大规模团队中各人对C++的掌握良莠不齐的情况下所带来的一些包袱本文也不作讨论,呵呵,语言之争别找我。)

下面就说说我们在这个项目中是如何使用C++11和现代C++风格来开发的,什么是现代C++风格以及它给我们开发带来的好处。

资源管理

说到Native Languages就不得不说资源管理,因为资源管理向来都是Native Languages的一个大问题,其中内存管理又是资源当中的一个大问题,由于堆内存需要手动分配和释放,所以必须确保内存得到释放,对此一般原则是“谁分配谁负责释放”,但即便如此仍然还是经常会导致内存泄漏、野指针等等问题。更不用说这种手动释放给API设计带来的问题(例如Win32 API WideCharToMultiByte就是一个典型的例子,你需要提供一个缓冲区给它来接收编码转换的结果,但是你又不能确保你的缓冲区足够大,所以就出现了一个两次调用的pattern,第一次给个NULL缓冲区,于是API返回的是所需的缓冲区的大小,根据这个大小分配缓冲区之后再第二次调用它,别提多别扭了)。

托管语言们为了解决这个问题引入了GC,其理念是“内存管理太重要了,不能交给程序员来做”。但GC对于Native开发也常常有它自己的问题。而且另一方面Native界也常常诟病GC,说“内存管理太重要了,不能交给机器来做”。

C++也许是第一个提供了完美折衷的语言(不过这个机制直到C++11的出现才真正达到了易用的程度),即:既不是完全交给机器来做,也不是完全交给程序员来做,而是程序员先在代码中指定怎么做,至于什么时候做,如何确保一定会得到执行,则交由编译器来确定。

首先是C++98提供了语言机制:对象在超出作用域的时候其析构函数会被自动调用。接着,Bjarne Stroustrup在TC++PL里面定义了RAII(Resource Acquisition is Initialization)范式(即:对象构造的时候其所需的资源便应该在构造函数中初始化,而对象析构的时候则释放这些资源)。RAII意味着我们应该用类来封装和管理资源,对于内存管理而言,Boost第一个实现了工业强度的智能指针,如今智能指针(shared_ptr和unique_ptr)已经是C++11的一部分,简单来说有了智能指针意味着你的C++代码基中几乎就不应该出现delete了。

不过,RAII范式虽然很好,但还不足够易用,很多时候我们并不想为了一个CloseHandle, ReleaseDC, GlobalUnlock等等而去大张旗鼓地另写一个类出来,所以这些时候我们往往会因为怕麻烦而直接手动去调这些释放函数,手动调的一个坏处是,如果在资源申请和释放之间发生了异常,那么释放将不会发生,此外,手动释放需要在函数的所有出口处都去调释放函数,万一某天有人修改了代码,加了一处return,而在return之前忘了调释放函数,资源就泄露了。理想情况下我们希望语言能够支持这样的范式:

void foo()

{

HANDLE h = CreateFile(…);

ON_SCOPE_EXIT { CloseHandle(h); }

… // use the file

}

ON_SCOPE_EXIT里面的代码就像是在析构函数里面的一样:不管当前作用域以什么方式退出,都必然会被执行。

实际上,早在2000年,Andrei Alexandrescu 就在DDJ杂志上发表了一篇文章,提出了这个叫做ScopeGuard 的设施,不过当时C++还没有太好的语言机制来支持这个设施,所以Andrei动用了你所能想到的各种奇技淫巧硬是造了一个出来,后来Boost也加入了ScopeExit库,不过这些都是建立在C++98不完备的语言机制的情况下,所以其实现非常不必要的繁琐和不完美,实在是戴着脚镣跳舞(这也是C++98的通用库被诟病的一个重要原因),再后来Andrei不能忍了就把这个设施内置到了D语言当中,成了D语言特性的一部分(最出彩的部分之一)。

再后来就是C++11的发布了,C++11发布之后,很多人都开始重新实现这个对于异常安全来说极其重要的设施,不过绝大多数人的实现受到了2000年Andrei的原始文章的影响,多多少少还是有不必要的复杂性,而实际上,将C++11的Lambda Function和tr1::function结合起来,这个设施可以简化到脑残的地步:

class ScopeGuard

{

public:

explicit ScopeGuard(std::function onExitScope)

: onExitScope_(onExitScope), dismissed_(false)

{ }

~ScopeGuard()

{

if(!dismissed_)

{

onExitScope_();

}

}

void Dismiss()

{

dismissed_ = true;

}

private:

std::function onExitScope_;

bool dismissed_;

private: // noncopyable

ScopeGuard(ScopeGuard const&);

ScopeGuard& operator=(ScopeGuard const&);

};

这个类的使用很简单,你交给它一个std::function,它负责在析构的时候执行,绝大多数时候这个function就是lambda,例如:

HANDLE h = CreateFile(…);

ScopeGuard onExit([&] { CloseHandle(h); });

onExit在析构的时候会忠实地执行CloseHandle。为了避免给这个对象起名的麻烦(如果有多个变量,起名就麻烦大了),可以定义一个宏,把行号混入变量名当中,这样每次定义的ScopeGuard对象都是唯一命名的。

#define SCOPEGUARD_LINENAME_CAT(name, line) name##line

#define SCOPEGUARD_LINENAME(name, line) SCOPEGUARD_LINENAME_CAT(name, line)

#define ON_SCOPE_EXIT(callback) ScopeGuard SCOPEGUARD_LINENAME(EXIT, __LINE__)(callback)

Dismiss()函数也是Andrei的原始设计的一部分,其作用是为了支持rollback模式,例如:

ScopeGuard onFailureRollback([&] { /* rollback */ });

… // do something that could fail

onFailureRollback.Dismiss();

在上面的代码中,“do something”的过程中只要任何地方抛出了异常,rollback逻辑都会被执行。如果“do something”成功了,onFailureRollback.Dismiss()会被调用,设置dismissed_为true,阻止rollback逻辑的执行。

ScopeGuard是资源自动释放,以及在代码出错的情况下rollback的不可或缺的设施,C++98由于没有lambda和tr1::function的支持,ScopeGuard不但实现复杂,而且用起来非常麻烦,陷阱也很多,而C++11之后立即变得极其简单,从而真正变成了每天要用到的设施了。C++的RAII范式被认为是资源确定性释放的最佳范式(C#的using关键字在嵌套资源申请释放的情况下会层层缩进,相当的不能scale),而有了ON_SCOPE_EXIT之后,在C++里面申请释放资源就变得非常方便

Acquire Resource1

ON_SCOPE_EXIT( [&] { /* Release Resource1 */ })

Acquire Resource2

ON_SCOPE_EXIT( [&] { /* Release Resource2 */ })

这样做的好处不仅是代码不会出现无谓的缩进,而且资源申请和释放的代码在视觉上紧邻彼此,永远不会忘记。更不用说只需要在一个地方写释放的代码,下文无论发生什么错误,导致该作用域退出我们都不用担心资源不会被释放掉了。我相信这一范式很快就会成为所有C++代码分配和释放资源的标准方式,因为这是C++十年来的演化所积淀下来的真正好的部分之一。

错误处理

前面提到,输入法是一个特殊的东西,某种程度上他就跟用户态的driver一样,对错误的宽容度极低,出了错误之后可能造成很严重的后果:用户数据丢失。不像其他独立跑的程序可以随便崩溃大不了重启(或者程序自动重启),所以从一开始,错误处理就被非常严肃地对待。

这里就出现了一个两难问题:严谨的错误处理要求不要忽视和放过任何一个错误,要么当即处理,要么转发给调用者,层层往上传播。任何被忽视的错误,都迟早会在代码接下去的执行流当中引发其他错误,这种被原始错误引发的二阶三阶错误可能看上去跟root cause一点关系都没有,造成bugfix的成本剧增,这是我们项目快速的开发步调下所承受不起的成本。

然而另一方面,要想不忽视错误,就意味着我们需要勤勤恳恳地检查并转发错误,一个大规模的程序中随处都可能有错误发生,如果这种检查和转发的成本太高,例如错误处理的代码会导致代码增加,结构臃肿,那么程序员就会偷懒不检查。而一时的偷懒以后总是要还的。

所以细心检查是短期不断付出成本,疏忽检查则是长期付出成本,看上去怎么都是个成本。有没有既不需要短期付出成本,又不会导致长期付出成本的办法呢?答案是有的。我们的项目全面使用异常来作为错误处理的机制。异常相对于错误代码来说有很多优势,我曾经在2007年写过一篇博客《错误处理:为何、何时、如何》进行了详细的比较,但是异常对于C++而言也属于不容易用好的特性:

首先,为了保证当异常抛出的时候不会产生资源泄露,你必须用RAII范式封装所有资源。这在C++98中可以做到,但代价较大,一方面智能指针还没有进入标准库,另一方面智能指针也只能管内存,其他资源莫非还都得费劲去写一堆wrapper类,这个不便很大程度上也限制了异常在C++98下的被广泛使用。不过幸运的是,我们这个项目开始的时候VS2010 SP1已经具备了tr1和lambda function,所以写完上文那个简单的ScopeGuard之后,资源的自动释放问题就非常简便了。

其次,C++的异常不像C#的异常那样附带Callstack。例如你在某个地方通过.at(i)来取一个vector的某个元素,然后i越界了,你会收到vector内部抛出来的一个异常,这个异常只是说下标越界了,然后什么其他信息都木有,连个行号都没有。要是不抛异常直接让程序崩溃掉好歹还可以抓到一个minidump呢,这个因素一定程度上也限制了C++异常的被广泛使用。Callstack显然对于我们迅速诊断程序的bug有至关重要的作用,由于我们是一个不大的团队,所以我们对质量的测试很依赖于微软内部的dogfood用户,我们release给dogfood用户的是release版,倘若我们不用异常,用assert的话,固然是可以在release版也打开assert,但assert同样也只能提供很有限的信息(文件和行号,以及assert的表达式),很多时候这些信息是不足够理解一个bug的(更不用说还得手动截屏拷贝黏贴发送邮件才能汇报一个bug了),所以往往接下来还需要在开发人员自己的环境下试图重现bug。这就不够理想了。理想情况下,一个bug发生的时刻,程序应该自己具备收集一切必要的信息的能力。那么对于一个bug来说,有哪些信息是至关重要的呢?

Error Message本身,例如“您的下标越界啦!”少部分情况下,光是Error Message已经足够诊断。不过这往往是对于在开发的早期出现的一些简单bug,到中后期往往这类简单bug都被清除掉了,剩下的较为隐蔽的bug的诊断则需要多得多的信息。

Callstack。C++的异常由于性能的考虑,并不支持callstack。所以必须另想办法。

错误发生地点的上下文变量的值:例如越界访问,那么越界的下标的值是多少,而被越界的容器的大小又是多少,等等。例如解析一段xml失败了,那么这段xml是什么,当前解析到哪儿,等等。例如调用Win32 API失败了,那么Win32 Error Message是什么。

错误发生的环境:例如目标进程是什么。

错误发生之前用户做了什么:对于输入法来说,例如错误发生之前的若干个键敲击。

如果程序能够自动把这些信息收集并打包起来,发送给开发人员,那么就能够为诊断提供极大的帮助(当然,既便如此仍然还是会有难以诊断的bug)。而且这一切都要以不增加写代码过程中的开销的方式来进行,如果每次都要在代码里面做一堆事情来收集这些信息,那烦都得烦死人了,没有人会愿意用的。

那么到底如何才能无代价地尽量收集充足的信息为诊断bug提供帮助呢?

首先是callstack,有很多种方法可以给C++异常加上callstack,不过很多方法会带来性能损失,而且用起来也不方便,例如在每个函数的入口处加上一小段代码把函数名/文件/行号打印到某个地方,或者还有一些利用dbghelp.dll里面的StackWalk功能。我们使用的是没有性能损失的简单方案:在抛C++异常之前先手动MiniDumpWriteDump,在异常捕获端把minidump发回来,在开发人员收到minidump之后可以使用VS或windbg进行调试(但前提是相应的release版本必须开启pdb)。可能这里你会担心,minidump难道不是很耗时间的嘛?没错,但是既然程序已经发生了异常,稍微多花一点时间也就无所谓了。我们对于“附带minidump的异常”的使用原则是,只在那些真正“异常”的情况下抛出,换句话说,只在你认为应该使用的assert的地方用,这类错误属于critical error。另外我们还有不带minidump的异常,例如网络失败,xml解析失败等等“可以预见”的错误,这类错误发生的频率较高,所以如果每次都minidump会拖慢程序,所以这种情况下我们只抛异常不做minidump。

然后是Error Message,如何才能像assert那样,在Error Message里面包含表达式和文件行号?

最后,也是最重要的,如何能够把上下文相关变量的值capture下来,因为一方面release版本的minidump在调试的时候所看到的变量值未必正确,另一方面如果这个值在堆上(例如std::string的内部buffer就在堆上),那就更看不着了。

所有上面这些需求我们通过一个ENSURE宏来实现,它的使用很简单:

ENSURE(0 <= index && index < v.size())(index)(v.size());

ENSURE宏在release版本中同样生效,如果发现表达式求值失败,就会抛出一个C++异常,并会在异常的.what()里面记录类似如下的错误信息:

Failed: 0 <= index && index < v.size()

File: xxx.cpp Line: 123

Context Variables:

index = 12345

v.size() = 100

(如果你为stream重载了接收vector的operator <<,你甚至可以把vector的元素也打印到error message里头)

由于ENSURE抛出的是一个自定义异常类型ExceptionWithMinidump,这个异常有一个GetMinidumpPath()可以获得抛出异常的时候记录下来的minidump文件。

ENSURE宏还有一个很方便的feature:在debug版本下,抛异常之前它会先assert,而assert的错误消息正是上面这样。Debug版本assert的好处是可以让你有时间attach debugger,保证有完整的上下文。

利用ENSURE,所有对Win32 API的调用所发生的错误返回值就可以很方便地被转化为异常抛出来,例如:

ENSURE_WIN32(SHGetKnownFolderPath(rfid, 0, NULL, &p) == S_OK);

为了将LastError附在Error Message里面,我们额外定义了一个ENSURE_WIN32:

#define ENSURE_WIN32(exp) ENSURE(exp)(GetLastErrorStr())

其中GetLastErrorStr()会返回Win32 Last Error的错误消息文本。

而对于通过返回HRESULT来报错的一些Win32函数,我们又定义了ENSURE_SUCCEEDED(hr):

#define ENSURE_SUCCEEDED(hr) \

if(SUCCEEDED(hr)) \

else ENSURE(SUCCEEDED(hr))(Win32ErrorMessage(hr))

其中Win32ErrorMessage(hr)负责根据hr查到其错误消息文本。

ENSURE宏使得我们开发过程中对错误的处理变得极其简单,任何地方你认为需要assert的,用ENSURE就行了,一行简单的ENSURE,把bug相关的三大重要信息全部记录在案,而且由于ENSURE是基于异常的,所以没有办法被程序忽略,也就不会导致难以调试的二阶三阶bug,此外异常不像错误代码需要手动去传递,也就不会带来为了错误处理而造成的额外的开发成本(用错误代码来处理错误的最大的开销就是错误代码的手工检查和层层传递)。

ENSURE宏的实现并不复杂,打印文件行号和表达式文本的办法和assert一样,创建minidump的办法(这里只讨论win32)是在__try中RaiseException(EXCEPTION_BREAKPOINT…),在__except中得到EXCEPTION_POINTERS之后调用MiniDumpWriteDump写dump文件。最tricky的部分是如何支持在后面capture任意多个局部变量(ENSURE(expr)(var1)(var2)(var3)…),并且对每个被capture的局部变量同时还得capture变量名(不仅是变量值)。而这个宏无限展开的技术也在大概十年前就有了,还是Andrei Alexandrescu写的一篇DDJ文章:Enhanced Assertions 。神奇的是,我的CSDN博客当年第一篇文章就是翻译的它,如今十年后又在自己的项目中用到,真是有穿越的感觉,而且穿越的还不止这一个,我们项目不用任何第三方库,包括boost也不用,这其实也没有带来什么不便,因为boost的大量有用的子库已经进入了TR1,唯一的不便就是C++被广为诟病的:没有一个好的event实现,boost.signal这种非常强大的工业级实现当然是可以的,不过对于我们的项目来说boost.signal的许多feature根本用不上,属于杀鸡用牛刀了,因此我就自己写了一个刚刚满足我们项目的特定需求的event实现(使用tr1::function和lambda,这个signal的实现和使用都很简洁,可惜variadic templates没有,不然还会更简洁一些)。我在03年写boost源码剖析系列的时候曾经详细剖析了boost.signal的实现技术,想不到十年前关注的技术十年后还会在项目中用到。

由于输入法对错误的容忍度较低,所以我们在所有的出口处都设置了两重栅栏,第一重catch所有的C++异常,如果是ExceptionWithMinidump类型,则发送带有dump的问题报告,如果是其他继承自std::exception的异常类型,则仅发送包含.what()消息的问题报告,最后如果是catch(…)收到的那就没办法了,只能发送“unknown exception occurred”这种消息回来了。

inline void ReportCxxException(std::exception_ptr ex_ptr)

{

try

{

std::rethrow_exception(ex_ptr);

}

catch(ExceptionWithMiniDump& ex)

{

LaunchProblemReporter(…, ex.GetMiniDumpFilePath());

}

catch(std::exception& ex)

{

LaunchProblemReporter(…, ex.what());

}

catch(…)

{

LaunchProblemReporter(“Unknown C++ Exception”));

}

}

C++异常外面还加了一层负责捕获Win32异常的,捕获到unhandled win32 exception也会写minidump并发回。

考虑到输入法应该“能不崩溃就不崩溃”,所以对于C++异常而言,除了弹出问题报告程序之外,我们并不会阻止程序继续执行,这样做有以下几个原因:

很多时候C++异常并不会使得程序进入不可预测的状态,只要合理使用智能指针和ScopeGuard,该释放的该回滚的操作都能被正确执行。

输入法的引擎的每一个输入session(从开始输入到上词)理论上是独立的,如果session中间出现异常应该允许引擎被reset到一个可知的好的状态。

输入法内核中有核心模块也有非核心模块,引擎属于核心模块,云候选词、换肤、还有我们的创新feature:Rich Candidates(目前被译为多媒体输入,但其实没有准确表达出这个feature的含义,只不过第一批release的apps确实大多是输入多媒体的,但我们接下来会陆续更新一系列的Rich Candidates Apps就不止是多媒体了)也属于非核心模块,非核心模块即便出了错误也不应该影响内核的工作。因此对于这些模块而言我们都在其出口处设置了Error Boundary,捕获一切异常以免影响整个内核的运作。

另一方面,对于Native Language而言,除了语言级别的异常,总还会有Platform Specific的“硬”异常,例如最常见的Access Violation,当然这种异常越少越好(我们的代码基中鼓励使用ENSURE来检查各种pre-condition和post-condition,因为一般来说Access Violation不会是第一手错误,它们几乎总是由其他错误导致的,而这个“其他错误”往往可以用ENSURE来检查,从而在它导致Access Violation之前就抛出语言级别的异常。举一个简单的例子,还是vector的元素访问,我们可以直接v[i],如果i越界,会Access Violation,那么这个Access Violation便是由之前的第一手错误(i越界)所导致的二阶异常了。而如果我们在v[i]之前先ENSURE(0 <= i && i < v.size())的话,就可以阻止“硬”异常的发生,转而成为汇报一个语言级别的异常,语言级别的异常跟平台相关的“硬”异常相比的好处在于: 语言级别异常的信息更丰富,你可以capture相关的变量的值放在异常的错误消息里面。 语言级别的异常是“同步”的,一个写的规范的程序可以保证在语言级别异常发生的情况下始终处于可知的状态。C++的Stack Unwind机制可以确保一切善后工作得到执行。相比之下当平台相关的“硬”异常发生的时候你既不会有机会清理资源回滚操作,也不能确保程序仍然处于可知的状态。所以语言级别的异常允许你在模块边界上设定Error Boundary并且在非核心模块失败的时候仍然保持程序运行,语言级别的异常也允许你在核心模块,例如引擎的出口设置Error Boundary,并且在出错的情况下reset引擎到一个干净的初始状态。简言之,语言级别的异常让程序更健壮。 理想情况下,我们应该、并且能够通过ENSURE来避免几乎所有“硬”异常的发生。但程序员也是人,只要是代码就会有疏忽,万一真的发生了“硬”异常怎么办?对于输入法而言,即便出现了这种很遗憾的情况我们仍然不希望你的宿主程序崩溃,但另一方面,由于“硬”异常使得程序已经处于不可知的状态,我们无法对程序以后的执行作出任何的保障,所以当我们的错误边界处捕获这类异常的时候,我们会设置一个全局的flag,disable整个的输入法内核,从用户的角度来看就是输入法不工作了,但一来宿主程序没有崩溃,二来你的所有键敲击都会被直接被宿主程序响应,就像没有打开输入法的时候一样。这样一来即便在最坏的情况之下,宿主程序仍然有机会去保存数据并体面退出。 所以,综上所述,通过基于C++异常的ENSURE宏,我们实现了以下几个目的: 极其廉价的错误检查和汇报(和assert一样廉价,却没有assert的诸多缺陷):尤其是对于快速开发来说,既不可忽视错误,又不想在错误汇报和处理这种(非正事)上消耗太多的时间,这种时候ENSURE是完美的方案。 丰富的错误信息。 不可忽视的错误:编译器会忠实负责stack unwind,不会让一个错误被藏着掖着,最后以二阶三阶错误的方式表现出来,给诊断造成麻烦。 健壮性:看上去到处抛异常会让人感觉程序不够健壮,而实际上恰恰相反,如果程序真的有bug,那么一定会浮现出来,即便你不用异常,也并没有消除错误本身,迟早错误会以其他形式表现出来,在程序的世界里,有错误是永远藏不住的。而异常作为语言级别支持的错误汇报和处理机制,拥有同步和自动清理的特点,支持模块边界的错误屏障,支持在错误发生的时候重置程序到干净的状态,从而最大限度保证程序的正常运行。如果不用异常而用error code,只要疏忽检查一点,迟早会导致“硬”异常,而一旦后者发生,基本剩下的也别指望程序还能正常工作了,能做得最负责任的事情就是别导致宿主崩溃。 另一方面,如果使用error code而不用异常来汇报和处理错误,当然也是可以达到上这些目的,但会给开发带来高昂的代价,设想你需要把每个函数的返回值腾出来用作HRESULT,然后在每个函数返回的时候必须check其返回错误,并且如果自己不处理必须勤勤恳恳地转发给上层。所以对于error code来说,要想快就必须牺牲周密的检查,要想周密的检查就必须牺牲编码时间来做“不相干”的事情(对于需要周密检查的错误敏感的应用来说,最后会搞到代码里面一眼望过去尽是各种if-else的返回值错误检查,而真正干活的代码却缩在不起眼的角落,看过win32代码的同学应该都会有这个体会)。而只有使用异常和ENSURE,才真正实现了既几乎不花任何额外时间、又不至于漏过任何一个第一手错误的目的。 最后简单提一下异常的性能问题,现代编译器对于异常处理的实现已经做到了在happy path上几乎没有开销,对于绝大多数应用层的程序来说,根本无需考虑异常所带来的可忽视的开销。在我们的对速度要求很敏感的输入法程序中,做performance profiling的时候根本看不到异常带来任何可见影响(除非你乱用异常,例如拿异常来取代正常的bool返回值,或者在loop里面抛接异常,等等)。具体的可以参考GoingNative2012@Channel9上的The Importance of Being Native的1小时06分处。

C++11的其他特性的运用

资源管理和错误处理是现代C++风格最醒目的标志,接下来再说一说C++11的其他特性在我们项目中的使用。 首先还是lambda,lambda除了配合ON_SCOPE_EXIT使用威力无穷之外,还有一个巨大的好处,就是创建on-the-fly的tasks,交给另一个线程去执行,或者创建一个delegate交给另一个类去调用(像C#的event那样)。(当然,lambda使得STL变得比原来易用十倍这个事情就不说了,相信大家都知道了),例如我们有一个BackgroundWorker类,这个类的对象在内部维护一个线程,这个线程在内部有一个message loop,不断以Thread Message的形式接收别人委托它执行的一段代码,如果是委托的同步执行的任务,那么委托(调用)方便等在那里,直到任务被执行完,如果执行过程中出现任何错误,会首先被BackgroundWorker捕获,然后在调用方线程上重新抛出(利用C++11的std::exception_ptr、std::current_exception()以及std::rethrow_exception())。BackgroundWorker的使用方式很简单: bgWorker.Send([&] { .. /* do something */ }); 有了lambda,不仅Send的使用方式像上面这样直观,Send本身的实现也变得很优雅:

bool Send(std::function action) { HANDLE done = CreateEvent(NULL, TRUE, FALSE, NULL); std::exception_ptr pCxxException; unsigned int win32ExceptionCode = 0; EXCEPTION_POINTERS* win32ExceptionPointers = nullptr; std::function synchronousAction = [&] { ON_SCOPE_EXIT([&] { SetEvent(done); }); AllExceptionsBoundary( action, [&](std::exception_ptr e) { pCxxException = e; }, [&](unsigned int code, EXCEPTION_POINTERS* ep) { win32ExceptionCode = code; win32ExceptionPointers = ep; }); }; bool r = Post(synchronousAction); if(r) { WaitForSingleObject(done, INFINITE); CloseHandle(done); // propagate error (if any) to the calling thread if(!(pCxxException == nullptr)) { std::rethrow_exception(pCxxException); } if(win32ExceptionPointers) { RaiseException(win32ExceptionCode, ..); } } return r; }

这里我们先把外面传进来的function wrap成一个新的lambda function,后者除了负责调用前者之外,还负责在调用完了之后flag一个event从而实现同步等待的目的,另外它还负责捕获任务执行中可能发生的错误并保存下来,留待后面在调用方线程上重新raise这个错误。 另外一个使用lambda的例子是:由于我们项目中需要解析XML的地方用的是MSXML,而MSXML很不幸是个COM组件,COM组件要求生存在特定的Apartment里面,而输入法由于是被动加载的dll,其主线程不是输入法本身创建的,所以主线程到底属于什么Apartment不由输入法来控制,为了确保万无一失,我们便将MSXML host在上文提到的一个专属的BackgroundWorker对象里面,由于BackgroundWorker内部会维护一个线程,这个线程的apartment是由我们全权控制的。为此我们给MSXML创建了一个wrapper类,这个类封装了这些实现细节,只提供一个简便的使用接口: XMLDom dom; dom.LoadXMLFile(xmlFilePath); dom.Visit([&](std::wstring const& elemName, IXMLDOMNode* elem) { if(elemHandlers.find(elemName) != elemHandlers.end()) { elemHandlers[elemName](elem); } }); 基于上文提到的BackgroundWorker的辅助,这个wrapper类的实现也变得非常简单: void Visit(TNodeVisitor const& visitor) { bgWorker_.Send([&] { ENSURE(pXMLDom_ != NULL); IXMLDOMElement* root; ENSURE(pXMLDom_->get_documentElement(&root) == S_OK);

InternalVisit(root, visitor);

});

}

所有对MSXML对象的操作都会被Send到host线程上去执行。

另一个很有用的feature就是static_assert,例如我们在ENSURE宏的定义里面就有一行:

static_assert(std::is_same::value, “ENSURE(expr) can only be used on bool expression”);

避免调ENSURE(expr)的时候expr不是bool类型,确给隐式转换成了bool类型,从而出现很隐蔽的bug。

至于C++11的Move Semantics给代码带来的变化则是润物细无声的:你可以不用担心返回vector, string等STL容易的性能问题了,代码的可读性会得到提升。

最后,由于VS2010 SP1并没有实现全部的C++11语言特性,所以我们也并没有用上全部的特性,不过话说回来,已经被实现的特性已经相当有用了。

代码质量

在各种长期和短期压力之下写代码,当然代码质量是重中之重,尤其是对于C++代码,否则各种积累的技术债会越压越重。对于创新项目而言,代码基处于不停的演化当中,一开始的时候什么都不是,就是一个最简单的骨架,然后逐渐出现一点prototype的样子,随着不断的加进新的feature,再不断重构,抽取公共模块,形成concept和abstraction,isolate接口,拆分模块,最终prototype演变成product。关于代码质量的书很多,有一些写得很好,例如《The Art of Readable Code》,《Clean Code》或者《Implementation Patterns》。这里没有必要去重复这些书已经讲得非常好的技术,只说说我认为最重要的一些高层的指导性原则:

持续重构:避免代码质量无限滑坡的办法就是持续重构。持续重构是The Boy Scout Rule的一个推论。离开一段代码的时候永远保持它比上次看到的时候更干净。关于重构的书够多的了,细节的这里就不说了,值得注意的是,虽然重构有一些通用的手法,但具体怎么重构很多时候是一个领域相关的问题,取决于你在写什么应用,有些时候,重构就是重设计。例如我们的代码基当中曾经有一个tricky的设计,因为相当tricky,导致在后来的一次代码改动中产生了一个很隐蔽的regression,这使得我们重新思考这个设计的实现,并最终决定换成另一个(很遗憾仍然还是tricky的)实现,后者虽然仍然tricky(总会有不得已必须tricky的地方),但是却有一个好处:即便以后代码改动的过程中又涉及到了这块代码并且又导致了regression,那么至少所导致的regression将不再会是隐蔽的,而是会很明显。

KISS:KISS是个被说烂了的原则,不过由于”Simple”这个词的定义很主观,所以KISS并不是一个很具有实践指导意义的原则。我认为下面两个原则要远远有用得多: 1) YAGNI:You Ain’t Gonna Need It。不做不必要的实现,例如不做不必要的泛化,你的目的是写应用,不是写通用库。尤其是在C++里面,要想写通用库往往会触及到这门语言最黑暗的部分,是个时间黑洞,而且由于语言的不完善往往会导致不完备的实现,出现使用上的陷阱。2) 代码不应该是没有明显的bug,而应该是明显没有bug:这是一条很具有指导意义的原则,你的代码是否一眼看上去就明白什么意思,就确定没有bug?例如Haskell著名的quicksort就属于明显没有bug。为了达到这个目的,你的代码需要满足很多要求:良好的命名(传达意图),良好的抽象,良好的结构,简单的实现,等等。最后,KISS原则不仅适用于实现层面,在设计上KISS则更加重要,因为设计是决策的第一环,一个设计可能需要三四百行代码,而另一个设计可能只需要三四十行代码,我们就曾遇到过这样的情况。一个糟糕的设计不仅制造大量的代码和bug(代码当然是越少越好,代码越少bug就越少),成为后期维护的负担,侵入式的设计还会增加模块间的粘合度,导致被这个设计拖累的代码像滚雪球一样越来越多,所以code review之前更重要的还是要做design review,前面决策做错了后面会越错越离谱。

解耦原则:这个就不多说了,都说烂了。不过具体怎么解耦很多时候还是个领域相关的问题。虽然有些通用范式可循。

Best Practice Principle:对于C++开发来说尤其重要,因为在C++里面,同一件事情往往有很多不同的(但同样都有缺陷的)实现,而实现的成本往往还不低,所以C++社群多年以来一直在积淀所谓的Best Practices,其中的一个子集就是Idioms(惯用法),由于C++的学习曲线较为陡峭,闷头写一堆(有缺陷)的实现的成本很高,所以在一头扎进去之前先大概了解有哪些Idioms以及各自适用的场景就变得很有必要。站在别人的肩膀上好过自己掉坑里。

对了,这篇文章从头到尾是用英库拼音输入法写的。最后贴个图:(http://pinyin.engkoo.com/)

======带个自私自利的小AD=========

欢迎向DoNews投递关于互联网业界的热点类、观点类、趣点类、分析类、爆料类稿件。地址:tougao@donews.com

转载请注明 DoNews著名作者/刘未鹏

Tags: ,,.

Welcome to DoNews Blog. This is your first post. Edit or delete it, then start blogging!