《代码整洁之道-Clean Code》

4,852 阅读19分钟

[美] Robert C.Martin

center

一句话总结

很多观点都认为“开发业务没有技术含量,做架构才能体现技术价值”,这种观点有失偏颇。业务架构并不简单,只是很多开发同学理解简单了而已。业务架构讲究工程代码在迭代过程中能持续地应对杂、变等难题并保持程序员的生产力不下降。既然代码需要持续维护,那整洁的架构就非常有必要,本书从微观、宏观上介绍了保持架构整洁的一些方法。

脑图

center

详情

第一章:整洁代码

本章节是对全书的大概介绍,作者的核心观点是:

  • 代码永远不可能被代替,因为编码过程就是把需求细节描述为机器可以理解的粒度。而这个过程中注定有些事情是无法抽象的
  • 保持代码的整洁非常必要,这需要程序员自己亲自实践。不可能任何程序员看了此书后都会变成整洁代码大师
  • 编码领域的规范:借用美国童子军军规——“让营地比你来的时候更整洁”

第二章:有意义的命名

对变量、函数、参数、类、package的命名是非常重要的,好的名字可以大幅降低代码的维护成本。

  • 名副其实:词能达意,不应该借助注解去解释名字的含义。
    • 名字也是不断精进的过程。如果发现有更好的名字就应该及时替换旧的名字
    • 杜绝魔法数
  • **避免误导 **
    • 不要挑战大家的直觉:比如如果数据规格不是一个数组那就不要叫 nameList
    • 工程代码前后的拼写规则应该一致,前面的 nameList 是一个数组的话,后面的 accountList 也应该是一个数组
  • 做有意义的区分
    • 命名里不要出现废话,比如 nameString 后面的 String 后缀就是废话
  • **使用可以读出来的名称。**人类的大脑有一部分就是用来处理单词的,用能读出来的单词命名可以借助这部分脑力让代码更清晰易懂
  • 使用可搜索的名称
  • **避免使用编码:**干掉一些类型前缀的编码,比如 phoneString ,因为一些语言是强类型的比如 Java,这种编码属于多余。在 JS 的世界里不一定
  • **避免思维映射:**比如不要使用i j k表达变量,因为大部分程序员直觉上都会认为这几个字母代表循环索引
  • **类名:**类名或对象名应该是名词或者名词短语,不应该是动词。
    • good:Customer、Account
    • bad:Manager、Processor 。注意:这里不好的原因是 Manager 前面没有前缀而不是因为 Manager 不是名词
  • **方法名:**方法名应该是动词或者动词短语,比如 save
  • **别抖机灵:**别炫技,意到言到,言到意到,简单易懂才是好代码
  • 每个概念对应一个词
    • 给每个概念选一个词并且一以贯之。比如 fetch 就代表网络请求
  • **别用双关语:**杜绝一个术语在不同的环境下对应不同的概念。这会给读者造成歧义
  • **使用解决方案领域名词:**只有程序员才会读你的代码,大胆的使用一些领域语言。比如 Visitor(访问者模式),Observer(观察者模式)
  • **使用领域语言:**多使用领域语言(参考《领域驱动设计》)
  • 添加有意义的语境
    • 比如方法名字取名为:printUserName 时可以把该方法放到 User 类中然后改名为 printName -> user.printName();
  • 不要添加没用的语境
    • GPS 模块下如有定位类那就直接叫 Location 就行了,不要添加无用的语境 GPSLocation(前面的 GPS 前缀纯粹是废话)
    • 只要短名字能描述清楚,那么短名字就比长名字好

第三章:函数

好函数的几个准则

  • 短小(笔者注:是好的提议,但是实际情况得再看)
    • 第一条规则是短小,第二条规则是更短小
    • 代码块与缩进:函数的缩进层级不应该多于一层或两层
  • 只做一件事
    • 判断函数是否只做了一件事的标准是看该函数能否拆出来一个新函数
    • 好的函数内部不应该有区段的区分。如下代码很明显就不只做了一件事情
function void test(Person persion){
	// get first name
	TODO
	// save first name
	TODO
}
  • 每个函数一个抽象层级
    • 同一个函数不要既有高抽象层级的概念(比如 getHtml())又有低抽象层级的概念(''.append('xxx')),这会让人迷惑
    • 自顶向下读代码:向下规则。被调用者放在调用者的下方,形成类似于报纸一样的排版(标题->内容)
  • Switch 语句
    • Switch 语句天生就是用来处理多种情况的,所以使用 Switch 语句的地方写出短小的函数很困难。作者建议使用多态将 Switch 语句做封装,埋藏在低抽象层次的函数中
center
  • 使用具有描述性的名称
    • Ward原则:如果每个函数都让你感到深合己意,那就是整洁代码
    • 使用长而具有描述性的名字比短而不知含义的名字强百倍,同样也好过长的注解
    • 描述性的名称也可以帮助你理清模块的设计思路
  • 函数参数
    • 最理想的参数数量是0,其次是 1 然后是 2 ,需要尽量避免 3 个参数。参数数量过多会带着调用者懵逼,顺序传错等情况并且不利于 TDD。如果函数的参数 >= 3 那就应该封装成对象了。
    • 单参数函数的普遍形式
      • 处理入参并返回加工后的出参,比如 public String buildString(String param) 这种是非常符合程序员直觉的
      • 事件:只有入参,没有出参。函数内部通过入参去改变不同的系统状态。
    • 标识参数:尽量杜绝往函数中传 boolean
  • 无副作用:副作用违反了“只做一件事”的规则(笔者注:这也得结合实际情况看)
    • 输出参数:尽量避免使用输出参数(输出参数的含义是在函数内部改变入参的形态比如 public void appendString(String input)),可以使用改变对象的状态或者使用返回值代替输出参数
  • 分隔指令与查询:函数要么是“做”一些事情,要么是“查询”一些事情。但是同一个函数不要两者都干,这会造成混乱
  • 使用异常代替返回错误码
    • 使用错误码代替异常可以帮助把错误流从主链路抽离出来
    • 分离 try catch 代码块,可以让代码更易于理解
    • 错误处理只干一件事情,不要在 try 中干别的事情
    • 使用继承解决 Error.java 依赖磁铁:很多时候我们都会把所有的错误常量都放到 Error.java 中,这会导致所有需要处理 Error 的地方都要依赖 Error.java,这形成了 Error.java 依赖磁铁,会导致后续增加 Error 常量的时候所有用到的地方都需要重新构建部署。我们可以使用继承的方式将依赖磁铁干掉。
  • 别重复自己:重复是万恶的根源
  • 结构化编程:Dijkstra 认为一个函数只能有一个入口跟一个出口,但是这种规范对整洁架构用处并不大。保持函数的短小,即使函数有多个出口也可以保持整洁架构
  • 好的函数也是一个精炼的过程,初稿可能很粗鄙,但是只要持续的优化并按照文中的规则进行持续迭代就能创建出一个好函数

第四章:注释

作者是一个极力反对注释的人,他认为软件中最真实的地方只有一处:代码。只有代码才能忠实的告诉你他想干的事情。注释是代码不具备表达力的时候一种“失败”的保护手段。只在必要的地方增加注释(比如解释某些领域概念等),其他地方使用变量名或者函数名使其词能达意。

  • 注释不能美化糟糕的代码:与其多花时间编写解释你代码的注释,不如花时间清理那堆糟糕的代码
  • 用代码来阐述
  • 一些必要的注释
    • 法律信息
    • 警告、TODO
    • 放大某种不合理之物的重要性。某个代码的实现逻辑可能看起来不合理,这时候就需要使用注释为读者解惑。
    • 公共 API 的 Doc
  • 坏注释
    • 自言自语,自己感觉要加注释的地方就写上注释
    • 多余的注释:本身代码已经能表达意思了就不要加注释了
    • 误导性注释(随着代码的迭代,注释总有一天会由于过于陈旧而导致产生误导)
    • 日志式注释:我们已经有 VSC 了,不需要增加改动范围
    • 能用函数或者变量的时候就不要用注释(不要写一大坨的判断代码,尽量拆的细一点)
    • 注释掉的代码

第五章:格式

你今天写的代码可能明天就被重构掉了,所以“让代码能工作”并不是唯一准则。维持代码的可读性很重要。

  • 垂直格式
    • 向报纸学习:高层次的概念与函数放在源码文件的顶部,细节放在后面依次展开(注:这玩意得结合实际情况看)
    • 垂直方向上的区隔:不同的概念之间要用空格分隔开(这就是所谓的垂直间距)
    • 垂直方向上的靠近:用空格隔开不同的概念后,概念相同或相近的代码要靠近,让读者不需要过多的翻页就能一览无余
    • 垂直距离:
      • 变量声明的地方要靠近使用的地方
      • 实体变量应放在类的顶部
      • 相关函数应该放在一起(比如 A 函数调用了 B 函数)
      • 相关的概念应该放在一起
    • 垂直顺序:被调用者放在调用者的下面,就像报纸一样(咋感觉这么别扭呢。。。。。)
  • 横向格式
    • 水平方向上的区隔与靠近! center
    • 缩进(现在大部分 IDE 都会自动缩进了)
  • 团队规则:在团队中要有同一种代码风格,不能每个人都玩自己的一套(可以通过 IDE 插件等规范去实现一套约束)

第六章:对象和数据结构

  • 数据抽象:类的好处是隐藏细节,所以尽量不要在类中加入 getter() setter()等函数暴露内部实现
  • 数据、对象的反对称性:【一切都是对象】只是一种幻想,真实的工程代码中肯定既有数据类又有对象类,数据类会暴露自己的内部实现(ctx.name);而对象类会隐藏实现(ctx.buildProject())
    • 数据结构的代码的好处是不改动现有的数据结构前提下增加新的函数;面相对象的代码好处是不改动既有函数的前提下增加新的类(多态)
  • 得墨忒耳率
    • 对于面相对象的代码,不要写**火车失事!**的代码比如 ctx.getOption().getDir().getPath()这种代码非常难理解并且线上出问题后难以调试
  • 如果对某个模块设计成是一个对象,那么调用他的实例时就需要让他干点什么而不是单纯的返回内部的数据结构

第七章:错误处理

  • 使用异常而不是错误码:面对一系列的代码调用,如果每执行一步都要检查错误码的话会让正常流程与异常流程耦合在一起,并且后续的维护者不见得知道检查错误码。及时抛异常能让错误处理链路与正常代码执行链路分隔开
  • 编写容易出现异常的代码时先写 try-catch-finally 语句(有点别扭,值得商榷)
  • 已检异常(Java 方法声明出来的明确能捕获的 Error 类型):对于核心代码库可能有帮助,对于一般的代码库会由于违背开闭原则而导致依赖该代码的应用重新构建(因为方法的签名变了,增加了新的异常类型),徒增成本
  • 给出异常发生的环境说明:Error 中 message 的关键信息要全面,方便调用者快速定位源码位置
  • 依调用者需要定义异常类:可以通过多态的形式按照不同的调用者生成不同的 Error 类,这样就避免所有信息都放到 message 中
  • 特例模式:定义一个配置或者对象来处理特殊情况,你处理了特殊情况后客户代码就不需要捕获异常了(仅做了解吧~~~)
  • **别返回 null 值:**如果你返回了 null ,所有用你方法的地方都得判断 if(null) 这样徒增工作量,可以定义一个默认的对象比如空数组啥的
  • **别传递 null 值:**理由同上

第八章:边界

  • 学习性测试:对于三方代码,我们可以使用“学习性测试”,通过编写测试用例来加深我们对三方 API 的理解。学习性测试还有一个好处是三方的代码库升级后我们可以继续运行我们学习过的测试 Case 看看三方的改动会不会影响我们
  • 定义好代码模块的“边界”,依赖你能控制的东西好过依赖你控制不了的东西。比如我们工程代码依赖的某个 API 还没定义出来,就可以通过定义“边界”让工程代码先调用一些 Fake(模仿)的 API,真实的 API 被定义出来后只需要修改适配层就行了!center

第九章:单元测试

注:TDD 这种观点得分开看,对于 GUI 系统,TDD 会变得特别困难且工作量倍增。所以针对一些逻辑相关的或者基础框架相关的必须要加单元测试,但是对于纯展示相关的就得看实际情况了。

  • TDD三定律:总结一下就是先写测试 case 再写生产代码
    • 定律一: 在编写不能通过的单元测试前,不可编写生产代码
    • 定律二: 只可编写刚好无法通过的单元测试,不能编译也算不能通过
    • 定律三:只可编写刚好足以通过当前失败测试的生存代码
  • **保持测试代码整洁:保持可读性(明确、简洁、足够有表达力)。**如果测试代码不整洁,很快就不会有人维护了,没人维护测试 Case 的时候你的工程代码也就快腐化了
  • 每个测试使用一个断言
    • 好处:跑自测 Case 时可以快速知道结果
  • 整洁的测试五法则:F.I.R.S.T
    • 快速 Fast:过慢了以后你就不会频繁的运行测试 Case 导致测试用例慢慢变成一堆废代码
    • 独立 Independent:测试 Case 之间不能互相依赖
    • 可重复 Repeatable:测试应该可以在任意环境中重复通过,不然就会有人给自己找主观理由解释不通过的原因
    • 自足验证 Self-Validating:无论成功或者失败都应该输出 boolean 而不是人肉去检查执行结果
    • 及时 Timely:编写业务代码前编写用例,不然你会被业务代码的复杂度缠住导致不想编写用例

第十章:类

  • 类的组织:标准的类代码书写应该像一篇报纸一样从上而下:公共静态常量 > 私有静态常量 > 私有实体常量 > (很少出现公共变量) > 公共函数 > 被公共函数调用的私有工具函数
  • **类应该短小!!!**不同于函数以代码行数来区分是否短小,类使用职责来区分是否短小
    • 单一权责原则:SRP 认为类或者模块有且仅有一条加以修改的理由(这里不是说某个模块只干一件事情,比如两个团队共同维护了同一个模块本质上也是违反了单一职责原则!)系统应该由许多短小的类而不是少量巨大的类组成,每个小类封装一个权责,只有一个被修改的原因并且与其他类共同达成期望的系统行为
    • 内聚:类应该只有少数实体变量
    • 保持内聚就会得到许多短小的类:如果类丧失了内聚性,那就果断拆分他
    • 要通过洞悉未来可能修改的地方来组织类,最大程度降低修改对现有程序的影响
      • 依赖倒置 DIP

第十一章:系统

一个人再怎么强大也不可能管理一个城市的所有细节,只有将城市划分成更细的模块并且每个模块都有自己的关注点(街道、商圈等)才能将城市管理的很好,软件架构也是一样,大型软件架构必须实现关注点分离

  • 将系统的构造与使用分离开(好处:1. 实现关注点分离,2. 方便 TDD)
    • 分解 main:将系统的其他部分与构造部分(main)分离,其他地方使用时假定所有变量都得到了初始化
    • 工厂:通过抽象工厂等设计模式将构造过程封装起来
    • 依赖注入:通过依赖注入或控制反转将构造与使用分类开。典型的 Spring 系列 Java 框架
  • 不断地演进及扩容:好的软件架构是一个迭代的过程,一开始就构造正确的系统纯属神话。只要我们能不断地将关注面进行恰当的拆分,就能实现良性递增式演进
  • 测试驱动架构:不要一开始就做大设计(过度设计),我们需要从“自然简单”但是切分合理(即关注面:模块的边界)的架构开始交付,随着业务迭代添加更多的基础架构
  • 设计专用的领域层

第十二章:迭进

敏捷开发的概念,通过不断地实现小而正确的迭代去完成大型项目

  • Kent 关于简单设计的四条规则
    • 运行所有测试(1. 保障代码正确性 2. 保障后期代码改动后的影响范围)
    • 不可重复:重复的代码,功能相似的方法
    • 代码具备良好的表达力:良好的命名、规范的放置目录等正确且快速的表达程序员的意图
    • 尽可能减少类和方法的数量:避免过于机械地套用各种概念导致类与方法数量上涨导致的复杂度上升
  • **重构:**通过不断的重构去实现架构的整洁,在重构的过程中你可以应用所有优秀的知识比如:提升内聚、降低耦合、切分关注面,缩小类与方法的尺寸,更改更好的命名等。在重构的过程中由于有自测 Case 的保障,你可以不必过于担心影响影响范围而大胆的实践

第十三章:并发编程

多线程语言的专属,对 JS 这种单线程的开发者用处不大

  • 为什么要并发:并发帮助我们把做什么(目的)与什么时候做(时机)分离
    • 需要注意:并发只有在多个线程或者处理器可以共同工作以减少等待时间的时候有用,其他时间不会提升性能反而会增加系统复杂度
  • 并发防御原则:
    • 单一权责原则:将并发的代码与非并发的代码分离开
    • 限制数据作用域:并发是非常复杂的,通过限制数据作用域从根源上避免并发读写问题
    • 使用数据副本:比如复制某些对象并以只读的方式供其他线程读取;或者对象副本可以让其他线程写,最后由一个线程汇总结果
    • 线程应该尽量独立:尽可能避免使用共享的数据
  • 一些多线程的执行模型:
    • 生产者-消费者模型
    • 读者-作者模型
    • 宴席哲学家模型
  • 保持同步方法区的微小,如果同步方法区过大会由于线程等待降低执行效率
  • 测试线程代码:
    • 将偶现的问题看成是线上问题,不要因为偶现就放过
    • 让 非 现成代码可工作,不要将 非 线程代码与线程代码混杂
    • 编写可插拔的线程代码:线程代码的一些配置不要写死,通过配置让线程代码在不同的环境下工作
    • 运行多于处理器数量的线程
    • 在不同平台上运行
    • 通过硬编码的方式主动设置线程等待,查看线程是否正常工作
    • 自动化:使用工具制造“异动”(比如随机随眠、随机让步、随机径直执行等方式),测试线程代码的稳定性

第十四章:逐步改进

仅仅满足能让代码工作的程序员是不专业的,随着时间的发酵,烂代码之间会越来越耦合,那时候清理起来困难及影响范围会非常大,成本很高。所以解决方案是:早晨制造的烂代码下午就清理掉,最好是 5min 前制造的烂代码马上就清理掉。永远不让“腐坏”有机会开始。

一些思考

好的架构应该是整洁、清晰的。至少能做到以下几点:

  • 新人可以快速找到代码中的所有业务逻辑
  • 通过目录、文件名、方法名、变量名等维度可以快速知晓逻辑脉络
  • 有正确的关注面分离,模块的职责、模块与模块之间的交互要定义清楚
  • 架构是迭代的过程,“一开始就做正确的设计”是扯淡

一些物料

欢迎大家微信扫下面二维码,关注我的公众号【趣code】,一起成长~ center