《代码整洁之道》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 简单设计四规则(优先级排序):

  1. 通过所有测试
  2. 没有重复
  3. 表达了系统中所有的设计思想
  4. 最小化类、方法、函数等实体数量

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 三定律:

  1. 在写好一个会失败的测试之前,不要写生产代码
  2. 只写刚好能让测试失败的测试(不编译也算失败)
  3. 只写刚好能让测试通过的生产代码

测试的重要性:

  • 测试不是”有了更好”,是必须
  • 测试让你的代码保持灵活、可维护、可复用——测试是 -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 简单设计四规则(按重要性排序),满足这四条就能涌现出好的设计:

  1. 通过所有测试:系统必须按照预期工作。测试驱动出小而单一职责的类。
  2. 没有重复:每一处重复都是额外的职责和不必要的复杂度。
    • DRY 原则
    • 模板方法模式(Template Method)是消除重复的常用手段
  3. 表达力:代码要清晰地表达作者的意图。
    • 好名字、小函数、好命名的测试
  4. 最少的类和方法:在满足前三条的前提下,越少越好。
    • 不要为了教条而创造无意义的抽象

第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/switchswitch 天生违反开闭原则
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:启发式交叉索引 — 每个启发式在书中案例中被引用的页码

关键概念关联

可行动点

  1. 下次写代码前:先想”这个函数只做一件事吗?“——不是就拆分
  2. 下次命名时:问自己”这个名字需要注释解释吗?“——需要就重命名
  3. 下次提交代码前:应用童子军规则——至少让一处代码比你发现时更整洁
  4. 团队层面:建立统一的编码风格规范,一个命令构建、一个命令跑测试
  5. 学习方法:不要只读原则——找真实代码练习重构,把每一个改动的理由记下来

“Practice, son. Practice!” — 卡内基音乐厅的老路人对迷路小提琴手的回答