代码整洁之道

字数 3778 · 2021-03-04

有意义的命名

  • 使用读得出来的名称
  • 使用可搜索的名称

专业的程序员了解,明确才是王道。

方法名应当是动词或动词短语,如 postPayment, deletePage, save

每个概念对应一个词

反例: 同时使用 controller, manager, driver

记住,只有程序员才会读你的代码。

函数

短小

函数的第一规则是要短小。第二规则是还要更短小。

if, else, while 等,其中的代码块应该只有一行。 (很极端…)

分层

每个函数一个抽象层级

长而具有描述性的名称,比描述性的长注释要好。

命名约定

命名方式要保持一致

includeSetupAndTeardownPagesincludeSetupPageincludeSuiteSetupPage

函数参数

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

从测试的角度看,参数多难于测试。(条件覆盖率)

标识参数

标识参数丑陋不堪。

向函数传入布尔值兼职就是骇人听闻的做法。

这样做,方法签名立刻变得复杂,大声宣布函数不止做一件事。

二元函数

两个参数通常没有自然顺序 assetEqual(expected, actual) 中, expected 在前, actual 在后, 只是一种需要学习的约定罢了。

参数对象

1
2
Circle makeCircle(double x, double y, double radius);
Circle makeCircle(Point center, double radius);

无副作用

时序性耦合

1
appendFooter(s);

这个函数式把 s 添加到什么东西后面吗?或是把什么东西添加到了 s 的后面?

面向对象:

1
report.appendFooter();

分隔指令与询问

函数要么做什么事,要么回答什么事,单二者不可得兼。

使用异常代替返回错误码

函数应该只做一件事情。错误处理就是一件事情。

Don’t Repeat Youself.

注释

别给糟糕的代码加注释——重新写吧!

注释并不纯然的好,实际上,注释最多也就是一种必须的恶。

注释的恰当用法是弥补我们在用代码表达意图时遭遇的失败。

注释存在的时间越久,就离其所描述的代码越远,原因很简单,程序员不能坚持维护注释。

注释不能美化糟糕的代码

所谓每个函数都要有 Javadoc 或是每个变量都要有注释全然是愚蠢可笑的。

能用函数或变量时就别用注释。

格式

保持良好的代码格式。
选用一套管理代码格式的简单规则并贯彻这些规则。

今天编写的功能,极有可能在下一版本中被修改,但代码的可读性却会对以后可能发生的修改行为产生深远的影响。
原始代码修改之后很久,其代码风格和可读性仍会影响到可维护性和扩展性。
即便代码已不复存在,你的风格和律条仍然存活了下来。

垂直格式

向报纸学习

想想看写得好的报纸文章。你从上到下阅读。
在顶上,你期望有个头条,告诉你故事主题。
第一段是整个故事的大纲,给出粗线条概述,但隐藏了故事细节。
接着读下去,细节渐次增加,直至你了解所有其他细节。

报纸由许多文章组成,多数短小精悍,有的稍微长点,很少有占满一整页的。

垂直间隔

不同概念间的空白行。

垂直距离

关系密切的概念应该互相靠近。

变量声明应尽可能靠近其使用的位置。

因为函数很短,本地变量应该在函数的顶部出现。

实体变量应该在类的顶部声明,因为在设计良好的类中,它们会被大部分类使用。

相关函数

若某个函数调用了了另一个函数,就应该把它们放到一起,而且调用者应尽可能在被调用者前面。

概念相关

概念相关的代码应该放到一起。相关性越强,彼此之间的距离就应该越近。

水平格式

作者认为一行代码的长度应小于 120。

不必僵化。

可遵循无需滚动条到右边的原则。

水平方向的间隔

操作符两边加空格 - 强调操作符有左右两个要素

函数名和左右不加空格 - 强调函数和参数的相关

函数参数列表 , 后加空格 - 强调参数是相互分离的

1
2
3
4
// * 两边不加空格强调优先级高
// = 两边不加空格强调优先级低
// 不幸的是多数格式化工具会让这种微妙的用法消失
var r = b*b - 4*a*c

水平对齐

没有意义,阅读顺序会由从左向右变为从上到下。

缩进

非常重要

团队规则

每个程序员都有自己喜欢的格式规则,但如果在一个团队中工作,就是团队说了算。

一组开发者应当认同一种格式风格,每个成员都应该采用那种风格。

对象和数据结构

将变量设置为私有 (private) 有一个理由,我们不想其他人依赖这些变量。

数据抽象

1
2
3
4
5
// 具体的点
public class Point {
  public double x;
  public double y;
}
1
2
3
4
5
6
7
8
9
10
// 抽象的点
public interface Point {
  double getX();
  double getY();
  void setCartesian(double x, double y);

  double getR();
  double getTheta();
  void setPolar(double r, double theta);
} 

第二段代码的漂亮之处在于,你不知道该实现会是在矩形坐标系中还是在极坐标系中。可能两个都不是!

第一段代码则非常清除地是在矩形坐标系中实现,并要求我们单个操作那些坐标。这就暴露了实现。

隐藏实现并非只是在变量之间放上一个函数层那么简单。隐藏实现关乎抽象!

类并不简单采用取值器和赋值器将其变量推向外间,而是暴露抽象接口,以便用户无需了解数据的实现就能操作数据的本体。

第二段代码更好。我们不愿暴露数据细节,更愿意以抽象形态表述数据。

并不是用了接口和 getter/setter 就万事大吉了,要以最好的方式某个对象包含的数据,需要做严肃的思考。

数据、对象的反对称性

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

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
// 过程式代码

public class Square {
  public Point topLeft;
  public double side;
}

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

public class Geometry {
  public final double PI = 3.14;

  public double area(Object shape) throws NoSuchShapeException {
    if (shape instanceof Square) {
      Square s = (Square) shape;
      return s.side * s.side;
    } else if (shape instanceof Circle) {
      Circle c = (Circle) shape;
      return PI * c.radius * c.radius;
    } else {
      throw new NoSuchShapeException();
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 面向对象代码

public class Square implements Shape {
  private Point topLeft;
  private double side;

  public double area() {
    return side * side;
  }
}

public class Circle implements Shape {
  private Point center;
  private double radius;

  public double area() {
    return PI * radius * radius;
  }
}

对象与数据结构的二分原理:

  • 过程式代码便于在不改动既有数据结构的前提下添加新的函数
  • 面向对象代码便于在不改动既有函数的前提下添加新类

反过来说:

  • 过程式代码难以添加新的数据结构 - 因为必须修改所有的函数
  • 面向对象代码难以添加新的函数 - 因为必须修改所有的类

所以,对于面向对象比较难的事,对于过程式代码却比较容易,反之亦然!

迪米特法则

The Law of Demeter

最少知识原则

模块不应该了解它所操作对象的内部情况。

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

上列代码是否违反了迪米特法则呢?

这些代码是否违反了迪米特法则,取决于 ctxOptionsScratchDir对象还是数据结构

如果是对象,则它的内部结构应当隐藏不暴露,而有关其内部细节的知识就明显违反了迪米特法则。

如果是数据结构,没有任何的行为,则它们自然会暴露其内部结构,迪米特法则也就不适用了。

如果数据结构只简单的拥有公共变量,没有函数,而对象则拥有私有变量和公共函数,这个问题就不那么混淆。

这种混淆有时会不幸导致混合结构,一版是对象,一版是数据结构。

此类混杂增加了添加新函数的难度,也增加了添加新数据结构的难度,应避免创造这种结构。

1
2
// 直接让 ctx 完成操作
BufferedOutputStream bos = ctx.createScratchFileStream(classFileName);

数据传送对象

最为精炼的数据结构是一个只有公共变量、没有函数的类。

这种数据结构有时被称为数据传送对象 (DTO - Data Transfer Objects)

Bean 结构拥有由赋值器和取值器操作的私有变量,对 Bean 结构的半封装会让某些 OO 纯化者感觉舒服些,不够通常没有其他好处。

小结

有时我免费希望代码能够灵活的添加新的数据类型,这时候我们运用对象,另一些时候我们希望代码能够灵活的添加新的行为,这时候我们使用数据类型和过程。
优秀的软件工程师不带成见地了解这种情形,并根据手边工作的性质选择其中的一种。

错误处理

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

使用异常而非错误码

很久以前,许多语言都不支持异常。这批语言处理和报错的手段都很有限,要么设置一个错误标识,要么返回一个错误码。

这类手段的问题在于,它们搞乱了使用者的代码。- 必须在调用之后马上检查错误

1
2
3
4
5
6
DeviceHandle handle = getHandle(DEV1);
if (handle != DeviceHandle.INVALID) {
  // ...
} else {
  // error handling
}
1
2
3
4
5
6
try {
  DeviceHandle handle = getHandle(DEV1);
  // ...
} catch (DeviceInvalidError e) {
  // error handling
}

环境说明

抛出的每个异常,都应当提供足够的环境说明,以便判断错误的来源和位置。

自定义异常类

对错误的分类有很多种方式。

  • 来源 - 是来自组件还是其他地方?
  • 类型 - 是设备错误、网络错误还是编程错误?

不过,最重要的考虑应该是 它们如何被捕获

将第三方 API 打包是个良好的实践手段,当你打包一个第三方 API,你就降低了对它的依赖,未来你可以不太痛苦地改用其他代码库,另外也有助于模拟第三方调用。

打包的好处还在于你不必绑死在摸个特定厂商的 API 设计上。你可以自定义自己觉得舒服的 API

定义常规流程

如果你遵循前文提及的建议,在业务逻辑和错误处理代码之间就会有良好的区隔。然而,这样做却把错误检测推到了程序的边缘地带。

不处理异常代码会更加简洁。

特例模式 (SPECIAL CASE PATTERN):创建一个类或配置一个对象,用来处理特例。你来处理特例,客户代码就不用处理异常了,因为异常已经被封装到特例对象中了。

不要返回 null

要讨论错误处理,就一定要提及哪些容易引发错误的做法。

第一项就是返回 null

返回 null 值,基本上是给自己增加工作量,也是在给调用者添乱。只要有一处没检查 null 值,程序就会失控。

1
2
3
4
5
6
7
8
9
public void registerItem(Item item) {
  if (item != null ){
    ItemRegistry registry = peristemtStore.getItemRegistry();
    if (registry != null) {
      Item existing = registry.getItem(item.getID());
    // ...
    }
  }
}

如果你打算在方法中返回 null,不如抛出异常,或是返回特例对象。

如果你再调用某个第三方 API 中可能返回 null 的方法,可以考虑用新方法打包这个方法,在新方法中抛出异常或是返回特例对象。

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

1
2
3
4
5
6
7
8
// getEmplyees 可能返回 null
// 如果返回空列表,代码就会更加简洁
List <Employee> emplyees = getEmployees();
// if (employees != null) {
  for(Employee e: employees) {
    totalPay += e.getPay();
  }
// }

不要传递 null

在方法中返回 null 是糟糕的做法,但将 null 传给其他方法更加糟糕。

除非 API 要求,否则尽量避免传递 null

在大多数语言中,没有良好的方法能对付由调用者意外传入的 null,所以恰当的做法就是禁止传入 null

小结

整洁的代码是可读的,但也要强固。可读与强固并不冲突。如果将错误处理独立于主要逻辑之外,就能写出整洁的代码。
做到这一步,我们就能单独处理它,也及大地提升了代码的可维护性。

边界

我们很少控制系统中的全部软件。有时我们购买第三方程序包或使用开源代码,有时我们依靠公司中其他团队打造的组件或是子系统。
不管哪种情况,我们都得将外来代码干净利落地整合进自己的代码中。本章将介绍一些保持软件边界整洁的实践手段和技巧。

使用第三方代码

在接口提供者和使用者之间,存在与生俱来的张力。

  • 第三方程序包和框架提供者追求普适性,以在多个环境中工作,吸引广泛的用户。
  • 使用者则想要集中满足特定需求的接口。

这种张力会导致系统边界上出现问题。

如果使用类似 Map 这样的边界接口,就把它保留在类或者近亲类中,避免从公共 API 中返回边界接口或将边界接口作为参数传递给公共 API

使用尚不存在的代码

还有另一种边界,那种将已知和未知分隔开来的边界。(比如接口还没给)

Adapter 模式

接口 Mock

整洁的边界

边界上会发生有趣的事,改动便是其中之一。有良好的软件设计,无需巨大投入和重写即可进行修改。
在使用我们控制不了的代码时,必须倍加小心,确保未来的修改不至于代价太大。

边界上的代码需要清晰的分割,并定义期望的测试。
应避免我们的代码过多地了解第三方代码中的特定信息。

依靠能控制的东西好过依靠控制不了的东西,免得日后受他控制。

单元测试

如今,我们的专业领域进步甚多,我们会编写测试,确保代码中的每个角落都正常的工作,我们会将代码合操作系统隔开,而不是直接调用标准计时功能,我们会伪造一套计时函数,这样就能全面的控制时间。

有了一套运行通过的测试,我们会确保任何需要用到代码的人都能方便地使用这些测试。

TDD 三定律

谁都知道 TDD 要求我们在编写代码前先编写单元测试。但这条规则只是冰山一角。

看看下列三定律:

  • 在编写单元测试前,不可编写生产代码
  • 只允许编写刚好能够导致失败的单元测试
  • 只允许编写刚好能够使失败的单元测试通过的生产代码

这三条定律将你限制在大概 30 秒一个的循环中,测试与生产代码一起产生。

这样写代码,每天都会产生数十个测试用例,每月会有数百个,每天则会有数千个之多,测试代码将覆盖所有生产代码,测试的代码量足以匹敌生产的代码量,导致令人生畏的管理问题。

保持测试整洁

测试代码必须随生产代码的演进而修改。

没有了测试代码,人们就无法确保对系统摸个部分的修改不会影响到其他部分,故障率开始增加,人们开始害怕做出改动,不再清理生产代码,害怕修改带来的损害多余收益。于是,生产代码开始腐坏,最后只剩下没有测试,纷乱而缺陷缠身的代码。

测试代码和生产代码一样重要,它可不是二等公民,它需要被思考、被设计和被照料,它应该像生产代码一样保持整洁。

测试带来一切好处

如果测试不能保持整洁,你就会失去它们。没了测试,你就会失去保证生产代码可扩展的一切要素。
正是单元测试让你的代码可扩展、可维护、可复用。
有了测试,你就不担心对代码的修改。
没有测试,每次修改都可能带来缺陷。

整洁的测试

整洁的测试有三个要素:可读性可读性可读性

在单元测试中,可读性甚至比在生产代码中还重要。

Q: 测试如何才能做到可读?
A: 和其他代码一样:明确,简洁,还有足够的表达力。

在测试中,我们要以尽可能少的文字表达大量的内容。

BUILD-OPERATE-CHECK 模式

面向测试领域的测试语言,这种 API 并非期初就设计出来,而是在对测试代码重构时逐渐演进的。
守规矩的开发者会将他们的测试代码重构为更简洁、更具表达力的形式。

双重标准

1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void turnOnLoTempAlarmAtThreashold() throws Exception {
  hw.setTemp(WAY_TOO_COLD);
  // 细节太多
  controller.tic();
  // 难以阅读
  assertTrue(hw.heaterState());
  assertTrue(hw.blowerState());
  assertFalse(hw.coolerState());
  assertFalse(hw.hiTempAlarm());
  assertTrue(hw.loTempAlarm());
}

重构后:

1
2
3
4
5
6
7
@Test
public void turnOnLoTempAlarmAtThreashold throws Exception {
  wayTooCold();
  // 大写表示打开,小写表示关闭,
  // 尽管破坏了思维映射的规则,但这种情况下是使用的,只要明白其含义,就能迅速理解
  assertEqual("HBchL", hw.getState());
}

比如在嵌入式系统中,CPU 和内存资源都很有限,而在测试环境中,可能完全不受限制。(可以写一些效率不够高但是简洁清晰的代码)

每个测试一个断言

有个流派认为 JUnit 中每个测试函数都应该有且只有一个断言语句。

这条规则看似过于苛刻,但好处是所有的测试都归结为一个可快速理解的结论。

单个测试中的断言数量应该最小化,但也不必太过苛求单个断言准则。

given-when-then 约定。

每个测试一个概念

更好一些的规则或许是每个测试函数中只测试一个概念。

F.I.R.S.T.

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

  1. 快速 (Fast)
    • 测试运行缓慢,人们就不会频繁的运行它
  2. 独立 (Independent)
    • 测试应该互相独立,每个测试应该可以单独运行
    • 当测试相互依赖时,一个测试没通过就会导致一连串的测试失败
  3. 可重复 (Repeatable)
    • 测试应当能在任何环境中重复通过
    • 当测试不能在任何环境中重复,你总有个解释其失败的借口
    • 当环境不具备时,你也无法运行测试
  4. 自足检验 (Self-Validating)
    • 测试应当有布尔值输出
    • 无论成功或是失败,不应该通过查看日志,人工对比来判断测试是否通过
  5. 及时 (Timely)
    • 测试应及时编写
    • 测试代码应该恰好在 使其通过的生产代码 之前编写
    • 如果在编写生产代码之后编写测试,会发现生产代码难以测试,你可能会认为某些生产代码本身难以测试,你可能不会去设计可测试的代码

# 类

尽管已经讨论了许多关于代码语句及函数的表达力,但如果我们不将注意力放到代码组织的更高层面,我们还是无法得到整洁的代码。

类的组织

遵循标准的 Java 约定,类应该从一组变量开始。

  • 公共静态变量最先
  • 其次私有静态变量和私有成员变量
  • 很少会有公共变量

封装

我们喜欢爆出变量和工具函数的私有性,但并不执著于此。

类应该短小

关于类的第一条规则就是短小。第二条是还要更短小。

对于函数我们通过计算代码行数衡量大小。对于类,我们采用不同的衡量方法,计算权责(responsibility)

类的名称应当描述其权责。命名正是帮助判断类的长度的第一个手段。

如果无法为某个类精确命名,这个类大概就太长,类名越含混,该类就可能拥有过多的权责。

例如:如果类名中包含 ProcessorManagerSuper 这类模糊的词,往往存在不恰当的权责聚集。

我们应该能够用大概 25 个单词描述一个类,且不用 ifandor 或是 but 等词汇。

单一职责原则 SRP

类或模块有且只有一条加以修改的理由。

鉴别权责(修改的理由)常常帮助我们在代码中认识到并创建出更好的抽象。

SRPOO 设计中最为重要的概念之一,也是较为容易理解的和遵循的概念之一,奇怪的是 SRP 往往也是最容易被破坏的原则。

让软件能工作和保持软件整洁是两种截然不同的工作。大多数人脑力有限,只能更多地把精力放在让代码能工作上,这完全正确,但问题是太多人在程序能工作时就以为万事大吉了,没有把思维转向代码组织和整洁上。我们直接转向下一个问题,而不是回头将臃肿的类切分成只有单一之职责的去耦式单元。

于此同时,许多开发者害怕数量巨大的短小类会导致难以一目了然地抓住全局。
他们认为,要搞清楚一件较大的工作如何完成,就得在类与类之间找来找去。

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

每个达到一定规模的系统都会包括大量逻辑,管理这种复杂系统的首要目标就是加以组织,以便开发者知道到哪儿能找到东西,并且在某个特定时间只需要理解直接有关的复杂性。反之,拥有巨大、多目的的类的系统,总是让我们在目前并不需要了解的一大堆东西中艰难跋涉。

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

内聚

类应该只有少数实体变量。类中的每个方法都应该操作一个或多个这种变量。

通常而言,方法操作的变量越多,就越粘聚到类上。如果一个类中每个变量都被方法所用到,则该类具有最大的内聚性。

一般来说,创造这种极大化内聚的类是不可取也不可能的,但我们希望内聚性保持在较高位置,内聚性高说明类中的方法和变量互相依赖、互相结合成一个逻辑整体。

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

当类丧失了内聚性,就拆分它!

为了修改而组织

对于多数系统,修改将一直持续。在整洁的系统中,我们对类加以组织,以降低修改的风险。

1
2
3
4
5
6
7
8
9
10
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)
  private String columnList(Column[] columns)
  private String valuesList(Object[] fields, final Column[] columns)
}

这个类还没写完,所以暂不支持 update,当需要增加 update 语句时,我们就得「打开」这个类进行修改,于是风险也随之而来,对类的任何修改都有可能破坏类中的其他代码。

当我们需要增加一种新的语句时,就要修改 Sql 类,当我们想要修改单个语句,比如让 select 支持子查询,也需要修改 Sql 类。存在两个修改的理由,说明违反了 SRP 原则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
abstract public class Sql {
  public Sql(String table, Column[] columns)
  abstract public String gengerate()
}

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 ColumnList {
  public ColumnList(Column[] columns)
  public String generate()
}

// ...

每个接口都重构到 Sql 的派生类中,valueList 直接移动到了需要使用它们的地方,私有方法也被划分到了独立的工具类中。

每个类中的代码都变得极为简单,理解每个类花费的时间几乎缩减到几零。函数对其他函数造成破坏的风险也几乎消失。从测试的角度看,验证每一处的逻辑也变成了极为简单的任务,因为类与类之间相互隔离了。

当需要增加 update 语句时,现存类无需做任何修改,这也同等重要!

重新架构的 Sql 逻辑百利而无一害,符合 SRP,同时也符合 OCP:类应当对扩展开放,对修改关闭。

子类化

我们希望将系统打造成在添加或修改特性时尽可能少惹麻烦的架子。在理想的系统中,我们通过扩展系统而非修改现有代码来添加新特性。

隔离修改

需求会改变,所以代码也会改变。

具体类包含实现细节,而接口则只呈现概念。依赖于具体细节的客户类,当细节改变时,就会有风险。我们可以借助接口和抽象来隔离这些细节带来的影响。

对具体细节的依赖,给系统的测试带来了挑战。

比如一个依赖外部 API 的类,预期直接依赖外部 API,不如创建一个接口:

1
2
3
4
5
6
7
8
9
10
11
public interface StockExchange {
  Money currentPrice(String symbol);
}

public class Portfolio {
  private StockExchange stockExchange;

  public Portfolio(StockExhange exchange) {
    this.stockExchange = exchange;
  }
}

现在就可以为 StockExchange 接口创建可测试的尝试性实现了。

如果系统解耦到可以测试的程度,也就更加灵活,更加可复用。部件之间的解耦代表着系统中的元素互相隔离的很好。隔离也让系统对每个元素的理解变得更加容易。

通过降低链接度,我们就遵循了另一条类设计原则 —— 依赖倒置原则(Dependency Inversion Principle - DIP)

本质而言,DIP 认为类应当依赖于抽象而不是具体细节。

我们的 Protfilio 类不再依赖 TokyoStickExchange 类的实现细节,而是依赖于 StockExchange 接口。StockExchange 接口呈现的是有关查询某只股票价格的抽象概念。这种抽象隔离了所有询价的细节,包括价格数据来自何处之类。

系统

如何建造一个城市

你能自己掌管一切细节吗?大概不行,即便是管理一个既存的城市,也是一个人无法做到的。
不过,城市还是在运转,因为每个城市都有一组组人管理不同的部分:供水系统、供电系统、交通、执法、理发,诸如此类。有些人负责全局,其他人负责细节。

城市能运转,还应为它演化出恰当的抽象等级和模块,好让个人和他们所管理的「组件」即便在不了解全局时也能有效的运转。

将系统的构造与使用分开

首先,构造使用是非常不一样的过程。

比如有一间酒店正在建设,今天,那只是个框架结构,起重机和升降机附着在外,忙碌的人们身穿工作服,头戴安全帽。大概一年之后,酒店就将建成。起重机和升降机都会消失无踪,建筑变得整洁,覆盖着玻璃幕墙和漂亮的漆色。在其中工作和住宿的人,会看到完全不同的景象。

每个应用程序都应该留意起始过程。将关注的方面分离开,是软件技艺中最古老也是最重要的设计技巧。

不幸的是,多数应用程序都没有做分离处理,起始过程代码很特殊,被混杂到运行时逻辑中,下列就是典型的情形:

1
2
3
4
5
6
public Service getService() {
  if (service == null) {
    service = new MyServiceImpl(/* ... */);
  }
  return service;
}

这就是所谓 懒加载,有一些好处,启动时间更短,而且能保证永远不会返回 null

然而,我们也得到了 MyServiceImpl 及其构造函数所需的硬编码依赖。如果 MyServiceImpl 是个重型对象,则测试也会是个问题。

对象的构造和配置过程也应当从运行时逻辑分离出来。

分解 main

将构造与使用分开的方法之一就是将全部构造过程搬迁到 main 或者被称为 main 的模块中,
设计系统的其余部分时,假设所有对象都已经正确构造和设置了。

控制流程很容易理解,main 函数创建系统所需的对象,在传递给应用程序,应用程序只管使用。

工厂

当然,有时应用程序也要负责确定何时创建对象。

我们可以使用抽象工厂模式让应用自行控制何时创建对象,但构造的细节缺隔离在应用程序之外。

依赖注入

有一种强大的机制可以分离构造与使用 —— 依赖注入(Dependency Injection - DI)
DI 是 控制反转(Inversion of Control - IoC) 在依赖管理中的一种手段。

扩容

城镇由城市而来,一开始,道路狭窄,随后逐渐拓宽,小型建筑和空地逐渐被大楼取代。
一开始,水电,网络等服务都不完善,搜着人口和建筑的增加,这些服务也开始出现。

「一开始就做对系统」纯属神话。反之,我们应该只去实现今天的用户故事,然后重构,明天再扩展系统、实现新的用户故事。这就是迭代和增量敏捷的精髓所在。

测试驱动开发、重构以及它们打造出的整洁的代码,在代码层面保证了这个过程的实现。

但在系统层面有如何呢?难道系统架构不需要预先做好计划么?系统可能从简单递增到复杂么?

软件系统与物理系统可以类比,它们的架构都可以递增式地增长,只要我们持续将关注面恰当地切分。

横贯式关注面

AOP

Java 代理

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
// 接口
public interface Bank {
  Collection<Account> getAccounts();
  void setAccounts(Collection<Account> accounts);
}

// POJO
public class BankImpl implements Bank {
  private List<Account> accounts;

  public Collection<Account> getAccounts() {
    return this.accounts;
  }

  public void setAccounts(Collection<Account> accounts) {
    this.accounts = new ArrayList<Account>();
    for (Account account: accounts) {
      this.accounts.add(account);
    }
  }
}

// Proxy
// InvocationHandler required from proxy API
public class BankProxyHandler implements InvocationHandler {
  private Bank bank;

  public BankProxyHandler(Bank bank) {
    this.bank = bank;
  }

  public Object invoke(Object proxy, Method method, Object[] args) {
    String methodName = method.getName();
    if (methodName.equals("getAccounts")) {
      bank.setAccounts(getAccountsFromDatabase());
      return bank.getAccounts();
    } else if (methodName.equals("setAccounts")) {
      bank.setAccounts((Collection<Account>) args[0]);
      setAccountsToDatabase(bank.getAccounts());
      return null;
    } else {
      // ...
    }
  }
}

Bank bank = (Bank) Proxy.newProxyInstance(
  Bank.class.getClassLoader(),
  new Class[] { Bank.class },
  new BankProxyHandler(new BankImpl())
);

即便是对于这样简单的例子,也有许多相对复杂的代码。使用字节操作类库也同样具有挑战性。
代码量和复杂度是代理的两大弱点,创建整洁代码变得很难!
另外,代理也没有提供在系统范围内指定执行时间的机制,而那正是真正的 AOP 解决方案所必须的。

Java AOP 框架

幸运的是,编程工具能自动处理大多数代理模板代码。在数个 Java 框架中,代理都是内嵌的,如 Spring AOP,从而能够以纯 Java 代码实现 AOP。

使用描述性配置文件或者 API,吧需要的应用程序架构组合起来,包括持久化、事务、安全、缓存、回复等横贯问题。这些声明驱动了 DIDI 容器再实体化主要对象,并按需将对象连接起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<beans>
  <bean 
    id="appDataSource"
    class="org.apache.commons.dbcp.BasicDataSource"
    destory-methid="close"
    p:driverClassName="com.mysql.jdbc.Driver"
    p:url="jdbc:mysql://localhost:3306/mydb"
    p:username="me"
  />

  <bean
    id="bankDataAccessObject"
    class="com.expamle.banking.presistence.BankDataAccessObject"
    p:dataSource-ref="appDataSource"
  />

  <bean
    id="bank"
    class="com.example.banking.model.Bank"
    p:dataAccessObject-ref="bankDataAccessObject"
  />
  <!-- ... -->
</beans>

尽管 XML 可能会冗长且难以阅读,配置文件中定义的策略还是比那种隐藏在幕后自动创建的复杂代理和方面代理简单的多。

AspectJ

通过方面来实现关注面切分的功能最全的工具是 AspectJ,一种将方面作为模块构造处理的 Java 扩展。

在 80% ~ 90% 的情况下 Spring AOP 提供的纯 Java 实现手段足够使用。然而,AspectJ 提供了一套丰富而强大的工具来切分关注面。AspectJ 的弱势在于,需要采用几种新工具,学习新语言。

测试驱动系统架构

通过方面式的手段切分关注面的威力不可低估。假使你能用 POJO 编写应用程序的领域逻辑,在代码层面与架构关注面分开,就有可能真正地用测试来驱动架构。采用一些新技术,就能将架构按需从简单演化到精细。没必要先做大设计(Big Design Up Front, BDUFBDFU 甚至是有害的,它阻碍改进,因为心理上会抵制丢弃既成之事,也因为架构上的方案选择影响到后续的设计思路。

当然,不是说要毫无准备地进入一个项目,对于总的覆盖范围、目标、项目进度和最终系统的总体架构,我们会有所预期,不过,我们必须有能力随机应变。

最佳的系统架构由模块化的关注面领域组成,每个关注面用纯 Java 对象实现。不同的领域之间用最不具有侵害性的方面或类方面工具整合起来。这种架构能测试驱动。就像代码一样。

优化决策

明智使用添加了可论证价值的标准

系统需要领域特定语言

领域特定语言(Domain-Specific Language - DSL

小结

系统也应该是整洁的。侵害性架构会湮灭领域逻辑,冲击敏捷能力。当领域逻辑受到困扰,质量也就堪忧,因为缺陷更易隐藏,用户故事更难实现。当敏捷能力受到损害时,生产里也会降低,TDD 的好处遗失殆尽。

在所有的抽象层级上,意图都应该清晰可辨,只用在编写 POJO 并使用类方面机制来无损地组合其他关注面时,这种事情才会发生。

迭进

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

假使有 4 条简单的规矩,跟着走就能帮助你创建优良的设计,会如何?
假使遵循这些规矩你就能洞见代码的结构和设计,更轻松地应用 SRPDIP 之类的原则,又会如何?

Kent Beck 关于 简单设计的四条规则,对于创建具有良好设计的软件有莫大的帮助:

只要遵循以下规则,设计就能变得简单:

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

以上规则按其重要程度排序

运行所有测试

缺乏验证系统是否按预期工作的简单方法,如同纸上谈兵。

遵循 SRP 的类测试起来比较简单。

遵循有关编写测试并持续运行测试这一简单明确的规则,系统就会更贴近高内聚低耦合的目标。

编写测试引致更好的设计。

重构

有了测试,就能保持代码和类的整洁,方法就是递增式地重构代码。添加了几行代码后,就要暂停,琢磨一下变化了的设计。设计退步了吗?如果是,就要清理它,并运行所有测试,保证没有破幻任何东西。测试消除了对清理代码的恐惧

在重构软件的过程中,可以应用有关优秀软件设计的一切知识。提升内聚性,降低耦合度。切分关注面,模块化系统性关注面,缩小函数和类的尺寸,选用更好的名称,如此等等。这也是应用简单设计后三条规则的地方:消除重复,保证表达力,尽可能减少类和方法的数量。

不可重复

重复是拥有良好设计系统的大敌。它代表着额外的工作、额外的风险和额外切不必要的复杂度。

重复也有实现上的重复等其他一些形态。

例如,在某个集合类中可能会有如下两个方法:

1
2
int size() {};
boolean isEmpty() {};

可以分别实现,也可以在 isEmpty 中使用 size 来消除重复:

1
2
3
boolean isEmpty() {
  return 0 == size();
}

要想创建整洁的系统,需要有消除重复的意愿,即便对于短短几行代码也是如此。

1
2
3
4
// 即使是这样的两三行代码也要避免重复
image.dispose();
System.gc();
image = newImage;

「小规模复用」可以大大降低系统的复杂性。

要想实现大规模复用,必须理解如何实现小规模复用。

模板方法模式是一种移除高层重复的通用技巧。

表达力

写出自己能理解的代码很容易,因为在写这些代码的时候,我们正深入其中,而代码的其他维护者不会那么深入,也就不易理解代码。

软件项目的主要成本在于长期维护。

可以通用选用好的名称来表达,也可以通过保持函数和类尺寸短小来表达。

例如,设计模式很大程度上就关乎沟通和表达。对实现这些模式的类使用标准的命名,如 CommandVisitor,就能充分地向其他开发者描述你的设计。

编写良好的单元测试也具有表达性。测试的主要目的之一就是通过实例起到文档的作用。读到测试的人应该能很快理解某个类是做什么的。

不过,写出有表达力的代码最重要的方式是尝试。有太多时候,我们写出了能工作的代码,就转移到下一个问题上,没有下足功夫调整代码,让后来者易于阅读。记住,下一位读代码的人最有可能是你自己。

所以,多少尊重一下你拆额手艺吧。花一些时间在每个函数和类上,选用较好的名称,将大的函数切分,时时照拂自己创造的东西,用心是最珍贵的资源。

尽可能少的类和方法

即便是消除重复、代码表达力和 SRP 等最基本的概念也会被过度使用。为了保持类和函数短小,我们可能会造出太多细小的类和方法。所以这条规则主张函数和类的数量要小。

类和方法的数量太多,有时是由毫无意义的教条主义导致的。例如,某个标准就坚称应当为每个类创建接口。也有开发者认为,字段和行为必须切分到数据类和行为类中。应该地址这类教条,采用更实用的手段。

我们的目标是在保持函数和类短小的同时,保持整个系统短小精悍。不过要记住,这条的优先级最低,更重要的是测试、消除重复和表达力。

小结

有没有能替代经验的一套简单实践手段呢?当然不会有。

另一方面,遵循简单设计的时间手段,开发者不必经年学习就能掌握好的原则和模式。

并发编程

对象是过程的抽象,线程是调度的抽象。

为什么要并发

并发是一种解耦策略,它帮我们把做什么(目的)和何时做(时机)分开。

解耦目的时机能明显的改进应用程序的吞吐量和结构。从结构的角度来说,应用程序看起来更像是许多台协同工作的计算机,而不是一个大循环。系统因此会更易于被理解,给出许多切分关注面的有力手段。

例如,Servlet 标准模式,这类系统运行于 Web 容器之中,当有 Web 请求时,servlet 就会异步执行,程序员无锡管理所有请求。原则上,servlet 是在自己的小世界中执行,与其他 servlet 的执行是分离的。

当然,Web 容器提供的解耦手段离完美还差得远。Servlet 程序员得非常小心的保证并发程序不出错。

同样,servlet 模式的结构性好处还是很明显的,但结构并非采用并发的唯一动机。有些系统对响应时间和吞吐量有要求,需要手工编写并发方案。

迷思与误解

看起来我们有足够的理由采用并发方案,但是并发编程很难,看看以下常见的迷思和误解:

  • 并发总能改进性能
    • 并发有时能改进性能,但只在多个线程之间能分享大量等待时间时管用。事情没那么简单
  • 编写并发程序无需修改设计
    • 并发算法的设计可能与单线系统的设计极不相同。目的与时机的解耦往往对系统结构产生巨大的影响。
  • 在采用 Web 容器时,理解并发并不重要
    • 实际上,最好先了解容器再做什么,及如何对付并发更新、死锁等问题。

下面是一些关于并发软件中肯的说法:

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

挑战

并发编程为何如此之难?来看看下面这个示例:

1
2
3
4
5
6
public class X {
  private int lastIdUsed;
  public int getNextId() {
    return ++lastIdUsed;
  }
}

创建一个 X 的示例,将 lastIdUsed 设置为 42,在两个线程中调用 getNextId(),结果可能有三种:

  • 线程 1 得到 43,线程 2 得到 44lastIdUsed44
  • 线程 1 得到 44,线程 2 得到 43lastIdUsed44
  • 线程 1 得到 43,线程 2 得到 43lastIdUsed43

第三中结果令人惊异,当两个线程互相影响时就会出现这种情况,这是因为线程在执行那行代码时有许多可能路径可行,有些路径会产生错位的结果。

那有多少种不同路径呢?要回答这个问题,需要理解 JIT 编译器是如何生成字节码,还要理解 Java 内存模型认为什么东西具有原子性。

简单回答,就生成字节码而言,对于在 getNextId 方法中执行的那两个线程,有 12870 种不同的执行路径,如果是 long 型,则可能有 2704156 种!

当然,多数路径都得到正确的结果。问题是其中有一些不能得到正确结果。

并发防御原则

下面给出一系列防御并发问题的原则和技巧。

单一权责原则

单一权责原则(SRP)认为,方法、类、组件应当只有一个修改的理由。

并发设计自身足够复杂到成为修改的理由,所以也该从其他代码中分离出来。

  • 并发相关代码有自己的开发、修改和调用生命周期
  • 并发相关代码有自己要应对的挑战

建议: 分离并发相关的代码

推论:限制数据作用域

两个线程修改共享对象的同一字段时,可能互相干扰,导致未预期的行为。

解决方案之一是采用 synchronized 在代码中保护一块使用共享对象的临界区(critical section)

限制临界区的数量很重要。更新共享数据的地方越多,就越可能:

  • 会忘记保护临界区 - 破坏了修改共享数据的代码
  • 得多花力气保证一切都受到有效保护 - 破坏了 DRY 原则
  • 很难找到错误源,也很难判断错误源

建议:谨记数据封装,严格限制对可能被共享的数据的访问。

推论:使用数据副本

避免共享数据的好方法之一就是一开始就避免共享数据。某些情况下,可以复制对象,从多个线程收集所有结果,并在单个线程中合并结果。

如果有避免共享数据的简易手段,就能大大减少出错的可能。你可能会关心创建额外对象的成本,值得实验一下看看那是否真的是个问题。假使使用对象副本能避免代码同步执行,则因避免锁定而省下的价值有可能补偿得上额外创建和回收对象的成本。

推论:线程应尽可能地独立

让每个线程在自己的世界中存在,不与其他线程共享数据。

建议:尝试将数据分解到可被独立线程操作的的独立子集。

了解 Java

Java 5 提供了许多并发开发方面的改进,在使用时要注意:

  • 使用类库提供的线程安全群集
  • 使用 executor 框架
  • 尽可能使用非锁定解决方案
  • 有几个类并不是线程安全的

线程安全群集

  • ConcurrentHashMap
  • ReentrantLock
  • Semaphore
  • CountDownLatch

  • java.util.concurrent
  • java.util.concurrent.atomic
  • java.util.concurrent.locks

了解执行模型

有几种在并发应用中切分行为的途径。

要讨论这些途径,我们需要理解一些基础定义:

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

有了这些定义,我们就能讨论在并发编程中用到的几种执行模型了。

生产者 - 消费者模型

一个或多个生产者线程创建工作,并置于缓存或队列中,一个或多个消费者线程从队列中获取并完成这些工作。

生产者和消费者之间的队列是一种 限定资源

读者 - 作者模型

  • 存在一个信息源
  • 信息源主要为读者线程提供信息
  • 作者线程偶尔更新信息源

吞吐量会是一个问题,增加吞吐量,会导致线程饥饿,过时的信息将累积。

挑战在于平衡读者线程和作者线程的需求。

宴席哲学家

资源竞争。

我们遇到的并发问题大多数是这三个问题的变种。

建议:学习这些基础算法,理解其解决方案。

警惕同步方法间的依赖

synchronized 可以用来保护单个方法。然而,如果在同一个共享类中有多个同步方法,就可能不太正确了。

建议:避免使用一个共享对象的多个方法。

有时必须使用一个共享对象的多个方法。在这种情况下,有 3 钟正确的处理方法:

  • 基于客户端的锁定 - 客户端在调用第一个方法前锁定服务端,确保锁的范围覆盖了最后一个方法的调用。
  • 基于服务端的锁定 - 在服务端内创建锁方法,调用所有方法,然后解锁。
  • 适配服务端 - 创建执行锁定的中间层,这是一种基于服务端的锁定,但是不修改原始服务端代码

保持同步区微小

synchronized 制造了锁,锁维护的代码区域保证了在任意时刻只有一个线程执行。

锁是昂贵的,它带来了延迟和额外开销。

建议:尽可能减小同步区域

很难编写正确的关闭代码

建议: 尽早考虑关闭问题,尽早令其关闭正常。

测试线程代码

好的测试能尽可能的降低风险。这在单线程的情况下是对的,在多线程的情况下就变得非常复杂了。

建议:编写有潜力暴露问题的测试,在不同的配置,负载条件下频繁地运行。

有一大堆问题要考虑,下面是一些精炼的建议:

  • 将伪失败看作可能的线程问题
    • 线程代码导致「不可能失败的失败」
    • 不要将系统错误归咎于「宇宙射线」
  • 先使非线程代码可工作
    • 不要同时追踪非线程缺陷和线程缺陷,确保代码在线程之外可工作
  • 编写可插拔的线程代码
    • 编写可在数个配置环境下运行的线程代码
  • 编写可调整的线程代码
    • 要获得良好的线程平衡,常常需要试错
    • 要允许线程数量可调整
  • 运行多于处理器数量的线程
    • 系统在切换线程的时候会发生一些事情
    • 任务交换越频繁,越有可能找到错临界区或是触发死锁的代码
  • 在不同平台上运行
    • 不同的操作系统有着不同的线程策略
    • 在不同的环境中,多线程代码的行为也不一样
  • 调整代码并强迫错误发生

小结

并发代码很难写正确,加入多线程和共享数据,简单的代码也会变成噩梦。

第一要诀是遵循 SRP。将系统切分为线程相关代码合线程无关代码。

逐步改进

不要指望一次写出整洁、漂亮的程序。要写出整洁的代码,必须先写肮脏的代码,然后再清理它。

你应该不会对此感到惊讶,我们在小学就学过这条真理了。那时,老师(通常是徒劳)努力让我们写作文的草稿。她告诉我们,应该先写草稿,再写二稿,一次又一次写草撰,直至写出终稿。尽力告诉我们,写作是一个逐步改进的过程。

多数新手程序员(就像多数小学生一样)没有特别认真地遵循这个建议。他们相信,首要任务是写出能工作的程序。只要程序「能工作」,就转移到下一个任务上,而那个「能工作」的程序就一直留在了那个所谓「能工作」的状态。多数老手程序员都知道,这是一种自毁行为。

渐进

毁坏程序的最好方法张之一就是以改进之名大动其结构、为了避免这种情况,我们采用 TDD 来保证系统能像以前一样工作。

优秀的软件设计,大都关乎分隔 – 创造合适的空间放置不同种类的代码。对关注面的分隔让代码更易于理解和维护。

小结

代码能工作还不够。能工作的代码经常会严重崩溃。满足于仅仅让代码能工作的程序员不够专业。他们会害怕没时间改进代码的结构和设计,但是没什么能比糟糕的代码给开发项目带来更深远和长期的损害了。

当然,糟糕的代码可以清理。不过成本高昂,随着代码腐败,模块之间互相渗透,出现大量隐藏的依赖关系。找出它们并解决又费时又费力,而另一方面,保持代码的整洁却相对容易。早晨在代码中制造出一堆混乱,下午就能轻易清理掉。更好的情况是,5 分钟之前制造出的混乱,马上就能清理掉。

所以,解决之道就是保持代码持续整洁和简单。永不让腐坏有机会开始。

JUnit 内幕

重构常会导致另一次推翻此次重构的重构。重构是一种不停试错的迭代过程,不可避免地集中于我们认为是专业人员该做的事。

美国童子军一条简单的军规:让营地比你来时更干净。

清理代码也许只是改好一个变量名,拆分一个有点过长的函数,消除一点点重复代码,清理一个嵌套 if 语句。

这是让项目代码随着时间流逝而越变越好的最专业的做法。

持续改进也是专业性的内在组成部分。

重构 SerialDate

味道与启发

作者每次修改,就把修改原因列下来,于是得到了如下清单:

注释

C1:不恰当的信息

如作者信息,修改历史等,应该放在 VCS 中,而不是在注释里。

注释应当只描述与代码和设计有关的技术性信息。

C2:废弃的注释

发现过时、无关或不正确的注释,请尽快更新或删除。

C3:冗余的注释

如果注释描述的是某个充分自我描述的东西,那么注释就是多余的。

C4: 糟糕的注释

值得编写的注释,也值得好好写。

别闲扯,保持简洁。

C5:注释掉的代码

被注释掉的代码会令人抓狂,谁知道它有多旧?谁知道它有没有意义?没人会删除它,因为大家都假设别人需要它或是有进一步的计划。

于是被注释的代码就慢慢腐烂,随着时间推移,越来越和系统无关,调用不复存在的函数,使用已改名的变量,如此等等。

看到注释掉的代码,就删除它!

环境

E1:需要多步才能实现的构建

构建系统应该是单步的小操作。

E2:需要多步才能做到的测试

发出单个指令就能运行全部测试。

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

函数

F1:过多的参数

函数的参数应当尽量少,没有最好,一个次之,两个、三个再次。三个以上的参数非常值得质疑,应坚决避免。

F2:输出参数

输出参数违反直觉。

F3:标识参数

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

F4:死函数

永远不被调用的方法应该丢弃。(交给 VCS 保存)

一般性问题

G1:一个源文件中存在多种语言

理想的源文件仅包括已一种语言。但实际上,我们可能不得不使用多种语言。

但应该尽力减少源文件中额外语言的数量和范围。

G2:明显的行为未被实现

遵循「最新惊异原则」(The Principle of Least Suprise),函数或类应该实现其他程序员有理由期待的行为。

例如,考虑一个将日期名称翻译为枚举的函数:

1
Day day = DayDate.StringToDay(String dayName);

我们期待字符串 Monday 翻译为 Day.MONDAY,我们也期待常用缩写形式也能被翻译出来,我们还期待函数忽略大小写。

如果明显的行为未被实现,读者和用户就不能依靠他们对函数的直觉,不得不阅读源代码的细节。

G3:不正确的边界行为

我们很少能明白正确的行为有多复杂,别依赖直觉,追索每种边界条件,并编写测试。

G4:忽视安全

忽视安全相当的危险。(不要对报错视而不见)

G5:重复

重复最明显的形态是明显一样的代码。

较为隐蔽的是在不同模块中不断重复出现,监测同一组条件的 switch/caseif/else 链。(可以用多态来替代)

更隐蔽的是采用类似的算法,但是具体代码不同的模块。(可以使用模板模式或策略模式来修正)

过去出现的许多设计模式都是消除重复的有名手段,OO 吱声也是组织模块和消除重复的策略,毫不出奇,结构化编程也是。

重点已经在那里了,尽可能找到并消除重复。

G6:在错误的抽象层级上的代码

分离较高层级的一般性概念与较低层级的细节概念非常重要。

有时,我们创建抽象类来容纳较高层级的概念,创建派生类来容纳较低层次的概念。

这样做的时候要确保分离完整,所有较低层次的概念都放在派生类中,所有较高层次的概念都放在基类中。

G7:基类依赖于派生类

基类对派生类应该一无所知。