引言
《代码整洁之道》(Clean Code)这本书非常适合每一个希望提高编程质量的程序员阅读。如何给参数命名?如何定义函数?如何编排子函数?等等这些问题的答案,你都可以在书中找到。
项目质量的好坏很大程度上取决于代码风格,如果团队的代码风格能够基本保持一致,对于每一位开发者和维护者来说都是福音。优秀的代码易于理解,逻辑清晰,命名规范,也是非常值得学习的。
本文记录了在阅读 Robert C. Martin 的《代码整洁之道》一书时学习到的关键内容,便于后期复习、反思、总结和进一步提升。努力做一个拥有良好「代码洁癖」的码农~
命名规则
- 名副其实:
- 不需要注释来补充命名的含义
- 避免误导:
- 避免使用与本意相悖的词
- 堤防使用不同之处较小的名称
- 做有意义的区分:
- 不要以数字区分
- 避免废话
- 废话都是冗余,Variable 一词永远不应当出现在变量名中
- 要区分名称,就要以读者能鉴别不同之处的方式来区分。
- 使用读得出来的名称
- 使用可搜索的名称:
- 避免使用单字母的词
- 名称长短应与其作用域大小相对应
- 避免使用编码:
- 对于现代编译器,请摒弃匈牙利命名法
- 不需要成员前缀,如
_m
- 避免思维映射:
- 明确是王道
- 类名:
- 类名和对象应当是名词或者名词性短语
- 避免
Manager
,Processor
,Data
或Info
这类的类名 - 类名不应该是动词。
- 方法名:
- 方法名应当是动词或动词短语
- 别扮可爱:
- 宁可明确,毋为好玩
- 言到意到,意到言到
- 每个概念对应一个词
- 别用双关语:
- 避免将同一个单词用于不同目的
- 使用解决方案领域名称:
- 多使用 CS 术语、算法、模式、数学术语吧
- 使用源自所涉问题领域的名称
- 添加有意义的语境:
- 良好命名的类、函数或命名空间放置名称,提供语境
- 使用前缀
- 不要添加没用的语境:
- 只要短名称足够清楚,就比长名称好
函数
- 短小:
- 函数的第一规则是短小,第二条是更短小
- 函数,20 行封顶最佳
- 只做一件事:
- 函数应该只做一件事情,最好这件事情
- 判断函数是否不止做了一件事情,就是看能否再拆出一个函数
- 如果函数只是做了该函数同一抽象层上的步骤,则函数还是只做了一件事
- 每个函数一个抽象层级:
- 自顶向下读代码:向下规则
- 让每个函数后面都跟着位于下一抽象层的函数
switch
语句:- 多态创建,隐藏于某个继承关系中,不要暴露出来
- 使用描述性的名称:
- 如果每个例程都让你感到深合己意,那就是整洁代码
- 长而具有描述性的名称,比短而令人费解的名称好
- 长而具有描述性的名称,要比描述性的长注释好
- 函数参数:
- 参数不要过多(尽可能)
- 参数和函数名称处于不同的抽象层级,要求你了解目前并不特别重要的细节
- 标识参数丑陋不堪,意味着要做不止一件事情
- 有两个参数的函数要比一元函数难懂
- 少用参数列表
- 无副作用:
- 普遍而言,应避免使用输出参数。如果函数必须要修改某种状态,就修改所属的对象状态吧。
- 分隔指令与询问:
- 函数要么做什么事情,要么回答什么,二者不可兼得
- 函数应该修改对象的状态或返回对象的有关信息。
- 使用异常替代返回的错误码:
- 抽离
try/catch
代码块 - 函数只应该做一件事,错误处理就是一件
- 抽离
- 别重复自己(DRY):
- 重复可能是软件中一切邪恶的根源
- 很多原则和实践规则都是为了控制与消除重复而创建
- 结构化编程
- 并不是从一开始按照规则写函数,没人可以做到。可以一开始让函数工作起来,再利用上面的规范进行优化,同时配合单元测试保证没有错误
- 真正的目标在于讲述系统的故事,编写的函数必须可以干净利落地拼装在一块,形成一种精确而清晰的语言,帮你讲故事
注释
- 注释总是一种失败,我们总无法找到不用注释就能表达自我的方法,所以总要有注释,并不值得庆祝
- 程序员不能坚持维护注释
- 真正好的注释是不写注释,用代码表达
- 好的注释:
- 法律信息
- 提供信息的注释
- 对意图的解释
- 阐释
- 警示
- TODO 注释
- 放大
- 坏的注释:
- 喃喃自语:任何迫使读者查看其它模块的注释都没能与读者沟通好
- 多余的注释
- 误导性注释
- 循规蹈矩式注释
- 日志式注释
- 废话注释
- 可怕的废话
- 能用函数或变量时就别用注释
- 位置标记
- 归属与署名:源码控制系统已经可以搞定
- 注释掉的代码
- HTML 注释
- 非本地信息:注释要靠近代码
- 信息过多
- 不明显的联系:注释要解释代码中不能自行解释的部分,不要让注释需要二次解析
- 函数头注释:用好的函数名称代替
- 范例
格式
- 代码格式很重要。代码格式关乎沟通,而沟通是专业开发的头等大事
- 让风格和律条永存
- 短文件通常比长文件易于理解
- 向报纸学习:循序渐进
- 概念间垂直方向上的区隔:空行
- 垂直方向上的靠近:紧密代码相互靠近
- 垂直距离:关系密切的概念应该相互靠近,应避免读者在源文件和类中跳来跳去
- 变量声明:尽可能靠近使用位置
- 实体变量:应在类的顶部声明
- 相关函数:若某个函数调用了另一个,就应该把它们放到一起,调用者应该尽可能放在被调用者上边
- 概念相关:此类代码应放在一起,相关性越强,应该越靠近
- 垂直顺序:自顶向下地展现调用依赖
- 横向格式:建议每行短小,120 列就是上限了
- 水平方向上的区隔与靠近:
- 加空格分隔
- 水平对齐:
- 不用太刻意关注
- 缩进:一种继承结构,体现一种层级关系
- 空范围:应当缩进开来
- 团队规则:遵守团队规则。好的软件系统是由一系列读起来不错的代码文件组成的
对象和数据结构
- 隐藏实现关乎抽象,类并不是简单地用取值器和赋值器将其变量推向外间,而是暴露抽象接口,以便用户无需了解数据的实现就能操作数据本体
- 数据、对象和反对称性:
- 对象把数据隐藏于抽象之后,暴露操作数据的函数
- 数据结构暴露数据,没有提供有意义的函数
- 过程式代码(使用数据结构的代码)便于在不改动既有数据结构的前提下新增函数。面向对象代码便于在不改动既有函数的前提下新增类,反之亦然
- 对于两种类型,应该根据情况考虑使用
- 得墨忒耳律:模块不应该了解它所操作对象的内部情形,方法不应调用任何函数返回的对象的方法(比如链式调用就违反了)
- 数据传送对象:
- 最为精炼的数据结构是一个只有公共变量、没有函数的类,即数据传送对象(DTO, Data Transfer Objects)
- 对象暴露行为,隐藏数据。便于添加新对象类型而无需修改既有行为,同时也难以在既有对象中添加新行为
- 数据结构暴露数据,没有明显的行为。便于向数据结构添加新行为,同时也难以向既有函数中添加新的数据结构
- 在任何系统中我们有时希望能够灵活添加新的数据类型,所以这部分使用对象。有时希望灵活添加新行为,这时更希望使用数据类型和过程。优秀的开发者不带成见地了解这些形式,根据情况选择合适的手段
错误处理
当错误发生时,程序员有责任保证代码照常工作。错误处理很重要,如果搞乱了代码逻辑,则是错误的做法。
- 使用异常而非返回码
- 先写
try-catch-finally
语句:- 异常的妙处之一,它们在程序中定义了一个范围
- 尝试编写强行抛出异常的测试,再往处理器中添加行为,使之满足测试要求
- 使用不可控异常:
- 可控异常的代价是违反“开闭原则”
- 一般应用开发,应该避免可控异常
- 给出异常发生的环境说明:创建信息充分的错误消息,并和异常一起传递出去。在消息中包括失败的操作和失败的类型
- 依调用者需要定义异常类:
- 定义异常,考虑它们应该如何捕获
- 将第三方 API 打包是个良好的实践手段,当打包一个第三方 API,就降低了对它的依赖,同时在测试时有助于模拟第三方调用
- 定义常规流程:
- 创建一个类或配置一个对象,用来处理特例,异常行为封装到特例对象中
- 避免打断业务逻辑
- 避免传递 None/NULL/nil 等,否则函数需要做很多额外的判断(当然,这些是必须的)
边界
如何保持软件边界整洁?这一章给出一些实践手段和技巧
- 使用第三方代码:
- 边界上的接口是隐藏的
- 不应将含有边界接口的对象到处传递,应当把它保留在类或近亲类中
- 学习性测试的好处不只是免费:
- 无论如何都要学习使用 API,而编写测试是获得这些知识的容易而不影响其他工作的途径
- 学习性测试是一种精确实验,帮助我们队 API 的理解
- 使用尚不存在的代码:
- 还有一种边界,将已知和位置分隔开的边界
- Fake 和 Adapter
- 整洁的边界:
- 边界上的代码需要清晰地分割和定义期望的测试。应该避免我们的代码过多了解第三方代码的特定信息
- 依靠你能控制的东西,好过依靠你控制不了的东西
- 可以采用内部隐藏边界接口或者使用适配模式保持边界整洁,从而减少接口的修改
单元测试
- TDD 三定律:
- 在编写不能通过的单元测试前,不可编写生产代码
- 只可编写刚好无法通过的单元测试,不能编译也算不通过
- 只编写刚好可以通过当前失败测试的生产代码
- 保持整洁的测试:
- 肮脏的测试等同于没有测试
- 测试必须跟随生产代码修改
- 测试代码和生成代码一样重要,需要仔细思考、设计和照料,保持整洁
- 正是单元测试让你的代码可扩展、可维护、可复用
- 测试带来了一切好处,使变动成为可能
- 测试越脏,代码最终会越脏
- 整洁的测试:
- 可读性
- 构造-操作-检验(BUILD-OPERATE-CHECK)模式
- 测试直达目的,只用真正需要的数据类型和函数
- 专业的开发者将他们的测试代码重构为简洁和具有表达力的形式
- 每个测试一个断言:
- 每个测试时一个概念
- FIRST:
- 快速 Fast:能够频繁运行
- 独立 Independent:某个测试不应为下一个测试设定条件
- 可重复 Repeatable:测试应当可以在任何环境中重复通过
- 自足验证 Self-Validating:测试应有布尔值输出,可以直接反馈测试结果
- 及时 Timely:测试应及时编写
- 或许测试更为重要,它保证和增强了生产代码的可扩展性、可维护性和可复用性
类
- 类的组织:
- 公共函数应跟在变量列表之后,我们喜欢把由某个公共函数调用的私有工具函数紧随在该公共函数的后面,符合自顶向下的原则
- 类应该短小:
- 计算「权责 responsibility」衡量类大小
- 类的名称应当描述权责,如果无法为某个类命名以精确的名称,这个类大概就太长了
- 单一权责原则(SRP):类或模块应该有且只有一条加以修改的理由
- 系统应该由许多短小的类而非少量巨大的类组成。每个小类封装一个权责,只有一个修改的原因,并与少数其他类一起协同达成期望的系统行为
- 内聚:类应该只有少量的实体变量,类中的每个方法都应该操作一个或多个这种变量
- 保持内聚会得到许多短小的类
- 在理想系统中,我们通过扩展而非修改现有系统的方式添加新特性
- 部件之间的解耦代表系统中的元素相互隔离的很好,隔离也让系统每个元素的理解变得更加容易
- 依赖倒置原(DIP):依赖于抽象而非实现的细节
系统(偏 Java)
“复杂要人命。它消磨开发者的生命,让产品难以规划、构建和测试”—— Ray Ozzie,微软首席技术官
- 城市管理:有人负责全局,有人负责局部
- 将系统的构造和使用分开:
- “软件系统应将起始过程和起始过程之后的运行时逻辑分离开,在起始过程中构建应用对象,也会存在互相纠缠的依赖关系”
- 分解 main
- 工厂
- 依赖注入:控制反转在依赖管理中的一种应用手段
- 扩容:
- “一开始就做对系统”纯属神话
- 测试驱动开发、迭代和增量敏捷扩展系统
小步快跑:迭代
- 通过迭代和改进设计达到整洁目的(Kent Beck 的简单设计规则):
- 运行所有测试
- 不可重复
- 表达了程序员的意图
- 尽可能减少类和方法的数量
- 以上规则按重要程度排序
- 简单设计规则 1:运行所有测试:
- 全面测试并持续通过所有测试的系统,就是可测试的系统
- 不可测试的系统不可验证,不可验证的系统,不可部署
- 紧耦合的代码难以编写测试,同样编写测试越多,就越会遵循 DIP 之类规则
- 遵循有关编写测试并持续运行测试的简单、明确的规划,系统就会更贴近 OO 低耦合度、高内聚度的目标,编写测试引致更好的设计
- 简单设计规则 2~4:重构:
- 测试消除了对清理代码就会破坏代码的恐惧
- 增量式重构改善代码
- 提升内聚性、降低耦合度、切分关注面、模块化系统性关注面、缩小函数和类的尺寸、选用更好的名称等
- 不可重复:
- 重复包括形式上和实现上的重复
- 模板方法模式是一种移除高层重复的通用技巧
- 表达力:
- 软件项目的主要成本在于长期维护
- 选用好的名称
- 保持函数和类尺寸短小
- 采用标准命名法
- 编写良好的单元测试
- 做到有表带最重要的是尝试,太多时候我们写出能工作的代码,就转移到下一个问题,没有下足功夫调整
- 尽可能减少类和方法:
- 类和方法的数量太多,有时候是由毫无意义的教条主义导致的
- 保持函数和类短小的同时,保持整个系统短小精悍
- 遵循简单设计的实践手段,开发者不必年轻学习就能掌握好的原则和模式
并发编程
“对象是对过程的抽象,线程是对调度的抽象”
- 为什么要并发:
- 并发是一种解耦策略,把做什么(目的)和何时(时机)分开
- 解耦目的与时机能明显地改进应用程序的吞吐量和结构
- 误区:
- 并发总能改进性能
- 编写并发程序无需修改设计
- 实际:
- 并发会在性能和编写额外代码上增加额外一些开销
- 正确的并发是复杂的,几遍对于简单的问题也是如此
- 并发缺陷并非总能重现
- 并发常常需要对设计策略的根本性修改
- 并发防御原则:
- 单一权责原则:
- 分离并发相关的代码和其它代码
- 推论:限制数据作用域:
- 保护临界区共享对象
- 谨记数据封装,严格限制对可能被共享的数据的访问
- 推论:使用数据副本
- 推论:线程应尽可能独立:尝试将数据分解到可被独立线程(可能不同的服务器上)操作的独立子集
- 单一权责原则:
- 了解执行模型:
- 几种概念:
- 限定资源
- 互斥
- 线程饥饿
- 死锁
- 活锁:执行次序一致的线程,每个都想起步,但发现其他线程已经在路上
- 生产者-消费者模型:中间队列是限定资源
- 读者-写着模型
- 宴席哲学家
- 几种概念:
- 保持同步区域微小:尽可能减少同步区域,否则会增加资源竞争,执行效率降低
- 尽早考虑关闭问题,尽早令其正常工作
- 测试线程代码:
- 建议编写有潜力暴露问题的测试,在不同的配置和负载下频繁运行。如果测试失败,则跟踪错误
- 将伪失败看做可能的线程问题
- 先使用单线程代码可工作
- 编写可插拔的线程代码
- 编写可调整的线程代码
- 运行多于处理器数量的线程
- 在不同平台上运行
- 调整代码并强迫发生错误
- 不要将系统错误归咎于偶发事件
- 任务交换越频繁,越有可能找到错过临界区或导致死锁的代码
Code Smell & Inspiration
- 注释:
- 不恰当的注释
- 废弃的注释
- 冗余的注释
- 糟糕的注释
- 注释掉的代码
- 环境:
- 需要多步才能实现的构建:构建系统应该是单步的小操作
- 需要多步才能做到的测试:应该能快速运行单元测试
- 函数:
- 过多的参数
- 输出参数
- 标志参数
- 死函数
- 一般性问题:
- 一个源文件中存在多种语言:应尽力减少源文件中额外语言的数量和范围
- 明显的行为未实现:应遵循“最小惊讶原则”
- 不正确的边界行为
- 忽视安全
- 重复
- 在错误的抽象层级上的代码:
- 创建分离较高层一般性概念与较低层细节概念的抽象模型很重要
- 孤立抽象是软件开发者最难做到的事情之一,而且一旦做错也没有快捷的修复手段
- 基类依赖于派生类:通常基类对派生类一无所知
- 信息过多:
- 良好设计的模块有非常小的接口
- 优秀开发者应该限制类或者模块中暴露的接口数量
- 隐藏数据、工具函数、常量、临时变量
- 死代码:死代码会变臭
- 垂直分离:
- 变量和函数应该靠近被使用的地方定义
- 私有函数应该刚好在其首次被使用的位置下面定义
- 前后不一致
- 混淆视听:
- 没有用到的变量、函数、注释等都要移除
- 保持文件整洁、良好组织
- 人为耦合:人为耦合是指两个没有直接目的的模块的耦合
- 特性依恋:类的方法只应对其所属类的变量和函数感兴趣,不应该垂青其他类中的变量和函数
- 选择标志参数:选择标志参数只是一种避免把大函数切分成小函数偷懒的做法
- 晦涩的意图
- 位置错误的权责
- 不恰当的静态方法
- 使用解释性变量
- 函数名称应该表达其行为
- 理解算法
- 把逻辑依赖改为物理依赖
- 用多态替换 if/else
- 遵循约定标准
- 用命名常量替代魔术数
- 准确:如果处理货币,请使用整数
- 结构甚于约定:
- 封装条件
- 避免否定性条件
- 函数只做一件事情
- 掩蔽时序耦合
- 别随意
- 封装边界条件
- 函数应该只在一个抽象层级上
- 在较高层级放置可配置数据
- 避免传递浏览
- 名称:
- 采用描述性名称
- 名称应与抽象层级相符
- 尽可能使用标准命名法
- 无歧义的名称
- 为较大作用范围选用较长的名称
- 避免编码:如一些前缀等
- 名称应该说明副作用
- 测试:
- 测试不足
- 使用覆盖率工具
- 别略过小测试
- 被忽略的测试就是对不确定事物的疑问
- 测试边界条件
- 全面测试相近的缺陷
- 测试失败的模式有启发性
- 测试应该快速