转载

编程的智慧(初稿)

(这只是初稿,稍候的一段时间还要多加修改,增添和减少内容,所以请勿转载)

编程是一件创造性的工作,是一门艺术。人需要很多的练习和领悟,才能精通一门艺术,所以这里提出的“智慧”,并不是减肥药,它并不能代替你自己辛苦的练习和领悟。然而我希望它能给迷惑中的人们指出一些正确的方向,让他们少走一些弯路。

反复修改代码

有人问我,提高编程水平最有效的办法是什么?我想了很久,终于发现最有效的办法,其实是反反复复地修改代码。

在IU的时候,我受到了Dan Friedman的严格教导,我们以写出冗长复杂的代码为耻。如果你解决一个问题写了超过他预期的代码数量,他就会笑你,说:“你应该只需要5行代码来解决这个问题。如果多了你就不大可能是对的。” 现实的工作编程,也许永远不可能达到Friedman的标准。然而他这个提炼代码,减少冗余的习惯,却深入了我的骨髓。

有些人喜欢炫耀自己写了多少多少万行的代码,仿佛代码的数量是衡量编程水平的标准。然而,如果你总是匆匆写出代码,却从来不回头去推敲,修改和提炼,你是不可能提高编程的水平的。你会制造出越来越多平庸甚至糟糕的代码。

在这种意义上,很多人所谓的“工作经验”,跟他代码的质量,其实不一定成正比。如果一个人有几十年的工作经验,却从来不回头去提炼代码,那么他也许还不如一个只有一两年经验,但是却反复推敲,仔细领悟的人。

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

就像文学作品一样,代码是不可能一蹴而就的。灵感似乎总是零零星星,陆陆续续到来的。任何人都不可能一笔呵成,写成最优雅高效的代码。就算再厉害的程序员,也需要经过一段时间,才能发现最简单优雅的写法。

有时候你反复提炼一段代码,觉得到了顶峰了,没法再改进了。可是过了几个月之后再回头来看,又可以发现好多可以改进和简化的地方。这跟写文章一模一样,回头看几个月或者几年前写的东西,你几乎总是能发现可以改进的地方。所以如果反复提炼代码已经不再有进展,那么你可以暂时把它放下,过几个星期或者几个月再回头来看,也许就有焕然一新的感觉。

这样反反复复很多次之后,你就积累起了灵感和智慧,从而能够在遇到新的问题的时候直接朝正确,或者接近正确的方向前进。

优雅的代码是什么形状的

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

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

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

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

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

如何让程序模块化

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

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

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

  • 避免写出太长的函数,把它拆分成更小的函数。通常来说,我写的函数长度都不超过50行,那正好也是我的MacBook屏幕所能容纳的代码的行数。这样我可以一目了然的看见一个函数,而不需要滚动屏幕。50行并不是一个很大的限制,因为函数里面比较复杂的部分,往往早就被我提取出去,做成了更小的函数,然后从原来的函数里面调用。

    有些人避免使用小的函数,因为他们认为函数调用有一定的开销。这种实际上是历史遗留的错觉。每一个现代的编译器都能自动的把小尺寸的函数嵌入(inline)到调用它的地方,而不产生一个函数调用,所以不会产生任何多余开销。

    同样的一些人,也爱使用宏(macro)来代替小函数,这也是一种历史遗留的错觉。在早期的C语言编译器里,只有macro是静态“嵌入”的,所以他们使用宏,其实是为了达到嵌入的目的。其实能否静态嵌入,并不是宏与函数根本的区别。宏跟函数具有实质上的巨大区别(这个我以后再讲),为了嵌入而使用宏,其实是滥用了宏,这会引起各种各样麻烦的问题,比如使程序难以理解,难以调试,容易出错等等。

  • 每个函数只做一件简单的事情。有些人喜欢制造一些“通用”的函数,既可以做这个又可以做那个,然后他们传递一个参数来“选择”这个函数所要做的事情。这种“复用”其实是有害的。如果一个函数可能做两种不一样的事情,你最好就写成两个不同的函数,否则这个函数的逻辑就不会很清晰,容易出现错误。

如何写出可读的代码

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

实际上,真正优雅可读的代码,是几乎不需要注释的。如果你发现需要写很多注释才能解释清楚你的代码在干什么,那么你的代码肯定是含混晦涩,逻辑不清晰的。其实,程序语言在逻辑方面是远远高于自然语言和数学家所用的符号语言的。使用大量的自然语言去解释程序工作的细节,是本末倒置的。

有人受到了Donald Knuth提出的所谓“文学编程”(Literate Programming)的误导,认为程序里面注释应该是主要的部分,而代码其次,其实并不是这样的。很多人(包括Knuth自己)使用文学编程,其实并没有写出一流的,容易理解的代码。Knuth认为人与人之间交流必须使用自然语言,其实程序语言如果使用得当,能够更加清晰精确地在人类之间传递信息。

之所以说“如果使用得当”,是因为虽然程序语言表达力非常强大,但如果你没有利用它提供的优势,就会发现程序还是很难懂,以至于需要写注释。所以我现在告诉你一些要点,也许可以帮助你大大减少写注释的必要:

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

    // put elephant elephant1 into fridge fridge2 putElephantIntoFridge(elephant1, fridge2);

    由于我的函数名 putElephantIntoFridge 已经解释得很清楚它要干什么(把大象放进冰箱),所以我完全没有必要写上面那句注释来解释它在干什么。

  2. 把复杂的逻辑提取出去,做成“帮助函数”。有些人写的函数很长很长,以至于你看不清楚它里面在干什么,然后就误以为需要写注释。可是你仔细观察之后就会发现,不清晰的那片代码,往往可以被提取出去,做成一个函数,然后在原来的地方调用。由于函数有一个名字,这样你就可以使用有意义的函数名来代替注释。举一个例子:

    ... ... ... ... // put elephant elephant1 into fridge fridge2 openDoor(fridge2); if (driveElephantIntoFridge(elephan1, fridge2)) {  feedElephant(new Treat(), elephant1); } else {  putBananaIntoFridge(new Banana(), fridge2);  waitForElephantEnter(elephant1, fridge2); } closeDoor(fridge2); ... ... ... ... 

    这里面的那片代码就可以被提取出去,做成一个函数:

    function putElephantIntoFridge(elephant, fridge) {   openDoor(fridge2);   if (driveElephantIntoFridge(elephan1, fridge2)) {     feedElephant(new Treat(), elephant1);   } else {     putBananaIntoFridge(new Banana(), fridge2);     waitForElephantEnter(elephant1, fridge2);   }   closeDoor(fridge2); }

    然后你就可以把原来的那片代码改成:

    ... ... ... ...  putElephantIntoFridge(elephant1, fridge2);  ... ... ... ...

看明白了吗?程序语言相比自然语言,是非常强大而严谨的,它其实已经具有自然语言最主要的要素。如果你充分利用了程序语言的表达能力,你完全可以用程序本身来表达它到底在干什么,而不需要自然语言的辅助。

有少数的时候,你也许会发现代码里有些违反直觉的作法,比如你做了一个检查,看似没必要,但确实是因为某种原因必须做的。这种时候你就可以使用很短的一条注释,说明这代码为什么要写成那个样子。这样的情况应该很少出现,否则这意味着整个代码的设计都有问题。

一些编码规范

现在我指出一些不大常见的代码规范,然后稍微解释一下为什么它们能提高代码的质量。

  • 避免使用i++和++i。这种自增减操作表达式含义很蹊跷,非常容易搞混淆。而且含有它们的表达式的结果,有可能取决于参数的求值顺序。其实这两个表达式完全可以分解成两步做,一步含有赋值的副作用,另外一步只是读i的值,而没有副作用。比如,如果你想写 foo(i++) ,你完全可以把它拆分成 int t = i; i += 1; foo(t); 。如果你想写 foo(++i) ,你可以把它拆成 i += 1; foo(i); 拆开之后的代码,含义完全跟原来一致,然而却清晰很多,到底增值是在取值之前还是之后,全都一目了然。

    有人也许以为i++或者++i的效率比拆开之后要高,这只是一种误解。这些代码经过最基础的编译器优化之后,生成的机器代码是完全没有区别的。i++和++i,只有在两种情况下可以安全的使用。一种是用在for循环语句的update部分,比如 for(int i=0; i<5; i++) ,另一种情况是写在单独的一行,比如 i++; ,这样的情况下是完全没有歧义的。但是一定要避免把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的原因,往往是对循环要执行的逻辑没有想得很清楚。因为如果你考虑周全了,你应该几乎不需要continue或者break语句。改写循环的办法有多种,你也许可以把某些复杂的终止条件的部分提取出来,做成一个函数调用。也许可以在分析清楚之后把它变成另一种循环。

大智若愚,避免奇技淫巧

我写代码有一条重要的原则:如果有更加直接,更加清晰的写法,就选择它,即使它看起来更长,更笨,也一样选择这种写法。相比之下,有些人为了所谓“简洁”和显得“聪明”,喜欢到处学一些奇技淫巧。比如,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 =如果, ! =失败,…… 你不需要经过逻辑背景知识的翻译,就知道它在说什么。

防止过度工程

人的脑子真是奇妙的东西。虽然大家都知道过度工程不好,在实际的工程中却经常不自觉的出现过度工程。所以我觉得必须了解一下过度工程出现的信号和兆头,在初期的时候就避免它。

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

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

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

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

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

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

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

无懈可击地处理corner case

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

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

使用这种方式,其实是为了无懈可击的处理所有可能出现的情况,避免漏掉corner case。原因很明显,如果if语句条件为真,你做某件事情,那么你必须考虑条件不成立的情况应该怎么做。很多人写if语句喜欢省略else的分支,因为他们觉得很多else分支的代码重复了。比如我的代码里,两个else分支都是 return true ,所以他们觉得重复了。为了避免重复,他们省略掉那两个else分支,在最后使用一个 return true 。他们的代码看起来像这个样子:

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

这种写法看似更加简洁,避免了重复,然而却很容易出现漏掉的情况。嵌套的if语句如果省略了一些else,是很难正确的分析和推理的。如果你的if条件里使用了 &&|| 之类的逻辑运算,就更难看出是否涵盖了所有的情况。即使你看一会儿之后确信是正确的,每次读这段代码,你都不能确信它cover了所有的情况,你又得重新推理一遍。这简洁的写法,带来的是反复的头脑开销,所以到头来你会觉得很累。

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

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

如何对待和处理NULL指针

接上一节,一个很简单的例子,可以说明这种“对所有可能性进行推理”的穷举思想,可以让你无懈可击的处理NULL指针。比如,我如果给你一个Java 函数 find ,它的类型说,它会返回给你一个 A 对象。其实这类型没有告诉你的是,如果没找到合适的值,它会给你一个null。

class A {   int value;   ... }  public A find() {   if (...) {     return new A(5);   } else {     return null;   } }

很多人调用find的时候也许就会这样写:

A x = find(); int v = x.value;   ... }

问题就在于find()有可能返回null,而null并不含有一个叫value的成员,所以Java会在运行时给你一个NullPointerException。实际上Java,C++之类语言的类型系统,对于null指针的处理是完全错误的:NullPointerException根本不应该存在,也不应该发生。

Java,C++的类型系统允许null被作为任何对象(比如A对象)返回,然而它却不具有A对象的基本特性(比如可以取其中一个叫value的成员)。这就是为什么当x是null的时候, x.value 会引起程序崩溃。事实上,null根本就不该被认为是A类型的对象。

可以把null指针作为任何对象返回,是程序语言界一个历史性的打错。Tony Hoare主动认罪,把null指针问题叫做自己的“billion dollar mistake”,这一点不假。然而,Tony Hoare是在告诉你,千万不要用null指针吗?其实不是的。null指针是有用的,否则你没法很好的表示“没有”这种情况。

null指针的真正问题,不在于它的存在,而在于类型系统如何对待它。null根本不是一个A对象,却被Java的类型系统作为A类型的对象。实际上null被作为所有对象类型的一个元素。这是极其错误的做法,因为这意味着null可以出现在任何对象类型可以出现的地方,然而你却不能对它进行任何那种对象支持的操作。一旦你做了这种操作,就会出现NullPointerException。Java强制你catch其它的Exception类型,却不要求你catch NullPointerException,所以null的问题往往只有运行的时候才会出现。这是非常危险的。

上述的find函数的返回类型,其实没有如实的反应所有可能的返回值。它正确的返回类型应该是一个“union类型”(Java和C++的类型系统不具有这种功能),是 {A, NULL} 。它显式的指出,find的返回值可能是一个A对象,也可能是一个null。

这样一来,上面使用find的代码就不可能通过静态类型检查:

A x = find(); int v = x.value;  // type error, can't take member value of type {A, NULL}   ... }

一个正确的类型系统,会报告因为find()返回了{A, NULL}(而不是A),而NULL里面根本没有一个叫value的成员,所以x.value这种写法不合法。这种可靠的union类型系统,已经存在于Typed Racket和Yin语言里面,然而工业界的语言要发展到这一步,恐怕还要等很多年。

虽然Java和C++之类语言犯下的历史错误,也许要很久以后才能改正,然而如果你自己小心一点,使用我这里提到的推理方式,其实可以有效地减少null指针带来的问题:

  • 首先你必须明确的理解:null指针根本不是一个合法的对象。它不是一个String,不是一个Integer,也不是一个自定义的类。它的类型本来应该是NULL,也就是null自己。

  • 尽量减少null指针的出现。如果你的函数要返回一个“没找到”,“出错了”之类的结果,尽量使用Java的异常机制。虽然写法上有点别扭,然而Java的异常,和函数的返回值合并在一起,基本上可以当成union类型来用。比如上面的函数可以改写成:

    public A find() throws NotFoundException { if (...) {   return new A(5); } else {   throw new NotFoundException(); } }

    Java的类型系统会强制你catch这个NotFoundException,所以你不可能漏掉这种情况。

  • 不要把null放进任何复杂的数据结构里面。null不应该被放进List,不应该出现在Map的value里面。把null放进这些复杂的数据结构里,是一些莫名其妙错误的来源。如果你真要表示“没有”,那么你可以干脆不把它放进去(List没有元素,Map根本没那个key),或者你可以指定一个特殊的,合法的对象,用来表示“没有”。

  • 尽早检查和处理null指针,尽量减少它的传递。有一种很诡异,很危险的写法是这样:

     A x = find();  if (x == null) {    return null;  }

    或者

     public B foo(A obj) {     if (obj == null) {       return null;     }  }

    当看到null,这两个代码自己也返回null。这样null就从一个地方,游走到了另一个地方。如果你不假思索就写出这样的代码,最后的结果就是你的代码里面很多地方都有可能出现null。到后来为了保护自己,你的每个函数都会写成这样:

     public void foo(A a, B b, C c) {    if (a == null) { ... }    if (a == null) { ... }    if (a == null) { ... }    ...  }
  • 进行“非null”假设,故意让客户代码crash。上面的例子之所以成为问题,就在于人们对于null的“容忍态度”。上面这种“保护式”的写法,试图“优雅的处理null”,其实会让调用者更加肆无忌惮的传递null指针给你的函数。

    这就像你如果给绑架者赎金,下次他还会继续来绑架你的小孩,因为他知道你软弱,有利可图!正确的做法,其实是强硬的态度。你要告诉函数的使用者,我的参数都不能是null,如果你给我null,我就让你crash!之于如果你的数据里有null怎么办,你自己处理,不关我的事。

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

正文到此结束
Loading...