• 下面我对这些情况举一些例子。

    情况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<String> names) { boolean result = false; for (String name: names) { if (name.contains("bad")) { result = true; break; } } return result; } 

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

    public boolean hasBadName(List<String> 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里面,应该包含尽量少的代码。比如,如果foobar都可能产生异常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,flatMaporElse之类,想法把它们组合起来,才能表达出原来代码的意思。比如之前的代码,只能改写成这样:

    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的代码,再考虑测试的问题。

来源 http://www.yinwang.org/blog-cn/2015/11/21/programming-philosophy

编程的智慧(下)
标签: