《Clean Code-A Handbook of Agile Software Craftsmanship》中文名称是《代码整洁之道》,这本书出版于2008年,10年时间对于飞速发展的技术而言显得很长,因此,其中的部分道理已经显得有点过时。但是,瑕不掩瑜,这本书仍然是每个软件开发工程师都应该阅读、理解并且实践的行为标准。

从英文名看,这本书是一个Handbook,就意味着内容并不长,充其量一个中篇小说的长度,从这本书实质的内容(除掉附录和大篇幅的代码等外)来看,并不多,因此不用担心会用很长时间。

本书一共十七章,第一章是讲为什么要注重代码整洁,最后三章是JUnit例子、SerialDate例子、前面所有规则的归纳总结,有凑字数的嫌疑。核心是中间13章,从命名、函数、注释、格式、对象讲起,一直到类、系统、并发(性能)等。

本文按照整本书的章节结构做重点解读。解读之前,一定要明白:

Team rules!(团队规则说了算),即便是你个人不同意,但一旦成为团队规则,你也得遵守。

第一章 整洁代码

1. 代码质量是生命线

代码代表了一家公司的生产力水平、糟糕的代码可以毁掉一家公司。保持代码整洁,不仅仅关系到效率,也关系到生存。

2. 架构设计同样决定了代码整洁程度

代码不仅仅是狭义的由字符组成的代码而已,也代表宏观的架构设计,因为架构设计也直接决定了代码的整洁程度

这个逻辑是:架构设计合理->边界划分清楚->代码行数少->代码整洁。

3. 不要挖坑

存在问题就要改,而不是放到后面,或者优先级很低。其原因是:勒布朗法则破窗理论,我们应该做到童子军规则

勒布朗/LaBlanc法则

稍后等于永不(Later equals never)。

破窗理论(Broken windows theory)

一幢有少许破窗的建筑为例,如果那些窗不被修理好,可能将会有破坏者破坏更多的窗户。最终他们甚至会闯入建筑内,如果发现无人居住,也许就在那里定居或者纵火。一面墙,如果出现一些涂鸦没有被清洗掉,很快的,墙上就布满了乱七八糟、不堪入目的东西;一条人行道有些许纸屑,不久后就会有更多垃圾,最终人们会视若理所当然地将垃圾顺手丢弃在地上。这个现象,就是犯罪心理学中的破窗效应。

我们不仅不能做第N次打破窗户的人,我们还要努力做修复“第一扇窗户”的人。即使是当我们无法选择环境,甚至无力去改变环境时,我们还可以努力,那就是使自己不要成为一扇“破窗”。

童子军军规
让营地比你来时更干净。

4. 免责声明

艺术书并不保证你读过之后能成为艺术家,只能告诉你其他艺术家用过的工具、技术和思维过程。本书也同样不担保你成为好程序员。

第二章 命名

命名是一个很小的细节,但是专业程度在此而体现。

代码应该是自解释的,要做到如下:

1. 名副其实

  1. 名副其实。比如 int d 不知道是什么用途,但是 int daysSinceCreation 就很清楚
  2. 如果做不到名副其实,那至少不要有误导嫌疑吧。比如两个变量名特别像,只有一个字符差异
  3. 还做不到那至少名字起得有意义吧。比如a1a2这种真的就不要用了
  4. 最好是能读得出来,比如genymdms,看起来是理解的,就是不好读出来,改成generateDateTime
  5. 使用常量,常量的一个好处在于方便搜索和定位,想想找5FIRDAY的难度好了

2. 顺其自然

  1. 不要使用intXXXstrXXXbtnXXX这样的了,看起来整洁,但是没什么用处,叫saveButton不是更好吗
  2. Eclipse的编码习惯里面的interface都写成IXXX,看起来怪怪的,Impl加了就好了,接口并不用加。
  3. 程序不是数学公式,数学公式为了追求抽象和简洁导致了不容易理解,对于程序而言,如果形象化容易理解就应该形象化,比如port总是比p要好
  4. 如果你不知道专业术语怎么说,别自己造词,多查一下
  5. 别扮可爱、不要在代码中表白

3. 准确无歧义

  1. 类名:避免ManagerProcessorInfo这样的,这种类太多了,容易搞混;避免出现动词
  2. 方法名:动词或者动词短语,如果是构造器,参数太多可以考虑Builder模式或者static初始化函数(可以自定义函数名,更容易理解,比如Complex.fromRealNumber()
  3. 每个概念对应一个词,定义好findgetfetchretrive的区别,并坚持
  4. 避免双关语,比如add(),对于list而言最好是insertappendconcat,而不是简单的add
  5. 使用专业术语,比如你用了监听器模式,就应该起名为ListenerEvent

4. 合理使用语境

语境是指包名、类名、前缀,举个例子:

1
2
3
4
5
6
7
8
public enum SellerApplyStatusEnum {
APPLY_STATUS_DEFAULT(0, "待审核"),
APPLY_STATUS_ENABLE(1, "通过审核"),
APPLY_STATUS_IGNORE(2, "忽略的申请"),
APPLY_STATUS_MODIFY(3, "退回修改"),
APPLY_STATUS_BRAND(4, "等待商家添加品牌"),
APPLY_STATUS_REJECT(5, "暂不合作");
}

这是一个bad case,类名 SellerApplyStatusEnum 已经决定了语境了,所以 APPLY_STATUS_MODIFY 前面的 APPLY_STATUS是多余的,直接写成 MODIFY(3, "退回修改") 更简洁。

  1. 名称一定是在语境中出现的,好的语境可以让变量在很短的情况下也容易理解,比如在Member的语境中,name就很容易理解成姓名
  2. 不添加没有用的语境。

第三章 函数

函数的第一规则是短小,第二规则是更短小。究竟要多短,作者并没有说,大概10行上下吧。缩进层不应该超过2层。

1. 函数应该只做一件事情

一件事情可以拆分为 A、B、C 三个步骤(A、B、C 是同一个抽象层级),其中A又可以分为 A1、A2 两个步骤,B 可以分为 B1、B2、B3 两个步骤。

  1. 如果f()中做了 A、B、C 三件事情就可以认为是同一件事情。
  2. 如果f'()中做了 A1、A2、B、C 四件事情,就是做了不同事情了,因为他们是不同的抽象层级。

保持函数中的任务始终是在同一个抽象层级上对于实现短的函数异常重要。

从这个意义上讲,编写函数是为了把高一层级的概念拆分成一系列低一层抽象层上的过程。

哪些函数可能不是只干了一件事情:

  1. 可以继续拆分的函数
  2. 被用空行分成了多个区段的函数

2. 参数与返回值

最理想的是 0 参数,其次是 1 个,在次是 2 个,尽量避免 3 个,仅仅有非常特殊的原因才需要用 3 个以上。

那种 truefalse 的参数最好不要用,而是拆成两个函数,更容易懂。

尽量用返回值,不要用参数来返回值。

3. 无副作用(side-effect)

副作用会带来时序性耦合。比如在验证用户名和密码的函数中直接初始化了 HTTP Session,这就意味这个这个函数必须其他使用了 HTTP Session的函数之前调用。

应该尽量避免使用输出参数,如果函数必须要修改某种状态,就修改所属对象的属性。比如appendFooter(Report report),改成report.appendFooter()

4. 不求一开始就完美,保持迭代

写出好的函数是一个迭代过程,“我并不一开始就按照规则写函数,我想没有人能做得到”。

第四章 注释

1. 一些思想(或者称为口舌之快):

  1. 注释是一种恶,因此越少越好。注释只有在代码无法表达其意思的时候才用。
  2. 代码写得烂,注释再好也没用
  3. 真正好的注释是你想办法不去写的注释
  4. 需要写注释,是因为你的代码写得差

有时候你觉得需要注释的时候可能尝试简化一下代码会更好,比如这种长代码长长的代码:if (submodule.getDependSubsystems().contains(subSysMod.getSubsystems()))

2. 按功用分注释有三种

  1. 错误的:真正起作用的只有代码,容易改了代码忘记了改注释
  2. 没用的:对代码重复(比如Java类的field的名称能代表其含义了,不用非得加上注释)
  3. 有用的:对算法的解释、对代码目的解释

3. 按用途分注释有两种

  1. 公共API中的JavaDoc这种(详细是必要的,但是也要遵守必要就好的原则,说明版本沿革等信息)
  2. 内部系统中(越简单越好)

4. 有用的注释

  1. 法律信息:有时候是必须的,如果能用链接替代最好,IDE能自动折叠起来
  2. 提供信息:比如正则表达式不容易懂,加上一个例子
  3. 对意图的解释:有时需求比较奇怪,加上简单说明可以解释为什么代码会是这样
  4. 阐释:参数或者返回值有时不能很好的自表达,加上示例或者简单说明
  5. 警示:记录一下在这里踩过的坑,让别人踩了
  6. TODO:最好不要有,不是在系统中留下糟糕代码的接口
  7. 放大:和警示一样,代码就一行,多加几行注释可以让后面的程序员更容易注意到

5. 没啥用的注释

  1. 一些发泄情绪的语句:比如表白、招聘
  2. 循规蹈矩类:比如内部系统中,所有的Java类的field都加上注释,只是为了通过checkstyle(可以改一下checkstyle规范)
  3. 内部系统中的版本严格:完全可以在git或者svn中看到
  4. 位置标记:比如 /* constructor */或者/* private*/ 等这种在类中间做为分割的
  5. 由于嵌套结构太长,放在结束位置的 //end if或者 // end for 这种
  6. 署名:比如added by,版本控制里面可以查到的
  7. 一些被注释掉的代码:记住这些代码一旦你删除了,以后一定没用

6. 错误的注释

  1. 误导性的:并没有揭露关键细节的,比如一个会遭遇并发情况的函数是否线程安全等
  2. 在注释之中硬编码,比如“default is false",要是以后代码中改成了ture,这个注释就错误了,最好是变成常量,在注释中引用一下

第五章 格式

专业开发者的头等大事不是“让代码能够工作”,而是“沟通”。今天开发的功能可能下个版本中就被修改,代码可能被修改,但是代码风格和可读性长存

对于Tomcat、JUnit的代码行数统计表明:

  1. 平均200行、最长500行的单个文件足以构造出色的系统
  2. 平均80宽,最长120宽,应该保持代码行窄一点

应该像报纸学习:最上部给出高层次概念和算法,细节往下渐次展开。多数短小精悍。

垂直方向上区隔:

  1. 方法之间区隔
  2. 方法与字段之间区隔
  3. 方法内逻辑处理之间区隔(比如for块前后)

垂直方向上靠近:

  1. 类中相关字段放在一起
  2. 有调用关系的方法放在一起
  3. 同类的方法放到一起(比如重载方法)
  4. 方法中变量的声明靠近其使用的地方

水平方向上区隔&靠近

  1. 运算符优先级(如 * 可以左右不空格,赋值一般要空格,这一点一般的格式检查工具并不会强制要求所以靠自觉)

变量声明或者赋值对齐:

  1. 意义不大(别浪费时间啦)

第六章 对象和数据结构

1. 对象和数据结构

在Java中容易误解一切都是对象(没有数据结构),实际上只要涉及编程一定就同时存在这两种东西,只是有时候会混用。

比如:

  1. Java中的DTO、常说的POJO也是一种数据结构,加上了 gettersetter 只是看起来更舒服一些
  2. Ruby中的ActiveRecord实际上是一类特殊的DTO,包含了一些通用的行为

我们常常会向DTO或者ActiveRecord中添加一些业务规则方法,这是错误的,更好的方法是创建一个新的类(DAO或者其他)来包含这些业务规则

“对象”与“数据结构”的区别:

  1. 对象:暴露行为隐藏数据,便于增加对象类型而无需修改行为,难以在既有对象中增加行为
  2. 数据结构:暴露数据没有明显的行为,便于像既有数据结构增加行为,难以向既有函数增加新数据结构

再解释一下:

  1. 过程式的代码(使用数据结构的代码)
    1. 便于在不改动既有数据结构的前提下,添加新函数
    2. 难于增加新的数据结构,因为必须修改所有的函数
  2. 面向对象的代码(使用对象的代码)
    1. 便于在不改动既有函数的情况下,增加新的类
    2. 难于增加新的函数,因为必须修改所有的类

2. Demeter定律/得墨忒定律

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

类 C 的方法 f() 只应该调用以下对象的方法:

  1. C 的实例对象
  2. 在 f() 中创建的对象
  3. 作为参数传递给 f() 的对象
  4. 由 C 的实例变量所持有的对象

这里举个例子:

1
String outpuDir = ctx.getOptions().getScratchDir().getAbsolutePath();

应该改成

1
2
3
Options opts = ctx.getOptions();
File scrathDir = opts.getScratchDir();
outputDir = scratchDir.getAbsolutePath();

当然这里是否应该这么改也取决于OptionsFile是数据结构还是对象,如果是数据结构,是没有行为的,就不适用于Demeter定律了。

这里需要好好理解一下,为什么拆成三行后就符合Demeter定律了?

一切都是对象只是一个传说。GetterSetter将事情搞复杂了。

第七章 错误处理

1. 使用异常而非返回码(前提是语言支持异常)

错误处理看起来和clean code没啥关系,但是如果它搞乱了代码逻辑,就是错误的做法。

1
2
3
4
5
$member = $this->memberService->getById($id);
if($member) {
$name = $member->name;
// 业务逻辑
}

原因:如果是返回码,在调用完成之后就要马上检查返回码,会让代码变坏,而使用异常大可不必:

1
2
3
4
5
6
7
try{
$member = $this->memberService->getById($id);
$name = $member->name;
// 业务逻辑
} catch($e) {
// error handling
}

2. 推荐使用unchecked exception(存疑)

Java中异常分为checked exception和runtime exception,前者需要在方法声明中显示声明,后者不需要

  1. 使用checked exception使得代码违反了“开闭原则”
  2. 底层抛出的异常需要修改上层代码来支持
  3. 对于关键liberary,checked exception很好,但是对于一般的应用开发好像有点多余

3. 依调用者需要定义异常类

如果有一块方法调用抛出了很多异常,每次都逐个去catch有点浪费代码,最好是做个warpper,将多个异常catch住之后throw同一个出来。

考虑Special Case Pattern,创建一个类用来处理特例,特例在这个类里面处理了客户代码就不用应付异常行为了。

不要返回null值&别传递null值,实在没办法使用assert语句比throw InvalidArgumentException要好一点

要养成try-catch-finally的思考习惯

第八章 边界

避免从公共API中使用Map、List这样通用数据结构的作为边界接口(参数、返回值):

  1. 这些类功能太强了,容易出问题
  2. 变动是可能的,比如Java 5.0加入泛型的时候带来了变动

推荐学习型测试(将学习使用第三方库的代码通过单元测试的方式来组织)

  1. 免费的,学习型的代码你一定会写的
  2. 不会浪费,以后升级库的时候可以再跑一遍看库是不是兼容

对于第三方合作时,尽量将其交互接口放到单独的类中(Adapter)模式

  1. 便于组织和修改
  2. 便于Mock或者Stub进行测试

第九章 单元测试

TDD三定律:

  1. 在编写不能通过的单元测试前,不可编写代码
  2. 只可编写刚好无法通过的单元测试,不能编译也算不通过
  3. 只可编写刚好足以通过当前失败测试的生产代码

从编写用后即扔的代码到编写全套自动化单元测试是一个非常大的进步

  1. 用后即扔的代码可以集合起来以后发生作用
  2. 通过解除耦合和mock/stub方式来进行有效的单元测试
  3. 测试代码和生产代码一样重要(测试越脏代码会越脏)

整洁的测试又三个要素:可读性、可读性、可读性,按照如下模式:

  1. 构造(Build)
  2. 操作(Operate)
  3. 检验(Check)

再抽象一个层次可以提供一些DSL类的测试语言(需要具有DSL能力的编程语言)

测试代码和生产代码的区别在于运行环境(的资源限制,如CPU和GPU),所以在测试环境中可以有一些不同的标准,但是代码整洁标准一定是相同的。

每个测试一个断言也许不一定好,一定好的是“尽可能减少每个概念的断言数量,而每个测试只测试一个概念”

第十章 类

核心思想是越小越好,具体的实现手段是封装(好像是废话)

衡量标准:内聚性。一个类中的每个变量都被每个方法使用,则该类有最大的内聚性

原则:

  1. 单一权责原则(SRP),类或者模块有且只有一个加以修改的理由
  2. 开放闭合原则(OCP),类应该对扩展开放,对修改封闭

方法:

  1. 保持函数短小、保持函数的参数列表短 -> 类的实例变量会增加 -> 导致内聚性降低 -> 将新的类从老的类中拆解出来
  2. 如果对一个系统需要修改而不是扩展才能增加新特性,那么这个类的内聚是不够的,可以拆分出多个类,来提升内聚性

隔离首先的好处就是便于mock或者stub,便于测试。

第十一章 系统

在有能力的市长不可能管理到每一个细节,总是有些人负责全局,有些人负责细节。在于演化出恰当的抽象等级和模块。

我们的软件系统感觉看起来很简单,所以在关注面切分和抽象层级方面做得非常不够。

1. 原则1:构造和使用分开

这段代码看起来实现了延迟初始化,非常棒,但是却让这个类依赖了MyServiceImpl,违反了抽象不能依赖于具体的原则。

后果是我们没办法创建MyServiceStub或者MyServiceMock来模拟Service的行为做单元测试了

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

return service;
}

具体的方法有:

  1. 抽奖工厂模式
  2. 依赖注入(Spring)

2. 原则2:关注切面

比如存储就是一个切面,Java中的GenericDao就是一个非常好的例子https://www.ibm.com/developerworks/library/j-genericdao/index.html

实现GenericDao的方式:

  1. java代理InvocationHandler
  2. Spring AOP(没细说)
  3. AspectJ(没细说)

好处是将代码层面和架构关注面分离开,就有可能真正地用测试来驱动架构。

原则3:创造并使用DSL降低复杂度

DSL填平了领域概念和实现领域概念的代码之间的壕沟

整洁最终的目的都是提升敏捷能力。而提升敏捷能力的方法是TDD。能实现TDD依赖于系统的切分。

第十二章 迭代

Kent Beck关于简单设计的四条原则(以下规则优先级依次降低):

1. 运行所有的测试

测试会让你遵守SRP、DIP,进而会使得系统贴近OO低耦合度高内聚的目标。

2. 不可重复

抽取共性的代码可能会导致系统内聚降低,会违反SRP原则,因此这时需要做类的拆分

解决代码重复的比较好的类拆分方法是“模板方法模式”

3. 让代码易懂(具有表达力)

  1. 选用好的名称,让人容易理解
  2. 保持函数和类尺寸短小
  3. 使用行业术语,比如Visitor、Factory、Builder等
  4. 编写良好的测试也能让人更容易理解,就像一个example一样

最终:用心是最珍贵的资源,尊重你的手艺。

4. 尽可能减少类和方法的数量

有可能前面几个原则可能会过度使用,比如 ServiceInterfaceServiceImpl ,不要太教条。

第十三章 并发编程

对象是过程的抽象,线程是调度的抽象。并发是一种解耦机制:将何事(目的)和何时(时机)分解开。好处是可以明显地改进吞吐量和结构(将循环变成独立的参与者,有利于切分关注面)。

常见错误:

  1. 并发总是能改进性能:得看情况,比如CPU密集型再并发计算就没有意义了
  2. 编写并发程序无需修改设计
  3. 对于一般的后端开发,无需考虑并发

正确的理解:

  1. 并发会在性能上增加一些开销
  2. 并发的程序会更加复杂
  3. 并发容易出现偶发性错误,而这些错误容易被忽略
  4. 并发需要测底对系统进行重新设计(简单重构并不会解决问题)

并发防御原则:

  1. 单一权责原则
  2. 推论:限制数据作用域(保护临界区)
  3. 推论:使用数据副本(减少sideeffect)
  4. 推论:线程尽可能地独立(减少或者不与其他线程共享数据)

Java中的并发库:

  1. java.util.concurrent
  2. java.util.concurrent.atomic
  3. java.util.concurrent.locks

Java中的并发类:

  1. ConcurrentHashMap几乎在所有的情况下实现都比HashMap好。
  2. ReentrantLock:可以在一个方法中获取、在另一个方法中释放的锁。
  3. Semaphore:经典的“信号”的一种实现,有计数器的锁
  4. CountDownLatch:在释放所有等待的线程前,等待制定数量时间发生的锁。这样,所有的线程都平等的几乎同时启动

第十四章 逐步改进

Java是一种唠叨型语言,要特别注意代码的简短,不简短一定没有可读性。

别随意重构,毁坏程序的最好办法之一就是以改进之名大动其结构。

  1. 为避免这种情况应该采用TDD
  2. TDD核心原则之一就是保持系统始终能运行

优秀的软件设计,大都关乎分割——创建合适的空间放置不同种类的代码。

最后

艺术书并不保证你读过之后能成为艺术家,只能告诉你其他艺术家用过的工具、技术和思维过程。读完本文也同样不担保你成为好程序员。但是,分享本文,应该可以供你装逼!

你还得练,孩子,还得练!共勉。

上图为最后笑话中的卡耐基音乐厅,位于纽约市第七大道881号,第56大街和第57大街中间,占据第七大道东侧。由慈善家安德鲁·卡耐基(Andrew Carnegie)出资建于1890年,是美国古典音乐与流行音乐界的标志性建筑。卡耐基音乐厅以历史悠久,外形美观以及声音效果出色而着称。