王垠:程序设计里的“小聪明”
很早就想写这样一篇博文了,可是一直没来得及动笔。在学校的时候,时间似乎总是不够用,因为一旦有点时间,你就想是不是该用来多看点论文。所以我很高兴,工作的生活给了我真正自由的时间,让我可以多分享一些自己的经验。
我今天想开始写这系列文章的原因是,很多程序员的头脑中都有一些通过“非理性”方式得到的错误观点。这些观点如此之深,以至于你没法跟他们讲清楚。即使讲清楚了,一般来说也很难改变他们的习惯。
程序员的世界,是一个“以傲服人”的世界,而不是一个理性的,“以德服人”的世界。很多人喜欢在程序里耍一些“小聪明”,以显示自己的与众不同。由于这些人的名气和威望,人们对这些小聪明往往不加思索的吸收,以至于不知不觉学会了很多表面上聪明,其实导致不必要麻烦的思想,根深蒂固,难以去除。接着,他们又通过自己的“傲气”,把这些错误的思想传播给下一代的程序员,从而导致恶性循环。人们总是说“聪明反被聪明误”,程序员的世界里,为这样的“小聪明”所栽的根头,可真是数不胜数。以至于直到今天,我们仍然在疲于弥补前人所犯下的错误。
所以从今天开始,我打算陆续把自己对这些“小聪明”的看法记录在这里,希望看了的人能够发现自己头脑里潜移默化的错误,真正提高代码的“境界”。可能一下子难以记录所有这类误区,不过就这样先开个头吧。
小聪明1:片面追求“短小”
我经常以自己写“非常短小”的代码为豪。有一些人听了之后很赞赏,然后说他也很喜欢写短小的代码,接着就开始说 C 语言其实有很多巧妙的设计,可以让代码变得非常短小。然后我才发现,这些人所谓的“短小”跟我所说的“短小”,完全不是一回事。
我的程序的“短小”,是建立在语义明确,概念清晰的基础上的。在此基础上,我力求去掉冗余的,绕弯子的,混淆的代码,让程序更加直接,更加高效的表达我心中设想的“模型”。这是一种在概念级别的优化,它其实只是间接的导致了程序的短小精悍。这种短小,往往是在“语义” (semantics) 层面的,而不只是在“语法”层面死抠几行代码。我绝不会为了程序“显得短小”而让它变得难以理解,或者容易出错。
相反,很多其它人所追求的“短小”,却是盲目的,没有原则的小伎俩。在很多时候,这些小伎俩都只是在“语法” (syntax) 层面,比如,想办法把两行代码写成一行。可以说,这种“片面追求短小”的错误倾向,造就了一批语言设计上的错误,以及一批“擅长于”使用这些错误的程序员。
举一个简单的例子,就是很多语言里都有的 i++ 和 ++i 这两个“自增”操作。很多人喜欢在代码里使用这两个东西,是因为这样可以“节省一行代码”。殊不知,节省掉的那区区几行代码,比起由于使用自增操作带来的混淆和错误,其实是九牛之一毛。
从理论上讲,i++ 和 ++i 本身就是错误的设计。因为它们把对变量的“读”和“写”两种根本不同的操作,毫无原则的合并在一起。这种对读写操作的混淆不清,带来了非常难以发现的错误,甚至在某些时候带来效率的低下。
相反,等价的一种“笨”一点的写法,i = i + 1,不但更易理解,而且更符合程序内在的一种精妙的“哲学”原理。这个原理,其实来自一句古老的谚语:你不能踏进同一条河流两次。也就是说,当你第二次踏进“这条河”的时候,它已经不再是之前的那条河!这听起来有点玄,但是我希望能够用一段话解释清楚它跟 i = i + 1 的关系:
现在来想象一下,你就是超人卡卡西,你拥有明察秋毫的“写轮眼”,你能看到处理器的每一步微小的操作,每一个电子的流动。现在对你来说,i = i + 1 的含义是,让 i 和 1 进入“加法器”。i 和 1 所含有的信息,以 bit 为大小,被加法器的线路分解,组合。经过这样一番复杂的转换之后,在加法器的“输出端”,出现了一个“新”的整数,它的值比 i 要大 1。接着,这个新的整数通过电子线路,被放进“另一个”变量,这个变量的名字,“碰巧”也叫做 i。特别注意我加了引号的词,你是否能用头脑想象出电子线路里面信息的流动?
我是在告诉你,i = i + 1 里面的第一个 i 跟第二个 i,其实是两个完全不同的变量——它们只不过名字相同而已!如果你把它们换个名字,就可以写成 i2 = i1 + 1。当然,你需要把这条语句之后的所有的 i 全都换成 i2(直到 i 再次被“赋值”)。这样变换之后,程序的语义不会发生改变。
我是在说废话吗?这样把名字换来换去有什么意义呢?如果你了解编译器的设计,就会发现,其实我刚刚告诉你的哲学思想,足以让你“重新发明”出一种先进的编译器技术,叫做 SSA(single static assignment)。我只是通过这个简单的例子让你意识到,i++ 和 ++i 不但带来了程序的混淆,而且延缓甚至阻碍了人们发明像 SSA 这样的技术。如果人们早一点从本质上意识到 i = i + 1 的含义(其实里面的两个 i 是完全不同的变量),那么 SSA 很可能会提前很多年被发明出来。
(好了,到这里我承认,想用一段话讲清楚这个问题的企图,失败了。)
所以,有些人很在乎 i++ 与 ++i 的区别,去追究 (i++) + (++i) 这类表达式的含义,其实是徒劳的。“精通”这些细微的问题,并不能让你成为一个好的程序员。真正正确的做法其实是:完全不使用 i++ 或者 ++i。当然由于人们约定俗成的习惯,在某种非常固定,非常简单的,众人皆知“模式”下,你还是可以使用 i++ 和 ++i。比如: for (int i=0; i < n; i++)。但是除此之外,最好不要在任何其它地方使用。
如果你把它们放在表达式中间,或者函数的参数位置,比如 a[i++], f (++i) 等等,那么程序就会变得难以理解。而如果你把两个以上的 i++ 放在同一个表达式里,就会造成“非确定性”的错误。这种错误会造成程序在不同的编译器下出现不同的结果。
虽然我对这些都了解的非常清楚,但我不想继续探讨这些问题。因为与其记住这些,不如完全忘记 i++ 和 ++i 的存在。
好了,一个小小的例子,也许已经让你意识到了片面追求短小程序所带来的巨大代价。很可惜的是,程序语言的设计者们仍然在继续为此犯下类似的错误。一些“新”的语言,设计了很多类似的,旨在“缩短代码”,“减少打字量”的雕虫小技。也许有一天你会发现,这些雕虫小技所带来的,在短暂的兴奋之后,其实是无穷无尽的烦恼。
思考题:
1. Google 公司的“代码规范”里面规定,在任何情况下 for 语句和 if 语句之后必须写花括号,即使 C 和 Java 允许你在其只包含一行代码的时候省略它们。比如,你不能这样写
for (int i=0; i < n; i++) some_function (i);
而必须写成
for (int i=0; i < n; i++) { some_function (i); }
请分析:这样的代码规范,是好还是不好?请说明理由。
2. 当我第二次到 Google 实习的时候,发现我的一年前的代码很多被调整了结构。几乎所有如下结构的代码:
if (x > 0) { return 0; } else { return 1; }
都被人改成了:
if (x > 0) { return 0; } return 1;
请问这里省略了一个“else”和两个花括号,会带来什么好处或者坏处?
3. 根据本文对于 ++ 操作的看法,再参考传统的图灵机的设计,你是否发现图灵机的设计存在类似的问题?你如何改造图灵机,使得它不再存在这种问题?