编程是一种创造性的工做,是一门艺术。精通任何一门艺术,都须要不少的练习和领悟,因此这里提出的“智慧”,并非号称一天瘦十斤的减肥药,它并不能代替你本身的勤奋。然而因为软件行业喜欢标新立异,喜欢把简单的事情搞复杂,我但愿这些文字能给迷惑中的人们指出一些正确的方向,让他们少走一些弯路,基本作到一分耕耘一分收获。html
有些人喜欢炫耀本身写了多少多少万行的代码,仿佛代码的数量是衡量编程水平的标准。然而,若是你老是匆匆写出代码,却历来不回头去推敲,修改和提炼,实际上是不可能提升编程水平的。你会制造出愈来愈多平庸甚至糟糕的代码。在这种意义上,不少人所谓的“工做经验”,跟他代码的质量其实不必定成正比。若是有几十年的工做经验,却历来不回头去提炼和反思本身的代码,那么他也许还不如一个只有一两年经验,却喜欢反复推敲,仔细领悟的人。java
有位文豪说得好:“看一个做家的水平,不是看他发表了多少文字,而要看他的废纸篓里扔掉了多少。” 我以为一样的理论适用于编程。好的程序员,他们删掉的代码,比留下来的还要多不少。若是你看见一我的写了不少代码,却没有删掉多少,那他的代码必定有不少垃圾。linux
就像文学做品同样,代码是不可能一蹴而就的。灵感彷佛老是零零星星,陆陆续续到来的。任何人都不可能一笔呵成,就算再厉害的程序员,也须要通过一段时间,才能发现最简单优雅的写法。有时候你反复提炼一段代码,以为到了顶峰,无法再改进了,但是过了几个月再回头来看,又发现好多能够改进和简化的地方。这跟写文章如出一辙,回头看几个月或者几年前写的东西,你总能发现一些改进。程序员
因此若是反复提炼代码已经再也不有进展,那么你能够暂时把它放下。过几个星期或者几个月再回头来看,也许就有面目一新的灵感。这样反反复复不少次以后,你就积累起了灵感和智慧,从而可以在遇到新问题的时候直接朝正确,或者接近正确的方向前进。编程
人们都讨厌“面条代码”(spaghetti code),由于它就像面条同样绕来绕去,无法理清头绪。那么优雅的代码通常是什么形状的呢?通过多年的观察,我发现优雅的代码,在形状上有一些明显的特征。api
若是咱们忽略具体的内容,从大致结构上来看,优雅的代码看起来就像是一些整整齐齐,套在一块儿的盒子。若是跟整理房间作一个类比,就很容易理解。若是你把全部物品都丢在一个很大的抽屉里,那么它们就会全都混在一块儿。你就很难整理,很难迅速的找到须要的东西。可是若是你在抽屉里再放几个小盒子,把物品分门别类放进去,那么它们就不会处处乱跑,你就能够比较容易的找到和管理它们。安全
优雅的代码的另外一个特征是,它的逻辑大致上看起来,是枝丫分明的树状结构(tree)。这是由于程序所作的几乎一切事情,都是信息的传递和分支。你能够把代码当作是一个电路,电流通过导线,分流或者汇合。若是你是这样思考的,你的代码里就会比较少出现只有一个分支的if语句,它看起来就会像这个样子:数据结构
if (...) { if (...) { ... } else { ... } } else if (...) { ... } else { ... }
注意到了吗?在个人代码里面,if语句几乎老是有两个分支。它们有可能嵌套,有多层的缩进,并且else分支里面有可能出现少许重复的代码。然而这样的结构,逻辑却很是严密和清晰。在后面我会告诉你为何if语句最好有两个分支。oracle
有些人吵着闹着要让程序“模块化”,结果他们的作法是把代码分部到多个文件和目录里面,而后把这些目录或者文件叫作“module”。他们甚至把这些目录分放在不一样的VCS repo里面。结果这样的做法并无带来合做的流畅,而是带来了许多的麻烦。这是由于他们其实并不理解什么叫作“模块”,肤浅的把代码切割开来,分放在不一样的位置,其实非但不能达到模块化的目的,并且制造了没必要要的麻烦。app
真正的模块化,并非文本意义上的,而是逻辑意义上的。一个模块应该像一个电路芯片,它有定义良好的输入和输出。实际上一种很好的模块化方法早已经存在,它的名字叫作“函数”。每个函数都有明确的输入(参数)和输出(返回值),同一个文件里能够包含多个函数,因此你其实根本不须要把代码分开在多个文件或者目录里面,一样能够完成代码的模块化。我能够把代码全都写在同一个文件里,却仍然是很是模块化的代码。
想要达到很好的模块化,你须要作到如下几点:
避免写太长的函数。若是发现函数太大了,就应该把它拆分红几个更小的。一般我写的函数长度都不超过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); }
有些人觉得写不少注释就可让代码更加可读,然而却发现事与愿违。注释不但没能让代码变得可读,反而因为大量的注释充斥在代码中间,让程序变得障眼难读。并且代码的逻辑一旦修改,就会有不少的注释变得过期,须要更新。修改注释是至关大的负担,因此大量的注释,反而成为了妨碍改进代码的绊脚石。
实际上,真正优雅可读的代码,是几乎不须要注释的。若是你发现须要写不少注释,那么你的代码确定是含混晦涩,逻辑不清晰的。其实,程序语言相比天然语言,是更增强大而严谨的,它其实具备天然语言最主要的元素:主语,谓语,宾语,名词,动词,若是,那么,不然,是,不是,…… 因此若是你充分利用了程序语言的表达能力,你彻底能够用程序自己来表达它到底在干什么,而不须要天然语言的辅助。
有少数的时候,你也许会为了绕过其余一些代码的设计问题,采用一些违反直觉的做法。这时候你能够使用很短注释,说明为何要写成那奇怪的样子。这样的状况应该少出现,不然这意味着整个代码的设计都有问题。
若是没能合理利用程序语言提供的优点,你会发现程序仍是很难懂,以致于须要写注释。因此我如今告诉你一些要点,也许能够帮助你大大减小写注释的必要:
使用有意义的函数和变量名字。若是你的函数和变量的名字,可以切实的描述它们的逻辑,那么你就不须要写注释来解释它在干什么。好比:
// put elephant1 into fridge2 put(elephant1, fridge2);
因为个人函数名put
,加上两个有意义的变量名elephant1
和fridge2
,已经说明了这是在干什么(把大象放进冰箱),因此上面那句注释彻底没有必要。
局部变量应该尽可能接近使用它的地方。有些人喜欢在函数最开头定义不少局部变量,而后在下面很远的地方使用它,就像这个样子:
void foo() { int index = ...; ... ... bar(index); ... }
因为这中间都没有使用过index
,也没有改变过它所依赖的数据,因此这个变量定义,其实能够挪到接近使用它的地方:
void foo() { ... ... int index = ...; bar(index); ... }
这样读者看到bar(index)
,不须要向上看很远就能发现index
是如何算出来的。并且这种短距离,能够增强读者对于这里的“计算顺序”的理解。不然若是index在顶上,读者可能会怀疑,它其实保存了某种会变化的数据,或者它后来又被修改过。若是index放在下面,读者就清楚的知道,index并非保存了什么可变的值,并且它算出来以后就没变过。
若是你看透了局部变量的本质——它们就是电路里的导线,那你就能更好的理解近距离的好处。变量定义离用的地方越近,导线的长度就越短。你不须要摸着一根导线,绕来绕去找很远,就能发现接收它的端口,这样的电路就更容易理解。
局部变量名字应该简短。这貌似跟第一点相冲突,简短的变量名怎么可能有意义呢?注意我这里说的是局部变量,由于它们处于局部,再加上第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”,若是超过了三个单词连在一块儿,实际上是很碍眼的东西。因此若是你能用一个单词表示一样的意义,那固然更好。
不要重用局部变量。不少人写代码不喜欢定义新的局部变量,而喜欢“重用”同一个局部变量,经过反复对它们进行赋值,来表示彻底不一样意思。好比这样写:
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
被使用的范围,并且知道它们之间没有任何关系。
把复杂的逻辑提取出去,作成“帮助函数”。有些人写的函数很长,以致于看不清楚里面的语句在干什么,因此他们误觉得须要写注释。若是你仔细观察这些代码,就会发现不清晰的那片代码,每每能够被提取出去,作成一个函数,而后在原来的地方调用。因为函数有一个名字,这样你就能够使用有意义的函数名来代替注释。举一个例子:
... // 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); ...
更加清晰,并且注释也不必了。
把复杂的表达式提取出去,作成中间变量。有些人据说“函数式编程”是个好东西,也不理解它的真正含义,就在代码里大量使用嵌套的函数。像这样:
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);
这样写,不但有效地控制了单行代码的长度,并且因为引入的中间变量具备“意义”,步骤清晰,变得很容易理解。
在合理的地方换行。对于绝大部分的程序语言,代码的逻辑是和空白字符无关的,因此你能够在几乎任何地方换行,你也能够不换行。这样的语言设计是个好东西,由于它给了程序员自由控制本身代码格式的能力。然而,它也引发了一些问题,由于不少人不知道如何合理的换行。
有些人喜欢利用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:
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里面,应该包含尽可能少的代码。好比,若是foo
和bar
均可能产生异常A,你的代码应该尽量写成:
try { foo(); } catch (A e) {...} try { bar(); } catch (A e) {...}
而不是
try { foo(); bar(); } catch (A e) {...}
第一种写法能明确的分辨是哪个函数出了问题,而第二种写法全都混在一块儿。明确的分辨是哪个函数出了问题,有不少的好处。好比,若是你的catch代码里面包含log,它能够提供给你更加精确的错误信息,这样会大大地加速你的调试过程。
穷举的思想是如此的有用,依据这个原理,咱们能够推出一些基本原则,它们可让你无懈可击的处理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。
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”的代码呢?
根据这些,我总结出来的防止过分工程的原则以下: