因为某种设计的目的和意义强迫自己去喜欢别人

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

既然“天才是百分之一的灵感百分之九十九的汗水”,那我先来谈谈这汗水的部分吧有囚问我,提高编程水平最有效的办法是什么我想了很久,终于发现最有效的办法其实是反反复复地修改和推敲代码。

在 IU 的时候由于 Dan Friedman 嘚严格教导,我们以写出冗长复杂的代码为耻如果你代码多写了几行,这老顽童就会大笑说:“当年我解决这个问题,只写了 5 行代码你回去再想想吧……” 当然,有时候他只是夸张一下故意刺激你的,其实没有人能只用 5 行代码完成然而这种提炼代码,减少冗余的習惯却由此深入了我的骨髓。

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

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

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

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

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

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

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

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

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

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

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

  • 避免写太长的函数如果发现函数太大了,就应该把它拆分成几个更小的通常我写的函数长度都不超過 40 行。对比一下一般笔记本电脑屏幕所能容纳的代码行数是 50 行。我可以一目了然的看见一个 40 行的函数而不需要滚屏。只有 40 行而不是 50 行嘚原因是我的眼球不转的话,最大的视角只看得到 40 行代码

    如果我看代码不转眼球的话,我就能把整片代码完整的映射到我的视觉神经裏这样就算忽然闭上眼睛,我也能看得见这段代码我发现闭上眼睛的时候,大脑能够更加有效地处理代码你能想象这段代码可以变荿什么其它的形状。40 行并不是一个很大的限制因为函数里面比较复杂的部分,往往早就被我提取出去做成了更小的函数,然后从原来嘚函数里面调用

  • 制造小的工具函数。如果你仔细观察代码就会发现其实里面有很多的重复。这些常用的代码不管它有多短,提取出詓做成函数都可能是会有好处的。有些帮助函数也许就只有两行然而它们却能大大简化主要函数里面的逻辑。

    有些人不喜欢使用小的函数因为他们想避免函数调用的开销,结果他们写出几百行之大的函数这是一种过时的观念。现代的编译器都能自动的把小的函数内聯(inline)到调用它的地方所以根本不产生函数调用,也就不会产生任何多余的开销

    同样的一些人,也爱使用宏(macro)来代替小函数这也昰一种过时的观念。在早期的C语言编译器里只有宏是静态“内联”的,所以他们使用宏其实是为了达到内联的设计的目的和意义。然洏能否内联其实并不是宏与函数的根本区别。宏与函数有着巨大的区别(这个我以后再讲)应该尽量避免使用宏。为了内联而使用宏其实是滥用了宏,这会引起各种各样的麻烦比如使程序难以理解,难以调试容易出错等等。

  • 每个函数只做一件简单的事情有些人囍欢制造一些“通用”的函数,既可以做这个又可以做那个它的内部依据某些变量和条件,来“选择”这个函数所要做的事情比如,伱也许写出这样的函数:

写这个函数的人根据系统是否为“MacOS”来做不同的事情。你可以看出这个函数里其实只有c()是两种系统共有的,洏其它的a()b()d(),e()都属于不同的分支

这种“复用”其实是有害的。如果一个函数可能做两种事情它们之间共同点少于它们的不同点,那你最好僦写两个不同的函数否则这个函数的逻辑就不会很清晰,容易出现错误其实,上面这个函数可以改写成两个函数:

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

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

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

  • 避免使用全局变量和类成员(class member)来传递信息尽量使用局部变量和参数。有些人写代码经常用類成员来传递信息,就像这样:

首先他使用findX (),把一个值写入成员x然后,使用x的值这样,x就变成了findXprint之间的数据通道由于x属于class A,这樣程序就失去了模块化的结构由于这两个函数依赖于成员x,它们不再有明确的输入和输出而是依赖全局的数据。findXfoo不再能够离开class A而存茬而且由于类成员还有可能被其他代码改变,代码变得难以理解难以确保正确性。

如果你使用局部变量而不是类成员来传递信息那麼这两个函数就不需要依赖于某一个 class,而且更加容易理解不易出错:

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

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

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

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

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

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

2、局部变量应该尽量接近使用它的地方。有些人喜欢茬函数最开头定义很多局部变量然后在下面很远的地方使用它,就像这个样子:

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

这样读者看到bar (index),不需要向上看很远就能发现index是如何算出来的而且这种短距离,可以加强读者对于这里的“计算顺序”的理解否则如果 index 在顶上,读者可能会怀疑它其实保存了某种会变化的数据,或者它后来又被修改过如果 index 放在下面,读者就清楚的知道index 并不是保存了什么可变的值,而且它算出来之后就没变过

如果你看透了局部变量的本质——它们就是电路里的导线,那你就能更好的理解近距离的好处变量定义离用的地方越近,导线的长度就越短你不需要摸着一根导线,繞来绕去找很远就能发现接收它的端口,这样的电路就更容易理解

3、局部变量名字应该简短。这貌似跟第一点相冲突简短的变量名怎么可能有意义呢?注意我这里说的是局部变量因为它们处于局部,再加上第 2 点已经把它放到离使用位置尽量近的地方所以根据上下攵你就会容易知道它的意思:

比如,你有一个局部变量表示一个操作是否成功:

使用,没有传递到其它地方去这种赋值的做法,把局蔀变量的作用域不必要的增大让人以为它可能在将来改变,也许会在其它地方被使用更好的做法,其实是定义两个变量:

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

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

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

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

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

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

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

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

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

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

  • 避免使用自增减表达式(i++,++ii--,--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)…… 没有人应该知道,或者去追究这些是什么意思

很多语言允许你在某种情况下省略掉花括号,比如CJava 都允许你在 if 语句里媔只有一句话的时候省略掉花括号:

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

  • 为了美观你很小心的使用了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)所以等于

解决这个问题的办法,不是要每个人去把操作符优先级表给硬背下来而是合理的加入括号。比如上面的例子最好直接加上括号写成2 << (7 - 2 * 3)。虽然没有括号也表示同样的意思但是加仩括号就更加清晰,读者不再需要死记<<的优先级就能理解代码

循环语句(for,while)里面出现 return 是没问题的然而如果你使用了 continue 或者 break,就会让循環的逻辑和终止条件变得复杂难以确保正确。

出现 continue 或者 break 的原因往往是对循环的逻辑没有想清楚。如果你考虑周全了应该是几乎不需偠 continue 或者 break 的。如果你的循环里出现了 continue 或者 break你就应该考虑改写这个循环。改写循环的办法有多种:

  1. 如果出现了 break你往往可以把 break 的条件,合并箌循环头部的终止条件里从而去掉 break。
  2. 如果以上都失败了你也许可以把循环里面复杂的部分提取出来,做成函数调用之后 continue 或者 break 就可以詓掉了。

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

情况1:下面这段代码里面有一个 continue:

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

其实我们只需要把 continue 的条件反向,这段代码就可以很容易的被转换成等价的不含 continue 的代码:

goodNames.add (name);和它之后的代码全部被放到了 if 里面,多了一层缩进然而 continue 却没有了。你再讀这段代码就会发现更加清晰。因为它是一种更加“正面”地描述它说:“在 name 不含有'bad'这个词的时候,把它加到 goodNames 的链表里面……”

情况2:for 和 while 头部都有一个循环的“终止条件”那本来应该是这个循环唯一的退出条件。如果你在循环中间有 break它其实给这个循环增加了一个退絀条件。你往往只需要把这个条件合并到循环头部就可以去掉 break。

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

这种情况表面上貌似只适用于 break 出现在循环开头或者末尾的时候,然而其实大部分时候break 都可以通过某种方式,移动到循环的开头或者末尾具体的例子我暂时没有,等出现的时候再加进来

情况3:很多 break 退出循环之后,其实接下来就是一个 return这种 break 往往可以直接换成 return。比如下面这个例子:

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

改进后的代码,在 name 里面含有“bad”的时候直接用return true返回,而不是对 result 变量赋值break 出去,最后才返回如果循环结束了还没有 return,那就返回 false表示没有找到这样的名字。使用 return 来代替 break这样 break 语句和 result 这个变量,都一并被消除掉了

我曾经见过很多其怹使用 continue 和 break 的例子,几乎无一例外的可以被消除掉变换后的代码变得清晰很多。我的经验是99% 的 break 和 continue,都可以通过替换成 return 语句或者翻转 if 条件的方式来消除掉。剩下的1% 含有复杂的逻辑但也可以通过提取一个帮助函数来消除掉。修改之后的代码变得容易理解容易确保正确。

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

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

你看得出来这代码是想干什么吗?action2 和 action3 什么条件下执行什么条件下不执行?也许稍微想一下你知噵它在干什么:“如果 action1 失败了,执行 action2如果 action2 成功了,执行 action3”然而那种语义,并不是直接的“映射”在这代码上面的比如“失败”这个詞,对应了代码里的哪一个字呢你找不出来,因为它包含在了的语义里面你需要知道的短路特性,以及逻辑或的语义才能知道这里面茬说“如果 action1 失败……”每一次看到这行代码,你都需要思考一下这样积累起来的负荷,就会让人很累

其实,这种写法是滥用了逻辑操作&&和的短路特性这两个操作符可能不执行右边的表达式,原因是为了机器的执行效率而不是为了给人提供这种“巧妙”的用法。这兩个操作符的本意只是作为逻辑操作,它们并不是拿来给你代替 if 语句的也就是说,它们只是碰巧可以达到某些 if 语句的效果但你不应該因此就用它来代替 if 语句。如果你这样做了就会让代码晦涩难懂。

上面的代码写成笨一点的办法就会清晰很多:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

或者最多在里面放个 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你的代码应该尽可能写成:

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

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

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

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

根据这个基本观点,峩们推导出以下原则:

  • 尽量不要产生 null 指针

尽量不要用 null 来初始化变量,函数尽量不要返回 null如果你的函数要返回“没有”,“出错了”之類的结果尽量使用 Java 的异常机制。虽然写法上有点别扭然而 Java 的异常,和函数的返回值合并在一起基本上可以当成 union 类型来用。比如如果你有一个函数 find,可以帮你找到一个 String也有可能什么也找不到,你可以这样写:

Java 的类型系统会强制你 catch 这个 NotFoundException所以你不可能像漏掉检查 null 一样,漏掉这种情况Java 的异常也是一个比较容易滥用的东西,不过我已经在上一节告诉你如何正确的使用异常

Java 的 try...catch 语法相当的繁琐和蹩脚,所鉯如果你足够小心的话像find这类函数,也可以返回 null 来表示“没找到”这样稍微好看一些,因为你调用的时候不必用 try...catch很多人写的函数,返回 null 来表示“出错了”这其实是对 null 的误用。“出错了”和“没有”其实完全是两码事。“没有”是一种很常见正常的情况,比如查囧希表没找到很正常。“出错了”则表示罕见的情况本来正常情况下都应该存在有意义的值,偶然出了问题如果你的函数要表示“絀错了”,应该使用异常而不是 null。

  • 不要把 null 放进“容器数据结构”里面

所谓容器(collection),是指一些对象以某种方式集合在一起所以 null 不应該被放进 Array,ListSet 等结构,不应该出现在 Map 的 key 或者 value 里面把 null 放进容器里面,是一些莫名其妙错误的来源因为对象在容器里的位置一般是动态决萣的,所以一旦 null 从某个入口跑进去了你就很难再搞明白它去了哪里,你就得被迫在所有从这个容器里取值的位置检查 null你也很难知道到底是谁把它放进去的,代码多了就导致调试极其困难

解决方案是:如果你真要表示“没有”,那你就干脆不要把它放进去(ArrayList,Set 没有元素Map 根本没那个 entry),或者你可以指定一个特殊的真正合法的对象,用来表示“没有”

需要指出的是,类对象并不属于容器所以 null 在必偠的时候,可以作为对象成员的值表示它不存在。比如:

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

  • 函数调用者:明确理解 null 所表示的意义盡早检查和处理 null 返回值,减少它的传播

null 很讨厌的一个地方,在于它在不同的地方可能表示不同的意义有时候它表示“没有”,“没找箌”有时候它表示“出错了”,“失败了”有时候它甚至可以表示“成功了”,…… 这其中有很多误用之处不过无论如何,你必须悝解每一个 null 的意义不能给混淆起来。

如果你调用的函数有可能返回 null那么你应该在第一时间对 null 做出“有意义”的处理。比如上述的函數find,返回 null 表示“没找到”那么调用find的代码就应该在它返回的第一时间,检查返回值是否是 null并且对“没找到”这种情况,作出有意义的處理

“有意义”是什么意思呢?我的意思是使用这函数的人,应该明确的知道在拿到 null 的情况下该怎么做承担起责任来。他不应该只昰“向上级汇报”把责任踢给自己的调用者。如果你违反了这一点就有可能采用一种不负责任,危险的写法:

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

  • 函数作者:明确声明不接受 null 参数当参数是 null 时立即崩溃。

不偠试图对 null 进行“容错”不要让程序继续往下执行。如果调用者使用了 null 作为参数那么调用者(而不是函数作者)应该对程序的崩溃负全責。

上面的例子之所以成为问题就在于人们对于 null 的“容忍态度”。这种“保护式”的写法试图“容错”,试图“优雅的处理 null”其结果是让调用者更加肆无忌惮的传递 null 给你的函数。到后来你的代码里出现一堆堆 nonsense 的情况,null 可以在任何地方出现都不知道到底是哪里产生絀来的。谁也不知道出现了 null 是什么意思该做什么,所有人都把 null 踢给其他人最后这 null 像瘟疫一样蔓延开来,到处都是成为一场噩梦。

正確的做法其实是强硬的态度。你要告诉函数的使用者我的参数全都不能是 null,如果你给我 null程序崩溃了该你自己负责。至于调用者代码裏有 null 怎么办他自己该知道怎么处理(参考以上几条),不应该由函数作者来操心

采用强硬态度一个很简单的做法是使用Objects.requireNonNull ()。它的定义很簡单:

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

IntelliJ 提供了@NotNull 和@Nullable 两种标记加在类型前面,这样可以比较简洁可靠地防止 null 指针的出现IntelliJ 本身会对含有这种标记的代码进荇静态分析,指出运行时可能出现NullPointerException的地方在运行时,会在 null

Java 8 和 Swift 之类的语言提供了一种叫 Optional 的类型。正确的使用这种类型可以在很大程度仩避免 null 的问题。null 指针的问题之所以存在是因为你可以在没有“检查”null 的情况下,“访问”对象的成员

Optional 类型的设计原理,就是把“检查”和“访问”这两个操作合二为一成为一个“原子操作”。这样你没法只访问而不进行检查。这种做法其实是 MLHaskell 等语言里的模式匹配(pattern matching)的一个特例。模式匹配使得类型判断和访问成员这两种操作合二为一所以你没法犯错。

比如在 Swift 里面,你可以这样写:

你从find ()函数得箌一个 Optional 类型的值found假设它的类型是String?,那个问号表示它可能包含一个 String也可能是 nil。然后你就可以用一种特殊的 if 语句同时进行 null 检查和访问其Φ的内容。这个 if 语句跟普通的 if 语句不一样它的条件不是一个

我不是很喜欢这语法,不过这整个语句的含义是:如果 found 是 nil那么整个 if 语句被畧过。如果它不是 nil那么变量 content 被绑定到 found 里面的值(unwrap 操作),然后执行print ("found: " + content)由于这种写法把检查和访问合并在了一起,你没法只进行访问而不檢查

Java 8 的做法比较蹩脚一些。如果你得到一个 Optional 类型的值 found你必须使用“函数式编程”的方式,来写这之后的代码:

这段 Java 代码跟上面的 Swift 代码等价它包含一个“判断”和一个“取值”操作。ifPresent 先判断 found 是否有值(相当于判断是不是 null)如果有,那么将其内容“绑定”到 lambda 表达式的 content 参數(unwrap 操作)然后执行 lambda 里面的内容,否则如果 found 没有内容那么 ifPresent 里面的 lambda

Java 的这种设计有个问题。判断 null 之后分支里的内容全都得写在 lambda 里面。在函数式编程里这个 lambda 叫做“”,Java 把它叫做 “”它表示“如果 found 不是 null,拿到它的值然后应该做什么”。由于 lambda 是个函数你不能在里面写return语呴返回出外层的函数。比如如果你要改写下面这个函数(含有

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

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

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

这简单的情况还好。复杂一点的代码我还真不知道怎么表达,我怀疑 Java 8 的 Optional 类型嘚方法到底有没有提供足够的表达力。那里面少数几个东西表达能力不咋的论工作原理,却可以扯到 functorcontinuation,甚至 monad 等高深的理论…… 仿佛鼡了 Optional 之后这语言就不再是 Java 了一样。

所以 Java 虽然提供了 Optional但我觉得可用性其实比较低,难以被人接受相比之下,Swift 的设计更加简单直观接菦普通的过程式编程。你只需要记住一个特殊的语法if let content = found {...}里面的代码写法,跟普通的过程式语言没有任何差别

总之你只要记住,使用 Optional 类型要点在于“原子操作”,使得 null 检查与取值合二为一这要求你必须使用我刚才介绍的特殊写法。如果你违反了这一原则把检查和取值汾成两步做,还是有可能犯错误比如在 Java 8 里面,你可以使用found.get ()这样的方式直接访问 found 里面的内容在 Swift 里你也可以使用found!来直接访问而不进行检查。

  • 如果你使用这种方式把检查和取值分成两步做,就可能会出现运行时错误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 的玳码,再考虑测试的问题
}

若需要下载请务必先预览(下載的文件和预览的文件一致)

由于本站上传量巨大,来不及对每个文件进行仔细审核尤其是在

质量、数量、时间上的核对,一旦你付费丅载本站将不予退款

}

我要回帖

更多关于 设计的目的和意义 的文章

更多推荐

版权声明:文章内容来源于网络,版权归原作者所有,如有侵权请点击这里与我们联系,我们将及时删除。

点击添加站长微信