《代码整洁之道》学习笔记

如何写好代码

《代码整洁之道》参考源码

函数

  • 使用描述性的名称,不要担心名字太长
  • 函数参数不要超过 2 个
  • 如果函数需要 3 个或 3 个以上参数,说明期中一些参数应该封装成类
  • 使用异常代替返回错误码,错误处理代码就能从住路径代码中分离出来
  • 最好把 try 和 catch 代码块的主题部分抽离处理,另外形成函数,让丑陋的代码变得美丽
  • 使用异常代替错误码,新异常就可以从异常类派生处理,无需编译或重新部署
  • 不要重复自己
  • 如何写出这样的函数:开始随意写 -> 单元测试覆盖每行代码 -> 分解函数、修改名称、消除重复 -> 保持测试通过 -> 优秀的函数

格式

  • 一个文件尽量不超过 200~500 行
  • 相关函数
    • 若某个函数调用了另外一个,就应该把它们放到一起,而且调用者应该尽可能放在被调用者上面
    • 这样,程序就有个自然的顺序
  • 概念相关:概念相关的代码应该放到一起。相关性越强,彼此之间的距离就该越短
  • 一行代码不要超过 120 字符

对象和数据结构

对象暴露行为,隐藏数据 数据结构暴露数据,没有明显的行为

得墨忒耳定律(最少知识原则)

  • 模块不应了解它所操作对象的内部情形
  • 对象隐藏数据,暴露操作
  • 对象不应该通过存取器(get/set)暴露其内部结构
  • 得墨忒耳定律认为,类 C 的方法 f 只应该调用以下对象的方法
    • C
    • 由 f 创建的对象
    • 作为参数传递给 f 的对象
    • 由 C 的实体变量持有的对象
  • 方法不应该调用由任何函数返回的对象的方法。(只跟朋友说话,不与陌生人谈话)

下列代码(出自 Apache 某项目)违反了得墨忒耳定律

1
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

这类代码常被称作火车失事,最好做类似如下的切分

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

但上例依然违反了得墨忒耳定律

  • 模块知道 ctxt 对象包含有多个选项
  • 每个选项中都有一个临时目录
  • 而每个临时目录都有一个绝对路径
  • 对于一个函数,这些知识太多了
  • 调用函数需要懂得如何在一大堆不同对象间浏览

这些代码是否违反得墨忒耳定律,取决于 ctxt、Options 和 ScratchDir 是对象还是数据结构

  • 如果是对象,则他们的内部结构应当隐藏而不暴露,而不是暴露其内部细节,明显违反了得墨忒耳定律
  • 如果 ctxt、Options 和 ScratchDir 只是数据结构,没有任何行为,本来就是暴露出来用于操作,所以不违反得墨忒耳定律

这样就不违反得墨忒耳定律

1
final String outputDir = ctxt.options.scratchDir.absolutePath;

上面说了那么多,已经懵圈了,因为代码的最终目的是创建一个临时文件

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

隐藏结构正确示例

1
BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);

数据传送对象 DTO

DTO (Data Transfer Object) 最为精炼的数据结构,是一个只有公共变量、没有函数的类。这种数据结构有时被称为数据传送对象

DTO 是非常有用的结构,尤其在下列场景中

  • 与数据库通信
  • 解析套接字传递的消息

⚠️ 不幸

  • 开发者经常往这里数据结构中塞进业务规则方法,把这类数据结构当成对象来用
  • 这将导致数据结构和对象的混杂体

错误处理

使用异常而非返回码

使用不可控异常

可控异常时非必须的,以下语言都不支持

  • C#
  • C++
  • Python
  • Ruby

可控异常的代价是违反开闭原则

  • 如果你在方法中抛出可控异常,而 catch 语句在三个层级之上,你就得在 catch 语句和抛出异常处之间的每个方法签名中声明该异常
  • 这异味着对软件中较低层级的修改,都将波及较高层级的签名
  • 修改好的模块必须重新构建、发布,就算它们自身所关注的任何东西都没改动过

给出异常发生的环境说明

  • 你抛出的每个异常,都应当提供足够的环境说明,以便判断错误的来源和处所
  • Java 中的异常堆栈踪迹无法告诉你该失败操作的初衷
  • 应创建信息充分的错误消息,并和异常一起传递出去
  • 在消息中,包括失败的操作失败类型
  • 如果你的应用程序有日志系统,传递足够的信息给 catch 块,并记录

依调用者需要定义异常类

  • 按来源分类:来自组件或其他地方
  • 按类型分类:设备错误、网络错误、编程错误
  • 最重要是考虑它们如何被捕获
    • 例如封装第三方 API 异常
      • 好处是你不必绑死在蘑菇特定厂商的 API 设计上。可以定义自己喜欢的 API
      • 将无限可能缩小到有限范围

定义常规流程

下面的笨代码来自某个记账应用的总开支总计模块

1
2
3
4
5
6
try {
  MealExpenses expense = expenseReportDao.getMeals(employee.getID());
  m_total += expenses.getTotal();
} catch(MealExpensesNotFound e) {
  m_total += getMealPerDime();
}

业务逻辑

  • 如果消耗了餐食,则计入总额中
  • 如果没有消耗,则员工得到当日餐食补贴
  • 异常打断了业务逻辑

让代码简洁

  • 修改 ExpenseReportDao,使其总是返回 MealExpense 对象
  • 如果没有餐食消耗,就返回一个餐食补贴的 MealExpense 对象
1
2
MealExpenses expense = expenseReportDao.getMeals(employee.getID());
m_total += expense.getTotal();

这种手法叫做特例模式(Special case pattern)。创建一个类或配置一个对象,用来处理特例

  • 你来处理特例,客户代码就不用应付异常行为了
  • 异常行为被封装到特例对象中

别返回 null 值

别传递 null 值

  • 相对好点的方式是使用断言效验入参
  • 在大多数编程语言中,没有良好的方法能对付由调用者意外传入的 null 值

单元测试

构造-操作-检验(BUILD-OPERATE-CHECK)模式

  • 构造测试数据
  • 操作测试数据
  • 检验操作是否得到期望的结果

正是单元测试让你的代码可扩展、可维护、可复用

  • 有了测试,你就不担心对代码的修改!能毫无顾虑地改进架构和设计
  • 没有测试,每次修改都可能带来缺陷。
  • 无论架构多有扩展性,无论设计划分得有多好,没有了测试,你就很难做改动,因为你担忧改动会引入不可预知的缺陷。

每个测试一个断言

given-when-then

单个测试中的断言数量应该最小化

每个测试一个概念

F.I.R.S.T.

整洁的测试还遵循以下5条规则

  • 快速(Fast)测试应该够快
    • 测试应该能快速运行。
    • 测试运行缓慢,你就不会想要频繁地运行它。
    • 如果你不频繁运行测试,就不能尽早发现问题,也无法轻易修正,从而也不能轻而易举地清理代码。
    • 最终,代码就会腐坏。
  • 独立(Independent)测试应该独立
    • 某个测试不应为下一个测试设定条件
    • 你应该可以单独运行每个测试,及以任何顺序运行测试
    • 当测试相互依赖时,头一个没通过就会导致一连串的测试失败,使问题诊断变得困难,隐藏了下级错误
  • 可重复(Repeatable)测试应当可在任何环境中重复通过
    • 你应该能够在生产环境、质检环境中运行测试,也能够在无网络的列车上用户笔记本运行测试
    • 如果测试不能在任意环境中重复,你就总会有个解释其失败的借口
    • 当条件不具备时,你也无法运行测试
  • 自足验证(Self-Validating)测试应该有布尔值输出
    • 无论通过或失败,你不应该检查日志文件来确认测试是否通过
    • 你不应该手工对比两个不同文本文件来确认测试是否通过
    • 如果测试不能自足验证,对失败的判断就会变得依赖主观,而运行测试也需要更长的手工操作时间
  • 及时(Timely)测试应及时编写
    • 单元测试应该恰好在使其通过的生产代码之前编写
    • 如果编写生产代码之后编写测试,你会发现生产代码难以测试
    • 你可能会认为某些生产代码本身难以测试
    • 你可能不会去设计可测试的代码

类的组织

由某个公共函数调用的私有工具函数紧随在该公共函数后面

  • 这符合了自定向下的原则,让程序读起来就像一篇报纸文章

类应该短小

计算权责(responsibility)

  • 类的名称应当描述其权责
  • 命名是帮助判断类的长度的第一个手段。如果无法为某个类精确命名,这个类大概就太长了
  • 类名越含混,该类越有可能拥有过多权责
  • 我们应该能用大概 25 个单词简要描述一个类,且不用“if、and、or、but”等词汇

单一权责原则(SRP)

类只应有一个权责——只有一条修改的理由

有大量短小类的系统并不比有少量庞大类的系统拥有更多移动部件,其数量大致相等。问题是:

  • 你想把工具归置到有许多抽屉、每个抽屉中装有定义和标记良好的组件的工具箱
  • 还是想要少数几个能随便把所有东西扔进去的抽屉

结论

  • 系统应该由许多短小的类而不是少量巨大的类组成
  • 每个小类封装一个权责,只有一个修改的原因,并与少数其他类一起协同达成期望的系统行为

内聚

  • 类应该只有少量实体变量
  • 类中的每个方法都应该操作一个或多个这种变量
  • 如果一个类中的每个变量都被每个方法所使用,则该类具有最大的内聚性
    • 一般来说,创建正在极大化内聚类是既不可取也不可能的
    • 另一方面,我们希望内聚性保持在较高位置
    • 内聚性高,意味着类中的方法和变量相互依赖、相互结合成一个逻辑整体

这个类非常内聚。在三个方法中,只有size()方法没有使用所有两个变量

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Stack {
    private int topOfStack = 0;
    List<Integer> elements = new LinkedList<Integer>();

    public int size() {
        return topOfStack;
    }

    public void push(int element) {
        topOfStack++;
        elements.add(element);
    }

    public int pop() throws PoppedWhenEmpty {
        if (topOfStack == 0) {

            throw new PoppedWhenEmpty();
        }

        int element = elements.get(topOfStack);

        elements.remove(topOfStack);

        return element;
    }
}

保持内聚性就会得到许多短小的类

将一团糟的代码优化后

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class PrimePrinter {
    public static void main(String[] args) {
    final int NUMBER_OF_PRIMES = 1000;
    int[] primes = PrimeGenerator.generate(NUMBER_OF_PRIMES);

    final int ROWS_PER_PAGE = 50;
    final int COLUMNS_PER_PAGE = 4;
    RowColumnPagePrinter tablePrinter =
      new RowColumnPagePrinter(ROWS_PER_PAGE,
                              COLUMNS_PER_PAGE,
                              The First  + NUMBER_OF_PRIMES +
                                       Prime Numbers);
      tablePrinter.print(primes);
    }
}
  • 重构的程序采用更长、更有描述性的变量名
  • 重构后的程序将函数和类声明当做是给代码添加注释的一种手段
  • 采用了空格和格式技巧让程序更可读

留意程序是如何被拆分为3个主要权责

  • PrimePrinter 类中只有主程序
    • 权责是处理执行环境
    • 如果调用方式改变,它也会随之改变(例如程序转为 SOAP 服务,该类也会被影响)
  • RowColumnPagePrinter 类懂得如何将数字列表格式化到有着固定行、列数的页面上
    • 若输出格式需要改动,则该类也会被影响
  • PrimeGenerator 类懂得如何生成素数列表
    • 注意,这并不意味着要实体化对象
    • 该类就是个有用的作用域,在其中声明并隐藏变量
    • 如果计算素数的算法发生改变,则该类也会改动

为了修改而组织

在整洁的系统中,我们对类加以组织,以降低修改的风险

Listing 10-9 一个必须打开修改的类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public class Sql {   public Sql(String table, Column[] columns)
    public String create()
    public String insert(Object[] fields)
    public String selectAll()
    public String findByKey(String keyColumn, String keyValue)
    public String select(Column column, String pattern)
    public String select(Criteria criteria)
    public String preparedInsert()
    private String columnList(Column[] columns)
    private String valuesList(Object[] fields, final Column[] columns)
    private String selectWithCriteria(String criteria)
    private String placeholderList(Column[] columns)
}
  • 当需要 Sql 类支持 update 语句时,我们就得”打开“这个类进行修改
  • 当增加一种新语句类型时,就要修改 Sql 类
  • 改动单个语句类型时,也要进行修改,比如打算让 select 功能支持子查询
  • 存在两个修改的理由,说明 Sql 违反了 SRP 原则

解决方案

  • 注意哪些方法,直接移到了需要用它们的地方
  • 公共私有行为被划分到独立的两个工具类 Where 和 ColumnList 中

Listing 10-10

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
abstract public class Sql {
  public Sql(String table, Column[] columns)
  abstract public String generate();
}

public class CreateSql extends Sql {
  public CreateSql(String table, Column[] columns)
  @Override public String generate()
}

public class SelectSql extends Sql {
  public SelectSql(String table, Column[] columns)
  @Override public String generate()
}

public class InsertSql extends Sql {
  public InsertSql(String table, Column[] columns, Object[] fields)
  @Override public String generate()
  private String valuesList(Object[] fields, final Column[] columns)
}

public class SelectWithCriteriaSql extends Sql {
  public SelectWithCriteriaSql(
  String table, Column[] columns, Criteria criteria)
  @Override public String generate()
}

public class SelectWithMatchSql extends Sql {
  public SelectWithMatchSql(
      String table, Column[] columns, Column column, String pattern)
  @Override public String generate()
}

public class FindByKeySql extends Sql
  public FindByKeySql(
      String table, Column[] columns, String keyColumn, String keyValue)
  @Override public String generate()
}

public class PreparedInsertSql extends Sql {
  public PreparedInsertSql(String table, Column[] columns)
      @Override public String generate() {
  private String placeholderList(Column[] columns)
}

public class Where {
  public Where(String criteria)
  public String generate()
}

public class ColumnList {
  public ColumnList(Column[] columns)
  public String generate()
}

修改后支持 SRP、开闭原则

隔离修改

  • 针对接口编程
  • 依赖倒置原则(Dependency Inversion Principle,DIP)
    • 类应该依赖于抽象而不是依赖具体细节

系统

依赖注入

有一种强大的机制可以实现分离构造与使用

  • 依赖注入(Dependency Injection,DI)
  • 控制反转(Inversion of Control,IoC)
    • 将第二权责从对象中拿出来,转移到另一个专注于此的对象中,从而遵循了单一权责原则
    • 在依赖管理情景中,对象不应负责实体化对自身的依赖
    • 反之,它应当将这份权责移交给其他”有权力“的机制,从而实现控制的反转

测试驱动系统架构

没必要先做大设计(Big Design Up Front,BDUF)

12 迭进

12.1 通过迭进设计达到整洁目的

Kent Beck 关于简单设计的四条规则(按其重要程度排列)

  • 运行所有测试
  • 不可重复
  • 表达了程序员的意图
  • 尽可能减少类和方法的数量

12.4 不可重复

  • 小规模复用可大量降低系统复杂性。要想实现大规模复用,必须理解如何实现小规模复用
  • 模板方法模式是一种移除高层级重复的通用技巧

12.5 表达力

  • 可以通过选用好名称来表达。在查看起权责时不会大吃一惊
  • 可以通过保存函数和类尺寸短小来表达。短小的类和函数通常易于命名,易于编写,易于理解
  • 可以通过标准命名法来表达。例如:command、visitor
  • 编写良好的单元测试也具有表达性。测试的主要目的之一就是通过实例起到文档的作用。读测试的人应该能很快理解某个类是做什么的
  • 做到以上几点最重要的方式是尝试

13 并发编程

13.1 为什么要并发

  • 并发会在性能和编写额外代码上增加一些开销
  • 正确的并发是复杂的,即便对于简单的问题也是如此
  • 并发 Bug 并非总能重现,所以常被看做偶发事件而忽略,未被当做真的 Bug
  • 并发常常需要对设计策略的根本性修改

13.3 并发防御原则

  • 单一权责原则(SRP)
  • 建议
    • 分离并发相关代码与其他代码
    • 谨记数据封装;严格限制对可能被共享的数据的访问
  • 使用数据复本:避免共享数据
  • 线程应尽可能的独立
    • 让每个线程在自己的世界中存在,不与其他线程共享数据
    • 每个线程处理一个客户端请求,从不共享的源头接纳所有请求数据,存储为本地变量
    • 这样,每个线程都像是世界中的唯一线程,没有同步需要
    • 建议:尝试将数据分解到可被独立线程(可能在不同处理器上)操作的独立子集

13.5 了解执行模型

模型描述
限定资源并发环境中有着固定尺寸或数量的资源。例如数据库连接和固定尺寸读/写缓存等
互斥每一时刻仅有一个线程能访问共享数据或共享资源
线程饥饿一个或一组线程在很长时间内或永久被禁止。例如,总是让执行得快的线程先运行,假如执行得快的线程没完没了,则执行时间长的线程就会“挨饿”
死锁两个或多个线程互相等待执行结束。每个线程都拥有其他线程需要的资源,得不到其他线程拥有的资源,就无法终止
活锁执行次序一致的线程,每个都想要起步,但发现其他线程已经“在路上”。由于竞步的原因,线程会持续尝试起步,但在很长时间内却无法如愿,甚至永远无法启动

13.9.9 自动化

可以使用AspectOrientedFramework、CGLIB或ASM之类工具通过编程来装置代码。

1
2
3
4
5
public class ThreadJigglePoint {
    public static void jiggle() {
        // 什么都不做,或者生成一个随机数,睡眠一段时间
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public synchronized String nextUrlOrNull() {
  if(hasNext()) {
      ThreadJiglePoint.jiggle();
      String url = urlGenerator.next();
      ThreadJiglePoint.jiggle();
      updateHasNext();
      ThreadJiglePoint.jiggle();
      return url;
  } 
  return null;
}

建议:使用异动策略搜出错误

14 逐步改进

要编写整洁代码,必须先写脏代码,然后再清理它

15 JUnit 内幕

16 重构 SerialDate

17 味道与启发

17.2 环境

应当能够发出单个指令就可以运行全部单元测试

  • 运行全部测试是如此基础和重要
  • 应该快速、轻易和直截了当的做到

17.3 函数

过多的参数

  • 函数的参数量应该少
  • 没参数最好,一个辞职,两个、三个再次之
  • 三个以上的参数非常值得质疑,应坚决避免

标识参数

布尔值参数大声宣告函数做了不止一件事。它们令人迷惑,应该消灭

17.4 一般性问题

重复

  • DRY 原则(Don’t Repeat Yourself)
  • Kent Beck 将它列为极限编程核心原则之一
  • Ron Jeffries 将这条规则列在第二位,低位只低于通过所有测试
  • 可以使用模板方法或策略模式来修正

避免否定性条件

否定式要比肯定式难明白一些。

例如

1
if (buffer.shouldCompact())

要好于

1
if (!buffer.shouldNotCompact())

函数只该做一件事

1
2
3
4
5
6
7
8
public void pay() {
  for (Employee e : employees) {
    if (e.isPayday()) {
      Money pay = e.calculatePay();
      e.deliverPay(pay);
    }
  }
}

这段代码做了三件事

  • 遍历所有雇员
  • 检查是否该给雇员付工资
  • 支付薪水

代码可以写的更好

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public void pay() {
  for (Employee e : employees)
    payIfNecessary(e);
}

private void payIfNecessary(Employee e) {
  if (e.isPayday())
    calculateAndDeliverPay(e);
}

private void calculateAndDeliverPay(Employee e) {
  Money pay = e.calculatePay();
  e.deliverPay(pay);
}

掩蔽时序耦合

常常有必要使用时序耦合,但你不应该掩饰它

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class MoogDiver {
  Gradient gradient;
  List<Spline> splines;

  public void dive(String reason) {
    saturateGradient();
    reticulateSplines();
    diveForMoog(reason);
  }
  
}
  • 三个函数的次序很重要。捕鱼之前先织网,织网之前先编绳。
  • 不幸的是,代码并没有强制这种时序耦合
  • 其他程序员可以在调用 saturateGradient 之前调用 reticulateSplines,从而导致抛出异常

更好的方式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class MoogDiver {
  Gradient gradient;
  List<Spline> splines;

  public void dive(String reason) {
    Gradient gradient = saturateGradient();
    List<Spline> splines = reticulateSplines(gradient);
    diveForMoog(splines, reason);
  }
  
}
  • 这样就通过创建顺序队列暴露了时序耦合
  • 每个函数都产生出下一个函数所需的结果,这样一来就没理由不按顺序调用了
  • 虽然增加了函数复杂度,但却暴露了该种情况真正的时序复杂性

在较高层级放置可配置数据

  • 位于较高层级的配置性常量易于修改
  • 它们向下贯穿应用程序
  • 应用程序的较低层级并不拥有这些常量的值

避免传递浏览

不要和陌生人说话(德墨忒尔律)

  • 如果 A 与 B 协作,B 与 C 协作,我们不想让使用 A 的模块了解 C 的信息
  • 不想写类似的代码:a.getB().getC().doSomething()

17.6 名称

  • 采用描述性名称
  • 名称应与抽象层级相符
  • 尽可能使用标准命名法
  • 无歧义的名称
  • 为较大作用范围选用较长名称
  • 避免编码(类似 vis_ 表示图形系统)
  • 名称应该说明副作用
1
2
3
4
5
6
public ObjectOutputStream getOos() throws IOException {
  if (m_oos == null) {
    m_oos = new ObjectOutputStream(m_socket.getOutputStream());
  }
  return m_oos;
}

该函数不只是获取一个 oos,如果 oos 不存在,还会创建一个。所以,更好的名称大概是 createOrReturnOos

17.7 测试

  • 100% 覆盖
  • 使用覆盖率工具:又快有容易找到尚未检测过的 if 或 catch 语句
  • 测试应该快速:慢速的测试是不会被运行的测试
comments powered by Disqus