《代码整洁之道》Clean Code
“Writing clean code is what you must do in order to call yourself a professional.” — Robert C. Martin
全书概览
Robert C. Martin(Uncle Bob)带领 Object Mentor 团队的经典著作,敏捷软件工艺(Software Craftsmanship)运动的核心读本。全书围绕”如何写出整洁的代码”展开,从命名、函数、注释等微观层面,到类、系统、并发等宏观层面,最后以三个逐步深入的代码重构案例收尾,附录总结了完整的代码坏味道与启发式规则清单。
全书结构:三大部分 17 章 + 3 个附录
| 部分 | 内容 | 章节 |
|---|---|---|
| 第一部分:原则与模式 | 整洁代码的原则、模式与实践 | 第1-13章 |
| 第二部分:案例研究 | 代码重构实战(由简到深) | 第14-16章 |
| 第三部分:启发式清单 | 坏味道与启发式总结 | 第17章 |
| 附录 | 并发扩展、SerialDate 源码、启发式交叉索引 | 附录A-C |
核心观点
1. 为什么需要整洁代码
- LeBlanc 定律:Later equals never.(稍后等于永不)
- 烂代码的总成本:团队生产力随时间指数衰减,最终趋近于零。管理层加人只会制造更多混乱。
- 大重写的陷阱:老虎团队 vs 维护团队的赛跑,通常持续数年,新系统最终也会变成烂摊子。
- 程序员的职业责任:烂代码的根源在我们自己。正如医生不能因为病人要求就放弃洗手,程序员也不能因为经理催促就放弃代码质量。
2. 什么是整洁代码(六位大师的定义)
Bjarne Stroustrup(C++ 发明者):
“I like my code to be elegant and efficient. … Clean code does one thing well.”
- 优雅、高效、逻辑直接、依赖最少、错误处理完整、性能接近最优
- 烂代码会”诱惑”更多烂代码滋生(破窗效应)
Grady Booch(OOAD 作者):
“Clean code reads like well-written prose.”
- 简洁直接,读起来像写得好的散文
- 清晰暴露设计意图,充满利落的抽象和直接的控制流
“Big” Dave Thomas(OTI 创始人,Eclipse 教父):
“Clean code can be read, and enhanced by a developer other than its original author.”
- 他人可读、可改进
- 有单元测试和验收测试
- 有意义的命名,最小的依赖,清晰且最小的 API
- 没有测试的代码不可能整洁
Michael Feathers(《修改代码的艺术》作者):
“Clean code always looks like it was written by someone who cares.”
- 一个词:关心(care)
- 你想不出任何明显可以改进的地方
Ron Jeffries(XP 创始人之一):
Beck 简单设计四规则(优先级排序):
- 通过所有测试
- 没有重复
- 表达了系统中所有的设计思想
- 最小化类、方法、函数等实体数量
Ward Cunningham(Wiki 发明者):
整洁的代码总是让你预期它会做什么,而它确实那么做了。
3. 童子军规则(The Boy Scout Rule)
“Leave the campground cleaner than you found it.” — 让代码比你发现它时更整洁一点
- 不需要大改动:改一个变量名、拆分一个稍大的函数、消除一小段重复、清理一个复杂的 if
- 如果每个人签入的代码都比签出时干净一点,代码就永远不会腐烂
- 持续改进是职业精神的内在组成部分
各章核心要点
第2章:有意义的命名(Meaningful Names)
命名是软件中最普遍的事。好命名的规则:
| 规则 | 说明 |
|---|---|
| 名副其实 | 名字要能表达意图,不需要注释来解释 |
| 避免误导 | 不用 hp、aix 等有特定含义的缩写做变量名;不用 list 命名非列表的东西 |
| 做有意义的区分 | 不要用 a1、a2,或 ProductInfo / ProductData 这种没有实质区别的名字 |
| 使用可发音的名字 | 能读出来才能讨论 |
| 使用可搜索的名字 | 避免魔数和单字母变量(除了循环变量) |
| 避免编码 | 不要用匈牙利命名法、成员前缀 m_、接口前缀 I |
| 避免思维映射 | 读者不应该在脑子里把你的变量名翻译成真实含义 |
| 类名用名词 | Customer、WikiPage、Account |
| 方法名用动词 | postPayment、deletePage、save |
| 不要卖萌 | 别用 whack() 代替 kill(),别用 holyHandGrenade 这种梗 |
| 每个概念对应一个词 | 别同时用 fetch、retrieve、get 表示同一件事 |
| 别用双关语 | add 既表示”增加”又表示”插入”,就会混淆 |
| 用解决方案领域的词 | 访问者模式、单例、队列——程序员能懂 |
| 用问题领域的词 | 如果没有程序员熟悉的术语,就用业务领域的词 |
| 添加有意义的语境 | 用前缀或类提供语境:addrFirstName 不如放在 Address 类里的 firstName |
| 不要添加无意义的语境 | 别给所有类都加项目前缀 |
第3章:函数(Functions)
- 越小越好:函数应该非常短。缩进层级不超过一到两层。
- 只做一件事:如果一个函数里可以合理地拆出另一个函数,它就在做多件事。
- 每个函数一个抽象层级:函数中所有语句都在同一抽象层级上。
- 自上而下的阶梯规则(Stepdown Rule):代码从上往下读,每个函数后面跟着下一层抽象的函数。
- switch 语句:天生违反单一职责,应该用多态替换,埋在工厂方法的底层。
- 使用描述性的名字:长而清晰的名字 > 短而模糊的名字。
- 函数参数:
- 理想:0 个参数(niladic)
- 可接受:1-2 个(monadic, dyadic)
- 尽量避免:3 个(triadic)
- 参数对象:如果参数太多,说明应该封装成对象
- 标志参数(flag argument)是反模式——函数做了不止一件事
- 没有副作用:函数名承诺一件事,暗地里做另一件事,是欺骗。
- 命令查询分离:函数要么做什么(命令),要么回答什么(查询),不能同时做。
- 用异常代替错误码:错误码导致深层嵌套的 if 链。
- try/catch 块应该抽成独立函数
- 错误处理是”一件事”,函数应该只做错误处理
- 不要重复你自己(DRY):重复是软件中一切万恶之源。
第4章:注释(Comments)
注释不能弥补烂代码。 最好的注释是写得足够清楚、不需要注释的代码。
好的注释:
- 法律信息(版权、许可)
- 提供信息的注释(如正则表达式的含义)
- 解释意图(为什么这么做)
- 澄清代码含义(翻译晦涩的参数或返回值)
- 警告后果(// 这个测试很慢,别在构建时跑)
- TODO 注释(但要定期清理)
- 放大重要性(// 这里非常重要,因为…)
- 公共 API 的 Javadoc
坏的注释:
- 喃喃自语(写了等于没写)
- 多余的注释(i++; // increment i)
- 误导性注释
- 强制注释(每个函数都必须有 Javadoc——废话)
- 日志式注释(每次修改都记一笔——用 git 就行)
- 噪声注释(废话)
- 注释掉的代码(删了它!版本控制系统会记住)
- HTML 注释
- 非局部信息(注释描述的不是它旁边的代码)
- 太多信息
- 不明显的关联(注释和代码对不上)
- 函数头注释
- 非公共代码的 Javadoc
第5章:格式(Formatting)
垂直格式化:报纸比喻
- 源文件应该像报纸文章:顶部是标题和摘要,越往下越细节。
- 概念之间用空行隔开(垂直开放)
- 紧密相关的代码靠在一起(垂直密度)
- 变量声明靠近使用位置
- 被调用的函数在调用者下面(垂直顺序:自上而下)
水平格式化:
- 行宽:别让读者横向滚动。Uncle Bob 的规则:约 120 字符。
- 水平对齐不要强求(反而会隐藏真正的结构)
- 缩进是必须的——表达层级结构
- 空作用域(while 后面直接加分号)是恶魔
团队规则:
- 团队应该有统一的编码风格规则
- 规则是什么不重要,重要的是一致
第6章:对象与数据结构(Objects and Data Structures)
数据/对象反称性:
- 对象:把数据藏起来,暴露操作数据的函数。容易加新类型,难加新操作。
- 数据结构:暴露数据,没有(或很少)函数。容易加新操作,难加新类型。
得墨忒耳定律(Law of Demeter):模块不应该知道它操作的对象的内部结构。
- 火车残骸(链式调用):
a.getB().getC().getD()是典型违规 - 混合体(Hybrid):一半对象一半数据结构的怪物——避免
- 数据传输对象(DTO):只有公共变量没有函数的类——数据库通信等场景常见
- Active Record:DTO + 导航方法(save、find)——是数据结构,不要往里面塞业务逻辑
第7章:错误处理(Error Handling)
- 用异常代替返回码:返回码导致调用链上层层检查
- 先写 try-catch-finally:先定义异常情况下的行为,再写正常路径
- 使用未检查异常(unchecked exception):checked exception 导致脆弱的签名依赖
- 异常要提供上下文:说明失败的操作和原因
- 异常类按调用者需求定义:不是按错误来源分类,而是按捕获方式分类
- 定义正常流程:用特殊情况模式(Special Case Pattern),避免调用者处理异常
- 不要返回 null:返回空集合或特殊情况对象
- 不要传递 null:在入口处检查,拒绝 null 参数
第8章:边界(Boundaries)
系统与第三方代码、外部系统、还不存在的代码之间的边界。
- 使用第三方代码:API 提供者追求通用性,使用者追求特定性——两者之间存在天然张力
- 学习测试:写测试来学习第三方 API,比看文档更有效
- 学习测试是免费的:不仅学会了 API,还验证了它的行为
- 第三方库升级时,学习测试能第一时间发现行为变化
- 尚不存在的代码:定义你自己的接口,等真实代码出来后用 Adapter 连接
- 整洁的边界:用 Adapter 模式把第三方代码包裹起来,减少对它的依赖
第9章:单元测试(Unit Tests)
TDD 三定律:
- 在写好一个会失败的测试之前,不要写生产代码
- 只写刚好能让测试失败的测试(不编译也算失败)
- 只写刚好能让测试通过的生产代码
测试的重要性:
- 测试不是”有了更好”,是必须
- 测试让你的代码保持灵活、可维护、可复用——测试是 -ilities 的使能者
- 没有测试,每一次修改都可能引入 bug——你就不敢改了
整洁的测试:
- 测试代码和生产代码一样重要,需要保持整洁
- 领域特定测试语言:用辅助函数和工具函数让测试读起来像规格说明
- 双重标准:测试代码的效率标准可以比生产代码低,但清晰度要求一样高
- 每个测试一个断言(理想)/ 每个测试一个概念(更实际)
- F.I.R.S.T. 原则:
- Fast(快速):测试要跑得快
- Independent(独立):测试之间互不依赖
- Repeatable(可重复):任何环境都能跑
- Self-Validating(自验证):有布尔输出,不用人工判断
- Timely(及时):在生产代码之前写
第10章:类(Classes)
- 类的组织:公共变量 → 公共函数 → 私有工具函数(由上而下)
- 类应该小:比你想象的还要小
- 单一职责原则(SRP):类应该只有一个被修改的理由
- 类名应该描述它的职责
- 如果你不能用 25 个字描述一个类是做什么的,它可能太大了
- 内聚(Cohesion):类的每个方法都使用每个成员变量 → 最大内聚
- 保持内聚的结果是产生更多更小的类
- 为变化而组织:
- 依赖倒置原则(DIP):依赖抽象,不依赖具体
- 隔离变化:用接口和抽象解耦
第11章:系统(Systems)
- 把系统的构建和使用分开:
- Main 分离:所有依赖在 main 中装配好,再交给业务逻辑
- 工厂模式:对象创建时机由应用控制,但创建细节交给工厂
- 依赖注入(DI):控制反转的应用——对象不自己查找依赖,而是被动接收
- 横切关注点(Cross-Cutting Concerns):日志、安全、事务等跨越模块的关注点
- Java 代理、纯 Java AOP 框架、AspectJ 切面——从弱到强
- 测试驱动系统架构:好的架构应该可以测试驱动
- 优化决策:推迟决策到最后责任时刻,用最简单的方案满足当前需求
- 明智地使用标准:标准只有在能带来明确价值时才用
- 系统需要领域特定语言(DSL)
第12章:涌现式设计(Emergence)
Kent Beck 简单设计四规则(按重要性排序),满足这四条就能涌现出好的设计:
- 通过所有测试:系统必须按照预期工作。测试驱动出小而单一职责的类。
- 没有重复:每一处重复都是额外的职责和不必要的复杂度。
- DRY 原则
- 模板方法模式(Template Method)是消除重复的常用手段
- 表达力:代码要清晰地表达作者的意图。
- 好名字、小函数、好命名的测试
- 最少的类和方法:在满足前三条的前提下,越少越好。
- 不要为了教条而创造无意义的抽象
第13章:并发(Concurrency)
- 为什么需要并发:解耦”做什么”和”什么时候做”,提高响应性和吞吐量
- 并发的迷思:并发不总能提高性能;并发有额外开销;并发 bug 不容易复现
并发防御原则:
- 单一职责:并发代码应该和其他代码分离
- 限制数据范围:共享数据是并发问题的根源
- 使用数据副本:能不共享就不共享
- 线程应该尽可能独立:每个线程在自己的世界里,不与其他线程共享数据
了解你的库:
- 使用线程安全的集合(java.util.concurrent)
- 优先使用非阻塞方案
了解你的执行模型:
- 生产者-消费者
- 读者-写者
- 哲学家就餐
更多建议:
- 警惕同步方法之间的依赖
- 保持同步区域尽量小
- 关闭代码很难写正确
- 测试线程代码:多平台、多线程数、插桩强制交错、把偶发失败当并发问题候选
第二部分:案例研究
第14章:逐步改进(Successive Refinement)
- 以 Args 参数解析器为例,从一个能工作的粗糙版本开始,逐步重构
- 展示了增量改进的全过程:先让它工作,再让它正确,最后让它快
- 全书最具实践价值的一章——真正展示了”清洁代码不是写出来的,是改出来的”
第15章:JUnit 内幕
- 分析 JUnit 测试框架的核心代码
- 看大师级程序员如何设计和编码
第16章:重构 SerialDate
- 以 JFree 库的 SerialDate 类为例,进行完整的代码审查和重构
- First make it work, then make it right.
第三部分:坏味道与启发式(第17章)
全书精华浓缩。Uncle Bob 在做案例研究时,把每一个改动的理由都记录下来,形成了这份清单。
注释类(C)
| 编号 | 坏味道 | 说明 |
|---|---|---|
| C1 | 不适当的信息 | 注释里不该放作者、修改日期、SPR 编号等元数据(用版本控制) |
| C2 | 过时的注释 | 注释会很快过时,过期了就更新或删除 |
| C3 | 多余的注释 | 代码自己能说清楚的事不用注释 |
| C4 | 写得差的注释 | 要写就认真写,用正确的语法和拼写 |
| C5 | 注释掉的代码 | 删掉!版本控制系统会记住 |
环境类(E)
| 编号 | 坏味道 | 说明 |
|---|---|---|
| E1 | 构建需要多步 | 应该一个命令就能构建 |
| E2 | 测试需要多步 | 应该一个命令就能跑所有测试 |
函数类(F)
| 编号 | 坏味道 | 说明 |
|---|---|---|
| F1 | 参数太多 | 0 个最好,1-2 个可以,3 个值得怀疑 |
| F2 | 输出参数 | 反直觉,参数应该是输入 |
| F3 | 标志参数 | 说明函数做了不止一件事 |
| F4 | 死函数 | 没人调用的函数删掉 |
通用类(G)——最重要的一组
| 编号 | 坏味道 | 说明 |
|---|---|---|
| G1 | 一个源文件多种语言 | 别在 Java 里嵌一堆 XML、SQL |
| G2 | 明显的行为未实现 | 该做的事没做,读者会被骗 |
| G3 | 边界处行为不正确 | off-by-one、边界条件错误是最常见的 bug |
| G4 | 覆盖了安全措施 | 取消异常、忽略错误——危险 |
| G5 | 重复 | DRY,一切万恶之源 |
| G6 | 错误抽象层级的代码 | 高层概念和底层细节混在一起 |
| G7 | 基类依赖派生类 | 基类应该对派生类一无所知 |
| G8 | 太多信息 | 暴露太多内部实现 |
| G9 | 死代码 | 永远不会执行的代码 |
| G10 | 垂直分离 | 变量和使用位置离太远 |
| G11 | 不一致 | 同一件事在不同地方做法不一样 |
| G12 | 杂乱 | 无用的变量、从不调用的工具函数——清理 |
| G13 | 人为耦合 | 不该在一起的东西绑在一起 |
| G14 | 特性嫉妒 | 方法过度使用另一个对象的方法 |
| G15 | 选择器参数 | 用参数选择行为——应该拆分函数或用多态 |
| G16 | 意图模糊 | 读者得猜这段代码想干嘛 |
| G17 | 责任错位 | 代码放在了不该放的地方 |
| G18 | 不适当的静态方法 | 不该是 static 的方法写成了 static |
| G19 | 使用解释性变量 | 好的实践——用变量名解释复杂表达式 |
| G20 | 函数名应该说明它做什么 | 不确定就改名,直到准确 |
| G21 | 理解算法 | 别在没理解算法的情况下就改代码 |
| G22 | 让逻辑依赖变成物理依赖 | 依赖要明确表达出来 |
| G23 | 优先用多态代替 if/else/switch | switch 天生违反开闭原则 |
| G24 | 遵循标准约定 | 团队约定优于个人偏好 |
| G25 | 用命名常量代替魔数 | 别用魔法数字 |
| G26 | 要精确 | 模糊的代码是 bug 的温床 |
| G27 | 结构优于约定 | 用代码结构保证,而不是靠人记住约定 |
| G28 | 封装条件 | 把复杂条件抽成函数 |
| G29 | 避免否定条件 | 肯定条件比否定条件好读 |
| G30 | 函数应该只做一件事 | 核心原则 |
| G31 | 隐藏的时间耦合 | 函数有隐含的调用顺序——要明确表达出来 |
| G32 | 不要随心所欲 | 编码要有一致性,别随便选方案 |
| G33 | 封装边界条件 | 边界逻辑容易错,集中处理 |
| G34 | 函数只下降一个抽象层级 | Stepdown Rule |
| G35 | 把可配置数据放在高层 | 常量、配置参数应该在容易找到的地方 |
| G36 | 避免传递式导航 | 得墨忒耳定律:别 a.getB().getC().getD() |
Java 特定(J)
| 编号 | 坏味道 | 说明 |
|---|---|---|
| J1 | 用通配符避免长 import 列表 | 但要注意不引入不想要的类 |
| J2 | 不要继承常量 | 反模式,会导致命名空间污染 |
| J3 | 常量 vs 枚举 | 优先用枚举 |
命名类(N)
| 编号 | 坏味道 | 说明 |
|---|---|---|
| N1 | 选描述性的名字 | 名字要准确传达意图 |
| N2 | 选适当抽象层级的名字 | 别用底层实现细节命名高层抽象 |
| N3 | 尽可能使用标准命名 | 设计模式名、行业术语 |
| N4 | 无歧义的名字 | 名字不该有多种解读 |
| N5 | 长作用域用长名字 | 作用域越广,名字越要清晰 |
| N6 | 避免编码 | 不用匈牙利、前缀等 |
| N7 | 名字应该描述副作用 | 函数有副作用的话,名字要说清楚 |
测试类(T)
| 编号 | 坏味道 | 说明 |
|---|---|---|
| T1 | 测试不足 | 每个 bug 都应该有对应的测试 |
| T2 | 使用覆盖率工具 | 帮你找到没测试到的地方 |
| T3 | 别跳过简单测试 | 简单也值得测 |
| T4 | 被忽略的测试是对歧义的疑问 | 别留着 @Ignored 测试不处理 |
| T5 | 测试边界条件 | off-by-one 是最常见的 bug |
| T6 | 在 bug 附近穷尽测试 | bug 喜欢扎堆 |
| T7 | 失败模式是有启发性的 | 看测试怎么失败的,能发现设计问题 |
| T8 | 覆盖率模式是有启发性的 | 哪些代码总不被覆盖 |
| T9 | 测试应该快 | 慢测试就没人跑了 |
附录
- 附录A:并发 II — 更深入的并发话题:客户端/服务器示例、执行路径数量、非阻塞方案、死锁四条件、多线程测试工具
- 附录B:SerialDate 源码 — 第16章重构案例的原始代码
- 附录C:启发式交叉索引 — 每个启发式在书中案例中被引用的页码
关键概念关联
- 与 《重构》-改善既有代码的设计 互补:重构讲”怎么做”(70+重构手法),Clean Code 讲”什么是好”(原则和品味)
- 与 《实现模式》-Implementation Patterns-Kent Beck 同源:都是关于代码层面的设计哲学,Kent Beck 是 Uncle Bob 的同代人
- 与 《SICP》-计算机程序的构造和解释-第二版 共鸣:都强调”代码是写给人看的,顺便给机器执行”
- 与 《程序员修炼之道》-从小工到专家 精神一致:都是软件工艺(Software Craftsmanship)运动的核心读物
- 5S 原则(整理、整顿、清扫、清洁、素养)与清洁代码的对应关系——序文 James Coplien 对此有精彩论述
- 破窗理论与童子军规则——预防代码腐烂的两条路径
可行动点
- 下次写代码前:先想”这个函数只做一件事吗?“——不是就拆分
- 下次命名时:问自己”这个名字需要注释解释吗?“——需要就重命名
- 下次提交代码前:应用童子军规则——至少让一处代码比你发现时更整洁
- 团队层面:建立统一的编码风格规范,一个命令构建、一个命令跑测试
- 学习方法:不要只读原则——找真实代码练习重构,把每一个改动的理由记下来
“Practice, son. Practice!” — 卡内基音乐厅的老路人对迷路小提琴手的回答