如何写出优雅的函数(Clean Code读书笔记之二)

函数是代码组合的基本单位,高级编程语言的发展从结构化到面向对象,再到最近大有要复兴之势的函数式编程,函数都是组成这座大厦不可或缺的基本组成部分,它的重要性不言而喻。本文将依据「clean code」第三章的内容,大致捋一遍如何写出优雅的函数。

第三章讲了在写函数时应该注意的事情,作者首先拿一个开源的测试工具(Fitnesse)来举了一个例子,来说明好的函数该是什么样子。原则上其实和上一篇中讲到的命名的一些原则很相似,就是一个名字要是能够自解释的,当然这一章还会讲到很多新的东西,这里拿这个函数作为一个引子。

//代码2-1
public static String renderPageWithSetupsAndTeardowns(PageData pageData, boolean isSuite)throws Exception{
    boolean isTestPage = pageData.hasAttribute("Test");
    if(isTestPage){
        WikiPage testPage = pageData.getWikiPage();
        StringBuffer newPageContent = new StringBuffer();
        includeSetupPages(test Page, newPageContent, isSuite);
        newPageContent.append(pageData.getContent());
    }
}

从以上代码可以看到上一章中提到的一些东西,不要惧怕你的函数或者变量名定义的很长,在编译器已经长足发展的今天,对于很长的函数命名的处理已经不会成为语言或者性能的瓶颈了;然后整个函数就像是在叙述做一件事情的步骤,每一步我们都能看懂这是在干些什么事情,以上这个例子很好的展示了一个「好函数」应该的样子。

我曾经听过一个Oracle的工程师讲到他们的编码要求,包括一个函数内部的if不能超过两个,所有的函数应该限制在10行以内等,与这个例子的思想都是不谋而合。

下边开始列举作者对一个写好一个函数应该遵循的原则的描述:

1. 小!!!

The first rule of functions is that they should be small. The second rule of functions is that they should be smaller than that.

小的函数不一定好,但是可以肯定的是太长的函数一定是在某种程度上很烂的。

作者谈到他一直以来的意见是函数不应该超过一个屏幕能显示的程度,当然现在的屏幕越来越大,那么如果非要使用一个数字的话,他认为一行不应该超过150个字符(我认为其实最好还保持在80或120个字符以内),函数的行数最好不要超过100行,如果能少于20行最好不过了。

作者在这里讲述了他和著名的Kent Beck的一段讲话来举例,Kent Beck说一个函数最多应该不要超过4行。这个确实有点恐怖,我觉得能控制在20行以内就已经很厉害了。函数要设计的小并不是目的,而是通过写出来小函数来达到让程序的阅读者可以快速的看懂这段程序,而且更短的程序往往意味着更少的bug。接下来会有一些原则,如果你可以理解它们并尽量遵守,会有效的帮助你写出小而高质量的代码。

  • 代码块和缩进
    作者在这一节讲了两个事情,代码块(就是if或者else块)的内容最多不超过一行,这样这个在块中的函数往往可以有一个自解释的名字;一个函数的缩进等级最多不能超过2级。这两个要求在我看来都是极其严苛了,一般人都做不到,不过朝着这方面努力总是好的。

2. 只做一件事

一个好的函数应该只做一件事,这是大家经常见到的说法,那么怎么才能说一个函数是「只做了一家事」呢,如果一个函数做了3件事,这而3件事又可以说是另外一件事的3个步骤,那么这个算不算是「只做一件事」呢?

作者认为这种情况是属于「只做了一件事」的,但是这个函数应该只包含这3个步骤,而不包括3个步骤的具体实现,也就是说,如果这个函数里包含了某一个步骤的具体实现,那么这个函数就不是「只做一件事」。

换言之,如果一个函数function1里的几个语句可以被extract出来成为一个新的函数function2,那么function1就没有达到「只做一件事呢」的标准。

  • 函数里的小节
    有些人写代码喜欢在一个函数里使用不同的代码小节(比如第一节定义变量,第二节实例化这些变量,第三节再对这些变量进行一些操作),作者认为这严重违反了「只做一件事」原则,是非常不好的习惯。

3. 每个函数只包含同一个层级的抽象

这个原则是比较好理解的,比如代码2-1中的getWikiPage()函数的内部实现,和renderPageWithSetupsAndTeardowns()里的几个函数调用就不在一个抽象层级上;或者对newPageContent.append()的函数调用明显就与其他函数调用不是在同一抽象层级。

  • 自上而下的阅读代码:层层向下
    作者觉得好的代码读起来应该像是记叙文,自上而下的讲解完成一件事情的步骤。换种说法就是阅读一个程序就像是在阅读一堆的TO(英文单词to,为了……)段落:为了做某件事情1,我们去做了事情2,为了做事情2,我们去做了事情3。与此同时作者又说觉得这个原则遵循起来相当难,但是这是一个努力的方向,努力去学习这个原则会帮助你写出来更小的,只包含同一级抽象的函数。

4. Switch语句

在coding过程中很难避免要用到switch语句的情况,在这种情况下就很难去保持以上讲到的一些规则,作者的建议是对于Switch语句,应该将它封装起来,使用多态(具体讲可能就是定义抽象工厂方法,然后switch可以被放在抽象工厂方法的实现类里,同时让switch对于它的调用者完全透明)为这个函数的真正使用者提供服务。

5. 使用自解释(descriptive)的名字

函数的名字要能够描述它本身的工作内容,不要害怕函数名会变得很长,一个长的自解释的名字比一个短的不明所以的名字要好得多。

同时在名字的选择上要前后一致,这个原则同前一篇讲命名中的一些规则如出一辙。

6. 函数参数

最理想的函数应该没有参数的,其次比较好的是只有一个参数的、只有两个的,包含三个参数的函数应该尽量被避免使用,三个以上的参数的函数不应该存在。

含有参数的函数明显已经包含了一个和函数内容不在同一个层级上的抽象(参数本身),还有从测试的观点看,参数的存在也提高了写测试用例的难度。

有时候有些参数还被作为输出用途,这种情况应该尽量避免。

「Clean Code」整本书都是基于Java和其他类似的高级语言为基础的,但是在一些理念不同的语言中,含有多个参数的函数在理解上是完全没有问题的,但是它们可能在其他方面(比如编写测试用例)也会存在各种各样的问题。

  • 单个参数
    有两种比较常见的场景适合使用单个参数的函数,一种是「函数是要对此参数问一个问题,并得到一个答案」(比如 boolean fileExists("MyFile"),另一种是「此函数是要对此函数对此参数进行一个操作,把它变成另外一中东西,并将它返回」(InputStream fileOpen("MyFile"))。

此外还有一种场景比较不那么常见,但是也十分有用的单个参数函数形式,event。这种情况下函数接受一个参数event,但是没有返回值,函数会根据这个event对象来进行一些其他操作。

使用这些形式时也同时要使用一个合适的名字来清晰的描述函数的用途,从而让代码阅读者可以清晰快速地了解函数的目的。

  • Flag参数
    Flag参数是丑陋的,不应该被使用;往往一个含有Flag参数的函数可以被分解或简单的使用if else代码块就可以达到同样的效果。

  • 两个参数
    作者认为两个参数的函数是难以理解的,唯一可能比较合适的场景是,这两个参数本身就是「一个单独对象的两个有序的组件」,比如Point(x, y),x轴坐标和y轴坐标共同组成了平面坐标系中的一个点。
    但是,作者认为两个参数的函数并不是邪恶了,所有人都不可避免的在实际编程中使用它们,但是一定要了解它们是会带来一些不良后果的,最好是可以把这些函数都转化成单个参数的函数。

  • 三个参数
    三个参数的函数非常难以理解甚至常常会被误解,所以在使用前最好三思再三思。

  • 参数对象
    当一个函数需要2个或者3个参数是,往往可以将他们全部或者一部分封装成一个对象,从而达到减少参数数量,使函数更加易于理解和维护的目的。

  • 参数列表
    有时候一个函数会接受一个变长的参数列表,这种情况下其实它们可以看做一个List结果的参数对象,所以可以被看做一个单个参数的函数,作者是比较推荐这种方式的。

      public String format(String format, Object...args)
    
  • 动词和关键词
    使用一个好的函数名可以清晰的解释函数和参数的目的。对于单个参数的函数,函数和参数应该是一个「动词 + 名词」的组合。

还有一种「关键词」的模式来作为函数的名字,比如使用assertEquals(expected, actural),而不是assertExpectedEqualsActual(expected, actural),这样就不需要读者必须知道参数的顺序,从而降低了阅读此代码的难度。

7. 不要有副作用

函数的副作用就像谎言一样,一个函数声称它要做一件事,但是同时它又做了另外一件「隐藏的」事,有时候它会修改自己的类中的属性,有时候它会修改传进来的参数或者其他全局变量,不管哪种情况,这都是不好的。

  • 输出参数
    在「面向对象编程」出现之前(比如C语言),有时候必须使用一个参数(通常是一个指针)来作为函数的输出,但是在「OOP」中,「this」指针往往隐喻了它是用来作为输出指针的作用,那么输出参数应该尽可能的避免使用,如果有这样的需求,修改「this」中的属性往往是更好的选择。

8. 执行和检索分离

一个函数要不执行了某个行为(比如改变了一个对象的状态),要不回答了某个问题(比如返回某个对象的某些信息),但是不应该同时做这样两件事。

9. 使用Exception,不要使用返回错误码

返回错误码轻微地违反了上一个规则,作者建议不要使用返回错误码而使用抛出Exception的方法。这样的方法往往会时代码更短而清晰易读。

  • 提取try/catch代码块
    作者建议在使用try代码块时要将try中的代码提取,使得整个try/catch代码块是一个完全的错误处理代码块,而不包含具体的其他操作逻辑,这就符合了「只做一件事」的原则。

  • 错误处理是「一件事」
    作者建议做错误处理的函数,应该只做错误处理这「一件事」,也就是说一个包含try关键词的错误处理函数应该是以try这个单词为开始的。如代码2-2所示:

      代码2-2
      public void delete(Page page){
          try{
              deletePageAndAllReferences(page).
          }catch(Exception e){
              logError(e);
          }
      }
    
  • 依赖磁铁
    返回错误码的方法还有一个问题是,往往在这种场景下,所有的需要错误处理的地方都会需要依赖这个类或者文件,这样的被广泛依赖的类叫做「依赖磁铁」

依赖磁铁在普通的日常开发中很难避免,而且我觉得这也不是一个需要强力避免的原则。

10. 不要重复自己(Don't repeat yourself)

我们在写代码时往往会将同样一个算法、或者一段处理逻辑、甚至一段相同的代码重复的出现在多个地方,甚至是同一个源文件的不同地方。这往往是很多代码质量问题的源头,也有很多编程原则和最佳实践都是为了控制或者消灭重复而产生的。

  • 结构化编程
    作者认为如果能保证保持函数「很小」,那么很多问题就可以解决了,那么结构化编程的一些规则(比如一个函数只有一个输入和一个输出)则不需要被遵守了。

11. 你如何才能写出这样的函数

程序员写代码跟其他类型的写作一样,是一个不断改善的过程。作者认为写出好的函数大致是这样几个步骤

  • 先直接把想法写成代码,它们可能是很长而且复杂的,可能违反了以上很多规则,同时也会有一套单元测试来cover所有的代码用来做回归测试,做为将来对代码重构的基础
  • 然后开始对代码进行修改,分离函数,修改名称,消灭重复,同时要保持测试用例完全通过。
  • 最后使用以上所有规则来对函数进行地毯式的最后修改。

12. 总结

如果你遵循以上所有的规则,你的函数会变得体量短小、良好命名、并且具有良好的组织结构。但是永远不要忘记这不是目的而是手段,你的最终目的是让整个系统更加完美。

我的博客

你可能感兴趣的:(如何写出优雅的函数(Clean Code读书笔记之二))