《代码整洁之道》——有一个好的代码品味

The only valid measurement of code quality: WTFs/minute

在成为一个程序员的初期,实现功能还需磕磕绊绊的阶段,我们大抵没有精力操心代码风格的问题;而在能够搞定环境和API使用的时间段,大部分人又会沉湎于使用一门语言让想象实现的成就感,而没有发现暗藏在迭代后的危机。往往迭代了一段时间后,才发现之前埋下的巨坑已经让自己无从下手。这时一部分人醒悟过来,意识到一个优良的代码风格对于项目推进的长远意义。这也是《Clean Code》这本书的宗旨。它较之《程序员修炼之道》更为具体,较之《重构》更为宏观。对于工作一段时间后的程序员来说,是一个很好的提醒和反思归纳的建议。让代码work的方式是千万种,而让代码可持续,可扩展,长久work的方式也许需要前辈指引些方法。

观念

Later equals never —— Leblanc Law

糟糕的代码会让人难以下手,拖慢进度,若无人着手改善,混乱会持续增加,进而降低团队生产力,降低人效,然后搞砸整个项目。为什么不一开始就打好基础,写出整洁代码呢?

下面是一些大师对“整洁代码”的界定

  • “代码逻辑直截了当,缺陷难以隐藏;减少依赖关系,从而便于维护;性能调优,省得引人做出没规矩的优化,干出蠢事;整洁的代码只干一件事” —— Bjarne Stroustrup
  • “代码简单直接,如同优美的散文;从不隐藏设计者的意图,充满干净利落的抽象和直截了当的控制语句” —— Grady Booch
  • “可由作者外的人阅读和扩展,应该有单元测试和验收测试;只使用有意义的命名;提供尽量正交的使用方法(一种而非多种做一件事的方法);尽量少的API;尽量少的依赖关系,且要明确定义和清晰提供;代码应从字面意义上表达其含义” —— Dave Thomas
  • “整洁的代码总是看起来像某位特别在意的人写的,几乎没有改进的余地,所有的改进都会回到原点” —— Michael Feather
  • “能通过所有测试;没有重复代码,表达力强大;体现系统中的全部设计理念;包括尽量少的实体,如类、方法、函数。” —— Ron Jeffries
  • “整洁代码让每个例程都深合己意;漂亮代码让语言看起来像是专门为解决那个问题而存在” —— Ward Cunningham

编写代码的难度,取决于读周边代码的难度,要想干得快,就先让代码易读。

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

命名

好的命名,可以让人一眼就明白代码的逻辑。看下面两段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public List<int[]> getThem() {
List<int[]> list1 = new ArrayList<int[]>();
for (int[] x : theList)
if (x[0] == 4)
list1.add(x)
return list1;
}

public List<Cell> getFlaggedCells() {
List<Cell> flaggedCells = new ArrayList<Cell>();
for (Cell cell : gameBoard)
if (cell.isFlagged)
flaggedCells.add(cell);
return flaggedCells;
}

下面是一些准则:

  • 名副其实,一旦发现更好的,就替换掉旧的。如果名称还需要注释补充,那就不算名副其实
  • 避免误导,提防使用不同之处较小的名称,比如UsernameListEmptyFilterUsernameListNullFilter,不要混用1和l,以及0和O
  • 做有意义的区分,少废话,反例是a1a2nameStringname
  • 使用能读出来的名称,方便程序员的沟通
  • 使用可搜索的名称,仅在块作用域或短函数内使用单字母名称
  • 不把类型信息放在名称中
  • 减少不必要的前缀和后缀
  • 类名应该是名词,方法名应该是动词或动词短语
  • 别玩梗
  • 标准化语素,为每一个抽象概念选择一个统一的词
  • 别用有多重含义的词汇,这会增加使用者顾虑和理解成本
  • 只在没有明确定义的场景下使用语境(类,前缀……)

函数

下面是一些准则:

  • 短小,更短小
  • 只做一件事,只做一件事,只做一件事,重要的事情说三遍
    • 还有一种方式可以帮助判断函数是不是只做了一件事:函数语句是否在一个抽象层级上
    • 只做一件事的函数无法被继续合理拆分成多段
  • 让代码拥有从上到下的自然的阅读顺序,尽量避免跳来跳去的阅读顺序
  • 为函数使用描述性的名称
  • 函数最多3个入参,最理想是没有参数,其次是1个,再其次是2个,要避免3个参数
    • 布尔类型的参数会让你的函数难以理解
    • 使用二元参数时,最好保证前后顺序不敏感
    • 对于复杂的入参,可以用对象封装起来
  • 函数要么只做副作用(做什么事),要么没有副作用(回答什么事),而且能从名称中一目了然地看到
  • 用异常代替错误码,错误处理也是一件事
  • 别重复自己(Don’t repeat yourself, DRY)。重复是软件中一切邪恶的根源,软件开发领域的所有创新都在不断尝试从源代码中消灭重复

函数是语言的动词,类是名词。大师级的程序员把系统当做故事来讲,而不是程序来写。他们使用特定编程语言提供的工具构建一种更丰富和更具表达力的语言。好的函数必须干净利落的组合在一起,形成清晰明确的语言,帮你讲好故事。这个过程不是一蹴而就的,你可以现象什么就写什么,然后一点点打磨它。

注释

使用注释 = 承认自己无法用代码表达清楚意图

列举注释的准则前,必须摆正观念。注释是一种必须的恶,如果编程语言足够有表达力,或你长于用语言表达自己的意图,那么就不需要注释。注释的恰当用法是弥补我们用代码表达意图时遭遇的失败。注释存在的时间越久,具体所描述的事实就越远。原因很简单,程序员不可能坚持维护注释。

的确,程序员应该让注释保持可维护而精准,但最好能直接写清楚代码,保证无须编写注释。真实只在一处:代码,只有代码能忠实告诉你它做的事。

  • 注释无法挽救糟糕的代码,与其为糟糕代码补充大量注释,不如花时间写出整洁有表达力的代码
  • 用代码代替注释
  • 有些无法避免且合理存在的注释
    • 法律信息
    • 对函数名的补充
    • 对稍微反常规意图的解释,避免误解
    • 糟糕的代码来自外部库或外部API
    • 敏感代码的警告
    • TODO、FIXME
    • Javadoc
  • 下面则是一些很常见的糟糕注释,它们只是糟糕代码的借口
    • 只有自己看得懂的注释
    • 对函数名的复述
    • 误导性注释
    • 日志型注释,如Milestone记录
    • 废话
    • 位置标记,如====================
    • 代码署名
    • 大多数注释掉的代码
    • 百科式的介绍
    • 私有代码的Javadoc

格式

格式即代码风格,可以利用lint这样的自动化工具完成,需要在团队内保持一致。

垂直格式

在从上到下的组织上,

  • 可以向报纸一样,先大纲,再粗线条概述,再给出细节,越往下细节越多
  • 空行分隔概念
  • 靠近的代码行暗示了代码间的紧密关系
  • 应避免迫使读者在源文件和类之间跳来跳去
    • 变量声明应尽量靠近使用位置
    • 循环中的控制变量总在循环语句中声明
    • 类成员在类顶部声明
    • 如果某个函数调用了另一个,就应该把它们放一起
    • 概念相关的代码应该放在一起,如getHourgetMinute
    • 如果可以,最好把被调用的函数放在执行调用的函数下面

横向格式

  • 水平字符的上限,100或120
  • 使用空格分隔概念
  • 不需要水平对齐,以为从左到右的阅读顺序优先于从上到下
  • 学会用缩进表现层级

对象和数据结构

  • 对象把数据隐藏于抽象的后面,暴露操作数据的函数,数据结构暴露数据,不提供有意义的函数。
  • 过程式代码难以添加新的数据结构,因为必须修改所有函数;面向对象代码难以添加新函数,因为必须修改所有类
  • 得墨忒尔律:类C的方法f只应该调用以下对象的方法:C、f创建的对象、作为参数传给f的对象、C的成员所持有的对象
  • 数据结构只简单地拥有公有变量,没有函数;而对象则只拥有私有变量和函数。一半是对象一半是数据结构的混淆会增加添加新函数和数据结构的难度
  • DTO(Data Transfer Objects,数据传输对象)就是只有公有变量,没有函数的类,Active Record就是DTO的一种

错误处理

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

  • 返回异常而不是错误码
  • try-catch-finally语句块就像事务,可以帮你定义用户应该期待些什么
  • 在Java中,可控异常违反开闭原则带来的成本要高于收益
  • 打包第三方API,一方面降低了依赖的覆盖面,另一方面也有助于模拟第三方调用
  • 可以把抛出错误封装成特定的函数
  • 别返回和传递null值

边界

边界即我们代码和第三方代码的连接处。

  • 通过编写测试来概览和理解第三方代码的形式叫做学习性测试(learning tests)。它可以帮助我们快速试错和反馈,从而对第三方API快速上手。
  • 在第三方代码尚未就绪时,编写我们想要的接口,可以使我们能保持代码在自己控制中,并在未来通过编写adapter的形式无痛迁移
  • 应尽量避免过多依赖第三方的特定信息,更多依靠你能控制的东西,好过依靠你控制不了的东西,免得日后受其控制

单元测试

  • TDD(Test-Driven Development)三定律
    • 在编写不能通过的单元测试前,不编写生产代码
    • 只编写刚好无法通过的单元测试,不能编译也算
    • 只编写刚好足已通过失败测试的生产代码
  • 测试代码和生产代码一样重要。正是单元测试让你的代码可扩展、可维护、可复用
  • 整洁的测试代码一样要求可读性。大多数测试代码可以总结为构造-操作-检验(Build-Operate-Check)模式。第一个环节构造测试数据,第二个环节操作数据,第三个环节验证是否得到期望的结果
  • 每个测试中的断言数量应该尽量少,且只测试一个概念
  • FIRST原则
    • Fast,测试应该能够快速运行
    • Independent,测试间应该相互独立
    • Repeatable,测试应该在任何环境下可重复通过
    • Self-Validating,测试应该有布尔值输出
    • Timely,测试应及时编写

  • 类应该由一组成员开始,从静态到普通,从共有到私有。且很少会有公有成员。
  • 类应该短小,类的名称应该能描述其权责。类名无法精确明明时,类大概就太长了。类名越含糊,类越有可能拥有过多权责。类名应该控制在25个字母内,且不应该包含连词。
  • 单一权责原则(Single Responsibility Principle,SRP)认为,类和模块应有且仅有一条加以修改的原因。这个原则可以帮助创造更好的抽象。它也是OO设计中最重要的概念之一。
  • 内聚:类应该只有少量实体变量,且所有方法都应该操作其中一些。当类的每个变量都被每个方法使用时,我们认为该类具有最大的内聚性。当发现类逐渐丧失内聚性时,尽早拆分它!让它变成多个短小的类。这个拆分的过程也是权责的拆分过程。
  • 通过基类和子类,可以在不修改类的同时,保持类对新功能的开放。在理想系统中,我们通过扩展系统而非修改现有代码来添加新特性。可以通过抽象类和接口隔离细节修改带来的影响。
  • 降低类之间的连接耦合,可以采用依赖倒置原则(Dependency Inversion Principle,DIP),让类依赖于抽象(接口)而不是具体细节(自行构造类)

系统

这一章的Java概念较多

  • 分开系统的构造和使用
  • 依赖注入是控制反转的一种思路,它将第二权责从对象中拿出来,转移到专门的对象中去,从而遵循单一权责原则
  • 我们应该专注于今天的用户故事,并且持续适当切分我们的关注面。书中举了Java AOP、AspectJ框架的例子
  • 实现时,使用大致可工作的最简单方案。只要软件构架有效切分了关注面,就比较好做根本性改动

迭代

Kent Beck关于测试的4个原则:

  • 运行所有测试,全面测试并持续通过所有测试的系统,就是可测试的系统。测试也能减少重构时可能破坏代码的顾虑。
  • 不可重复,使用模板生成或继承等高级概念
  • 表达程序员的意图。代码应当清晰表达作者的意图。使用好名称、保持类和函数的短小,以及之前章节提到的各种方法
  • 尽可能减少类和方法的数目,避免前两条规范的矫枉过正

并发编程

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

  • 并发有时能改善性能,会在编写额外代码上带来额外开销
  • 正确的并发是复杂的
  • 并发会带来系统结构的变化

有些防御并发代码问题的原则:

  • 单一权责:分离并发代码和其他代码
  • 限制对可能共享的数据的访问
  • 线程应尽可能独立

并发执行模式:

  • 生产者-消费者模式:数据通过队列传递,队列本身是一种限定资源
  • 读者-作者模式
  • 宴席哲学家问题

还有一些需要注意的事情:

  • 警惕同步方法间的依赖
  • 尽可能减小sychronized区域
  • 尽早考虑程序关闭问题
  • 测试线程代码

3个实例

书中以三个实例的重构过程向我们表现了一些将之前思路应用于优化代码的方式。

命令行参数解析:args

编程是一种技术甚于科学的东西,要编写整洁代码,必须先写肮脏代码,然后再清理它

在你的初稿,当代码糟糕透顶时甚至是前几稿中,很可能还是会存在烂摊子:成员多得吓人,奇怪命名的魔法字符串,一大堆的try-catch-finally代码。程序员们不都是蠢人,这堆糟糕透顶的代码其实是从最初看起来很合理但是扩展性差的代码一步步演化来的。

需要使用一些总结和抽象,来简明地表达你的目的。另外,在重构前,“我”(其实是作者)要不厌其烦地强调TDD的必要性,它能保证你重构的每一步,系统都可以工作。在重构过程中,放进拿出是常见的事,小步幅、保持测试通过,你可能会不断移动各种东西。

优秀的代码设计,大都关乎分隔——创建合适的空间防止不同种类的代码。对关注面的分隔让代码更易于理解和维护(减少理解所需要的大脑缓存)

JUnit

  • 不必要的编码前缀(f_
  • 未封装的条件判断
  • 建议使用肯定式代替否定式判断
  • 奇怪的不直观的函数名
  • 易造成理解困难的变量名
  • 拆分违反SRP原则的函数
  • 避免隐式时序耦合的函数,用hardcode的形式显示表现时序耦合

SerialDate重构

再强调一遍,重构前要有一个完整的验证可行性的测试。然后开始重构:

  • 没有描述力的类名和术语名
  • 使用枚举代替常量类
  • 抽象类中不应知道实现细节
  • 基类不宜知道子类的情况
  • 多余的注释
  • 变量声明应该放在尽量靠近使用的地方
  • 如果有专业术语,就不要自己命名了
  • 不要写无用的模板代码
  • 如果函数对成员进行操作,它就不应该是静态的
  • 解释临时变量的方式,让大段的代码更为简化和有表达力
  • 消除魔术数

味道和启发

作者在这里对《重构:既有代码设计的改善》里提到的味道做了自己的一些扩充,也可以作为对上面章节的回顾。

“味道”,即那些看起来不大对劲的代码

注释

  • 不恰当的信息,如修改记录
  • 过时的注释
  • 多余的废话
  • 错误的有误导性的注释
  • 注释掉的代码

环境

  • 多步才能完成的构建
  • 多步才能完成的测试

函数

  • 过多的入参
  • 布尔类型参数
  • 从未被调用的函数
  • 用于返回的参数

一般性问题

  • 源文件中有多种语言
  • 明显违背字面意义的直觉
  • 不考虑边界情况
  • 忽视安全问题
  • 重复,这也是最常见的问题。每次看到重复代码都代表遗漏了抽象。有一些常见的设计模式可以帮助你。
  • 代码的抽象层级有问题,或混杂。抽象类用来容纳高层级概念,子类用来容纳低层级概念。不同层级概念放在不同容器中。
  • 基类依赖于子类。通常来说,基类对子类应该一无所知
  • 信息过多,违背SRP
  • 从未使用的代码
  • 不恰当的垂直分隔
  • 语素前后不一致
  • 基于巧合、预设假设的耦合。异或是两个没有直接目的之间的模块的耦合。
  • 特性依恋,类的方法只应对自身的成员和方法感兴趣,不应关注其他类的成员和方法
  • 使用boolean或枚举参数让一个函数表现多态。使用多个函数通常由于向单个函数传递代码来选择函数行为
  • 晦涩的意图,如魔术数、魔术字符串、过度简写的表达式
  • 位置错误的权责
  • 不恰当的静态方法,如完全不需要多态的函数
  • 使用自解释的变量名
  • 使用自解释的函数名
  • 理解算法
  • 把逻辑依赖(脑海中的限制/已知条件)改为物理依赖
  • 使用if/else、switch前想想有没有多态的实现方法
  • 遵循团队lint规则
  • 足够准确
  • 未封装的条件判断
  • 未封装的边界条件检测
  • 避免否定性条件
  • 函数应该只做一件事
  • 函数应该只在一个抽象层级上
  • 隐蔽的时序性耦合
  • 别随意,先好好思考再下手
  • 应该在较高层级放置可配置数据
  • 避免传递浏览,即遵守德墨忒尔律

Java

  • 使用通配符避免过长的导入清单
1
import package.*
  • 不要继承常量,使用静态导入
1
import static EmployeeConstants.*
  • 在可以的情况下,用枚举代替常量

名称

  • 使用描述性名称
  • 名称应该与抽象层级相符
  • 使用标准化语素
  • 使用无歧义的名称
  • 在较大作用范围使用较长名称,较小作用范围可以使用较短名称
  • 名称应该明确说明有副作用存在

测试

  • 要有足够的测试
  • 使用覆盖率工具
  • 别放过小测试
  • 被忽略的测试是对不确定事物的疑问
  • 测试边界条件
  • 测试失败的模式(pattern)会有启发性
  • 测试覆盖率的模式会有启发性
  • 测试应该快速

并发编程示例

客户端/服务端

  • 如果吞吐量与I/O有关,则并发编程可以提升运行效率
  • 保持并发系统整洁,把线程管理隔离到一个位置

可能的执行路径

深入到字节码和汇编语句的执行上,有些并非线程安全的操作中,不同的执行路径会带来不同结果。

了解类库

  • Executor框架
  • 非锁定方案:AtomicBoolean,AtomicInteger和AtomicReference
  • 数据库连接、java.util中的容器、Servlet天生不是线程安全的

提升吞吐量

  • synchronized代码块最好能限制在小范围内

死锁

死锁需要满足4个条件:

  • 互斥,即资源数量有限,或无法在同一时间为多个线程公用
  • 上锁及等待,从线程获取资源到完成工作前,不会释放这个资源
  • 无抢先机制,线程无法从其他线程处夺取资源
  • 循环等待

相反地,有4种避免死锁的方式:

  • 不互斥,使用允许同时使用的资源,或增加资源数目
  • 不上锁及等待,如果有等待情况就释放所有资源从新来过
  • 满足抢先机制
  • 不做循环等待

测试多线程代码

  • 复现问题可能很难,可以借助工具(如ConTest)帮助

–END–