代码整洁之道(Clean Code)读书笔记

阅读本书有两种原因:第一,你是个程序员;第二,你想成为更好的程序员。很好,我们需要更好的程序员。

PS:本文读书笔记主要截取了部分章节的个人认为关键内容,整理精华,以供分享。

 

第1章 整洁代码

介绍好代码与糟糕代码的差异,了解如何写出好代码,以及将糟糕代码改成好代码。

糟糕的代码

  • 因为时间赶着推产品,代码写的乱七八糟,特性越加越多,代码越来越烂,认为会有朝一日回头清理。注意勒布朗(LeBlanc)法则:稍后等于永不(Later equals never)

混乱的代价

  • 项目初期进展迅猛,然而随着时间代码腐化,每次修改代码都需要了解已有混乱的代码,才往上添加依旧混乱的代码,导致乱麻越来越多。

  • 混乱增加,导致生产力下降,增加更多的人手尝试解决,然而新人不熟悉系统,同时背负提升生产力压力,导致制造更多混乱。

  • 程序员遵从不了解混乱风险的产品经理的意愿,奋力追赶进度和需求,是不专业的。

  • 制造混乱无助于赶上期限,赶上期限唯一方法:保持代码整洁

什么是整洁的代码

作者通过询问知名且经验丰富的程序员得到什么是整洁代码的回答

  • 令人愉悦且高效,不会“引诱”代码越改越乱;完善的错误处理代码,还有重视如内存泄露、竞态条件代码的;整洁代码只做好一件事。

  • 不隐藏设计者意图,明确展现解决问题的张力。

  • 有单元测试和验收测试,使用有意义的命名,尽量少的依赖;易于其他人加以增补,区分易读和易修改的代码;代码应在字面上表单含义,用人类可读方式写代码。

  • 整洁代码看起来是某位特别在意它的人写的,几乎没有改进余地。

  • 消除重复,提高表达力,提早构建简单抽象(小规模抽象既能快速前进,又能为未来修改留有余地)。

  • 每个例程深合己意,专为解决特定问题存在。

我们是作者

  • Javadoc中的@author告诉我们是作者,作者都有读者;作者有责任与读者做良好沟通

  • Emac的“编辑器回放”功能显示,多数时间都在滚动屏幕,浏览其他模块

  • 读与写代码花费的时间比例超过10:1,写新代码时,会一直阅读旧代码

童子军军规

  • 美国童子军军规:让营地比你来时更干净

  • 每次签入时,代码都比签出时干净(如修改变量名、拆分过长函数、消除重复代码、清理嵌套if)

  • 持续改进,防止代码腐化

 

第2章 有意义的命名

介绍在给变量、函数、参数、类和封包(jar包、war包、ear包等)、源代码及源代码目录命名时的简单规则

名副其实

  • 一旦发现更好的名称,应该换掉旧的

  • 名称应该已经答复了所有的大问题:做什么,该怎么用,若名称需要注释补充,则不算名副其实

  • 体现本意的名称更容易理解和修改代码,如指明计量对象/计量单位的名称

  • 用名副其实的函数名称替换魔术数

if (cell[STATUS_VALUE] == FLAGGED)
// 替换为
if (cell.isFlagged)

避免误导

  • 避免留下掩藏代码本意的错误信息

  • 别用accountList表示一组账号,除非真是List类型,若包纳账号的容器不是List,会导致错误的判断,可用accountGroup或accounts

  • 避免使用不同之处较小的名称,会导致难以区分两个名称,如XYZControllerForEfficientHandlingOfStrings和另一个XYZControllerForEfficientStorageOfStrings

  • 避免使用小写字母l和大写字母O作为变量名,和常量“一”和“零”很相似

做有意义的区分

  • 仅仅添加数据系列号或是废话是不够的,即使此可以满足编译器需要。

  • 如果名称相异,则其意思应该不同才对。

  • 区分名称,要以读者能鉴别不同之处的方式区分

添加数字

数字系列命名(a1,a2,….,aN)这样的名称没有提供正确信息,纯属误导

废话

  • 如Product类和ProductInfo、ProductData,名称虽然不同,但意义却无区别,属于意义含糊的废话

  • 废话是冗余的。Variable永远不该出现在变量名中,Table永远不该出现表名中

使用读的出来的名称

  • 名称读不出来,讨论的时候就无法保持良好沟通,编程是一种“社会活动”

  • 避免不合理的自造词(genmdhms),使用恰当的英语词

使用可搜索的名称

  • 单字母名称数字常量难以在一大篇文字中搜索出来,可见,长名称胜于短名称。

  • 单字名称仅适用于短方法中的本地变量。名称的长短应与其作用域大小相对应

避免思维映射

  • 避免让读者将你的名称翻译为他们熟知的名称

  • 单字母变量名就有思维映射问题。除非是在作用域小,没有名称冲突时,传统上惯用i、j、k代表循环计数器

  • 名称的明确是王道

类名和方法名

  • 类名、对象名应该是名称或名词短语

  • 方法名应该是动词或动词短语

  • 重载构造器时,使用描述了参数意义的静态工厂方法名

Complex fulcrumPoint = Complex.FromRealNumber(23,0);// 将相应构造器设置为private
// 通常好于
Complex fulcrumPoint = new Complex(23,0);

每个概念对应一个词

每个抽象概念选一个词,并且一以贯之。如避免使用fetch、get、retrieve不同的词语表示同种方法命名。

别用双关语

避免将同一个单词用于不同目的,遵循“一词一义”原则。如好多类都有add方法,需要保持这些add方法在语义上等价。

添加有意义的语境

  • 很少有名称能自我说明,需要给读者提供语境信息

  • 可以添加前缀,如addrFirstName,addrState等表示地址相关信息。更好的方案时创建相关类Address

  • 只要短名称足够清楚,就比长名称号,别给名称添加不必要的语境。

 

第3章 函数

函数是所有程序中的第一组代码,本章讨论如何写好函数

短小

短小程度

每个函数尽量保持到两行、三行或四行长,每个函数的事情一目了然,并依次把你带到下一个函数。

代码块和缩进

  • if、else、while语句等中的代码块应该只有一行(一个函数的调用语句),不仅能保持短小,同时由于调用函数有具有说明性的名称,增加文档上的价值

  • 函数不应大到足以容纳超过两次的嵌套结构,否则不易阅读和理解

只做一件事

  • 函数应该只做一件事。若函数只是做了该函数名下同一抽象层级上的步骤,则函数只做了一件事

  • 判断函数是否做了一件事:看是否能再拆出一个函数,且该函数不仅只是单纯地重新诠释其实现(并未改变抽象层级)

  • 函数中的区段:通常只做一件事的函数无法被合理地切分为多个区段

每个函数一个抽象层级

  • 自顶向下读代码:向下规则,每个函数后面都跟着位于下一抽象层级的函数

  • 让代码就像是一系列TO do起头的段落,每一段描述当前抽象层级,并引用位于下一抽象层级的后续TO do起头段落

Switch语句

  • switch天生要做N件事,虽无法避开switch语句,但确保每个switch都埋藏较低的抽象层级,且永远不重复,利用多态实现。

举例

反例

// 可能依赖于雇员类型的一种操作
public Money calculatePay(Employee e) {
    switch (e.type) {
        case HOURLY:
            return calculateHourlyPay(e);
        case SALARIED:
            return calculateSalariedPay(e);
        default:
            return null;
    }
}

此例有四种问题:

  1. 函数太长,增加新类型后,会更长

  2. 做了不止一件事,违反了单一权责原则

  3. 违反了开放封闭原则,添加新类型时,必须修改之

  4. 该结构可能到处重复出现,可能会有其他方法,isPayday(Employee e)等等方法

正例

  • 将switch语句埋藏到抽象工厂,该工厂使用switch语句为Employee的继承派生物创建适当实体,不同的函数calculatePay、isPayday则由Employee接口多态的接受调用派遣

  • switch语句尽量只出现一次,用于抽象工厂中创建多态对象,系统其它部分看不到。

// Employee与使用switch的抽象工厂
public abstract class Emloyee {
    public abstract boolean isPayday();
    public abstract Money calculatePay();
}
----------
public class EmployeeFactoryImple implements EmployeeFactory {
    public Employee makeEmployee(EmployeeRecord r) {
        switch(r.type) {
            case HOURLY:
                return new HourlyEmployee(r);  // 返回Employee派生类对象
            case SALARIED:
                return new SalariedEmployee(r);
            default:
                return null;
        }
    }
}

 

使用描述性的名称

  • 函数越短小、功能越集中,就越便于取好名字

  • 别害怕花时间取名字,尝试不同的名称,实测其阅读效果

  • 描述性的名称能够理清你关于模块的设计思路,并可改进之

  • 命名方式保持一致,使用与模块名一脉相承的短语、名词和动词等措辞给函数命名

  • 注意完整函数签名中,考虑函数所在类名的namespace的描述作用和函数参数名的描述作用,在函数被调用使用时可组合起来描述函数作用(可以避免函数名过度冗长,如下例)

// 如EmployeeManager类中保存Employee的操作
public class EmployeeManager {
    // 写法一
    public void saveEmployee(EmployeeDO employeeDO);
    // 写法二:考虑实际使用时会通过类对象调用方法,如类对象employeeManager本身也有描述意义,即通过employeeManager.save(employeeDO)使用,函数名不用再用较为冗长的saveEmployee
    public void save(EmployeeDO employeeDO);
}

函数参数

  • 最理想情况参数数量是零,其次是一,再次是二,应尽量避免三参数

  • 从测试角度,对于多参数,要编写所有可能的参数组合正常的测试用例很困难

  • 输入参数与输出参数。习惯通过参数(即输入参数)输入函数,通过返回值从函数输出。不太期望通过参数(输出参数)输出结果

一元函数

  • 单参函数作用:

    1. 询问关于入参的问题,如Boolean fileExists("MyFile")

  1. 操作该参数

    • 转换。InputStream fileopen("MyFile")

    • 事件(event),使用该参数修改系统状态,有入参而无返回值(void)

注意选用能区别上述两种理由的函数名称

  • 对于转换。使用输出参数(即函数入参中在函数执行完后带有输出结果)而非返回值令人迷惑。转换结果体现在返回值上。

二元函数

  • 有时两个参数正好。如Point p = new Point(0,0),因为此为单个值的有序组成部分。对应自然组合的参数适合二元函数,如笛卡尔坐标

  • 尽量将二元函数转换为一元函数。如对于writeField(outputStream, name)的改进方法:

    • 把writeField写成outputStream的成员方法,调用方式改为:outputStream.writeField(name)

    • outputStream写为当前类的成员变量,从而无需再传递它

    • 分离出类似FieldWriter新类,在构造器中采用outputStream,新类包含一个write方法

三元函数

  • 三元函数会使排序、琢磨、忽略(即忽略某个参数)等问题加倍体现,慎写三元函数

标识参数

  • 向函数传入Boolean值不可取,表示该函数不止做一件事:true一种做法,false一种做法

  • 应该将带有标识参数的函数一分为二:如render(Boolen isSuite)改为renderForSuite()和renderForSingleTest()

参数对象

  • 如果函数参数需要两个、三个或三个以上的参数,说明其中一些参数需要封装为类

  • 从参数创建对象,减少参数数量,同时一组参数被共同传递,则说明需要有自己名称的某个概念。如下例子

Circle makeCircle(double x, double y, double radius);
// 修改为
Circle makeCircle(Point center, double radius);

动词与关键词

  • 对于一元函数,函数和参数应形成良好的动/名词对形式,如write(name),更好的是writeField(name)

  • 通过在函数名中使用关键字,将参数名编码到函数名,如assertEqual改成assertExpectedEqualsActual(expected, actual),减轻记忆参数顺序负担

要无副作用

  • 副作用是一种谎言,函数有时会对自己类中的变量做出未能预期的改动,有时会把变量搞成向函数传递的参数或是系统全局变量,都是具有破坏性的,会导致古怪的时序性耦合和顺序依赖(即函数能否正常调用依赖于其他函数调用顺序)。可通过重命名函数声明表达潜在的时序性问题,如将简单的checkPassword()重命名为checkPasswordAndInitializeSession()解决其中对Session.initialize()的调用依赖(此即为副作用)。

输出参数

  • 避免被参数是用作输入还是输出而迷惑,如appendFooter(s),该函数是否会向s后面添加东西?此时会付出检查函数声明的代价而中断思路

  • 避免输出参数方式

    • 使用report.appendFooter()调用,可避免输出参数,即若函数需要修改某种状态,修改所属对象状态

    • 直接使用返回值

分离指令与询问

  • 函数要么做某事(指令),要么回答某事(询问),二者不可得兼,否则会导致混乱。要么修改某对象状态,要么返回该对象的有关信息

if (set("username","unclebob")) // 是在问unclebob是否设置过?还是是否成功设置unclebob?
// 将上述设置与返回分开
if (attributeExists("username")) {
    setAttribute("username","unclebob")
}

 

使用异常代替错误码

  • 错误码会导致更深层次的嵌套结构(需要判断:if (deletePage(page) == E_OK));使用异常能将错误处理代码从主路径分离出来

  • 错误码依赖磁铁问题。使用错误码意味着某处有个类或枚举定义这所有错误码,许多其他依赖错误的地方都需要导入它,当修改时需要其他类重新编译部署;使用异常,新异常从异常类派生即可,无需重新编译部署。

抽离try/catch代码块

  • 将错误处理与正常流程分离,把try和catch代码块中的主体部分抽离出,形成函数

public void delete(Page page) {
    try {
        deletePageAndAllReferences(page);
    }
    catch(Exception e) {
        logError(e);
    }
}
​
private void deletePageAndAllReferences(Page page) {
    ....
}
​
​
private void logError(Exception e) {
    ...
}

上例将delete函数至于错误处理有关,很容易理解并忽略掉即可。如此美妙的区隔,代码更易理解和修改。

错误处理就是一件事

  • 函数应该只做一件事,错误处理就是一件事

  • 处理错误的函数不应该做其他事,意味着(如上例):

    • 如果try存在于某个函数,它就是该函数的第一个单词

    • catch/finally代码块后面不该有其他内容

别重复自己

  • 重复是软件中一切邪恶的根源

如何写出好函数

  • 并不从一开始就完全按照规则写函数。先想怎么写就怎么写,一开始都冗长而繁杂,有很多缩进、嵌套、过长的参数列表。名称随意取,重复的代码。不过注意配上一套单元测试,覆盖代码,用于重构的回归测试

  • 再打磨。分解函数、修改名称、消除重复、缩短和重新安置方法、拆散类等。同时保持测试通过

 

posted @ 2021-06-23 21:20  东哥byr  阅读(225)  评论(0)    收藏  举报