函数
- 使用描述性的名称,不要担心名字太长
- 函数参数不要超过 2 个
- 如果函数需要 3 个或 3 个以上参数,说明期中一些参数应该封装成类
- 使用异常代替返回错误码,错误处理代码就能从住路径代码中分离出来
- 最好把 try 和 catch 代码块的主题部分抽离处理,另外形成函数,让丑陋的代码变得美丽
- 使用异常代替错误码,新异常就可以从异常类派生处理,无需编译或重新部署
- 不要重复自己
- 如何写出这样的函数:开始随意写 -> 单元测试覆盖每行代码 -> 分解函数、修改名称、消除重复 -> 保持测试通过 -> 优秀的函数
格式
- 一个文件尽量不超过 200~500 行
- 相关函数
- 若某个函数调用了另外一个,就应该把它们放到一起,而且调用者应该尽可能放在被调用者上面
- 这样,程序就有个自然的顺序
- 概念相关:概念相关的代码应该放到一起。相关性越强,彼此之间的距离就该越短
- 一行代码不要超过 120 字符
对象和数据结构
对象暴露行为,隐藏数据 数据结构暴露数据,没有明显的行为
得墨忒耳定律(最少知识原则)
- 模块不应了解它所操作对象的内部情形
- 对象隐藏数据,暴露操作
- 对象不应该通过存取器(get/set)暴露其内部结构
- 得墨忒耳定律认为,类 C 的方法 f 只应该调用以下对象的方法
- C
- 由 f 创建的对象
- 作为参数传递给 f 的对象
- 由 C 的实体变量持有的对象
- 方法不应该调用由任何函数返回的对象的方法。(只跟朋友说话,不与陌生人谈话)
下列代码(出自 Apache 某项目)违反了得墨忒耳定律
|
|
这类代码常被称作火车失事,最好做类似如下的切分
|
|
但上例依然违反了得墨忒耳定律
- 模块知道 ctxt 对象包含有多个选项
- 每个选项中都有一个临时目录
- 而每个临时目录都有一个绝对路径
- 对于一个函数,这些知识太多了
- 调用函数需要懂得如何在一大堆不同对象间浏览
这些代码是否违反得墨忒耳定律,取决于 ctxt、Options 和 ScratchDir 是对象还是数据结构
- 如果是对象,则他们的内部结构应当隐藏而不暴露,而不是暴露其内部细节,明显违反了得墨忒耳定律
- 如果 ctxt、Options 和 ScratchDir 只是数据结构,没有任何行为,本来就是暴露出来用于操作,所以不违反得墨忒耳定律
这样就不违反得墨忒耳定律
|
|
上面说了那么多,已经懵圈了,因为代码的最终目的是创建一个临时文件
|
|
隐藏结构正确示例
|
|
数据传送对象 DTO
DTO (Data Transfer Object) 最为精炼的数据结构,是一个只有公共变量、没有函数的类。这种数据结构有时被称为数据传送对象
DTO 是非常有用的结构,尤其在下列场景中
- 与数据库通信
- 解析套接字传递的消息
⚠️ 不幸
- 开发者经常往这里数据结构中塞进业务规则方法,把这类数据结构当成对象来用
- 这将导致数据结构和对象的混杂体
错误处理
使用异常而非返回码
使用不可控异常
可控异常时非必须的,以下语言都不支持
- C#
- C++
- Python
- Ruby
可控异常的代价是违反开闭原则
- 如果你在方法中抛出可控异常,而 catch 语句在三个层级之上,你就得在 catch 语句和抛出异常处之间的每个方法签名中声明该异常
- 这异味着对软件中较低层级的修改,都将波及较高层级的签名
- 修改好的模块必须重新构建、发布,就算它们自身所关注的任何东西都没改动过
给出异常发生的环境说明
- 你抛出的每个异常,都应当提供足够的环境说明,以便判断错误的来源和处所
- Java 中的异常堆栈踪迹无法告诉你该失败操作的初衷
- 应创建信息充分的错误消息,并和异常一起传递出去
- 在消息中,包括失败的操作和失败类型
- 如果你的应用程序有日志系统,传递足够的信息给 catch 块,并记录
依调用者需要定义异常类
- 按来源分类:来自组件或其他地方
- 按类型分类:设备错误、网络错误、编程错误
- 最重要是考虑它们如何被捕获
- 例如封装第三方 API 异常
- 好处是你不必绑死在蘑菇特定厂商的 API 设计上。可以定义自己喜欢的 API
- 将无限可能缩小到有限范围
- 例如封装第三方 API 异常
定义常规流程
下面的笨代码来自某个记账应用的总开支总计模块
|
|
业务逻辑
- 如果消耗了餐食,则计入总额中
- 如果没有消耗,则员工得到当日餐食补贴
- 异常打断了业务逻辑
让代码简洁
- 修改 ExpenseReportDao,使其总是返回 MealExpense 对象
- 如果没有餐食消耗,就返回一个餐食补贴的 MealExpense 对象
|
|
这种手法叫做特例模式(Special case pattern)。创建一个类或配置一个对象,用来处理特例
- 你来处理特例,客户代码就不用应付异常行为了
- 异常行为被封装到特例对象中
别返回 null 值
别传递 null 值
- 相对好点的方式是使用断言效验入参
- 在大多数编程语言中,没有良好的方法能对付由调用者意外传入的 null 值
单元测试
构造-操作-检验(BUILD-OPERATE-CHECK)模式
- 构造测试数据
- 操作测试数据
- 检验操作是否得到期望的结果
正是单元测试让你的代码可扩展、可维护、可复用
- 有了测试,你就不担心对代码的修改!能毫无顾虑地改进架构和设计
- 没有测试,每次修改都可能带来缺陷。
- 无论架构多有扩展性,无论设计划分得有多好,没有了测试,你就很难做改动,因为你担忧改动会引入不可预知的缺陷。
每个测试一个断言
given-when-then
单个测试中的断言数量应该最小化
每个测试一个概念
F.I.R.S.T.
整洁的测试还遵循以下5条规则
- 快速(Fast)测试应该够快
- 测试应该能快速运行。
- 测试运行缓慢,你就不会想要频繁地运行它。
- 如果你不频繁运行测试,就不能尽早发现问题,也无法轻易修正,从而也不能轻而易举地清理代码。
- 最终,代码就会腐坏。
- 独立(Independent)测试应该独立
- 某个测试不应为下一个测试设定条件
- 你应该可以单独运行每个测试,及以任何顺序运行测试
- 当测试相互依赖时,头一个没通过就会导致一连串的测试失败,使问题诊断变得困难,隐藏了下级错误
- 可重复(Repeatable)测试应当可在任何环境中重复通过
- 你应该能够在生产环境、质检环境中运行测试,也能够在无网络的列车上用户笔记本运行测试
- 如果测试不能在任意环境中重复,你就总会有个解释其失败的借口
- 当条件不具备时,你也无法运行测试
- 自足验证(Self-Validating)测试应该有布尔值输出
- 无论通过或失败,你不应该检查日志文件来确认测试是否通过
- 你不应该手工对比两个不同文本文件来确认测试是否通过
- 如果测试不能自足验证,对失败的判断就会变得依赖主观,而运行测试也需要更长的手工操作时间
- 及时(Timely)测试应及时编写
- 单元测试应该恰好在使其通过的生产代码之前编写
- 如果编写生产代码之后编写测试,你会发现生产代码难以测试
- 你可能会认为某些生产代码本身难以测试
- 你可能不会去设计可测试的代码
类
类的组织
由某个公共函数调用的私有工具函数紧随在该公共函数后面
- 这符合了自定向下的原则,让程序读起来就像一篇报纸文章
类应该短小
计算权责(responsibility)
- 类的名称应当描述其权责
- 命名是帮助判断类的长度的第一个手段。如果无法为某个类精确命名,这个类大概就太长了
- 类名越含混,该类越有可能拥有过多权责
- 我们应该能用大概 25 个单词简要描述一个类,且不用“if、and、or、but”等词汇
单一权责原则(SRP)
类只应有一个权责——只有一条修改的理由
有大量短小类的系统并不比有少量庞大类的系统拥有更多移动部件,其数量大致相等。问题是:
- 你想把工具归置到有许多抽屉、每个抽屉中装有定义和标记良好的组件的工具箱
- 还是想要少数几个能随便把所有东西扔进去的抽屉
结论
- 系统应该由许多短小的类而不是少量巨大的类组成
- 每个小类封装一个权责,只有一个修改的原因,并与少数其他类一起协同达成期望的系统行为
内聚
- 类应该只有少量实体变量
- 类中的每个方法都应该操作一个或多个这种变量
- 如果一个类中的每个变量都被每个方法所使用,则该类具有最大的内聚性
- 一般来说,创建正在极大化内聚类是既不可取也不可能的
- 另一方面,我们希望内聚性保持在较高位置
- 内聚性高,意味着类中的方法和变量相互依赖、相互结合成一个逻辑整体
这个类非常内聚。在三个方法中,只有size()方法没有使用所有两个变量
|
|
保持内聚性就会得到许多短小的类
将一团糟的代码优化后
|
|
- 重构的程序采用更长、更有描述性的变量名
- 重构后的程序将函数和类声明当做是给代码添加注释的一种手段
- 采用了空格和格式技巧让程序更可读
留意程序是如何被拆分为3个主要权责
- PrimePrinter 类中只有主程序
- 权责是处理执行环境
- 如果调用方式改变,它也会随之改变(例如程序转为 SOAP 服务,该类也会被影响)
- RowColumnPagePrinter 类懂得如何将数字列表格式化到有着固定行、列数的页面上
- 若输出格式需要改动,则该类也会被影响
- PrimeGenerator 类懂得如何生成素数列表
- 注意,这并不意味着要实体化对象
- 该类就是个有用的作用域,在其中声明并隐藏变量
- 如果计算素数的算法发生改变,则该类也会改动
为了修改而组织
在整洁的系统中,我们对类加以组织,以降低修改的风险
Listing 10-9 一个必须打开修改的类
|
|
- 当需要 Sql 类支持 update 语句时,我们就得”打开“这个类进行修改
- 当增加一种新语句类型时,就要修改 Sql 类
- 改动单个语句类型时,也要进行修改,比如打算让 select 功能支持子查询
- 存在两个修改的理由,说明 Sql 违反了 SRP 原则
解决方案
- 注意哪些方法,直接移到了需要用它们的地方
- 公共私有行为被划分到独立的两个工具类 Where 和 ColumnList 中
Listing 10-10
|
|
修改后支持 SRP、开闭原则
隔离修改
- 针对接口编程
- 依赖倒置原则(Dependency Inversion Principle,DIP)
- 类应该依赖于抽象而不是依赖具体细节
系统
依赖注入
有一种强大的机制可以实现分离构造与使用
- 依赖注入(Dependency Injection,DI)
- 控制反转(Inversion of Control,IoC)
- 将第二权责从对象中拿出来,转移到另一个专注于此的对象中,从而遵循了单一权责原则
- 在依赖管理情景中,对象不应负责实体化对自身的依赖
- 反之,它应当将这份权责移交给其他”有权力“的机制,从而实现控制的反转
测试驱动系统架构
没必要先做大设计(Big Design Up Front,BDUF)
12 迭进
12.1 通过迭进设计达到整洁目的
Kent Beck 关于简单设计的四条规则(按其重要程度排列)
- 运行所有测试
- 不可重复
- 表达了程序员的意图
- 尽可能减少类和方法的数量
12.4 不可重复
- 小规模复用可大量降低系统复杂性。要想实现大规模复用,必须理解如何实现小规模复用
- 模板方法模式是一种移除高层级重复的通用技巧
12.5 表达力
- 可以通过选用好名称来表达。在查看起权责时不会大吃一惊
- 可以通过保存函数和类尺寸短小来表达。短小的类和函数通常易于命名,易于编写,易于理解
- 可以通过标准命名法来表达。例如:command、visitor
- 编写良好的单元测试也具有表达性。测试的主要目的之一就是通过实例起到文档的作用。读测试的人应该能很快理解某个类是做什么的
- 做到以上几点最重要的方式是尝试
13 并发编程
13.1 为什么要并发
- 并发会在性能和编写额外代码上增加一些开销
- 正确的并发是复杂的,即便对于简单的问题也是如此
- 并发 Bug 并非总能重现,所以常被看做偶发事件而忽略,未被当做真的 Bug
- 并发常常需要对设计策略的根本性修改
13.3 并发防御原则
- 单一权责原则(SRP)
- 建议:
- 分离并发相关代码与其他代码
- 谨记数据封装;严格限制对可能被共享的数据的访问
- 使用数据复本:避免共享数据
- 线程应尽可能的独立
- 让每个线程在自己的世界中存在,不与其他线程共享数据
- 每个线程处理一个客户端请求,从不共享的源头接纳所有请求数据,存储为本地变量
- 这样,每个线程都像是世界中的唯一线程,没有同步需要
- 建议:尝试将数据分解到可被独立线程(可能在不同处理器上)操作的独立子集
13.5 了解执行模型
模型 | 描述 |
---|---|
限定资源 | 并发环境中有着固定尺寸或数量的资源。例如数据库连接和固定尺寸读/写缓存等 |
互斥 | 每一时刻仅有一个线程能访问共享数据或共享资源 |
线程饥饿 | 一个或一组线程在很长时间内或永久被禁止。例如,总是让执行得快的线程先运行,假如执行得快的线程没完没了,则执行时间长的线程就会“挨饿” |
死锁 | 两个或多个线程互相等待执行结束。每个线程都拥有其他线程需要的资源,得不到其他线程拥有的资源,就无法终止 |
活锁 | 执行次序一致的线程,每个都想要起步,但发现其他线程已经“在路上”。由于竞步的原因,线程会持续尝试起步,但在很长时间内却无法如愿,甚至永远无法启动 |
13.9.9 自动化
可以使用AspectOrientedFramework、CGLIB或ASM之类工具通过编程来装置代码。
|
|
|
|
建议:使用异动策略搜出错误
14 逐步改进
要编写整洁代码,必须先写脏代码,然后再清理它
15 JUnit 内幕
16 重构 SerialDate
17 味道与启发
17.2 环境
应当能够发出单个指令就可以运行全部单元测试
- 运行全部测试是如此基础和重要
- 应该快速、轻易和直截了当的做到
17.3 函数
过多的参数
- 函数的参数量应该少
- 没参数最好,一个辞职,两个、三个再次之
- 三个以上的参数非常值得质疑,应坚决避免
标识参数
布尔值参数大声宣告函数做了不止一件事。它们令人迷惑,应该消灭
17.4 一般性问题
重复
- DRY 原则(Don’t Repeat Yourself)
- Kent Beck 将它列为极限编程核心原则之一
- Ron Jeffries 将这条规则列在第二位,低位只低于通过所有测试
- 可以使用模板方法或策略模式来修正
避免否定性条件
否定式要比肯定式难明白一些。
例如
|
|
要好于
|
|
函数只该做一件事
|
|
这段代码做了三件事
- 遍历所有雇员
- 检查是否该给雇员付工资
- 支付薪水
代码可以写的更好
|
|
掩蔽时序耦合
常常有必要使用时序耦合,但你不应该掩饰它
|
|
- 三个函数的次序很重要。捕鱼之前先织网,织网之前先编绳。
- 不幸的是,代码并没有强制这种时序耦合
- 其他程序员可以在调用 saturateGradient 之前调用 reticulateSplines,从而导致抛出异常
更好的方式
|
|
- 这样就通过创建顺序队列暴露了时序耦合
- 每个函数都产生出下一个函数所需的结果,这样一来就没理由不按顺序调用了
- 虽然增加了函数复杂度,但却暴露了该种情况真正的时序复杂性
在较高层级放置可配置数据
- 位于较高层级的配置性常量易于修改
- 它们向下贯穿应用程序
- 应用程序的较低层级并不拥有这些常量的值
避免传递浏览
不要和陌生人说话(德墨忒尔律)
- 如果 A 与 B 协作,B 与 C 协作,我们不想让使用 A 的模块了解 C 的信息
- 不想写类似的代码:a.getB().getC().doSomething()
17.6 名称
- 采用描述性名称
- 名称应与抽象层级相符
- 尽可能使用标准命名法
- 无歧义的名称
- 为较大作用范围选用较长名称
- 避免编码(类似 vis_ 表示图形系统)
- 名称应该说明副作用
|
|
该函数不只是获取一个 oos,如果 oos 不存在,还会创建一个。所以,更好的名称大概是 createOrReturnOos
17.7 测试
- 100% 覆盖
- 使用覆盖率工具:又快有容易找到尚未检测过的 if 或 catch 语句
- 测试应该快速:慢速的测试是不会被运行的测试