代码整洁之道 读书笔记

35 minute read Published: 2022-05-20

简单抄一遍《代码整洁之道》的主要思想,好记性不如烂笔头

0⃣️ 前言

The only valid measurement of code quality: WTFs/minute

1⃣️ 整洁代码

Later equals never.

维护代码的整洁是编程者的责任,就像在看病前洗手是医生的责任一样。写代码时,读代码的时长远长于写代码,因此有必要让代码够易读

童子军军规:

让营地比你来时更干净

2⃣️ 有意义的命名

取个好名字的几条简单规则

2.1 名副其实

如果名称需要注释来补充,那就不算名副其实

2.2 避免误导

避免留下掩藏代码本意的错误线索,如保留名

避免使用差别很小的命名

避免用有特殊意义的命名,如为非 List 的变量命含 List 的名

2.3 做有意义的区分

为满足编译器的需要而避免重名时,让名字变得有意义

2.4 使用读得出来的名称

别乱缩写,用单词

2.5 使用可搜索的名称

单字母和数字很难搜索,因为到处都有

名称长短应与作用域大小相对应

2.6 避免使用编码

不要用:

2.7 避免思维映射

不要让读者在脑中把你写的名称翻译为他们熟知的名称

2.8 类名

应该是名词或名词短语,而不是动词

2.9 方法名

应该是动词短语

Getter, Setter, Assertion 应该用变量名加对应前缀命名

2.10 别扮可爱

不要用俚语

2.11 每个概念对应一个词

比如,不要混用get, fetch, retrieve

2.12 别用双关语

避免将同一单词用于不同目的

2.13 添加语境

如果 statefirstName 等一起出现,就能被理解为州,但如果单独出现就语义不明,可以通过前缀来提供上下文信息

2.14 不要添加没用的语境

比如给所有名字都加一个相同的前缀

3⃣️ 函数

如何让函数易于阅读和理解,如何让函数表达意图,该让函数拥有哪些属性,好让读者一看就明白函数属于什么样的程序

3.1 短小

函数的第一规则是小,第二规则还是小,小到一屏能容纳,小到只有20行。函数不应该大到足以容纳嵌套结构,缩进也不该多于一层或两层

3.2 只做一件事

函数应该做一件事,做好这件事,只做这一件事。判断函数是否不止做了一件事,就看能不能再拆出一个函数,而新函数不仅仅重新诠释其实现

3.3 每个函数一个抽象层级

一个函数只容纳一个抽象层级的东西。让代码有自顶向下的阅读顺序,即当前层级的函数描述了当前抽象层级的事务,每个事务引向下一(更低)抽象层级的函数

3.4 Switch语句

确保 switch 语句埋藏在较低的抽象层级,而且永远不重复

3.5 使用描述性的名称

不要害怕长名字,长而有描述性的名字比长注释好

3.6 函数参数

最理想的参数数量是0,其次是1,再次是2,不要产生更多参数的函数。即便是两个参数的函数,如 assertEquals(excepted, actual) ,也让人要记忆两个参数的顺序。参数与函数名处在不同的抽象层级,理解参数叫人为难。也应当避免输出参数,因为违背了“参数是输入”这一直觉

标识参数丑陋不堪,向函数传入布尔值简直是个骇人听闻的做法,它大声宣布本函数做了不止一件事

3.7 无副作用

副作用是对“函数只做一件事”的谎言,产生了不必要的耦合

3.8 分隔指令与查询

函数要么做什么事,要么回答什么事,但二者不可得兼。比如 bool set(key, val) 就让人迷惑,因为它既设置了一个值,又查询了一个信息,我怎么知道它查询了什么信息呢?

3.9 使用异常代替返回错误码

返回错误码的风格要求立刻处理错误,因此会导致 nested-if,而且会让错误处理耦合在逻辑中。异常则将错误处理抽离。使用 try-catch 时,抽离它们单独为函数,以避免耦合,如:

tryLogic() {
	try {
		logic()
	} catch {
		...
	}
}

上例同样符合函数只做一件事的原则: 错误处理就是一件事,而执行具体的逻辑又是一件单独的事

3.10 Don’t Repeat Yourself

永远不要重复

4⃣️ 注释

别给糟糕的代码加注释,重新写吧 —— Brian W. Kernighan 与 P. J. Plaugher

什么也比不上放置良好的注释来得有用,什么也不会比乱七八糟的注释更有本事搞乱一个模块,什么也不会比陈旧的、提供错误信息的注释更有破坏性

注释是一种必须的恶,如果编程语言有足够的表达力,则我们根本不需要注释——使用注释是为了弥补我们在用代码表达意图时的失败——因此如果发现自己需要写注释,那就再想想是否有办法翻盘,用代码来表达

注释存在的越久,就离其所描述的代码越远,因为程序员不能坚持维护注释。真实只在代码中存在

4.1 注释不能美化糟糕的代码

写注释的常见动机:代码太糟糕。我们写了一个模块,发现它太丑了,于是想写点注释来美化或是让人更好理解。但注释不能美化糟糕的代码。带有少量注释的整洁而有表达力的代码,要比带有大量注释的复杂代码像样得多,因此与其花时间写注释来解释糟糕的代码,不如花时间清理那堆糟糕的代码

4.2 用代码来阐述

例子

if ((employee.flags & HOURLY_FLAG) && (employee.age > 65))
// V.S.
if (employee.isEligibleForFullBenefits())

用代码解释意图,有时候简单到只需要多创建一个描述与注释所言一致的函数即可

4.3 好注释

罗列一些必须的注释

  1. 法律信息,版权声明
  2. 提供信息,如解释函数的返回值
  3. 解释意图
  4. 阐释,把某些晦涩难明的参数或返回值的意义翻译成可读形式,尤其当这些函数来自你不能修改的部分
  5. 警示语
  6. TODO,但记得定期清理
  7. 放大,放大某种看来不合理之物的重要性
  8. 公共API的Doc

4.4 坏注释

大多数注释都属此类

  1. 喃喃自语,只有编写者自己能懂,而其他人需要查看系统其他部分代码才能理解此处所言

  2. 多余的注释,简单函数头部的注释读起来可能比读这个函数本身还长

  3. 误导性注释,和代码不一致

  4. 循规式注释,比如要求每个函数/变量都有注释

  5. 日志式注释,在版本控制出现之后它们就应该被消灭

  6. 废话注释,没有意义的话,或者单纯重新描述一遍函数名

  7. 可怕的废话,废话注释的进阶版,甚至有typo或者复制粘贴错误

    /** The name **/
    String name;
    
    /** The version **/
    String version;
    
    /** The licenseName **/
    String licenseName;
    
    /** The version **/
    String info;
    
  8. 能用函数或变量时就别用注释

  9. 位置标记,如 // Actions ///////////////////////////////////////,这类标记出现太多时就会被自动忽略,沦为背景噪声

  10. 括号后面的注释,如果你发现你想注释右括号,那应该做的是缩短函数

  11. 归属与署名,让版本控制系统来做吧

  12. 注释掉的代码,太讨厌了!别人不敢删掉注释掉的代码,以为留着肯定有什么用,然后注释掉的代码堆积在一起,变成一堆垃圾。让源代码控制系统来记住这些代码吧

  13. HTML注释,太难读了,用诸如IDE等更先进的工具来替代吧

  14. 非本地信息,请确保注释描述了离它最近的代码,不要描述非它所描述的代码所控制的内容

  15. 过多信息,别在注释中添加无关的细节描述

  16. 不明显的联系,注释的作用是解释未能自行解释的代码,如果注释本身还需要解释,就太遗憾了

  17. 函数头,选个好名字吧

  18. 非公共API中的Javadoc(对Java而言)

5⃣️ 格式

5.1 格式的目的

代码格式关乎沟通,而沟通是开发者的头等大事

5.2 垂直格式

讨论一个代码文件该有多少行

  1. 向报纸学习,从上向下阅读,从头条标题,到大纲,再到细节。源文件的名称应当足够提供是否在正确模块中的信息,源文件顶部给出高层次大的概念和算法,细节应该往下依次展开
  2. 概念间垂直方向上的区隔,用空白行区分不同的概念
  3. 概念相似的代码在垂直距离上应该靠近,以做到一览无遗
  4. 垂直距离,在函数间跳转,本想理解做什么,但花费时间去找代码在哪里。关系密切的代码应该互相靠近,除非有很好的理由,否则不要把关系密切的概念放到不同文件中,这也是避免使用 protected 变量的原因。变量声明应该尽可能靠近其使用位置,因为函数很短,本地变量应尽可能在函数顶部出现;循环控制变量声明应该出现在循环语句中;实体变量应该在类的顶部声明;互相调用的函数应该被放在一起,调用者尽可能在被调用者之上;概念相关(比如功能相似)的函数应当放在一起
  5. 垂直顺序,最上面的代码包含整体逻辑,而越往下越涉及细节

5.3 横向格式

讨论一行代码该有多宽

  1. 水平方向上的区隔与靠近,用空格分隔概念无关的东西(如操作符与操作数或不同参数),或用于强调运算符优先级
  2. 水平对齐,一般不做水平对齐
  3. 缩进,不要为了代码的简短违反缩进规则
  4. 空范围,尽量不要让 while, for 等语句的语句体为空,或者至少要用另起一行的分号来强调

5.4 团队规则

团队应当认同一套规则,即便每个人都可能不满意其中的一些点

6⃣️ 对象和数据结构

将变量设置为 private 有一个理由,我们不想其他人依赖这些变量,希望能有随时修改其实现的自由。那为什么还有那么多人给对象自动添加 Getter/Setter,来将私有变量公之于众呢

6.1 数据抽象

考虑一下两个几何点的实现

public class Point {
	public double x;
	public double y;
}

public interface Point {
	double getX();
	double getY();
	void setCartesian(double x, double y);
	double getR();
	double getTheta();
	void setPolar(double r, double theta);
}

前者暴露了实现,而后者完全隐藏了实现,并且约定了点的存取策略,坐标的获取可以单独进行,但设置必须是原子操作。抽象的一个关键点就在于隐藏实现而暴露接口,这样其实现就可以随时被更改

6.2 数据、对象的反对称性

对象把数据隐藏在抽象之后,暴露操作数据的函数;数据结构暴露其数据,而不提供任何有意义的方法——它们是对立的

考虑如下两个完全对立的实现,前者只有形状的数据结构和一个一揽子实现了所有数据结构对应的 area 方法的类;后者则约定了一个抽象的包含 area 接口的抽象类,利用多态让所有其具体类实现自己的接口

前者代表了一种 Procedural programming 的范式,当我们需要增加数据结构时,需要修改所有 Geometry 中的方法!但当我们需要增加方法时,则所有数据结构都无需修改自身(因为他们不包含任何方法的实现)。因此它便于在不改动既有数据结构的情况下增加方法,不便于添加新的数据结构

后者代表了一种 OOP 的范式,当我们需要增加对象时,其他对象的方法全都无需修改,但当我们需要增加抽象类的方法时,每个对象都需要增加其实现。因此它便于在不改动既有方法的情况下添加新类,不便于添加新的方法

// Procedural-oriented
public class Square {
	public Point topLeft;
	public double side
}

public class Circle {
	public Point center;
	public double radius;
}

public class Geomtry {
	public final double PI = 3.14;
	
	public double area(Object shape) {
		if (shape instanceof Square) {
			// ...
		}
		else if (shape instanceof Circle) {
			// ...
		}
		throw new NoSuchShapeException();
	}
}
// Object-oriented
public class Square implements Shape {
	private Point topLeft;
	private double side;
	
	public double area() {
		// ...
	}
}

public class Circle implements Shape {
	private Point center;
	private double radius;
	
	public double area() {
		// ...
	}
}

6.3 Demeter 定律

Demeter 定律认为,模块不应该了解它所操作对象的内部情形,即偏好 OOP 风格的实现。定律认为,类 C 的方法 f 只应该调用以下对象的方法:

不应该调用由任何函数返回的对象的方法,以下代码即是反例,因为它调用了 getOptions() 返回的对象的 getScratchDir(),又调用了其返回对象的 getAbsolutePath() 方法

final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath()
  1. 火车失事,因为看起来像一列火车,这类代码常被称作火车失事,这一连串的调用是一种肮脏的风格,单纯考虑风格也至少应当被拆封成如下几行

    Options opts = ctxt.getOptions();
    File scratchDir = opts.getScratchDir();
    final String outputDir = scratchDir.getAbsolutePath();
    

    而关于这段代码是否违反 Demeter 定律,取决于 ctxt, Options, ScratchDir 是对象还是数据结构,如果是对象则违反,如果是数据结构则不违反

    如果数据结构只简单地拥有公共变量,没有函数,而对象则拥有私有变量和公共函数(没有属性访问器),则可能就会有如下代码,也就无关违反 Demeter 定律了

    final String outputDir = ctxt.options.scratchDir.absolutePath;
    
  2. 混杂,这种混淆有时会导致混合结构,一半是对象,一半是数据结构,既有执行操作的函数,也有修改私有变量的方法,后者将私有变量公开化。这种结构既有对象不易添加新函数的缺点,又有数据结构不易添加新数据结构的缺点,两面不讨好

  3. 隐藏结构,假使 ctxt, Options, ScratchDir 是拥有真实行为的对象,那应当如何改写上述火车代码呢?先看两种方案:

    ctxt.getAbsolutePathOfScratchDirectoryOption();
    

    或者

    ctx.getScratchDirectoryOption().getAbsolutePath();
    

    第一种方法可能导致 ctxt 对象中方法的暴露,第二种方案是在假设 getScratchDirectoryOption 返回一个数据结构而非对象,两种方案都不好。如果 ctxt 是个对象,就应该要求它做点什么,而不是要求它给出内部情形,那我们为何还要得到这个临时目录的绝对路径?我们要它做什么呢?看看同一模块许多行之后的这块代码:

    String outFile = outputDir + "/" className.replace('.', '/') + ".class";
    FileOutputStream fout = new FileOutputStream(outFile);
    BufferedOutputStream bos = new BufferedOutputStream(fout);
    

    我们发现取得临时目录的绝对路径的初衷是为了创建指定名称的临时文件,那何不直接让 ctxt 来做这件事

    BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);
    

    这样就不违反 Demeter 定律了

6.4 数据传送对象

最为精炼的数据结构,是一个只有公共变量、没有函数的类,这种数据结构有时候被称为数据传送对象(DTO, Data Transfer Objects),DTO 是非常有用的结构,尤其是在与数据库通信、或解析 Socket 传递的消息时,另一种 bean 结构则是在 DTO 上附加 Getter/Setter 后的结构

7⃣️ 错误处理

错误处理很重要,但如果它搞乱了代码逻辑,就是错误的做法。本章将罗列一些错误处理的技巧

7.1 使用异常而非返回码

返回码最大的问题在于,要求调用者在调用之后立即检查错误,但这个步骤很容易被遗忘

7.2 先写 Try-Catch-Finally 语句

异常的好处在于,它在程序中定义了一个范围,try 部分的代码随时可以取消,然后在 catch 中接续。在某种意义上,try 就像一个事务,而 catch 将维护程序的一致性,因此在写可能有异常的程序时,先写 try-catch-finally,这能帮忙定义代码的用户应该期待什么,不论 try 里出现了什么错误

7.3 使用不可控异常

可控异常(每个方法签名列出其可能抛出的异常)的代价是,违反了开放闭合原则——如果方法中抛出可控异常,而 catch 语句在三个层级之上,你就得在 catch 语句和抛出异常处之间的每个方法签名中声明该异常,一个软件最底层的错误类型的修改将导致整个从底向上调用链上的 thorw 子句的修改

7.4 给出异常发生的环境说明

每个抛出的异常都应该提供足够的环境说明,以便判断错误的来源,stack trace 不能提供错误代码“希望干什么”的信息,因此应创建充分的错误消息和异常一起传递出去,在消息中应包含失败的操作和失败类型

7.5 依调用者需要定义异常类

对错误分类有很多方式,其中最重要的是考虑它如何被捕获,以下是一个不太好的例子

ACMEPort port = new ACMEPort(12);

try {
	port.open();
} catch (DeviceResponseException e) {
	reportPortError(e);
	logger.log(e);
} catch (ATM1212UnlockedException e) {
	reportPortError(e);
	logger.log(e);
} catch (GMXError e) {
	reportPortError(e);
	logger.log(e);
} finally {
	// ...
}

这里的语句包含了太多重复代码,既然我们所做的事不外如此,就可以通过打包调用API,确保它返回通用异常类型而简化代码

LocalPort port = new LocalPort(12);
try {
	port.open();
} catch (PortDeviceFailure e) {
	reportError(e);
	logger.log(e.getMessage(), e);
}

public class LocalPort {
	private ACMEPort innerPort;
	
	public LocalPort(int portNumber) {
		innerPort = new ACMEPort(portNumber);
	}

	public void open() {
		try {
			innerPort.open();
		}
		catch (DeviceResponseExeption e) {
			throw new PortDeviceFailure(e);
		}
		// catch ...
	}
}

通过在 ACMEPort 之上打包一层 LocalPort ,我们收获了

  1. 更少的重复代码
  2. 更强的灵活性,将来可以将 innerPort 的实现改为其他 API

7.6 定义常规流程

特例模式(SPECIAL CASE PATTERN),创建一个类或配置一个对象用来处理特例,这样客户代码就不需要处理异常行为了,异常行为被封装到对象中

7.7 别返回 null

否则代码每一行都在检查 null 值。如果打算在方法中返回 null 值,不如抛出异常,或是返回特例对象。如果在调用第三方 API 中可能返回 null 的方法,可以考虑用新方法打包这个方法

许多情况下,特例对象都是爽口良药,比如:

List<Employee> employees = getEmployees();
if (employees != null) {
	for (Employee e : employees) {
		// ...
	}
}

其中 getEmployees 可能返回 null,使整个代码复杂起来,如果修改 getEmployees 返回空列表,整个代码就整洁起来

List<Employee> employees = getEmployees();
for (Employee e : employees) {
	// ...
}

7.8 别传递 null

在方法中返回 null 是糟糕的,但将 null 传递给其他方法就更糟了,除非 API 要求传递 null,否则尽可能避免这样做。在大多数编程语言中,没有什么好办法处理意外传入的 null,它总倾向于导致运行时的错误——要么导致代码需要用更多的错误处理逻辑覆盖这个边界条件,要么直接导致程序出错。因此恰当的做法就是禁止传递 null

8⃣️ 边界

我们很少控制系统中的全部软件,而是需要将第三方代码整合进自己的代码。本章介绍一些保持软件边界整洁的实践技巧和手段

8.1 使用第三方代码

第三方程序提供者追求普适性,而使用者希望集中满足特定需求的代码,这种矛盾会导致系统边界上出现问题。以 [java.util.Map](<http://java.util.Map>) 为例,它提供了一系列通用的接口,其中包含了 clear() ,但程序开发者可能希望他的 Map 的所有用例都不能删除 item,即通用的第三方库提供了超出期望的功能;而且如果在系统中不受限制地传递 Map 实体,一旦 Map 的接口被修改,许多地方都要跟着修改

因此一个合适的方法是封装一层,并且只暴露期望提供的功能

8.2 浏览和学习边界

对第三方库进行测试不是我们的职责,但我们最好对我们用到的第三方代码进行测试。假设我们还不太清楚如何使用第三方库,可能我们要花一两天来读文档然后决定怎么用它,然后我们再写点代码,测试这些接口是否如我们所想的一样工作,最后可能还要花时间 debug,思考究竟是我们的用法不对还是他们的接口有 bug

学习第三方代码很难,在我们的代码里集成它们更难,一个不错的方法就是在我们的代码里写针对第三方接口的测试——这被称为 learning tests (Jim Newkirk)

8.4 learning tests 的好处不只是免费

learning tests 毫无成本,因为无论如何我们都要学习使用这些 API,编写测试帮助我们学习、增进对 API 的理解。同时当 API 发布了新版本,我们可以通过这些测例来检查 API 的行为是否发生改变,它确保第三方代码如我们所预期那样工作。如果没有这些测试,升级第三方代码库将会变得困难,我们可能会被长久地捆绑在低版本库上

8.5 使用不存在的代码

还有一种边界,那种将已知未知分隔开的边界。我们可能会依赖尚不存在的模块,又不希望它阻塞我们的进度。此时我们可以编写一个提供我们期望接口的类,在此之上进行开发,然后在未知模块真正发布之后通过一个 ADAPTER 来将真正的模块转接成我们之前所用的接口

8.6 整洁的边界

边界上的代码需要清晰的分割和定义了期望的测试,避免我们的代码过多地了解第三方代码中的特定信息:依赖你能控制的东西,好过依赖你控制不了的东西,以免日后被他控制。这可以通过收敛代码中依赖第三方库的位置(比如包装所有接口到一个类中),或是通过 ADAPTER 模式来转换接口来实现

9⃣️ 单元测试

9.1 TDD 三定律

TDD 要求在编写代码之前先编写单元测试,其定律有三

这三定律将你限制在大概30秒的一个循环中,测试于生产代码一同编写,测试只比生产代码早写几秒钟。这样写程序,我们会每天写几十个,每月写几百个,每年写几千个测试,这样产出的测试会覆盖所有代码,测试代码量足以匹敌生产代码量,导致可怕的管理问题

9.2 保持测试整洁

只要不是即写即弃的测试,则其代码应该和生产代码一样整洁规范。脏测试等同于没测试,因为测试必须随着生产代码的演进而修改,它有和生产代码一样的生命周期,不可维护的测试最后只会变成债务,带来越来越高的维护成本。最后不得不弃用它,因为维护它的成本已经太高

测试和生产代码一样重要,它们不是二等公民

9.3 整洁的测试

整洁的测试有三要素:可读性可读性可读性。在单元测试中,可读性甚至比在生产代码中还重要,以尽可能少的文字表达尽可能多的内容,之前针对整洁代码的技巧在这里同样适用

  1. 面向特定领域的测试语言,针对我们的生产代码包装一套方便测试的 API,以便编写测试及其可读性,这就是一种测试语言,这种测试语言需要在对旧测试的重构中产生,而非一蹴而就
  2. 双重标准,由于测试代码运行在测试环境,因此可以有和生产环境不同的标准,比如性能,测试代码可以为了可读性牺牲性能

9.4 每个测试一个断言

每个测试只有一个断言可以更易读,但会引入很多重复代码

可以把单个断言放宽,变成单个测试中的断言数量应该最小化,即每个测试一个概念

9.5 F.I.R.S.T.

五条规则