Programming Philosophy and Tricks

2014-01-12
PL

开发工具篇

使用高效IDE

应包括快速定义跳转、函数重构、自动补全等。

IDE的选用应与语言对应,并且关注效率插件的使用。

比如:Python:VS code, Java:IntelliJ IDEA等

注意工具链的搭建。

使用高效编辑器

使用Motion快捷键多的编辑器,可以提高效率。比如Emacs,VIM,VS Code等。

使用错误检查工具(Linting)

Lint was the name of a program that would go through your code and identify problems before you compiled, linked, and ran it. It was a static checker, much like FindBugs today for Java.

Python: pylint

Haskell: Stack

Lisp:Emacs的语法插件。

PHP:PHPLint

Javascript:JSLint

使用API文档浏览器

比如,Dash,MSDN,快速跳转到函数定义。

使用Cheatsheet

减少学习成本,活用Cheatsheet

Use Version Control Systems

规则 #1:为每个新项目创建一个 Git 仓库

规则 #2:为每个新特性创建一个新分支。

规则 #3:使用 Pull Request 将代码合并到 Master 分支

编程思维篇

在长期的程序语言研究和实际工作中,我摸索出了一些关于测试的道理。然而在我工作过的每一个公司,我发现绝大多数人都不明白这些道理,很多团队集体性的采用错误的做法而不自知。很多人把测试当成一种主义和教条,进行过度的测试,不必要的测试,不可靠的测试,并且把这些错误的做法传授给新手,造成恶性循环。本来目的是提高代码质量,结果不但没能达到目的,反而降低了代码质量,增大了工作量,大幅度延缓工程进度。

我也写测试,但我的测试方式比“测试教条主义者”们的方式聪明很多。在我心目中,代码本身的地位大大的高于测试。我不忽视测试,但我不会本末倒置,过分强调测试,我并不推崇测试驱动开发(TDD)。我知道该测试什么,不该测试什么,什么时候该写测试,什么时候不该写,什么时候应该推迟测试,什么时候完全不需要测试。因为这个原因,再加上高强的编程能力,我多次完成别人认为在短时间不可能完成的任务,并且制造出质量非常高的代码。

测试的道理

现在我就把这些自己领悟到的关于测试的道理总结一下,其中有一些是鲜为人知或者被误解的。

  1. 不要以为你处处显示出“重视代码质量”的态度,就能提高代码质量。总有些人,以为自己知道“单元测试”(unit test),“集成测试”(integration test)这样的名词,就很懂编程,就可以教育其他人。可惜,光有态度和口号是不解决问题的,你还必须有实战的技巧,深入的见解和智慧,必须切实地知道应该怎么做。代码的质量不会因为你重视它就得到提升,也不会因为你采取了措施(比如测试,静态分析)就一定会得到改善。你必须知道什么时候该写测试,什么时候不该写测试,需要写测试的时候,要写什么样的测试。其实,提高代码质量唯一可行的手段不是写测试,而是反复的提炼自己的思维,写简单清晰的代码。如果你想真的提高代码质量,我的文章『编程的智慧』是一个不错的出发点。

  2. 真正的编程高手不会被测试捆住手脚。是的,你身边那个你认为“不很在乎测试”的家伙,也许是个比你更好的程序员。我喜欢把编程比喻成开赛车,而测试就是放在路边用来防撞的轮胎护栏……护栏有时候是很有用,可以救命的,然而一个合格的车手,绝对不会一心想着有护栏保护,测试在编程活动中的地位也应该就是这样。优秀的车手会很快看见优雅而简单的路径,恰到好处地掌握速度和时机,直奔终点而去。护栏只是放在最危险的地段,让你出了意外不要死得太惨。护栏并不能让你成为好的车手,不能让你取得冠军。绝大多数时候,你的安全只有靠自己的技术,而不是护栏,你永远有办法可以撞死自己。测试的作用也是一样,即使有了很多的测试,代码的安全仍然只掌握在你的手里。你永远可以制造出新的 bug,而没有测试可以检测到它……

    通常情况下,一个合格的车手是根本碰不到这些护栏的,他们心里想的是更高的目标:快点到达终点。相比之下,一个不合格的车手,他经常撞到赛道外面去,所以在他的心里,护栏有着至高无上的地位,所以他总是跟别人宣扬护栏的重要性。他开车的时候为了防止犯错,要在他经过的路径两边密密麻麻摆上护栏,甚至把护栏摆到赛道中间,以确保自己的转弯幅度正确。他在护栏之间跌跌撞撞,最后只能算是勉强到达终点。鼓吹测试驱动开发的人,就是这种三流车手,这种人写再多的测试也不可能倒腾出可靠的代码来。

  3. 在程序和算法定型之前,不要写测试。TDD 的教条者喜欢跟你说,在写程序之前就应该先写测试。为什么写代码之前要写测试呢?这只是一种教条。这些人其实没有用自己的脑子思考过这个问题,而只是人云亦云,觉得这样“很酷”,符合潮流,或者以为这样做了别人就会认为自己是高手。实际上在程序框架完成,算法定型之前,你都不需要写测试。如果你想知道代码是否正确,用人工方式运行代码,看看结果足以。

    如果你发现编程初期需要保证的性质纷繁复杂,如此之多,不写测试你就没信心的话,那你还是想办法先提高下基本的编程技术吧:多做练习,简化代码,让代码更加模块化,看看我的『编程的智慧』或者『SICP』一类的东西。写测试并不能提高你的水平,正好相反,过早的写测试会捆住你的手脚,让你无法自由的修改代码和算法。如果你不能很快的修改代码,不能用直觉感觉到它的变化和结构,而是因为测试而处处卡顿,你的头脑里就不能产生所谓“flow”,就不能写出优雅的代码来,结果到最后你什么也没学会。只有在程序不再需要大幅度的改动之后,才是逐渐加入测试的时候。

  4. 不要为了写测试而改变本来清晰的编程方式。很多人为了满足“覆盖”(coverage)的要求,为了可以测试到某些模块,或者为了使用 mock,而把本来简单清晰地代码改成更加复杂而混淆的形式,甚至采用大量 reflection。这样一来其实降低了代码的质量。本来很简单的代码,一眼看去就知道是否正确,可是现在你一眼看过去,到处都是为了方便测试而加进去的各种转接插头,再也无法感觉到代码。这些用来辅助测试的代码,阻碍了你对代码进行直觉思维,而如果你不能把代码的逻辑完全映射在头脑里(进而产生直觉),你是很难写出真正可靠的代码的。

    有些 C# 程序员,为了测试而加入大量的 interface 和 reflection,因为这样可以在测试的时候很方便的把一片代码替换成 mock。结果你就发现这程序里每个类都有一个配套的 interface,还需要写另外一个 mock 类,去实现这个 interface。这样一来,不但代码变得复杂难以理解,而且还损失了 Visual Studio 的协助功能:你不再能按一个键(F12)就直接跳转到方法的定义,而需要先跳到对应的 interface 方法,然后再找到正确的实现。所以你不再能够在代码里面快速的跳转浏览。这种方便性的损失,会大幅度降低头脑产生整体理解的机会。而且为了 mock,每一个构造函数调用都得换成一个含有 reflection 的构造,使得编译器的静态类型检查无法确保类型正确,增加运行时出错的可能性,出错信息还难以理解,得不偿失的后果。

  5. 不要测试“实现细节”,因为那等同于把代码写两遍。测试应该只描述程序需要满足的“基本性质”(比如 sqrt(4) 应该等于 2),而不是去描述“实现细节”(比如具体的开平方算法的步骤)。有些人的测试过于详细,甚至把代码的每个实现步骤都兢兢业业的进行测试:第一步必须做A,第二步必须做B,第三步必须做C…… 还有些人喜欢给 UI 写测试,他们的测试里经常这样写:如果你浏览到这个页面,那么你应该在标题栏看见这行字……

    仔细想一下就会发现,这种作法本质上不过是把代码(或者UI)写了两遍而已。本来代码里面明白写着:先做A,再做B,再做C。UI 描述文件里面明白写着:标题栏里面是这些内容。你有什么必要在测试里把它们全都再检查一遍呢?这根本没有增加任何可靠性:你在代码里会犯错,你把同样的逻辑换种形式再写一遍,难道就不会错了吗?

    这就像某些脑子秀逗的人,他出门时总是担心门没锁好,关门之后要推推拉拉好几次,确认门是锁上了的。还没走几步,他仍然在怀疑门没锁好,又走回去推推拉拉好几次,却始终不能放心 :P 这种做法非但不能保证代码的正确,反而给修改代码制造了障碍。理所当然,你把同一段代码写了两遍,每当要修改代码,你就得修改两次!这样的测试就像紧箍咒一样,把代码压得密不透风。每一次修改代码,都会导致很多测试失败,以至于这些测试都不得不重写。本质上就是把代码修改了两遍,只不过更加痛苦一些。

  6. 并不是每修复一个 bug 都需要写测试。很多公司都流传一个常见的教条,就是认为每修复一个 bug,都需要为它写测试,用于确保这个 bug 不再发生。甚至有人要求你这样修复一个 bug:先写一个测试,重现这个 bug,然后修复它,确保测试通过。这种思维其实是一种生搬硬套的教条主义,它会严重的减慢工程的进度,而代码的质量却不会得到提高。写测试之前,你应该仔细的思考一个问题:这个 bug 有多大可能会在同一个地方再次发生?很多低级错误一旦被看出来之后,它就不大可能在同一个地方再次出现。在这种情况下,你只需手工验证一下 bug 消失了就可以。

    为不可能再出现的 bug 大费周折,写 reproducer,构造各种数据结构去验证它,保证它下次不会再出现,其实是多此一举。同样的低级错误就算再出现,也很可能不在同一个地方。写测试不但不能保证它不再发生,而且浪费你很多时间。这测试在每次 build 的时候都会消耗时间,每次编译都因为这些测试多花几分钟,累积起来之后,你就发现工程进度明显减慢。只有当发现已有的测试没有抓住程序必须满足的重要性质时,你才应该写新的测试。你不应该是为这个 bug 而写测试,而是为代码的性质而写测试。这个测试的内容不应该只是防止这个 bug 再次发生,而是要确保 bug 所反映出来的,之前缺失的“性质”得到保证。

  7. 避免使用 mock,特别是多层的 mock。很多人写测试都喜欢用很多 mock,堆积很多层,以为只有这样才能测试到路径比较深的模块。其实这样不但非常繁琐费事,而且多层的 mock 往往不能产生足够多样化的输入,不能覆盖各种边界情况。如果你发现测试需要进行多层的 mock,那你应该考虑一下,也许你需要的不是 mock,而是改写代码,让它更加模块化。如果你的代码足够模块化,你不应该需要多层的 mock 来测试它。你只需要为每一个模块准备一些输入(包括边界情况),确保它们的输出符合要求。然后你把这些模块像管道一样连接起来,形成一个更大的模块,测试它也符合输入输出要求,以此类推。

  8. 不要过分重视“测试自动化”,人工测试也是测试。写测试,这个词往往隐含了“自动运行”的含义,也就是假设了要不经人工操作,完全自动的测试。打一个命令,它过一会就会告诉你哪些地方有问题。然而,人们往往忽略了“人工测试”。他们没有意识到,人工去试验,去观察,也是一种测试。所以你就发现这样的情况,由于自动测试在很多时候非常难以构造(比如,如果你要测试一段复杂的交互式GUI代码的响应),很多人花了很多时间,利用各种测试框架和工具,甚至遥控 WEB 浏览器去做一些自动操作,花太多时间却发现各种不可靠,没法测到很多东西。

    其实换一个思路,他们只需要花几分钟的时间,就可以用人工的方式观察到很多深入的问题。过分的重视测试自动化的原因,往往在于一个不切实际的假设,他们假设错误会频繁的再次发生,所以自动化了可以省下人的力气。但是其实,一旦一个 bug 被修好,它反复出现的机会不会很大的。过分的要求测试自动化,不但延缓了工程进度,让程序员恼火,效率低下,而且失去了人工测试的精确性。

  9. 避免写太长,太耗时的测试。很多人写测试,叽里呱啦很长一串,到后来再看的时候,他已经不记得自己当时想测什么了。有些人本来用很小的输入就可以测试到需要的性质,他却总喜欢给一个很大的输入,下意识的以为这样更加靠谱,结果这测试每次都会消耗大量的 build 时间,而其实达到的效果跟很小的输入没有任何区别。

  10. 一个测试只测试一个方面,避免重复测试。有些人一个测试测很多内容,结果每次那个测试失败,都搞不清楚到底是哪个部件出了问题。有些人为了“放心”,喜欢在多个测试里面“附带”测某些他认为相关的部件,结果每次那个部件出问题,就发现好多个测试失败。如果一个测试只测一个方面,不重复测同一个部件,那么你就可以很快的根据失败的测试,发现出问题的部件和位置。

  11. 避免通过比较字符串来进行测试。很多人写测试的时候,喜欢通过打印出一些东西,然后使用字符串比较的方式来决定输出是否符合要求。一个常见的做法是把输出打印成格式化的 JSON,然后对比两个文本。甚至有人 JSON 都不用,直接就比较 printf 输出的结果。这种测试是非常脆弱的。因为字符串输出的格式往往会发生微小的变化,比如有人在里面加了一个空格之类的。把这种字符串作为标准输出,进行字符串比较,很容易因为微小的改动而使大量测试失败,导致很多的测试需要做不必要的修改。正确的做法,应该是进行结构化的比较,如果你要把标准结果存成 JSON,那么你应该先 parse 出 JSON 所表示的对象,然后再进行结构化的对比。PySonar2 的测试就是这样的做法,所以相当的稳定。

  12. “测试能帮助后来人”的误区。每当指出测试教条主义的错误,就会有人出来说:“测试不是为了你自己,而是为了你走了以后,以后进来的人不犯错误。” 首先,这种人根本没有看清楚我在说什么,因为我从来没有反对过合理的测试。其次,这种“测试能帮助后来人”,其实是没有经过实践检验,站不住脚的说法。如果你的代码写得很乱,就算你测试再多,后来人也无法理解,反倒被莫名其妙的测试失败给弄得更糊涂,不知道是自己错了还是测试错了。我已经说过了,测试不能完全保证代码不被改错,实际上它们防止代码被改错的作用是非常弱的。无论如何,后来人都必须理解原来的代码的逻辑,知道它在做什么,否则他们不可能做出正确的修改,就算你有再严密的测试也一样。

    举一个亲身的例子。我在 Google 做出 PySonar 之后,最后一个测试都没写。第二次我回到 Google,我的上司 Steve Yegge 对我说:“你走了之后,我改了一些你的代码,真是太清晰,太好把握了,修改你的代码是一种快乐!” 这说明什么问题呢?我并不是说你可以不写测试,但这个例子说明,测试对于后来人的作用,并不是你有些人想象的那么大。创造清晰的代码才是解决这个问题的关键。

    这种怕人突然走了,代码无法维护的想法,导致了一些人对测试过分的重视,但测试却不能解决这种问题。相反,如果测试太繁琐,做不必要的测试,反而容易让员工不满,容易走人,去加入在这方面更加有见地的公司。有些公司以为有了测试,就可以随便打发人走,这种想法是大错特错的。你需要明白的一个事情是,代码永远是属于写出它的那个人的,就算有测试也一样。如果核心人物真的走了,就算你有再多的测试也没用的,所以解决的方法就是把他们留住!一个有远见的公司总是通过其他的手段解决这个问题,比如优待和尊重员工,创造良好的氛围,使得他们没那么快想走。另外,公司必须注意知识的传承,防止某些代码只有一个人理解。

案例分析

有人会疑问,我凭什么可以给别人讲这些经验,我自己为此有什么成功的案例呢?所以现在来讲讲我做过的几个东西,以及我亲眼目睹的测试教条主义者们的失败案例。

Google

很多人可能听说过我在 Google 做的 PySonar。当时 Google 的队友们战战兢兢,说这么高难复杂的东西要从头做起,几乎是不可能的。特别是某位队友,一开头就吵着要我写测试,一直吵到最后,烦死我了。他们为什么这么担心呢?因为对 Python 做类型推导是非常高难度的代码,需要相当复杂的数据结构和算法,需要精通 Python 的语义实现。

作为一个训练有素的专家,我没有在乎他们的咋呼,没有信他们的教条。我按照自己的方式组织代码,进行精密的思考,设计和推理,最终在三个月之内做出了非常优雅,正确,高性能,而又容易维护的代码。PySonar 到现在仍然是世界上最先进的 Python 类型推导和索引系统,被多家公司采用,用于处理数以百万计的 Python 代码。,

如果我当时按照 Google 队友的要求,采用已有的开源代码,或者过早的写了测试,别说无法在三个月的实习时间之内完成这个东西,就算折腾好几年也没有可能。

hape Security

这种思维方式最近的成功实例,是给 Shape Security 做的一个先进的 JavaScript 混淆器(obfuscator)和对集群(cluster)管理系统的改进。不要小看了这个 JS 混淆器,它的混淆能力要比 uglify 之类的开源工具强很多,也快很多。它不但包含了 uglify 的变量换名等基本功能,而且含有专门针对人类和编译器的复杂化,使得没人能看出一点线索这个程序到底要干什么,让最先进的 JS 编译器也无法把它简化。

其实这个混淆器也是一种编译器,只不过它把 JavaScript 翻译成不可读的形式。在这个项目中,由于失之毫厘就可以差之千里,我采用了从 Chez Scheme 编译器学过来的,非常严密的测试方法。对每一个编译器的步骤(pass),我都给它设计一些正好可以测到这个步骤的输入代码(比如,具有函数定义的,for循环,try-catch的,等等)。Pass 输出的代码,经过 JavaScript 解释器执行,把结果跟原来程序的执行结果对比。每一个测试程序,经过每一个 pass,输出的中间结果都跟标准结果进行对比,如果错了就表明那个 pass 有问题,出错的小程序会指出大概是哪一个部分出了问题。遵循小巧,不冗余,不重复的原则,我总共只写了40多个非常小的 JavaScript 程序。由于这些测试涵盖了 JavaScript 的所有构造而且几乎不重复,它们能够准确的定位到错误的改动。最后,这个 JS 混淆器能够正确的转换像 AngularJS 那么大的项目,确保语义的正确,让人完全无法读懂,而且能有效地防止被优化器(比如 Closure Compiler)简化掉。

相比之下,过度鼓吹测试和可靠性的人,并没能制造出这么高质量的混淆器。其实在我进入团队之前,里面的两三位高手已经做了一个混淆器,项目延续了好多个月。这片代码一直没能发布给客户用,因为它的换名部件总是会在某些情况下输出错误的代码,修改了好多次仍然会出错。不是100%的正确,这对于程序语言的转换器来说,是不可接受的。换名只是我的混淆器里的一个步骤,它还包含大概十个类似的步骤,可以把代码进行各种转换。

在实现换名器的时候,队友们让我直接拿他们以前写的换名代码过来,把 bug 修好就可以。然而看了代码之后,我发现这代码没法修,因为它采用了错误的思路,缝缝补补也不可能达到100%的正确,而且明显效率低下,所以我决定自己重写一个。由于轻车熟路,我只花了一下午的时间,就完成了一个正确的换名器,它完全符合 JavaScript 的语义,各种奇葩的作用域规则,而且结构非常简单。说白了,这个换名器也是一种解释器。对解释器的深刻理解,让我可以很容易的写出任何语言的换名器。

不幸的是,历史再次重演了 ;) 队友们听说我花一下午重写了一个换名器,非常紧张,咋呼地跟我说:“你知道我们的换名器是花了多少个月的时间做出来的吗?你知道我们写了多少测试来保证它的正确性吗?你现在一下午做出来一个新的,你如何能保证它的正确!” 我不知道他们怎么好意思说出这样的话来,因为事实是,他们花了这么多个月,耗费这么多人力,写了这么多的测试,做出来的换名器却仍然有 bug,没法用。当我把我写的测试和几个大点的 open source 项目(AngularJS, Backbone 等)放进他们的换名器之后,就发现有些地方出问题了,而所有的测试和 open source 项目通过我的换名器,却得到完全正确的代码。另外经过性能测试,我的换名器速度要快四倍的样子。所以就像 Dijkstra 所说:“最优雅的程序往往也是最高效的。”

结束这个项目之后,我换了一个团队(cluster团队),这个团队的人要好很多,低调而且幽默。Shape Security 的产品(Shape Shifter)里面包含一个高可靠(HA)集群管理系统,它可以通过网络,选举 leader,构建一个高容错的并行处理集群。这个集群管理系统一直以来都是公司里很复杂,却是可靠性要求最高的一个部件,一旦出问题就可能有灾难性的后果。确实,它当时可靠性非常高,从来没出过问题。但由于历史原因,它的代码过度复杂而缺乏模块化,以至于很难扩展来应付新的客户需求。我进入这个新团队的任务,就是对它进行大规模的简化,模块化和扩展,让它满足新的需求。

在这个项目中,由于代码的改动幅度很大,在同事和部门领导的理解,信任和支持下,我们决定直接抛弃已有的测试,完全靠严格而及时的 code review,逻辑推理,推敲讨论,手工试验来保证代码的正确。在我修改代码的同时,一位更熟悉已有代码的队友一直通过 git 默默监视着我的每一次改动,根据他自己的经验来判断我的改动是否偏离了原来的语义,及时与我交流和讨论。由于这种灵活而严格的方式,工程不到两个月就完成了。改进后的代码不但更加模块化,更可扩展,适应了新的需求,而且仍然非常可靠。假设部门领导是“测试教条主义者”,不允许抛弃已有的测试,这样的项目是绝对不可能如期完成的。然而在当今世界遇到这样领导的机会,恐怕十个人里面不到一个吧。

Coverity

最后,我举一个由于测试方式不当而非常失败的案例,那就是 Coverity 的 Java 静态分析产品。我承认 Coverity 的 C 和 C++ 分析器也许是非常好的,然而 Java 的分析器,很难说。当我进入 Coverity 的时候,同事们已经忍受了整整一年的管理层的威逼和高压,超时过劳工作,写出了基本的新产品和很多的测试。可是由于技术债太多,再多的测试也没能保证产品的可靠性。

我的任务就是利用我深入的 PL 知识,不停的修补前人留下来的各种蹊跷 bug。有些 bug 需要运行20多分钟之后才出现,一次还看不出是怎么回事,所以修起来非常耗时。有时候我只好趴在电脑前面养神,时不时的睁眼看看结果。Coverity 是如此的在乎测试,他们要求每修复一个 bug 你就必须写出新的测试。测试必须能够如实的重现 bug 的现象,修复之后测试必须能够通过。这看似一个很在乎代码质量的做法,然而它不但没能保证产品的稳定可靠,而且大幅度的减慢了工程进度,并且造成员工的疲惫和不满。

有一次他们分配给我一个 bug:在分析一个中型项目的时候,分析器似乎进入了死循环,好几个小时都不能完成。因为 Coverity 的全局静态分析,其实就是某种图遍历算法。当这个图里面有回路的时候,你就必须小心,如果不问青红皂白就递归进去,就可能进入死循环。避免死循环的办法很简单,你构造一个图节点的集合(Set),然后把它传递到函数里面作为参数。 每当访问一个节点,你先检查这个节点是否已经在这个集合里,如果在你就直接返回,否则你就把这个节点加入到集合里,然后递归处理这个节点的子节点。它的 C++ 代码大概就像这个样子:

void traverse(Node node, Set<Node> &visited)
{
  if (visited.contains(node)) {
    return;
  } else {
    visited.add(node);
    process_node(node, visited);   // 里面会递归调用 traverse
  }
}

查看代码之后我发现,代码其实没有进入“死循环”,而是进入了指数复杂度的计算,所以很久都不能完成。这是因为写这函数的人不小心,或者没有理解 C++ 的函数参数缺省是传值(做拷贝)而不是传引用,所以他忘了打那个“&”,所以函数被递归调用的时候不是传递原来的集合,而是做了一个拷贝。每一次递归调用traverse,visited 都得到一个新的拷贝,所以返回之后,visited 的值就恢复到之前的状态,就像 node 被自动 remove 了一样。所以这个函数仍然会在某种情况下再次访问这个节点。这样的代码不会进入死循环,然而在某种特殊的图结构下,这会造成指数级的时间复杂度(请想一下这是什么样的一种图)。

本来很明显的一个图论算法问题,加一个“&”就修好了,手工试验也发现问题消失了。然而 Coverity 的测试教条主义者们(包括写出这 bug 的那人自己),吵着闹着,严肃命令我必须写出测试,构造出可以造成这种后果的数据结构,确保这个 bug 不会再重新出现。

为一个我根本不会犯的错误写测试,而且它不可能再次发生,这不是很搞笑吗?就算你写了测试,也不能保证同样的事情不再发生。如果你不小心漏掉“&”,下次同样的问题还会发生,并且发生在另外的地方,而你却没有给那块代码写测试,所以给这个 bug 写测试,并不能防止同样的问题再次发生。这就像一个技术不过关的赛车手,他在别人不大可能撞车的地方撞了车,然后就要求赛场在那个地方装上轮胎护栏。可是下一次,这个车手又会在另一个其他人都不会撞车地方撞车……

稍微有点图论常识,熟悉 C++ 基本概念的人,都不会犯这种错误。防止这种问题,只有靠个人的技术和经验,而不能靠测试。防止它再次发生的最好办法,恐怕是开个会把这个问题讲清楚,让大家理解,下次不要再犯。所以给这个 bug 写测试,完全是多此一举。跟队友们讲解了这个原理,他们听了之后,仿佛什么都没有听到一样,仍然强硬的要求:“可是你还是得写这个测试,因为这是我们的规定!你知道要是出了 bug,送一个销售工程师去客户那里,要花多少钱吗……” 无语了。

Coverity 的 Java 分析,就是经常因为这种测试教条主义,使得项目进展及其痛苦和缓慢,却仍然 bug 百出。Coverity 的其他的问题,还包括我上面指出的,写重复的测试,一个测试测太多东西,使用字符串比较来做测试,等等。你恐怕很难想象,一个制造旨在提高代码质量的产品的公司,自己代码的质量是这样维护的 :P

由于绝大多数人对测试的误解如此之深,测试教条主义的流毒如此之广,导致许许多多优秀的程序员沉沦在繁琐的测试驱动开发中,无法舒展自己的长处。为了大家有一个轻松,顺利又可靠的工作环境,我希望大家多多转发这篇文章,改变这个行业的陋习。我希望大家在工程中理性的对待测试,而不是盲目的写测试,只有这样才能更好更快的完成项目。

编程中,有哪些好的习惯从一开始就值得坚持?

如果你学习C或者C++这两门没有垃圾回收的语言,有一个好用的内存分析工具是很重要的。用这个东西的目的是及早学会好好管理内存,别内存泄露了或者访问冲突了自己都不知道。

Windows下的Visual Studio集成有许多的分析功能,安装VLD(Visual Leak Detector)之后可以对内存问题进行精确的诊断。

Linux/Unix下也有valgrind一个重量级工具,可以对程序的内存问题进行诊断、分析函数开销、分析Cache等。很遗憾的是这个工具现在还只能在Unix/Linux下使用,不过Windows版本已经开始有人移植了。而且你想想看顺便学习下Linux操作系统的使用也是不错的。

——

此外,一定要会用调试器,或者自己打log,帮助你诊断代码中的问题。不要单靠眼看代码,结果抓耳挠腮半天想不出来。

屋中的大象

  • 天才程序员写的代码库很简单,可重复使用,且功能强大。持续不断地对代码库进行小而有规律的改进,添加新的功能。写了足够多的相关测试。他们往往很谦虚知识渊博。经常要从头开始重写整个库。但他们能写出更好的代码,而不破坏兼容性

  • 接吻原理-保持简单,直接(The KISS principle - Keep It Simple, Stupid.)。当你写代码时,记住这是一件事。不要做额外的抽象。避免概括。避免继承(inheritance)。不要写你认为你将来可能需要的代码。当没有更多的代码可删除,那么工作就完成了。

  • 即使你正在工作的代码真的很混乱,很糟糕,请不要让这种情况变得更糟。

编程哲学

Author: thzt

编程哲学(一):愚者无疑,智者多虑

  • 语言本身的影响会随着深入程度而逐渐降低。
  • 任何事情要想做到极致,就不得不进行一系列的理性思考,总结和反思。而这些思考才是从业者的核心价值。

编程哲学(二):让我们想个办法

  • 重复会增加冗余,但是复用会增加依赖
  • 我们学会了如何根据问题的结构来组织代码, 根据业务的发展来促进代码的演进, 学会了推动别人来使用软件,为生态做出贡献。 然而,这并没有什么卵用。 我们仍然会遇到新状况,仍然有不能用已有经验去解决的问题, 我们可能会遇到沟通问题,遇到协作问题,遇到工程问题
  • 软件工程师并不仅仅是写代码的人。 而是发现问题,并思考如何解决的人。 能发现多大价值的问题并解决它,工程师就能创造多大的价值。
  • 陈皓:我给大约40多家公司做过相应的技术咨询和解决过很多技术问题,绝大多数公司都是因为性能和稳定性的问题来找我的,我给这些公司解决问题的时候,基本都是这样的Pattern:一开始,发现都是一些技术知识点的问题;然后,马上进入到系统架构方面方面的问题;当再解决架构问题的时候,我发现,已经是软件工程的问题;而软件工程问题的后面,又是公司管理上的问题;而公司管理的问题,结果又到了人的问题上;而人的问题,又到了公司文化的问题…… 我能做的是,观察这个公司的业务形态、和相关的思维方式,以及现有的资源和相应的技术实力,帮助他们从技术到管理上缓解或改善现有的问题。(这多年来我一直在钻研的技术)

编程哲学(三):是什么影响了我们的开发效率

  • 某个领域的专家,会更倾向于喜爱自己所在的领域, 认可自身领域专业性的价值,否则当初就难以成为专家了。 这是一件利弊参半的事, 专业性使得一些工作被巧妙的解决掉,也使得一些工作被解决的过于勉强。

  • 软件工程师需要主动得到工作反馈,确认待解决问题的动向, 向团队汇报自己的工作内容,向显然已经知道答案的同事学习经验。 不要自己扛下所有的事情,不要自己研究。

  • 好的工匠常常拥有自己的工具箱, 工程师也会思考如何利用团队的产出反哺团队自身。

编程哲学(四):把控间接性

  • 我们经常处于一种表达能力受限而不自知的状态。 为了得到编程语言相关的种种商业好处, 经常把自己局限在某个特定编程语言范围之内。 应该多从文学作品中学习经验, 训练自己怎样把事情说清楚, 以及在每个层面上把问题展开成什么样的细节程度。 只有在这种情况下,封装信息隐藏细节才突然有了意义。

  • 重复未必是有问题的,重复的描述细节才有问题。 过多的细节层次也是不恰当的,它增加了我们的描述复杂度。

  • 分析问题本身的数学结构,或者理解项目相关的业务背景, 我们可以看到具有逻辑完整性的模式和工具。

编程哲学(五):未雨绸缪

  • 如果我们一开始只有一种办法来实现功能, 那么这通常不是一个最优的选择。 实际上,我们应该不遗余力的寻找备选方案,未雨绸缪

编程哲学(六):从正确归因到个人影响力

  • 归因理论的研究者发现,人们在归因时存在一个普遍性的问题, 当我们解释他人的行为时,会低估环境造成的影响,而高估个人的特质和态度所造成的影响。这种个体在归因时低估情境因素作用的倾向,被称为基本归因错误。 人们习惯性的将自己的失误归因于环境,而将别人的失误归因于他们的内部秉性。

  • 与其试图改变别人,不如寻求有效的合作方法。

编程哲学(七):我写不出好代码

  • 编程,是一个解决问题的过程, 通过对问题本身进行分析,考虑目前可用的计算资源, 整合出一套自动化的解决问题的步骤,就是程序。

  • 在这个过程中,我们需要发现规律,找到普适性, 以降低软件的成本,覆盖大部分场景, 这是我们每天要做的事情。

  • 我们很少为自己编程, 我们做软件,并不是为了解决自己的问题,而是帮用户, 我们提供了一个代码库,也不是为了自己,而是因为别人要用它。

  • 因此,仅仅创造一个解决方案,这并不够, 还得告诉别人,该如何使用它, 编程的艺术或许在于如何提供功能。

编程哲学(八):偿还不起的技术债务

编程哲学(九):让技能被使用

  • 新技术只有被不断的使用,我们才能学会如何用它解决问题。 盯着它看,最终也只是知道它可以解决问题。

编程的智慧

转自:http://www.yinwang.org

编程是一种创造性的工作,是一门艺术。精通任何一门艺术,都需要很多的练习和领悟,所以这里提出的“智慧”,并不是号称一天瘦十斤的减肥药,它并不能代替你自己的勤奋。然而由于软件行业喜欢标新立异,喜欢把简单的事情搞复杂,我希望这些文字能给迷惑中的人们指出一些正确的方向,让他们少走一些弯路,基本做到一分耕耘一分收获。

反复推敲代码

有些人喜欢炫耀自己写了多少多少万行的代码,仿佛代码的数量是衡量编程水平的标准。然而,如果你总是匆匆写出代码,却从来不回头去推敲,修改和提炼,其实是不可能提高编程水平的。你会制造出越来越多平庸甚至糟糕的代码。在这种意义上,很多人所谓的“工作经验”,跟他代码的质量其实不一定成正比。如果有几十年的工作经验,却从来不回头去提炼和反思自己的代码,那么他也许还不如一个只有一两年经验,却喜欢反复推敲,仔细领悟的人。

有位文豪说得好:“看一个作家的水平,不是看他发表了多少文字,而要看他的废纸篓里扔掉了多少。” 我觉得同样的理论适用于编程。好的程序员,他们删掉的代码,比留下来的还要多很多。如果你看见一个人写了很多代码,却没有删掉多少,那他的代码一定有很多垃圾。

就像文学作品一样,代码是不可能一蹴而就的。灵感似乎总是零零星星,陆陆续续到来的。任何人都不可能一笔呵成,就算再厉害的程序员,也需要经过一段时间,才能发现最简单优雅的写法。有时候你反复提炼一段代码,觉得到了顶峰,没法再改进了,可是过了几个月再回头来看,又发现好多可以改进和简化的地方。这跟写文章一模一样,回头看几个月或者几年前写的东西,你总能发现一些改进。

所以如果反复提炼代码已经不再有进展,那么你可以暂时把它放下。过几个星期或者几个月再回头来看,也许就有焕然一新的灵感。这样反反复复很多次之后,你就积累起了灵感和智慧,从而能够在遇到新问题的时候直接朝正确,或者接近正确的方向前进。

写优雅的代码

人们都讨厌“面条代码”(spaghetti code),因为它就像面条一样绕来绕去,没法理清头绪。那么优雅的代码一般是什么形状的呢?经过多年的观察,我发现优雅的代码,在形状上有一些明显的特征。

如果我们忽略具体的内容,从大体结构上来看,优雅的代码看起来就像是一些整整齐齐,套在一起的盒子。如果跟整理房间做一个类比,就很容易理解。如果你把所有物品都丢在一个很大的抽屉里,那么它们就会全都混在一起。你就很难整理,很难迅速的找到需要的东西。但是如果你在抽屉里再放几个小盒子,把物品分门别类放进去,那么它们就不会到处乱跑,你就可以比较容易的找到和管理它们。

优雅的代码的另一个特征是,它的逻辑大体上看起来,是枝丫分明的树状结构(tree)。这是因为程序所做的几乎一切事情,都是信息的传递和分支。你可以把代码看成是一个电路,电流经过导线,分流或者汇合。如果你是这样思考的,你的代码里就会比较少出现只有一个分支的if语句,它看起来就会像这个样子:

if (...) {
  if (...) {
    ...
  } else {
    ...
  }
} else if (...) {
  ...
} else {
  ...
}

注意到了吗?在我的代码里面,if语句几乎总是有两个分支。它们有可能嵌套,有多层的缩进,而且else分支里面有可能出现少量重复的代码。然而这样的结构,逻辑却非常严密和清晰。在后面我会告诉你为什么if语句最好有两个分支。

写模块化的代码

有些人吵着闹着要让程序“模块化”,结果他们的做法是把代码分部到多个文件和目录里面,然后把这些目录或者文件叫做“module”。他们甚至把这些目录分放在不同的VCS repo里面。结果这样的作法并没有带来合作的流畅,而是带来了许多的麻烦。这是因为他们其实并不理解什么叫做“模块”,肤浅的把代码切割开来,分放在不同的位置,其实非但不能达到模块化的目的,而且制造了不必要的麻烦。

真正的模块化,并不是文本意义上的,而是逻辑意义上的。一个模块应该像一个电路芯片,它有定义良好的输入和输出。实际上一种很好的模块化方法早已经存在,它的名字叫做“函数”。每一个函数都有明确的输入(参数)和输出(返回值),同一个文件里可以包含多个函数,所以你其实根本不需要把代码分开在多个文件或者目录里面,同样可以完成代码的模块化。我可以把代码全都写在同一个文件里,却仍然是非常模块化的代码。

想要达到很好的模块化,你需要做到以下几点:

  • 避免写太长的函数。如果发现函数太大了,就应该把它拆分成几个更小的。通常我写的函数长度都不超过40行。对比一下,一般笔记本电脑屏幕所能容纳的代码行数是50行。我可以一目了然的看见一个40行的函数,而不需要滚屏。只有40行而不是50行的原因是,我的眼球不转的话,最大的视角只看得到40行代码。
    如果我看代码不转眼球的话,我就能把整片代码完整的映射到我的视觉神经里,这样就算忽然闭上眼睛,我也能看得见这段代码。我发现闭上眼睛的时候,大脑能够更加有效地处理代码,你能想象这段代码可以变成什么其它的形状。40行并不是一个很大的限制,因为函数里面比较复杂的部分,往往早就被我提取出去,做成了更小的函数,然后从原来的函数里面调用。

  • 制造小的工具函数。如果你仔细观察代码,就会发现其实里面有很多的重复。这些常用的代码,不管它有多短,提取出去做成函数,都可能是会有好处的。有些帮助函数也许就只有两行,然而它们却能大大简化主要函数里面的逻辑。
    有些人不喜欢使用小的函数,因为他们想避免函数调用的开销,结果他们写出几百行之大的函数。这是一种过时的观念。现代的编译器都能自动的把小的函数内联(inline)到调用它的地方,所以根本不产生函数调用,也就不会产生任何多余的开销。
    同样的一些人,也爱使用宏(macro)来代替小函数,这也是一种过时的观念。在早期的C语言编译器里,只有宏是静态“内联”的,所以他们使用宏,其实是为了达到内联的目的。然而能否内联,其实并不是宏与函数的根本区别。宏与函数有着巨大的区别(这个我以后再讲),应该尽量避免使用宏。为了内联而使用宏,其实是滥用了宏,这会引起各种各样的麻烦,比如使程序难以理解,难以调试,容易出错等等。

  • 每个函数只做一件简单的事情。有些人喜欢制造一些“通用”的函数,既可以做这个又可以做那个,它的内部依据某些变量和条件,来“选择”这个函数所要做的事情。比如,你也许写出这样的函数:
    void foo() {
    if (getOS().equals("MacOS")) {
      a();
    } else {
      b();
    }
    c();
    if (getOS().equals("MacOS")) {
      d();
    } else {
      e();
    }
    }
    

    写这个函数的人,根据系统是否为“MacOS”来做不同的事情。你可以看出这个函数里,其实只有c()是两种系统共有的,而其它的a(), b(), d(), e()都属于不同的分支。
    这种“复用”其实是有害的。如果一个函数可能做两种事情,它们之间共同点少于它们的不同点,那你最好就写两个不同的函数,否则这个函数的逻辑就不会很清晰,容易出现错误。其实,上面这个函数可以改写成两个函数:

    void fooMacOS() {
    a();
    c();
    d();
    }
    

    void fooOther() {
    b();
    c();
    e();
    }
    

    如果你发现两件事情大部分内容相同,只有少数不同,多半时候你可以把相同的部分提取出去,做成一个辅助函数。比如,如果你有个函数是这样:

    void foo() {
    a();
    b()
    c();
    if (getOS().equals("MacOS")) {
      d();
    } else {
      e();
    }
    }
    

    其中a(),b(),c()都是一样的,只有d()和e()根据系统有所不同。那么你可以把a(),b(),c()提取出去:

    void preFoo() {
    a();
    b()
    c();
    

    然后制造两个函数:

    void fooMacOS() {
    preFoo();
    d();
    }
    

    void fooOther() {
    preFoo();
    e();
    }
    

    这样一来,我们既共享了代码,又做到了每个函数只做一件简单的事情。这样的代码,逻辑就更加清晰。

  • 避免使用全局变量和类成员(class member)来传递信息,尽量使用局部变量和参数。有些人写代码,经常用类成员来传递信息,就像这样:
     class A {
     String x;
    
     void findX() {
        ...
        x = ...;
     }
    
     void foo() {
       findX();
       ...
       print(x);
     }
     }
    

    首先,他使用findX(),把一个值写入成员x。然后,使用x的值。这样,x就变成了findX和print之间的数据通道。由于x属于class A,这样程序就失去了模块化的结构。由于这两个函数依赖于成员x,它们不再有明确的输入和输出,而是依赖全局的数据。findX和foo不再能够离开class A而存在,而且由于类成员还有可能被其他代码改变,代码变得难以理解,难以确保正确性。
    如果你使用局部变量而不是类成员来传递信息,那么这两个函数就不需要依赖于某一个class,而且更加容易理解,不易出错:

     String findX() {
      ...
      x = ...;
      return x;
     }
     void foo() {
     String x = findX();
     print(x);
     }
    

    写可读的代码

有些人以为写很多注释就可以让代码更加可读,然而却发现事与愿违。注释不但没能让代码变得可读,反而由于大量的注释充斥在代码中间,让程序变得障眼难读。而且代码的逻辑一旦修改,就会有很多的注释变得过时,需要更新。修改注释是相当大的负担,所以大量的注释,反而成为了妨碍改进代码的绊脚石。

实际上,真正优雅可读的代码,是几乎不需要注释的。如果你发现需要写很多注释,那么你的代码肯定是含混晦涩,逻辑不清晰的。其实,程序语言相比自然语言,是更加强大而严谨的,它其实具有自然语言最主要的元素:主语,谓语,宾语,名词,动词,如果,那么,否则,是,不是,…… 所以如果你充分利用了程序语言的表达能力,你完全可以用程序本身来表达它到底在干什么,而不需要自然语言的辅助。

有少数的时候,你也许会为了绕过其他一些代码的设计问题,采用一些违反直觉的作法。这时候你可以使用很短注释,说明为什么要写成那奇怪的样子。这样的情况应该少出现,否则这意味着整个代码的设计都有问题。

如果没能合理利用程序语言提供的优势,你会发现程序还是很难懂,以至于需要写注释。所以我现在告诉你一些要点,也许可以帮助你大大减少写注释的必要:

  1. 使用有意义的函数和变量名字。如果你的函数和变量的名字,能够切实的描述它们的逻辑,那么你就不需要写注释来解释它在干什么。比如:
    // put elephant1 into fridge2
    put(elephant1, fridge2);
    

    由于我的函数名put,加上两个有意义的变量名elephant1和fridge2,已经说明了这是在干什么(把大象放进冰箱),所以上面那句注释完全没有必要。

  2. 局部变量应该尽量接近使用它的地方。有些人喜欢在函数最开头定义很多局部变量,然后在下面很远的地方使用它,就像这个样子:
    void foo() {
      int index = ...;
      ...
      ...
      bar(index);
      ...
    }
    

    由于这中间都没有使用过index,也没有改变过它所依赖的数据,所以这个变量定义,其实可以挪到接近使用它的地方:

    void foo() {
      ...
      ...
      int index = ...;
      bar(index);
      ...
    }
    

    这样读者看到bar(index),不需要向上看很远就能发现index是如何算出来的。而且这种短距离,可以加强读者对于这里的“计算顺序”的理解。否则如果index在顶上,读者可能会怀疑,它其实保存了某种会变化的数据,或者它后来又被修改过。如果index放在下面,读者就清楚的知道,index并不是保存了什么可变的值,而且它算出来之后就没变过。
    如果你看透了局部变量的本质——它们就是电路里的导线,那你就能更好的理解近距离的好处。变量定义离用的地方越近,导线的长度就越短。你不需要摸着一根导线,绕来绕去找很远,就能发现接收它的端口,这样的电路就更容易理解。

  3. 局部变量名字应该简短。这貌似跟第一点相冲突,简短的变量名怎么可能有意义呢?注意我这里说的是局部变量,因为它们处于局部,再加上第2点已经把它放到离使用位置尽量近的地方,所以根据上下文你就会容易知道它的意思:
    比如,你有一个局部变量,表示一个操作是否成功:
    boolean successInDeleteFile = deleteFile("foo.txt");
    if (successInDeleteFile) {
      ...
    } else {
      ...
    }
    

    这个局部变量successInDeleteFile大可不必这么啰嗦。因为它只用过一次,而且用它的地方就在下面一行,所以读者可以轻松发现它是deleteFile返回的结果。如果你把它改名为success,其实读者根据一点上下文,也知道它表示”success in deleteFile”。所以你可以把它改成这样:

    boolean success = deleteFile("foo.txt");
    if (success) {
      ...
    } else {
      ...
    }
    

    这样的写法不但没漏掉任何有用的语义信息,而且更加易读。successInDeleteFile这种“camelCase”,如果超过了三个单词连在一起,其实是很碍眼的东西。所以如果你能用一个单词表示同样的意义,那当然更好。

  4. 不要重用局部变量。很多人写代码不喜欢定义新的局部变量,而喜欢“重用”同一个局部变量,通过反复对它们进行赋值,来表示完全不同意思。比如这样写:
    String msg;
    if (...) {
      msg = "succeed";
      log.info(msg);
    } else {
      msg = "failed";
      log.info(msg);
    }
    

    虽然这样在逻辑上是没有问题的,然而却不易理解,容易混淆。变量msg两次被赋值,表示完全不同的两个值。它们立即被log.info使用,没有传递到其它地方去。这种赋值的做法,把局部变量的作用域不必要的增大,让人以为它可能在将来改变,也许会在其它地方被使用。更好的做法,其实是定义两个变量:

    if (...) {
      String msg = "succeed";
      log.info(msg);
    } else {
      String msg = "failed";
      log.info(msg);
    }
    

    由于这两个msg变量的作用域仅限于它们所处的if语句分支,你可以很清楚的看到这两个msg被使用的范围,而且知道它们之间没有任何关系。

  5. 把复杂的逻辑提取出去,做成“帮助函数”。有些人写的函数很长,以至于看不清楚里面的语句在干什么,所以他们误以为需要写注释。如果你仔细观察这些代码,就会发现不清晰的那片代码,往往可以被提取出去,做成一个函数,然后在原来的地方调用。由于函数有一个名字,这样你就可以使用有意义的函数名来代替注释。举一个例子:
    ...
    // put elephant1 into fridge2
    openDoor(fridge2);
    if (elephant1.alive()) {
      ...
    } else {
    ...
    }
    closeDoor(fridge2);
    ...
    

    如果你把这片代码提出去定义成一个函数:

    void put(Elephant elephant, Fridge fridge) {
      openDoor(fridge);
      if (elephant.alive()) {
     ...
      } else {
      ...
      }
      closeDoor(fridge);
    }
    

    这样原来的代码就可以改成:

    ...
    put(elephant1, fridge2);
    ...
    

    更加清晰,而且注释也没必要了。

  6. 把复杂的表达式提取出去,做成中间变量。有些人听说“函数式编程”是个好东西,也不理解它的真正含义,就在代码里大量使用嵌套的函数。像这样:
    Pizza pizza = makePizza(crust(salt(), butter()),
    topping(onion(), tomato(), sausage()));
    

    这样的代码一行太长,而且嵌套太多,不容易看清楚。其实训练有素的函数式程序员,都知道中间变量的好处,不会盲目的使用嵌套的函数。他们会把这代码变成这样:

    Crust crust = crust(salt(), butter());
    Topping topping = topping(onion(), tomato(), sausage());
    Pizza pizza = makePizza(crust, topping);
    

    这样写,不但有效地控制了单行代码的长度,而且由于引入的中间变量具有“意义”,步骤清晰,变得很容易理解。

  7. 在合理的地方换行。对于绝大部分的程序语言,代码的逻辑是和空白字符无关的,所以你可以在几乎任何地方换行,你也可以不换行。这样的语言设计是个好东西,因为它给了程序员自由控制自己代码格式的能力。然而,它也引起了一些问题,因为很多人不知道如何合理的换行。

有些人喜欢利用IDE的自动换行机制,编辑之后用一个热键把整个代码重新格式化一遍,IDE就会把超过行宽限制的代码自动折行。可是这种自动这行,往往没有根据代码的逻辑来进行,不能帮助理解代码。自动换行之后可能产生这样的代码:

   if (someLongCondition1() && someLongCondition2() && someLongCondition3() &&
     someLongCondition4()) {
     ...
   }

由于someLongCondition4()超过了行宽限制,被编辑器自动换到了下面一行。虽然满足了行宽限制,换行的位置却是相当任意的,它并不能帮助人理解这代码的逻辑。这几个boolean表达式,全都用&&连接,所以它们其实处于平等的地位。为了表达这一点,当需要折行的时候,你应该把每一个表达式都放到新的一行,就像这个样子:

   if (someLongCondition1() &&
       someLongCondition2() &&
       someLongCondition3() &&
       someLongCondition4()) {
     ...
   }

这样每一个条件都对齐,里面的逻辑就很清楚了。再举个例子:

   log.info("failed to find file {} for command {}, with exception {}", file, command,
     exception);

这行因为太长,被自动折行成这个样子。file,command和exception本来是同一类东西,却有两个留在了第一行,最后一个被折到第二行。它就不如手动换行成这个样子:

   log.info("failed to find file {} for command {}, with exception {}",
     file, command, exception);

把格式字符串单独放在一行,而把它的参数一并放在另外一行,这样逻辑就更加清晰。

为了避免IDE把这些手动调整好的换行弄乱,很多IDE(比如IntelliJ)的自动格式化设定里都有“保留原来的换行符”的设定。如果你发现IDE的换行不符合逻辑,你可以修改这些设定,然后在某些地方保留你自己的手动换行。

说到这里,我必须警告你,这里所说的“不需注释,让代码自己解释自己”,并不是说要让代码看起来像某种自然语言。有个叫Chai的JavaScript测试工具,可以让你这样写代码:

expect(foo).to.be.a('string');
expect(foo).to.equal('bar');
expect(foo).to.have.length(3);
expect(tea).to.have.property('flavors').with.length(3);

这种做法是极其错误的。程序语言本来就比自然语言简单清晰,这种写法让它看起来像自然语言的样子,反而变得复杂难懂了。

写简单的代码

程序语言都喜欢标新立异,提供这样那样的“特性”,然而有些特性其实并不是什么好东西。很多特性都经不起时间的考验,最后带来的麻烦,比解决的问题还多。很多人盲目的追求“短小”和“精悍”,或者为了显示自己头脑聪明,学得快,所以喜欢利用语言里的一些特殊构造,写出过于“聪明”,难以理解的代码。

并不是语言提供什么,你就一定要把它用上的。实际上你只需要其中很小的一部分功能,就能写出优秀的代码。我一向反对“充分利用”程序语言里的所有特性。实际上,我心目中有一套最好的构造。不管语言提供了多么“神奇”的,“新”的特性,我基本都只用经过千锤百炼,我觉得值得信赖的那一套。

现在针对一些有问题的语言特性,我介绍一些我自己使用的代码规范,并且讲解一下为什么它们能让代码更简单。

  • 避免使用自增减表达式(i++,++i,i–,–i)。这种自增减操作表达式其实是历史遗留的设计失误。它们含义蹊跷,非常容易弄错。它们把读和写这两种完全不同的操作,混淆缠绕在一起,把语义搞得乌七八糟。含有它们的表达式,结果可能取决于求值顺序,所以它可能在某种编译器下能正确运行,换一个编译器就出现离奇的错误。
    其实这两个表达式完全可以分解成两步,把读和写分开:一步更新i的值,另外一步使用i的值。比如,如果你想写foo(i++),你完全可以把它拆成int t = i; i += 1; foo(t);。如果你想写foo(++i),可以拆成i += 1; foo(i); 拆开之后的代码,含义完全一致,却清晰很多。到底更新是在取值之前还是之后,一目了然。
    有人也许以为i++或者++i的效率比拆开之后要高,这只是一种错觉。这些代码经过基本的编译器优化之后,生成的机器代码是完全没有区别的。自增减表达式只有在两种情况下才可以安全的使用。一种是在for循环的update部分,比如for(int i = 0; i < 5; i++)。另一种情况是写成单独的一行,比如i++;。这两种情况是完全没有歧义的。你需要避免其它的情况,比如用在复杂的表达式里面,比如foo(i++),foo(++i) + foo(i),…… 没有人应该知道,或者去追究这些是什么意思。

  • 永远不要省略花括号。很多语言允许你在某种情况下省略掉花括号,比如C,Java都允许你在if语句里面只有一句话的时候省略掉花括号:
    if (...)
    action1();
    

    咋一看少打了两个字,多好。可是这其实经常引起奇怪的问题。比如,你后来想要加一句话action2()到这个if里面,于是你就把代码改成:

    if (...)
    action1();
    action2();
    

    为了美观,你很小心的使用了action1()的缩进。咋一看它们是在一起的,所以你下意识里以为它们只会在if的条件为真的时候执行,然而action2()却其实在if外面,它会被无条件的执行。我把这种现象叫做“光学幻觉”(optical illusion),理论上每个程序员都应该发现这个错误,然而实际上却容易被忽视。
    那么你问,谁会这么傻,我在加入action2()的时候加上花括号不就行了?可是从设计的角度来看,这样其实并不是合理的作法。首先,也许你以后又想把action2()去掉,这样你为了样式一致,又得把花括号拿掉,烦不烦啊?其次,这使得代码样式不一致,有的if有花括号,有的又没有。况且,你为什么需要记住这个规则?如果你不问三七二十一,只要是if-else语句,把花括号全都打上,就可以想都不用想了,就当C和Java没提供给你这个特殊写法。这样就可以保持完全的一致性,减少不必要的思考。
    有人可能会说,全都打上花括号,只有一句话也打上,多碍眼啊?然而经过实行这种编码规范几年之后,我并没有发现这种写法更加碍眼,反而由于花括号的存在,使得代码界限明确,让我的眼睛负担更小了。

  • 合理使用括号,不要盲目依赖操作符优先级。利用操作符的优先级来减少括号,对于1 + 2 * 3这样常见的算数表达式,是没问题的。然而有些人如此的仇恨括号,以至于他们会写出2 « 7 - 2 * 3这样的表达式,而完全不用括号。
    这里的问题,在于移位操作«的优先级,是很多人不熟悉,而且是违反常理的。由于x « 1相当于把x乘以2,很多人误以为这个表达式相当于(2 « 7) - (2 * 3),所以等于250。然而实际上«的优先级比加法+还要低,所以这表达式其实相当于2 « (7 - 2 * 3),所以等于4!
    解决这个问题的办法,不是要每个人去把操作符优先级表给硬背下来,而是合理的加入括号。比如上面的例子,最好直接加上括号写成2 « (7 - 2 * 3)。虽然没有括号也表示同样的意思,但是加上括号就更加清晰,读者不再需要死记«的优先级就能理解代码。

  • 避免使用continue和break。循环语句(for,while)里面出现return是没问题的,然而如果你使用了continue或者break,就会让循环的逻辑和终止条件变得复杂,难以确保正确。
    出现continue或者break的原因,往往是对循环的逻辑没有想清楚。如果你考虑周全了,应该是几乎不需要continue或者break的。如果你的循环里出现了continue或者break,你就应该考虑改写这个循环。改写循环的办法有多种:
    1. 如果出现了continue,你往往只需要把continue的条件反向,就可以消除continue。
    2. 如果出现了break,你往往可以把break的条件,合并到循环头部的终止条件里,从而去掉break。
    3. 有时候你可以把break替换成return,从而去掉break。
    4. 如果以上都失败了,你也许可以把循环里面复杂的部分提取出来,做成函数调用,之后continue或者break就可以去掉了。 下面我对这些情况举一些例子。
      情况1:下面这段代码里面有一个continue:
      List<String> goodNames = new ArrayList<>();
      for (String name: names) {
      if (name.contains("bad")) {
        continue;
      }
      goodNames.add(name);
      ...
      }  
      

      它说:“如果name含有’bad’这个词,跳过后面的循环代码……” 注意,这是一种“负面”的描述,它不是在告诉你什么时候“做”一件事,而是在告诉你什么时候“不做”一件事。为了知道它到底在干什么,你必须搞清楚continue会导致哪些语句被跳过了,然后脑子里把逻辑反个向,你才能知道它到底想做什么。这就是为什么含有continue和break的循环不容易理解,它们依靠“控制流”来描述“不做什么”,“跳过什么”,结果到最后你也没搞清楚它到底“要做什么”。
      其实,我们只需要把continue的条件反向,这段代码就可以很容易的被转换成等价的,不含continue的代码:

      List<String> goodNames = new ArrayList<>();
      for (String name: names) {
      if (!name.contains("bad")) {
        goodNames.add(name);
        ...
      }
      }  
      

      goodNames.add(name);和它之后的代码全部被放到了if里面,多了一层缩进,然而continue却没有了。你再读这段代码,就会发现更加清晰。因为它是一种更加“正面”地描述。它说:“在name不含有’bad’这个词的时候,把它加到goodNames的链表里面……”
      情况2:for和while头部都有一个循环的“终止条件”,那本来应该是这个循环唯一的退出条件。如果你在循环中间有break,它其实给这个循环增加了一个退出条件。你往往只需要把这个条件合并到循环头部,就可以去掉break。
      比如下面这段代码:

      while (condition1) {
      ...
      if (condition2) {
        break;
      }
      }
      

      当condition成立的时候,break会退出循环。其实你只需要把condition2反转之后,放到while头部的终止条件,就可以去掉这种break语句。改写后的代码如下:

      while (condition1 && !condition2) {
      ...
      }
      

      这种情况表面上貌似只适用于break出现在循环开头或者末尾的时候,然而其实大部分时候,break都可以通过某种方式,移动到循环的开头或者末尾。具体的例子我暂时没有,等出现的时候再加进来。
      情况3:很多break退出循环之后,其实接下来就是一个return。这种break往往可以直接换成return。比如下面这个例子: ``` public boolean hasBadName(List names) { boolean result = false;

    for (String name: names) { if (name.contains(“bad”)) { result = true; break; } } return result; }

    这个函数检查names链表里是否存在一个名字,包含“bad”这个词。它的循环里包含一个break语句。这个函数可以被改写成:
    

    public boolean hasBadName(List names) { for (String name: names) { if (name.contains("bad")) { return true; } } return false; } ``` 改进后的代码,在name里面含有“bad”的时候,直接用return true返回,而不是对result变量赋值,break出去,最后才返回。如果循环结束了还没有return,那就返回false,表示没有找到这样的名字。使用return来代替break,这样break语句和result这个变量,都一并被消除掉了。
    我曾经见过很多其他使用continue和break的例子,几乎无一例外的可以被消除掉,变换后的代码变得清晰很多。我的经验是,99%的break和continue,都可以通过替换成return语句,或者翻转if条件的方式来消除掉。剩下的1%含有复杂的逻辑,但也可以通过提取一个帮助函数来消除掉。修改之后的代码变得容易理解,容易确保正确。

写直观的代码

我写代码有一条重要的原则:如果有更加直接,更加清晰的写法,就选择它,即使它看起来更长,更笨,也一样选择它。比如,Unix命令行有一种“巧妙”的写法是这样:

command1 && command2 && command3

由于Shell语言的逻辑操作a && b具有“短路”的特性,如果a等于false,那么b就没必要执行了。这就是为什么当command1成功,才会执行command2,当command2成功,才会执行command3。同样,

command1 || command2 || command3

操作符||也有类似的特性。上面这个命令行,如果command1成功,那么command2和command3都不会被执行。如果command1失败,command2成功,那么command3就不会被执行。

这比起用if语句来判断失败,似乎更加巧妙和简洁,所以有人就借鉴了这种方式,在程序的代码里也使用这种方式。比如他们可能会写这样的代码:

if (action1() || action2() && action3()) {
  ...
}

你看得出来这代码是想干什么吗?action2和action3什么条件下执行,什么条件下不执行?也许稍微想一下,你知道它在干什么:“如果action1失败了,执行action2,如果action2成功了,执行action3”。然而那种语义,并不是直接的“映射”在这代码上面的。比如“失败”这个词,对应了代码里的哪一个字呢?你找不出来,因为它包含在了||的语义里面,你需要知道||的短路特性,以及逻辑或的语义才能知道这里面在说“如果action1失败……”。每一次看到这行代码,你都需要思考一下,这样积累起来的负荷,就会让人很累。
其实,这种写法是滥用了逻辑操作&&和||的短路特性。这两个操作符可能不执行右边的表达式,原因是为了机器的执行效率,而不是为了给人提供这种“巧妙”的用法。这两个操作符的本意,只是作为逻辑操作,它们并不是拿来给你代替if语句的。也就是说,它们只是碰巧可以达到某些if语句的效果,但你不应该因此就用它来代替if语句。如果你这样做了,就会让代码晦涩难懂。
上面的代码写成笨一点的办法,就会清晰很多:

if (!action1()) {
  if (action2()) {
    action3();
  }
}

这里我很明显的看出这代码在说什么,想都不用想:如果action1()失败了,那么执行action2(),如果action2()成功了,执行action3()。你发现这里面的一一对应关系吗?if=如果,!=失败,…… 你不需要利用逻辑学知识,就知道它在说什么。

写无懈可击的代码

在之前一节里,我提到了自己写的代码里面很少出现只有一个分支的if语句。我写出的if语句,大部分都有两个分支,所以我的代码很多看起来是这个样子:

if (...) {
  if (...) {
    ...
    return false;
  } else {
    return true;
  }
} else if (...) {
  ...
  return false;
} else {
  return true;
}

使用这种方式,其实是为了无懈可击的处理所有可能出现的情况,避免漏掉corner case。每个if语句都有两个分支的理由是:如果if的条件成立,你做某件事情;但是如果if的条件不成立,你应该知道要做什么另外的事情。不管你的if有没有else,你终究是逃不掉,必须得思考这个问题的。

很多人写if语句喜欢省略else的分支,因为他们觉得有些else分支的代码重复了。比如我的代码里,两个else分支都是return true。为了避免重复,他们省略掉那两个else分支,只在最后使用一个return true。这样,缺了else分支的if语句,控制流自动“掉下去”,到达最后的return true。他们的代码看起来像这个样子:

if (...) {
  if (...) {
    ...
    return false;
  }
} else if (...) {
  ...
  return false;
}
return true;

这种写法看似更加简洁,避免了重复,然而却很容易出现疏忽和漏洞。嵌套的if语句省略了一些else,依靠语句的“控制流”来处理else的情况,是很难正确的分析和推理的。如果你的if条件里使用了&&和||之类的逻辑运算,就更难看出是否涵盖了所有的情况。

由于疏忽而漏掉的分支,全都会自动“掉下去”,最后返回意想不到的结果。即使你看一遍之后确信是正确的,每次读这段代码,你都不能确信它照顾了所有的情况,又得重新推理一遍。这简洁的写法,带来的是反复的,沉重的头脑开销。这就是所谓“面条代码”,因为程序的逻辑分支,不是像一棵枝叶分明的树,而是像面条一样绕来绕去。

另外一种省略else分支的情况是这样:

String s = "";
if (x < 5) {
  s = "ok";
}

写这段代码的人,脑子里喜欢使用一种“缺省值”的做法。s缺省为null,如果x<5,那么把它改变(mutate)成“ok”。这种写法的缺点是,当x<5不成立的时候,你需要往上面看,才能知道s的值是什么。这还是你运气好的时候,因为s就在上面不远。很多人写这种代码的时候,s的初始值离判断语句有一定的距离,中间还有可能插入一些其它的逻辑和赋值操作。这样的代码,把变量改来改去的,看得人眼花,就容易出错。

现在比较一下我的写法:

String s;
if (x < 5) {
  s = "ok";
} else {
  s = "";
}

这种写法貌似多打了一两个字,然而它却更加清晰。这是因为我们明确的指出了x<5不成立的时候,s的值是什么。它就摆在那里,它是”“(空字符串)。注意,虽然我也使用了赋值操作,然而我并没有“改变”s的值。s一开始的时候没有值,被赋值之后就再也没有变过。我的这种写法,通常被叫做更加“函数式”,因为我只赋值一次。

如果我漏写了else分支,Java编译器是不会放过我的。它会抱怨:“在某个分支,s没有被初始化。”这就强迫我清清楚楚的设定各种条件下s的值,不漏掉任何一种情况。

当然,由于这个情况比较简单,你还可以把它写成这样:

String s = x < 5 ? "ok" : "";

对于更加复杂的情况,我建议还是写成if语句为好。

正确处理错误

使用有两个分支的if语句,只是我的代码可以达到无懈可击的其中一个原因。这样写if语句的思路,其实包含了使代码可靠的一种通用思想:穷举所有的情况,不漏掉任何一个。

程序的绝大部分功能,是进行信息处理。从一堆纷繁复杂,模棱两可的信息中,排除掉绝大部分“干扰信息”,找到自己需要的那一个。正确地对所有的“可能性”进行推理,就是写出无懈可击代码的核心思想。这一节我来讲一讲,如何把这种思想用在错误处理上。

错误处理是一个古老的问题,可是经过了几十年,还是很多人没搞明白。Unix的系统API手册,一般都会告诉你可能出现的返回值和错误信息。比如,Linux的read系统调用手册里面有如下内容:

RETURN VALUE 
On success, the number of bytes read is returned...

On error, -1 is returned, and errno is set appropriately.

ERRORS

EAGAIN, EBADF, EFAULT, EINTR, EINVAL, ...

很多初学者,都会忘记检查read的返回值是否为-1,觉得每次调用read都得检查返回值真繁琐,不检查貌似也相安无事。这种想法其实是很危险的。如果函数的返回值告诉你,要么返回一个正数,表示读到的数据长度,要么返回-1,那么你就必须要对这个-1作出相应的,有意义的处理。千万不要以为你可以忽视这个特殊的返回值,因为它是一种“可能性”。代码漏掉任何一种可能出现的情况,都可能产生意想不到的灾难性结果。

对于Java来说,这相对方便一些。Java的函数如果出现问题,一般通过异常(exception)来表示。你可以把异常加上函数本来的返回值,看成是一个“union类型”。比如:

String foo() throws MyException {
  ...
}

这里MyException是一个错误返回。你可以认为这个函数返回一个union类型:{String, MyException}。任何调用foo的代码,必须对MyException作出合理的处理,才有可能确保程序的正确运行。Union类型是一种相当先进的类型,目前只有极少数语言(比如Typed Racket)具有这种类型,我在这里提到它,只是为了方便解释概念。掌握了概念之后,你其实可以在头脑里实现一个union类型系统,这样使用普通的语言也能写出可靠的代码。

由于Java的类型系统强制要求函数在类型里面声明可能出现的异常,而且强制调用者处理可能出现的异常,所以基本上不可能出现由于疏忽而漏掉的情况。但有些Java程序员有一种恶习,使得这种安全机制几乎完全失效。每当编译器报错,说“你没有catch这个foo函数可能出现的异常”时,有些人想都不想,直接把代码改成这样:

try {
  foo();
} catch (Exception e) {}

或者最多在里面放个log,或者干脆把自己的函数类型上加上throws Exception,这样编译器就不再抱怨。这些做法貌似很省事,然而都是错误的,你终究会为此付出代价。

如果你把异常catch了,忽略掉,那么你就不知道foo其实失败了。这就像开车时看到路口写着“前方施工,道路关闭”,还继续往前开。这当然迟早会出问题,因为你根本不知道自己在干什么。

catch异常的时候,你不应该使用Exception这么宽泛的类型。你应该正好catch可能发生的那种异常A。使用宽泛的异常类型有很大的问题,因为它会不经意的catch住另外的异常(比如B)。你的代码逻辑是基于判断A是否出现,可你却catch所有的异常(Exception类),所以当其它的异常B出现的时候,你的代码就会出现莫名其妙的问题,因为你以为A出现了,而其实它没有。这种bug,有时候甚至使用debugger都难以发现。

如果你在自己函数的类型加上throws Exception,那么你就不可避免的需要在调用它的地方处理这个异常,如果调用它的函数也写着throws Exception,这毛病就传得更远。我的经验是,尽量在异常出现的当时就作出处理。否则如果你把它返回给你的调用者,它也许根本不知道该怎么办了。

另外,try { … } catch里面,应该包含尽量少的代码。比如,如果foo和bar都可能产生异常A,你的代码应该尽可能写成:

try {
  foo();
} catch (A e) {...}

try {
  bar();
} catch (A e) {...}

而不是

try {
  foo();
  bar();
} catch (A e) {...}

第一种写法能明确的分辨是哪一个函数出了问题,而第二种写法全都混在一起。明确的分辨是哪一个函数出了问题,有很多的好处。比如,如果你的catch代码里面包含log,它可以提供给你更加精确的错误信息,这样会大大地加速你的调试过程。

正确处理null指针

穷举的思想是如此的有用,依据这个原理,我们可以推出一些基本原则,它们可以让你无懈可击的处理null指针。

首先你应该知道,许多语言(C,C++,Java,C#,……)的类型系统对于null的处理,其实是完全错误的。这个错误源自于Tony Hoare最早的设计,Hoare把这个错误称为自己的“billion dollar mistake”,因为由于它所产生的财产和人力损失,远远超过十亿美元。

这些语言的类型系统允许null出现在任何对象(指针)类型可以出现的地方,然而null其实根本不是一个合法的对象。它不是一个String,不是一个Integer,也不是一个自定义的类。null的类型本来应该是NULL,也就是null自己。根据这个基本观点,我们推导出以下原则:

  • 尽量不要产生null指针。尽量不要用null来初始化变量,函数尽量不要返回null。如果你的函数要返回“没有”,“出错了”之类的结果,尽量使用Java的异常机制。虽然写法上有点别扭,然而Java的异常,和函数的返回值合并在一起,基本上可以当成union类型来用。比如,如果你有一个函数find,可以帮你找到一个String,也有可能什么也找不到,你可以这样写:
    public String find() throws NotFoundException {
    if (...) {
      return ...;
    } else {
      throw new NotFoundException();
    }
    }
    

    Java的类型系统会强制你catch这个NotFoundException,所以你不可能像漏掉检查null一样,漏掉这种情况。Java的异常也是一个比较容易滥用的东西,不过我已经在上一节告诉你如何正确的使用异常。
    Java的try…catch语法相当的繁琐和蹩脚,所以如果你足够小心的话,像find这类函数,也可以返回null来表示“没找到”。这样稍微好看一些,因为你调用的时候不必用try…catch。很多人写的函数,返回null来表示“出错了”,这其实是对null的误用。“出错了”和“没有”,其实完全是两码事。“没有”是一种很常见,正常的情况,比如查哈希表没找到,很正常。“出错了”则表示罕见的情况,本来正常情况下都应该存在有意义的值,偶然出了问题。如果你的函数要表示“出错了”,应该使用异常,而不是null。

  • 不要catch NullPointerException。有些人写代码很nice,他们喜欢“容错”。首先他们写一些函数,这些函数里面不大小心,没检查null指针:
    void foo() {
    String found = find();
    int len = found.length();
    ...
    }
    

    当foo调用产生了异常,他们不管三七二十一,就把调用的地方改成这样:

    try {
    foo();
    } catch (Exception e) {
    ...
    }
    

    这样当found是null的时候,NullPointerException就会被捕获并且得到处理。这其实是很错误的作法。首先,上一节已经提到了,catch (Exception e)这种写法是要绝对避免的,因为它捕获所有的异常,包括NullPointerException。这会让你意外地捕获try语句里面出现的NullPointerException,从而把代码的逻辑搅得一塌糊涂。
    另外就算你写成catch (NullPointerException e)也是不可以的。由于foo的内部缺少了null检查,才出现了NullPointerException。现在你不对症下药,倒把每个调用它的地方加上catch,以后你的生活就会越来越苦。正确的做法应该是改动foo,而不改调用它的代码。foo应该被改成这样:

    void foo() {
    String found = find();
    if (found != null) {
      int len = found.length();
      ...
    } else {
      ...
    }
    }
    

    在null可能出现的当时就检查它是否是null,然后进行相应的处理。

  • 不要把null放进“容器数据结构”里面。所谓容器(collection),是指一些对象以某种方式集合在一起,所以null不应该被放进Array,List,Set等结构,不应该出现在Map的key或者value里面。把null放进容器里面,是一些莫名其妙错误的来源。因为对象在容器里的位置一般是动态决定的,所以一旦null从某个入口跑进去了,你就很难再搞明白它去了哪里,你就得被迫在所有从这个容器里取值的位置检查null。你也很难知道到底是谁把它放进去的,代码多了就导致调试极其困难。
    解决方案是:如果你真要表示“没有”,那你就干脆不要把它放进去(Array,List,Set没有元素,Map根本没那个entry),或者你可以指定一个特殊的,真正合法的对象,用来表示“没有”。
    需要指出的是,类对象并不属于容器。所以null在必要的时候,可以作为对象成员的值,表示它不存在。比如:
    class A {
    String name = null;
    ...
    }
    

    之所以可以这样,是因为null只可能在A对象的name成员里出现,你不用怀疑其它的成员因此成为null。所以你每次访问name成员时,检查它是否是null就可以了,不需要对其他成员也做同样的检查。

  • 函数调用者:明确理解null所表示的意义,尽早检查和处理null返回值,减少它的传播。null很讨厌的一个地方,在于它在不同的地方可能表示不同的意义。有时候它表示“没有”,“没找到”。有时候它表示“出错了”,“失败了”。有时候它甚至可以表示“成功了”,…… 这其中有很多误用之处,不过无论如何,你必须理解每一个null的意义,不能给混淆起来。
    如果你调用的函数有可能返回null,那么你应该在第一时间对null做出“有意义”的处理。比如,上述的函数find,返回null表示“没找到”,那么调用find的代码就应该在它返回的第一时间,检查返回值是否是null,并且对“没找到”这种情况,作出有意义的处理。
    “有意义”是什么意思呢?我的意思是,使用这函数的人,应该明确的知道在拿到null的情况下该怎么做,承担起责任来。他不应该只是“向上级汇报”,把责任踢给自己的调用者。如果你违反了这一点,就有可能采用一种不负责任,危险的写法:
    public String foo() {
    String found = find();
    if (found == null) {
      return null;
    }
    }
    

    当看到find()返回了null,foo自己也返回null。这样null就从一个地方,游走到了另一个地方,而且它表示另外一个意思。如果你不假思索就写出这样的代码,最后的结果就是代码里面随时随地都可能出现null。到后来为了保护自己,你的每个函数都会写成这样:

    public void foo(A a, B b, C c) {
    if (a == null) { ... }
    if (b == null) { ... }
    if (c == null) { ... }
    ...
    }
    
  • 函数作者:明确声明不接受null参数,当参数是null时立即崩溃。不要试图对null进行“容错”,不要让程序继续往下执行。如果调用者使用了null作为参数,那么调用者(而不是函数作者)应该对程序的崩溃负全责。
    上面的例子之所以成为问题,就在于人们对于null的“容忍态度”。这种“保护式”的写法,试图“容错”,试图“优雅的处理null”,其结果是让调用者更加肆无忌惮的传递null给你的函数。到后来,你的代码里出现一堆堆nonsense的情况,null可以在任何地方出现,都不知道到底是哪里产生出来的。谁也不知道出现了null是什么意思,该做什么,所有人都把null踢给其他人。最后这null像瘟疫一样蔓延开来,到处都是,成为一场噩梦。
    正确的做法,其实是强硬的态度。你要告诉函数的使用者,我的参数全都不能是null,如果你给我null,程序崩溃了该你自己负责。至于调用者代码里有null怎么办,他自己该知道怎么处理(参考以上几条),不应该由函数作者来操心。
    采用强硬态度一个很简单的做法是使用Objects.requireNonNull()。它的定义很简单:
    public static <T> T requireNonNull(T obj) {
    if (obj == null) {
      throw new NullPointerException();
    } else {
      return obj;
    }
    }
    

    你可以用这个函数来检查不想接受null的每一个参数,只要传进来的参数是null,就会立即触发NullPointerException崩溃掉,这样你就可以有效地防止null指针不知不觉传递到其它地方去。

  • 使用@NotNull和@Nullable标记。IntelliJ提供了@NotNull和@Nullable两种标记,加在类型前面,这样可以比较简洁可靠地防止null指针的出现。IntelliJ本身会对含有这种标记的代码进行静态分析,指出运行时可能出现NullPointerException的地方。在运行时,会在null指针不该出现的地方产生IllegalArgumentException,即使那个null指针你从来没有deference。这样你可以在尽量早期发现并且防止null指针的出现。

  • 使用Optional类型。Java 8和Swift之类的语言,提供了一种叫Optional的类型。正确的使用这种类型,可以在很大程度上避免null的问题。null指针的问题之所以存在,是因为你可以在没有“检查”null的情况下,“访问”对象的成员。
    Optional类型的设计原理,就是把“检查”和“访问”这两个操作合二为一,成为一个“原子操作”。这样你没法只访问,而不进行检查。这种做法其实是ML,Haskell等语言里的模式匹配(pattern matching)的一个特例。模式匹配使得类型判断和访问成员这两种操作合二为一,所以你没法犯错。
    比如,在Swift里面,你可以这样写:
    let found = find()
    if let content = found {
    print("found: " + content)
    }
    

    你从find()函数得到一个Optional类型的值found。假设它的类型是String?,那个问号表示它可能包含一个String,也可能是nil。然后你就可以用一种特殊的if语句,同时进行null检查和访问其中的内容。这个if语句跟普通的if语句不一样,它的条件不是一个Bool,而是一个变量绑定let content = found。
    我不是很喜欢这语法,不过这整个语句的含义是:如果found是nil,那么整个if语句被略过。如果它不是nil,那么变量content被绑定到found里面的值(unwrap操作),然后执行print(“found: “ + content)。由于这种写法把检查和访问合并在了一起,你没法只进行访问而不检查。
    Java 8的做法比较蹩脚一些。如果你得到一个Optional类型的值found,你必须使用“函数式编程”的方式,来写这之后的代码:

    Optional<String> found = find();
    found.ifPresent(content -> System.out.println("found: " + content));
    

    这段Java代码跟上面的Swift代码等价,它包含一个“判断”和一个“取值”操作。ifPresent先判断found是否有值(相当于判断是不是null)。如果有,那么将其内容“绑定”到lambda表达式的content参数(unwrap操作),然后执行lambda里面的内容,否则如果found没有内容,那么ifPresent里面的lambda不执行。
    Java的这种设计有个问题。判断null之后分支里的内容,全都得写在lambda里面。在函数式编程里,这个lambda叫做“continuation”,Java把它叫做 “Consumer”,它表示“如果found不是null,拿到它的值,然后应该做什么”。由于lambda是个函数,你不能在里面写return语句返回出外层的函数。比如,如果你要改写下面这个函数(含有null):

    public static String foo() {
    String found = find();
    if (found != null) {
      return found;
    } else {
      return "";
    }
    }
    

    就会比较麻烦。因为如果你写成这样:

    public static String foo() {
    Optional<String> found = find();
    found.ifPresent(content -> {
      return content;    // can't return from foo here
    });
    return "";
    }
    

    里面的return a,并不能从函数foo返回出去。它只会从lambda返回,而且由于那个lambda(Consumer.accept)的返回类型必须是void,编译器会报错,说你返回了String。由于Java里closure的自由变量是只读的,你没法对lambda外面的变量进行赋值,所以你也不能采用这种写法:

    public static String foo() {
    Optional<String> found = find();
    String result = "";
    found.ifPresent(content -> {
      result = content;    // can't assign to result
    });
    return result;
    }
    

    所以,虽然你在lambda里面得到了found的内容,如何使用这个值,如何返回一个值,却让人摸不着头脑。你平时的那些Java编程手法,在这里几乎完全废掉了。实际上,判断null之后,你必须使用Java 8提供的一系列古怪的函数式编程操作:map, flatMap, orElse之类,想法把它们组合起来,才能表达出原来代码的意思。比如之前的代码,只能改写成这样:

    public static String foo() {
    Optional<String> found = find();
    return found.orElse("");
    }
    

    这简单的情况还好。复杂一点的代码,我还真不知道怎么表达,我怀疑Java 8的Optional类型的方法,到底有没有提供足够的表达力。那里面少数几个东西表达能力不咋的,论工作原理,却可以扯到functor,continuation,甚至monad等高深的理论…… 仿佛用了Optional之后,这语言就不再是Java了一样。
    所以Java虽然提供了Optional,但我觉得可用性其实比较低,难以被人接受。相比之下,Swift的设计更加简单直观,接近普通的过程式编程。你只需要记住一个特殊的语法if let content = found {…},里面的代码写法,跟普通的过程式语言没有任何差别。
    总之你只要记住,使用Optional类型,要点在于“原子操作”,使得null检查与取值合二为一。这要求你必须使用我刚才介绍的特殊写法。如果你违反了这一原则,把检查和取值分成两步做,还是有可能犯错误。比如在Java 8里面,你可以使用found.get()这样的方式直接访问found里面的内容。在Swift里你也可以使用found!来直接访问而不进行检查。
    你可以写这样的Java代码来使用Optional类型:

    Option<String> found = find();
    if (found.isPresent()) {
    System.out.println("found: " + found.get());
    }
    

    如果你使用这种方式,把检查和取值分成两步做,就可能会出现运行时错误。if (found.isPresent())本质上跟普通的null检查,其实没什么两样。如果你忘记判断found.isPresent(),直接进行found.get(),就会出现NoSuchElementException。这跟NullPointerException本质上是一回事。所以这种写法,比起普通的null的用法,其实换汤不换药。如果你要用Optional类型而得到它的益处,请务必遵循我之前介绍的“原子操作”写法。

防止过度工程

人的脑子真是奇妙的东西。虽然大家都知道过度工程(over-engineering)不好,在实际的工程中却经常不由自主的出现过度工程。我自己也犯过好多次这种错误,所以觉得有必要分析一下,过度工程出现的信号和兆头,这样可以在初期的时候就及时发现并且避免。

过度工程即将出现的一个重要信号,就是当你过度的思考“将来”,考虑一些还没有发生的事情,还没有出现的需求。比如,“如果我们将来有了上百万行代码,有了几千号人,这样的工具就支持不了了”,“将来我可能需要这个功能,所以我现在就把代码写来放在那里”,“将来很多人要扩充这片代码,所以现在我们就让它变得可重用”……

这就是为什么很多软件项目如此复杂。实际上没做多少事情,却为了所谓的“将来”,加入了很多不必要的复杂性。眼前的问题还没解决呢,就被“将来”给拖垮了。人们都不喜欢目光短浅的人,然而在现实的工程中,有时候你就是得看近一点,把手头的问题先搞定了,再谈以后扩展的问题。

另外一种过度工程的来源,是过度的关心“代码重用”。很多人“可用”的代码还没写出来呢,就在关心“重用”。为了让代码可以重用,最后被自己搞出来的各种框架捆住手脚,最后连可用的代码就没写好。如果可用的代码都写不好,又何谈重用呢?很多一开头就考虑太多重用的工程,到后来被人完全抛弃,没人用了,因为别人发现这些代码太难懂了,自己从头开始写一个,反而省好多事。

过度地关心“测试”,也会引起过度工程。有些人为了测试,把本来很简单的代码改成“方便测试”的形式,结果引入很多复杂性,以至于本来一下就能写对的代码,最后复杂不堪,出现很多bug。

世界上有两种“没有bug”的代码。一种是“没有明显的bug的代码”,另一种是“明显没有bug的代码”。第一种情况,由于代码复杂不堪,加上很多测试,各种coverage,貌似测试都通过了,所以就认为代码是正确的。第二种情况,由于代码简单直接,就算没写很多测试,你一眼看去就知道它不可能有bug。你喜欢哪一种“没有bug”的代码呢?

根据这些,我总结出来的防止过度工程的原则如下:

  1. 先把眼前的问题解决掉,解决好,再考虑将来的扩展问题。
  2. 先写出可用的代码,反复推敲,再考虑是否需要重用的问题。
  3. 先写出可用,简单,明显没有bug的代码,再考虑测试的问题。

Comments

CONTENT