Clean Code

代码整洁之道

1. 有意义的命名

  1. 名副其实
    变量、函数或类的名称应该告诉你,它为什么会存在,它做什么事,应该怎么用,如果需要注释来补充,那就不算是名副其实。

  2. 避免误导

    • 某些系统的专有名词不要使用。
    • 堤防使用不同之处较小的名称。比如XYZControllerForEfficientHandlingOfStrings和另一处XYZControllerForEfficientStorageOfStrings,就难以区分。
    • 拼写前后不一致就是误导。
    • 不要使用误导性的名称,比如使用小写字母l和大写字母O。
  3. 做有意义的区分
    光是添加数字序列(a1,a2,……aN)或是废话(比如ProductInfo和ProductData,意思无啥区别,还不如叫Product)远远不够,应该依义命名。
    错误的示例如下:程序员很难确定该调用哪个函数。
    getActiveAccount();
    getActiveAccounts();
    getActiveAccountInfo();

  4. 使用读的出来的名称
    如果名称读不出来,讨论的时候就会像个傻鸟。

  5. 使用可搜索的名称
    名称长短应与其作用域大小相对应。e就是个不便于搜索的名称。

  6. 避免使用编码
    带编码的名称通常也不便发音,容易打错。

    • 匈牙利命名法:在C语言API的时代,编译器不会做类型检查,程序员需要使用这种标记法来帮助自己记住类型。Java和C#都是强类型(静态类型)的语言,在编译开始前就会进行类型检测,已经不需要这种标记法了。
    • 成员前缀:不必使用“m_”来标明成员变量。应当将类和函数做的足够小,消除对成员前缀的需要。而且现在的编辑器可以通过颜色来区分变量类型。
  7. 避免思维映射
    在作用域小时,循环计数器可能被命名为i,然后在多少情况下,单字母名称不是一个好的选择。

  8. 类名
    类名和对象名应该是名词或名词短语。

  9. 方法名
    方法名应当是动词或动词短语。属性访问器(get-)、修改器(set-)和断言(is-)应该根据其值命名。

  10. 别使用俚语

  11. 每个概念对应一个词
    给每个抽象概念选一个词,并且一以贯之。例如:fetch、retrieve和get来给多个类中的同种方法命名。
    函数名称应当独一无二,而且保持一致,这样才能不借助多余的浏览就能找到正确的方法。

  12. 别用双关语
    比如add方法,如果这些add方法的参数列表和返回值在语义上等价,就没有问题。或者需要考虑是否用insert或append之类的词来命名才对。

  13. 使用解决方案领域名称
    尽管用计算机术语、算法名、模式名、数学术语。不该让协作者老是跑去问客户每个名称的含义,他们早该通过另一名称了解这个概念了。

  14. 使用源自所涉及问题领域的名称
    优秀的程序员和设计师,工作之一就是分离解决方案领域和问题领域的概念。与所涉领域更为贴近的代码,应当采用源自领域的名称。

  15. 添加有意义的语境
    很少有名称能够自我说明的——多少都不能。需要使用良好命名的类、函数或命名空间来放在名称,提供语境。如果没这么做,最后一招——添加前缀。

  16. 不要添加没用的语境
    只要短名称足够清楚,就比长名称要好。将项目缩写作为所有方法的前缀是非常糟糕的做法。


2. 函数

  1. 短小
    函数该有多长?每个函数都一目了然,都只说一件事,而且都依序把你带到下一个函数。

    • 代码块:if语句、else语句、while语句等,其中的代码块应该只有一行(调用一个函数,这个函数拥有具有说明性的名称,从而增加文档上的价值)。
    • 缩进:函数不该大到足以容纳嵌套结构。所以函数的缩进层级不该多于一层或两层。
  2. 只做一件事
    该条建议以各种形式存在。单一职责原则等等。
    如果函数只做了该函数名下同一抽象层上的步骤,则函数只做了一件事。

    • 函数中的区段:如果函数被分成declaration、Initialization、和sieve等区段,则说明函数做了太多的事了。只做一件事的函数无法被合理地切分为多个区段。
  3. 每个函数一个抽象层级
    函数中混杂不同的抽象层级,往往令人迷惑。如同破窗一样,一旦细节与基础概念混杂,更多的细节就会在函数中纠结起来。

    • 向下规则:自顶向下读代码,每个函数下面跟着下移抽象层级的函数。
  4. switch语句
    写出短小的switch语句(包括if-else)很难。Switch天生做N件事。我们可以吧Switch都埋藏在较低的抽象层级,而且永远不重复。利用多态来实现这一点。
    Switch语句的问题:

    • 太长;
    • 明显做了不只一件事;
    • 违反了单一职责原则;
    • 违反了开闭原则;
    • 最麻烦的是类似结构可能到处都是。
      问题的解决方案是将Switch语句埋到抽象工厂底下。
  5. 使用描述性的名称
    长而具有描述性的名称,要比短而令人费解的名称好,也要比描述性的长注释好。
    高级的编辑器将重命名的成本降到了最低,追索好的名称,往往能助你理清关于模块的设计思路,导致代码的改善性重构。

  6. 函数参数
    最理想的参数数量是零,其次是一,再次是二,应尽量避免三。

    • 参数带有太多的概念性。参数可能与函数名各处在不同的抽象层级。这要求你了解并不特别重要的细节。
    • 从测试上讲,编写确保各种组合的参数运行正常的测试用例实在是强人所难。
      建议如下:
    • 一元函数:只有两种形式的一元函数——询问某个参数的状态,或者操作该参数,转换为其它类型,并返回它。避免编写不是这些类型的一元函数。
    • 标识参数:大声宣布本函数不只做一件事,应该将这种函数一分为二。
    • 二元参数:使用二元参数要小心(参数顺序),尽量利用一些机制(写成某个参数的成员之一,或将某个参数作为当前类的成员变量)将其转换为一元函数。
    • 三元函数:参数的排序、琢磨、忽略问题都会加倍体现。编写三元函数要三思。
    • 参数对象:如果函数需要两个、三个或三个以上的参数,说明其中一些参数应该封装为类了。如果一些参数总是被共同传递,它们往往该有自己的名称(类型)来表示某个概念了。
    • 参数列表:数量可变的参数列表,可变参数可以看成是单个参数。
    • 动词与关键字:对于一元函数,函数和参数应当形成一种动词/名称对形式。可以将参数的名称编码(关键字)成函数名来指明参数顺序。比如:
      assertExpectedEqualsActual(expected, actual)
  7. 无副作用
    函数承诺只做一件事,但很可能是一种谎言:有时它会对自己类的变量做出未能预期的改动。有时会把变量变成向函数传递的参数或是系统的全局变量。无论是哪种情况都会导致古怪的时序性耦合和顺序依赖(只能在特定的时刻调用)。

    • 输出参数:参数会被自然认为是函数的输入,在面向对象之前的时期,有时需要输出参数,然而,面向对象语言中已经无需输出参数。如果函数必须修改某种状态,就修改所属对象的状态吧。
  8. 分割指令和询问
    函数要么做什么事,要么回答什么事,二者不可兼得。解决方案就是一分为二。

  9. 使用异常替代返回错误码
    当返回错误码时,就是在要求调用者立刻处理错误。如果使用异常替代返回错误码,错误处理代码就能够从主路径中分离出来。

    • 抽离try/catch代码块:把错误处理提取成一个函数,使代码更易于理解和修改。
    • 错误处理就是一件事:处理错误的函数不该做其它事,也就是说try应该是函数的第一个单词并且catch/finally代码块后也没有其它语句。
    • 依赖磁铁:返回错误码通常暗示某处存在一个类或者枚举定义了所有的错误码,这样的类就是依赖磁铁——许多类都得导入并且使用它,以至于一旦修改就得重新编译和部署。
  10. 不要重复
    重复可能是软件中一切邪恶的根源。数据库范式是为了消灭数据重复、面向对象编程将代码集中到基类来避免冗余、面向方面编程、面向组件编程都是消灭重复的一种策略。软件开发领域的所有创新都是在不断尝试从源代码中消灭重复。

  11. 尽量避免使用goto语句
    在大函数中应当遵循结构化编程的规范——每个函数只有一个return语句,循环中没有break和continue语句,而且没有goto语句。小函数中偶尔出现return、break或continue并没有坏处。

  12. 持续重构
    一开始就遵循上述规则不现实,通过配以单元测试、覆盖每行丑陋的代码,持续重构它们。

编程艺术是且一直就是语言设计的艺术。函数是语言的动词、类是名词。目标在于讲述系统的故事。如果你的函数干净利落地拼接在一起,形成一种精确而清晰的语言,帮助你讲故事。


3. 注释

“别给糟糕的代码加注释——重新写吧。”

注释不是“纯然的好”,最多也就是一种必须的恶。注释总是一种失败,意味着我们无法找到不用注释就能表达自我的方法。
为何要如此贬低注释呢?因为注释会撒谎!注释存在的时间越久,就离其所描述的代码越远,越来越变的全然错误,原因很简单。程序员不能坚持维护注释。不准确的注释要比没有注释坏得多。只有代码能忠实地告诉你它做的事。那是唯一真正准确的信息来源。所以,尽管有时也需要注释。我们应该花心思尽量减少注释量。

  1. 注释不能美化糟糕的代码
    与其花时间编写解释代码的注释,不如花时间清洁那堆糟糕的代码。

  2. 用代码来阐述
    有时代码不足以解释其行为,正确的做法是创建一个描述行为的函数即可,即使代码很少。

    1. //Check to see if the emplyee is eligible for full benefits
    2. if((emplyee.flags & HOURLY_FLAG)&&(emplyee.age >65))

    还是这个:

    1. if(emplyee.isEligibleForFullBenefits())
  3. 好注释
    有些注释是必须的并且是有利的。

    • 法律信息:版权及著作权声明。
    • 提供信息的注释:比如参数的格式信息,正则表达式的格式信息。

      1. //format matched kk:mm:ss EEE, MM dd, yyyy
      2. Pattern timeMatcher =Pattern.compile("\\d*:\\d*:\\d* \\w*, \\w* \\d*, \\d*");
    • 对意图的解释:有时注释不仅提供了有关实现的有用信息,而且还提供了某个决定后面的意图。比如:

      1. //This is our best attempt to get a race condition
      2. //by creating large number of threads.
      3. for(int i =0; i <25000; i++)
      4. {
      5. WidgetBuilderThread widgetBuilderrThread =newWidgetBuilderThread(widgetBuilder, text, parent, failFlag);
      6. Thread thread =newThread(widgetBuilderThread);
      7. thread.start();
      8. }
    • 阐释: 如果参数或返回值是某个标准库的一部分,或者你不能修改的代码,帮助阐释其含义就会有用。但是它本身存在不正确的风险。

    • 警告: 用于警告其他程序员会出现某种后果的注释。

      1. // Don't run unless you have some time to kill
      2. publicvoid testWithRealBigFile()
      3. {
      4. writeLinesToFile(100000000000);
      5. response.setBody(testFile);
      6. response.readyToSend(this);
      7. String responseString = output.toString();
      8. assertSubString("Content-Length:100000000000", responseString);
      9. assertTrue(bytesSent >100000000000);
      10. }
    • TODO注释:程序员认为应该做,但是由于某种原因没做的工作。目前大多数IDE都提过定位TODO注释的手段(VS在视图->任务列表中打开查看,在工具->选项->文本编辑器->C/C++->格式设置->杂项->枚举注释任务-> True中开启)。

    • 放大:用来放大不合理之物的重要性。比如:

      1. String listItemContent = match.group(3).trim();
      2. //trim非常重要,它移除了开始位置的空格,如果不移除可能将这一项识别为另一个list。
      3. newListItemWidget(this, listItemContent,this.level +1);
      4. return buildList(text.substring(match.end()));
    • 公共API中的Javadoc:如果编写公共API,就该为它编写良好的Javadoc。

  4. 坏注释
    大多数注释都属于此类。通常坏注释都是糟糕代码的支持或借口,或对错误决策的修正,基本上等于自说自话。
    • 喃喃自语:如果决定写注释,就花时间确保写出最好的注释。
    • 多余的注释:不能提供比代码本身更多的信息。
    • 误导性的注释:不够精确的注释。
    • 循规式注释:每个函数都要有Javadoc或每个变量都有注释非常愚蠢可笑。
    • 日志式注释:如今有源代码控制系统,这种冗长的记录应当全部删除。
    • 废话注释:把整理代码的决心替代创造废话的冲动吧。
    • 能用函数或变量时就别用注释:应该重构,然后删掉注释。
    • 位置标记:尽量少用位置标记。如果滥用,会被忽略掉。
    • 括号后面的注释:如果发现自己想要标记右括号,其实应该做的是缩短函数。
    • 归属与署名:源代码控制系统应该是这类信息最好的归属地。
    • 注释掉的代码:直接注释掉代码非常令人讨厌,其他人不敢删除。应该直接删掉它们,如果有源代码管理系统,它们不会丢的。
    • HTML注释:在注释中插入html标记已经完全没必要了,目前有很多工具可以用。
    • 非本地的信息:确保你的注释描述了离它最近的代码。
    • 信息过多: 别在注释添加有趣的历史性话题或无关的细节描述。对读者完全没必要。
    • 不明显的联系:注释及其描述的代码之间的联系应该显而易见。
    • 函数头:短函数无需太多描述,起个好名字比什么都强。
    • 非公共代码中的javadoc:Javadoc注释额外的形式要求几乎等于八股文章,如果代码不打算作为公共用途,这种注释就太讨厌了。

4. 格式

应该保持良好的代码格式——选用一套管理代码格式的简单规则,然后在团队中贯彻这些规则。代码格式关乎沟通,而沟通是专业开发者的头等大事。

  1. 垂直格式
    用大多数为200行,最长500行的单个文件来构造出色的系统。因为短文件总是比长文件容易理解。
    • 概念间垂直方向上的间隔
      思路用空白行区隔开来,每个空白行都是一条线索,标识出新的独立概念。
    • 垂直方向上的靠近
      紧密相关的代码应该互相靠近
    • 垂直距离
      应避免迫使读者在源文件和类中跳来跳去,因此,除非有很好的理由,否则不要把关系密切的概念放在不同的文件中。
    • 变量声明:变量声明应尽可能靠近其使用位置。
      1. 局部变量应该在函数的顶部出现。
      2. 循环中的控制变量应该总在循环语句中声明。
      3. 在较长的函数中,变量也可能出现在某个代码块顶部。
    • 实体变量:实体变量应该在类的顶部声明。
    • 相关函数:若某个函数调用了另一个函数,就应该把它们放在一起,而且调用者应该尽可能放在被调用者上面。
    • 概念相关:概念相关的代码应该放在一起,相关性越强,彼此间的距离就该越短。相关性可能来自于执行相似操作的一组函数,因为他们拥有共同的命名模式。
    • 垂直顺序
      一般自上向下展示函数的调用依赖顺序。
  2. 横向格式
    应该尽量保持代码行短小。遵循无需拖动滚动条到右边的原则。大概上限是120个字符。
    • 水平方向上的区隔和靠近
      • 在赋值操作符周围加空格,达到强调目的。

        1. int lineSize = line.Length();
      • 不在函数名和左括号之间加空格,因为函数与其参数密切相关。

      • 乘法因子之间不加空格来表示它们具有较高优先级,加减法运算符之间用空格隔开。
        1. privatedouble determinat(double a,double b,double c)
        2. {
        3. return b*b -4*a*c;
        4. }
    • 水平对齐
      • 对齐风格:

        1. publicclassTest
        2. {
        3. privateSocket socket;
        4. privateFitNessContext context;
        5. protectedlong requestParsingTimeLimit;
        6. }
      • 不对齐风格

        1. publicclassTest
        2. {
        3. privateSocket socket;
        4. privateFitNessContext context;
        5. protectedlong requestParsingTimeLimit;
        6. }
      使用不对齐的声明和赋值,如果有很长的列表需要做对齐处理,说明问题在列表长度上,这个类应该被拆分了。
    • 缩进
      源文件是一种继承结构,要让这种范围式的继承结构可见,相当依赖缩进模式。

5. 对象和数据结构

对象把数据隐藏于抽象之后,暴露操作数据的函数,数据结构暴露其数据,没有提供有意义的函数。

  1. 数据、对象的反对称性
    它们本质上是对立的:
    • 过程式代码(使用数据结构)便于在不改动既有数据结构的前提下添加新函数,面向对象代码便于在不改动既有函数的前提下添加新类。
    • 过程式代码难以添加新数据结构,因为必须修改所有的函数。面向对象代码难以添加新函数,因为必须修改所有类。
  2. 得墨忒耳定律(最少知识原则)
    类C的方法f只应该调用以下对象的方法:
    • C类的方法;
    • f创建的对象的方法;
    • 作为参数传递给f的对象的方法;
    • 由C的实体变量持有的对象。

即方法不应该调用任何函数返回对象的方法——只和朋友谈话。
下例明显违反最少知识原则,被称为火车失事

String outputDir = ctxt.getOption().getScrachDir().getAbsolutePath();

如果使用属性访问器函数问题将会更复杂:

String outputDir = ctxt.options.scratchDir.absolutePath;

这种结构拥有执行操作的函数,也有公共变量的公共访问器及改值器。这诱导外部函数把对象当成数据结构来使用。既增加了添加新函数的难度,也增加了添加新数据结构的难度。

  1. 数据传送对象
    只有公共变量、没有函数的类称为数据传送对象(DTO,Data Transfer Objects)。一般用在数据库通信、解析套接字传递的消息之类的场景。不要往这类数据结构中塞入业务规则方法,不要把这类数据结构当成对象使用,把它们当做数据结构使用,并创建包含业务规则,隐藏内部数据的独立对象。

6. 错误处理

错误处理很重要,但如果它搞乱了代码逻辑,就是错误的做法。

  1. 使用异常而非返回码
    返回码的问题在于,它们搞乱了调用者代码。调用者必须在调用之后即可检查错误。遇到错误时,最好抛出一个异常。这样调用代码很整洁,逻辑不会被错误处理搞乱。

  2. 先写Try-Catch-Finally语句
    异常给程序定义了一个范围。在Try时,你实际上表明可随时取消执行,并在Catch语句中继续。Try代码块就是事务,catch代码块将程序维持在一种持续状态。先写Try-Catch-Finally语句帮助你定义代码的用户期待什么。

  3. 使用不可控异常
    C#中没有可控异常,在Java中的可控异常的代价是违反开闭原则。如果在方法中抛出可控异常,你就得在catch语句和抛出异常处的每个方法签名中声明该异常。这意味着对软件较低层次的修改,都将波及较高级的签名。

  4. 给出异常发生的环境说明
    创建信息充分的错误信息(包括失败的操作和失败类型),并和异常一起传递出去。

  5. 依调用者需要定义异常类
    不太好的异常分类例子:

    1. ACMEPort port =newACMEPort();
    2. try
    3. {
    4. port.open();
    5. }
    6. catch(DeviceResponseException e)
    7. {
    8. logger.log("Device response execuption", e);
    9. }
    10. catch(ATM1212UnlockedException e)
    11. {
    12. logger.log("Unlock exception", e);
    13. }
    14. catch(GMXError e)
    15. {
    16. logger.log("Device response exception");
    17. }
    18. finally
    19. {
    20. ...
    21. }

    语句包含了一大堆重复代码。实际上,将第三方API打包(替换为抛出一个指定的异常)是个良好的实践手段。打包第三方API不仅可以降低了对它的依赖,还可以定义自己舒服的API。

  6. 定义常规流程
    下面例子使用抛出异常的手法来处理特殊情况:

    1. try
    2. {
    3. MealExpense expense = expenseReportDAP.getMeals(emplyee.getID());
    4. m_total += expense.getTotal();
    5. }
    6. catch(MealExpensesNotFound e)
    7. {
    8. m_total += getMealPerDiem();
    9. }

    这个手法很不好,使用异常打断了业务逻辑。更好的手段是使用特例模式:创建一个类或配置一个对象用来处理特例。客户代码就不用应付异常行为了。

  7. 别返回null值
    如果打算在方法中返回null值,不如抛出异常,或是返回特例对象。这样客户代码就无需检查返回值了。

  8. 别传递null值
    除非API要求你向它传递null值,否则尽可能避免传递null值。在大多数编程语言中,没有良好的方法能对付由调用者意外传入的null值。恰当的方法就是禁止传入null值。





posted @ 2016-09-14 17:23  qianzi  阅读(775)  评论(0编辑  收藏  举报