《代码整洁之道》 读书笔记

细节之中自有天地,整洁成就卓越代码

Posted by Wudashan on May 3, 2017

保持代码整洁,保证用例覆盖,你会感谢我的。

本书结构

本书主要分为三个部分。第一部分1~10章分别从概念、命名、函数、注释、格式、对象与数据结构、错误处理、边界、单元测试、类的角度讲解如何保持代码整洁;第二部分11~13章从系统层面讲解如何保持代码整洁;第三部分14~17章则是从实战的角度对之前所学内容进行演练。

我在看这本书的时候,着重阅读了第一部分和第二部分。第三部分实战演练之所以略过,是因为在书本上长篇幅地看代码效率不高,没有颜色标识,没有方法跳转等等,与其硬吃书上代码,还不如用IDE看流行的开源框架来得过瘾一些。


第1章 整洁代码

什么是整洁代码?Ron Jeffries认为:

  1. 能通过所有测试;
  2. 没有重复代码(Don’t Repeat Yourself);
  3. 体现系统中的全部设计理念;
  4. 包括尽量少的实体,比如类、方法等。

上述这四点,说得简单,要全部做到其实非常困难。就拿第1点来说吧,有时候项目交付时间紧,基本上功能测通就合入代码,很少会在后续补齐测试用例,连测试用例都没有,更别说通过所有测试了。然而不写测试用例就是技术负债,等哪天新人增量开发产生BUG导致线上问题,那就是还债的时候了。


第2章 有意义的命名

名副其实。变量、函数或类的名称应该已经答复了所有的大问题。它该告诉你,它为什么会存在,它做什么事,应该怎么用。如果名称需要注释来补充,那就不算是名副其实。如下代码,第二个变量名比第一个好理解多了。

    int d;  // 消逝的时间,以日计
    int elapsedTimeInDays;

避免误导。别用accountList来指一组账号,除非它真的是List类型,但是直接用accounts不是更好更直接吗?

类名应该是名词或名词短语。如Customer、WikiPage、Account和AddressParser,避免使用Manager、Processor、Data或Info这样的类名。类名不应当是动词。

方法名应当是动词或动词短语。如postPayment、deletePage或save。属性访问器、修改器和断言应该根据其值命名,并依Javabean标准加上get、set或is前缀。

使用解决方案领域名称。比如系统中用到了抽象工厂模式,那么给对应的工厂命名为XXXFactroy更有意义。


第3章 函数

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

函数应该只做一件事。做好这件事,只做这一件事。

函数内的语句要在同一抽象层级上。如果函数中混杂不同的抽象层级就会使得读者无法判断表达式是基础概念还是细节。

尽量少的函数参数。有两个参数的函数要比一元函数的难懂。如果需要三个或者三个以上的参数应该封装成类了。

分隔指令与询问。函数要么做什么事,要么回答什么事,但二者不可得兼。

使用异常替代返回错误码。使用异常,错误处理代码就能从主路径代码中分离出来,得到简化。

别重复自己。如果一段相同或者相似的代码出现了两次,是不是应该重构了。


第4章 注释

作者认为,有注释的地方基本就宣告着失败。写注释的常见动机之一是糟糕代码的存在。与其花时间编写解释你搞出的糟糕的代码注释,不如花时间清洁那堆糟糕的代码。

其实,我刚开始是不同意作者的观点的。我觉得,是代码就应该有注释,不然其他人怎么看得懂。但是后来作者提出了一个观点,深深地说服了我,那就是用代码来阐述。想想看,你是愿意看到这个:

// check to see if the employee is eligible for full benefits
if ((employee.flags & HOURLY_FLAG) && (employee.age > 65)) {
    // do something
}

还是更愿意看到这个:

if (employee.isEligibleForFullBenefits()) {
    // do something
}

只要想上那么几秒钟,就能用代码解释你大部分的意图。很多时候,简单到只需要创建一个描述与注释所言同一事物的函数即可。

好注释

当然,有些注释是必须的,也是有利的。不过要记住,唯一真正好的注释是你想办法不去写的注释。下面对好注释进行分类。

法律信息。公司代码规范要求编写与法律有关的注释,例如版权和著作申明。

对意图的解释。有时注释不仅提供了有关实现的有用信息,而且还提供了某个决定后面的意图。

阐释。有时注释把某种晦涩难明的参数或返回值的意义翻译为某种可读形式。

警示。警告会出现某种后果。

放大。有的代码可能看着有点多余,但编码者当时是有他自己的考虑,这个时候需要注释下这个代码的重要性。避免后面被优化掉。


第5章 格式

这里的格式,其实就是代码风格。作者说了很多比较大众的格式。我更倾向于团队化和工具化。

团队化:遵循团队意见,由大家决定花括号需不需要另起一行,少数服从多数。由于团队代码风格一致,看代码的时候也就更加舒服、效率。

工具化:通过工具强制保障团队代码风格一致。主流的IDE如eclipse和IDEA,都是支持配置代码风格和格式化代码的。其次,还可以通过checkStyle插件检查提交的代码,若不合格则禁止合入代码库。


第6章 对象和数据结构

过程式代码(使用数据结构的代码)便于在不改动既有数据结构的前提下添加新的函数。面向对象代码便于在不改动既有函数的前提下添加新类。反过来讲也说的通,过程式代码难以添加新数据结构,因为必须修改所有函数,面向对象代码难以添加新函数,因为必须修改所有类。

得墨忒耳律:模块不应该了解它所操作对象内部情形。下列代码就违反了该定律:

String outputDir = context.getOptions().getScratchDir().getAbsolutePath();

方法不应调用由任何函数返回的对象的方法。换言之,只跟朋友谈话,不与陌生人谈话。


第7章 错误处理

先写Try-Catch-Finally语句。异常的妙处之一是,它们在程序中定义了一个范围。执行try-catch-finally语句中try部分的代码时,你是在表明可随时取消执行,并在catch语句中接续。

使用不可控异常。可控异常的代价就是违反开闭原则。如果你在方法中抛出可控异常,而catch语句在三个层级之上,你就得在catch语句和抛出异常处之间的每个方法签名中声明该异常。

给出异常发生的环境说明。应创建信息充分的错误消息,并和异常一起传递出去。

依调用者需要定义异常类。对错误分类有很多方式。可以依其来源分类:是来自组件还是其他地方?或依其类型分类:是设备错误、网络错误还是编程错误?不过,当我们在应用程序中定义异常类时,最重要的考虑应该是它们如何被捕获。

定义常规流程。创建一个类或配置一个对象,用来处理特例。你来处理特例,客户代码就不用应付异常行为了。异常行为被封装到特例对象中。

别返回null值,别传递null值。程序中不断的看到检测null值的代码,一处漏掉检测就可能会失控。作者认为这种代码很糟糕,建议抛出异常或者返回特定对象(默认值)。


第8章 边界

我们在使用第三方代码的时候,都应该干净利落地整合进自己的代码中。要知道,如果强依赖他们的代码,那么当他们升级之后没有考虑兼容性,比如方法移除、功能变更等等,那么你的程序可能连编译都不通过。下面介绍保持软件边界整洁的实践手段和技巧。

学习性测试(通过编写测试来遍览和理解第三方代码)。在学习性测试中,我们如在应用中那样调用第三方代码。我们基本上是在通过核对试验来检测自己对那个API的理解程度。测试聚焦于我们想从API得到的东西。

学习性测试的好处不只是免费。当第三方程序包发布了新版本,我们可以运行学习性测试,看看程序包的行为有没有改变。

使用尚不存在的代码。有时候我们的第三方API还没设计出来。为了不受阻碍,编写我们想得到的接口,使它在我们的控制之下。有助于保持客户代码更可读,且集中于它该完成的工作。

适配器模式管理边界。通过接口管理第三方边界,使用适配器模式将第三方提供的接口转换为我们的接口。


第9章 单元测试

TDD三定律(官方版):

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

TDD三定律(白话解读版):

  1. 先把单元测试写好了,再去写生产代码。
  2. 单元测试至少要能编译通过。
  3. 单元测试写好之后,生产代码要使失败的测试用例刚好能通过。

保持测试整洁。测试代码和生产代码一样重要。它可不是二等公民。它需要被思考、被设计和被照料。它该像生产代码一样保持整洁。有了测试,你就不必担心对代码的修改!没有测试,每次修改都可能带来新的缺陷。

整洁的测试三要素:可读性,可读性和可读性。如何提高可读性?使用构造-操作-检验模式。每个测试都清晰地拆分为三个环节。第一个环节构造测试数据,第二个环节操作测试数据,第三个环节检验操作是否得到期望的结果。

每个测试一个断言。每个测试函数都应该有且只有一个断言语句。这条规则看似过于苛求,但其好处是可快速方便地理解测试用例。

整洁的测试还应遵循以下5条规则——F.I.R.S.T快速(Fast)。测试应该能快速运行。测试运行缓慢,你就不会想要频繁地运行它。 独立(Independent)。测试应该相互独立,某个测试不应为下一个测试设定条件。 可重复(Repeatable)。测试应当可在任何环境中重复通过。 自足验证(Self-Validating)。测试应该有布尔值输出,无论通过或失败,你不应该查看日志文件来确认测试是否通过。 及时(Timely)。测试应及时编写。单元测试应该恰好在使其通过的生产代码之前编写。


第10章 类

类应该短小。如何确保短小,作者提供了两个思路。单一权责原则,类或模块应有且只有一条加以修改的理由;保持高度内聚,如果一个类中的每个变量都被每个方法所使用,则该类具有最大的内聚性。

为了修改而组织。对于多数系统,修改将一直持续。每处修改都让我们冒着系统其他部分不能如期望般工作的风险。在整洁的系统中,我们对类加以组织,以降低修改的风险。组织的过程可以参考两个原则:开闭原则,类应当对扩展开放,对修改关闭;依赖倒置原则,类应当依赖于抽象而不是依赖于具体细节。


第11章 系统

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

本章中作者用建造一个城市来比喻构造和使用是不一样的过程

三种方法将构造和使用隔离开来。第一种分解main函数,main函数创建系统所需的对象,再传递给应用程序,应用程序只管使用;第二种抽象工厂模式,让应用程序自行控制何时创建对象,但构造的细节却隔离于应用程序之外;第三种依赖注入,对象不负责实体化对自身的依赖,反之,它将这份权责移交给其他“有权力”的机制,可参考Spring框架。

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

面向切面编程。在AOP中,被称为方面(aspect)的模块构造指明了系统中哪些点的行为会以某种一致的方式被修改,从而支持某种特定的场景。


第12章 迭进设计

据Ken Beck所述,只要遵循以下规则,设计就能变得“简单”:

  1. 运行所有测试;
  2. 不可重复;
  3. 表达了程序员的意图;
  4. 尽可能减少类和方法的数量;
  5. 以上规则按其重要程度排列。

简单设计规则1:运行所有测试。遵循有关编写测试并持续运行测试的简单、明确的规则,系统就会更贴近OO低耦合度、高内聚度的目标。编写测试引致更好的设计。

简单设计规则2~4:重构。测试消除了对清理代码就会破坏代码的恐惧。在重构过程中,可以应用有关优秀软件设计的一切知识。提升内聚性,降低耦合度,切分关注面,模块化系统性关注面,缩小函数和类的尺寸,选用更好的名称,如此等等。

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

表达力。代码应当清晰地表达其作者的意图。作者把代码写得越清晰,其他人花在理解代码上的时间也就越少,从而减少缺陷,缩减维护成本,

尽可能少的类和方法。类和方法的数量太多,有时是由毫无意义的教条主义导致的。不过要记住,这在关于简单设计的四条规则里面是优先级最低的一条。所以,尽管使类和函数的数量尽量少是很重要的,但更重要的却是测试、消除重复和表达力。


第13章 并发编程

正如作者所说,编写整洁的并发程序很难,非常难!而作者只花了一章来讲解并发编程,只是做了一个概览。要真正理解并发编程,起码要看一本专题书籍才够。

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

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

并发防御原则:

  1. 单一权责原则
  2. 推论:限制数据作用域
  3. 推论:使用数据复本
  4. 推论:线程应尽可能地独立

在并发编程中用到的几种执行模型:

生产者-消费者模型: 一个或多个生产者线程创建某些工作,并置于缓存或队列中。一个或多个消费者线程从队列中获取并完成这些工作。生产者和消费者之间的队列是一种限定资源。

读者-作者模型:当存在一个主要为读者线程提供信息源,但只是偶尔被作者线程更新的共享资源,吞吐量就会是个问题。增加吞吐量,会导致线程饥饿和过时信息的积累。协调读者线程,不去读作者线程正在更新的信息(反之亦然),这是一种辛苦的平衡工作。作者线程倾向于长期锁定许多读者线程,从而导致吞吐量问题。

宴席哲学家模型:想象一下,一群哲学家环坐在圆桌旁。每个哲学家的左手边放了一把叉子。桌面中央摆着一大碗意大利面。哲学家们思索良久,直至肚子饿了。每个人都要拿起叉子吃饭。但除非手上有两把叉子,否则就没法进食。如果左边或右边的哲学家已经取用一把叉子,中间这位就得等到别人吃完、放回叉子。每位哲学家吃完后,就将把两把叉子放回桌面,直到肚子再饿。用线程代替哲学家,用资源代替叉子,就变成了许多企业级应用中进程竞争资源的情形,如果没有用心设计,这种竞争式系统就会遭遇死锁,活锁,吞吐量和效率低等问题。

警惕同步方法之间的依赖。同步方法之间的依赖会导致并发代码中的狡猾缺陷。避免使用一个共享对象的多个方法。

保持同步区域微小。尽可能少地设计临界区,尽可能减小同步区域。

很难编写正确的关闭代码。尽早考虑关闭问题,尽早令其工作正常。这会花费比你预期更多的时间。检视既有算法,因为这可能会比想象中难得多。

测试线程代码。下面是一些精炼的建议:

  1. 将伪失败看作可能的线程问题;
  2. 先使非线程代码可工作;
  3. 编写可插拔的线程代码;
  4. 编写可调整的线程代码;
  5. 运行多于处理器数量的线程;
  6. 在不同平台上运行;
  7. 调整代码并强迫错误发生。

总结

至此,这本书可算是阅读完了。不过,就算是一本武林秘籍,如果光看不练也没用。所有,只有把这本书的精华运用到实现项目之中,才能编写出整洁的代码!