概述
美国童子军有一条简单的军规:让营地比你来时更干净。当梳理代码时,坚守此军规:每次 review 代码,让代码比你发现它时更整洁。
一位大神说过:“衡量代码质量的唯一有效标准:WTF/min”,并配了一个形象的图:
通过别人在 review 代码过程中,每分钟 “爆粗” 的次数来衡量这个代码好的程度。
代码整洁的必要性
好的代码就是为了更美好的生活! Clean Code == Good Code == Good Life!
为了把自己和他人从 糟糕的代码维护工作 中解脱出来,必由之路 就是 整洁的代码。于个人来说,代码是否整洁影响心情;于公司来说,代码是否整洁,影响经营生存(因为代码写的烂而倒闭的公司还少吗?)。
一念天堂,一念地狱。
坏味道的代码
开始阅读之前,大家可以快速思考一下,大家脑海里的 好代码 和 坏代码 都是怎么样的“形容”呢?
如果看到这一段代码,如何评价呢?
if (a && d || b && c && !d || (!a || !b) && c) {
doSomething()
} else {
doSomethingElse()
}
上面这段代码,尽管是特意为举例而写的,要是真实遇到这种代码,想必大家都 “一言难尽” 吧!大家多多少少都有一些 坏味道的代码 的 “印象”,坏味道的代码总有一些共性:
那坏味道的代码是怎样形成的呢?
- 上一个写这段代码的程序员经验、水平不足,或写代码时不够用心;
- 业务方提出的奇葩需求导致写了很多 hack 代码;
- 某一个模块业务太复杂,需求变更的次数太多,经手的程序员太多。
当代码的坏味道已经 “弥漫” 到处都是了,这时我们应该了解一下 重构。接下来,通过了解 圈复杂度 去衡量我们写的代码。
圈复杂度
圈复杂度 可以用来衡量一个模块 判定结构 的 复杂程度,数量上表现为 独立现行路径条数,也可理解为覆盖 所有执行路径 使用的 最少测试用例数。
圈复杂度(Cyclomatic complexity,简写CC)也称为 条件复杂度,是一种 代码复杂度 的 衡量标准。由托马斯·J·麦凯布(Thomas J. McCabe, Sr.)于1976年提出,用来表示程序的复杂度。
1. 判定方法
圈复杂度可以通过程序控制流图计算,公式为:
V(G) = e + 2 - n
- e : 控制流图中边的数量
- n : 控制流图中节点的数量
有一个简单的计算方法:圈复杂度 实际上就是等于 判定节点的数量 再加上 1
。
2. 衡量标准
代码复杂度低,代码不一定好,但代码复杂度高,代码一定不好。
圈复杂度 | 代码状况 | 可测性 | 维护成本 |
---|---|---|---|
1 - 10 | 清晰、结构化 | 高 | 低 |
10 - 20 | 复杂 | 中 | 中 |
20 - 30 | 非常复杂 | 低 | 高 |
>30 | 不可读 | 不可测 | 非常高 |
3. 降低代码的圈复杂度
3.1. 抽象配置
通过 抽象配置 将复杂的逻辑判断进行简化。
- 优化前
if (type === '扫描') {
scan(args)
} else if (type === '删除') {
delete(args)
} else if (type === '设置') {
set(args)
} else {
other(args)
}
- 优化后
const ACTION_TYPE = {
'扫描': scan,
'删除': delete,'
'设置': set
}
ACTION_TYPE[type](args)
3.2. 方法拆分
将代码中的逻辑 拆分 成单独的方法,有利于降低代码复杂度和降低维护成本。当一个函数的代码很长,读起来很费力的时候,就应该思考能否提炼成 多个函数。
- 优化前
function example(val) {
if (val > MAX_VAL) {
val = MAX_VAL
}
for (let i = 0; i < val; i++) {
doSomething(i)
}
}
- 优化后
function setMaxVal(val) {
return val > MAX_VAL ? MAX_VAL : val
}
function getCircleArea(val) {
for (let i = 0; i < val; i++) {
doSomething(i)
}
}
function example(val) {
return getCircleArea(setMaxVal(val))
}
3.3. 简单条件分支优先处理
对于复杂的条件判断进行优化,尽量保证 简单条件分支优先处理,这样可以 减少嵌套、保证 程序结构清晰。
- 优化前
function checkAuth(user){
if (user.auth) {
if (user.name === 'admin') {
doSomethingByAdmin(user)
} else if (user.name === 'root') {
doSomethingByRoot(user)
}
}
}
- 优化后
function checkAuth(user){
if (!user.auth) {
return
}
if (user.name === 'admin') {
doSomethingByAdmin(user)
} else if (user.name === 'root') {
doSomethingByRoot(user)
}
}
3.4. 合并条件简化条件判断
- 优化前
if (fruit === 'apple') {
return true
} else if (fruit === 'cherry') {
return true
} else if (fruit === 'peach') {
return true
} else {
return true
}
- 优化后
const redFruits = ['apple', 'cherry', 'peach']
if (redFruits.includes(fruit) {
return true
}
3.5. 提取条件简化条件判断
对 晦涩难懂 的条件进行 提取并语义化。
- 优化前
if ((age < 20 && gender === '女') || (age > 60 && gender === '男')) {
doSomething()
} else {
doSomethingElse()
}
- 优化后
function isYoungGirl(age, gender) {
return age < 20 && gender === '女'
}
function isOldMan(age, gender) {
return age > 60 && gender === '男'
}
if (isYoungGirl(age, gender) || isOldMan(age, gender)) {
doSomething()
} else {
doSomethingElse()
}
重构
重构一词有名词和动词上的理解。
- 名词:
对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
- 动词:
使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。
1. 为何重构
如果遇到以下的情况,可能就要思考是否需要重构了:
- 重复的代码太多
- 代码的结构混乱
- 程序没有拓展性
- 对象结构强耦合
- 部分模块性能低
为何重构,不外乎以下几点:
- 重构改进软件设计
- 重构使软件更容易理解
- 重构帮助找到BUG
- 重构提高编程速度
重构的类型
- 对现有项目进行代码级别的重构;
- 对现有的业务进行软件架构的升级和系统的升级。
本文讨论的内容只涉及第一点,仅限代码级别的重构。
2. 重构时机
第一次做某件事时只管去做;第二次做类似的事会产生反感,但无论如何还是可以去做;第三次再做类似的事,你就应该重构。
-
添加功能:当添加新功能时,如果发现某段代码改起来特别困难,拓展功能特别不灵活,就要重构这部分代码使添加新特性和功能变得更容易;
-
修补错误:在你改
BUG
或查找定位问题时,发现自己以前写的代码或者别人的代码设计上有缺陷(如扩展性不灵活),或健壮性考虑得不够周全(如漏掉一些该处理的异常),导致程序频繁出现问题,那么此时就是一个比较好的重构时机; -
代码检视:团队进行
Code Review
的时候,也是一个进行重构的合适时机。
代码整洁之道
代码应当 易于理解,代码的写法应当使别人理解它所需的时间最小化。
代码风格
关键思想:一致的风格比 “正确” 的风格更重要。
原则:
- 使用一致的 代码布局 和 命名
- 让相似的代码看上去 相似
- 把相关的代码行 分组,形成 代码块
注释
注释的目的是尽量帮助读者了解到和作者一样多的信息。因此注释应当有很高的 信息/空间率。
1. 好注释
- 特殊标记注释:如 TODO、FIXME 等有特殊含义的标记
- 文件注释:部分规约会约定在文件头部书写固定格式的注释,如注明作者、协议等信息
- 文档类注释:部分规约会约定 API、类、函数等使用文档类注释
- 遵循统一的风格规范,如一定的空格、空行,以保证注释自身的可读性
2. 坏注释
- 自言自语,自己感觉要加注释的地方就写上注释
- 多余的注释:本身代码已经能表达意思就不要加注释
- 误导性注释(随着代码的迭代,注释总有一天会由于过于陈旧而导致产生误导)
- 日志式注释:日志本身可以体现出具体语意,不需要多余的注释
- 能用函数或者变量名称表达语意的就不要用注释
- 注释掉的代码应该删除,避免误导和混淆
有意义的命名
良好的命名是一种以 低代价 取得代码 高可读性 的途径。
1. 选择专业名词
单词 | 更多选择 |
---|---|
send | deliver, despatch, announce, distribute, route |
find | search, extract, locate, recover |
start | launch, create, begin, open |
make | create, set up, build, generate, compose, add, new |
2. 避免像tmp和retval这样泛泛的名字
retval
这个名字没有包含明确的信息tmp
只应用于短期存在且临时性为其主要存在因素的变量
3. 用具体的名字代替抽象的名字
在给变量、函数或者其他元素命名时,要把它描述得更具体,而不是让人不明所以。
- 反例:
class DtaRcrd102 {
private genymdhms: Date; // 你能读出这个变量名么?
private modymdhms: Date;
private pszqint = '102';
}
class DtaRcrd102 {
private genymdhms: Date; // 你能读出这个变量名么?
private modymdhms: Date;
private pszqint = '102';
}
- 正例
class Customer {
private generationTimestamp: Date;
private modificationTimestamp: Date;
private recordId = '102';
}
class Customer {
private generationTimestamp: Date;
private modificationTimestamp: Date;
private recordId = '102';
}
4. 为名字附带更多信息
如果关于一个 变量 有什么重要的含义需要让读者知道,那么是值得把额外的 “词” 添加到名字中。
5. 名字的长度
- 在小的作用域里可以使用短的名字
- 为作用域大的名字采用更长的名字
- 丢掉没用的词
6. 不会被误解的名字
- 用
min
和max
来表示极限 - 用
first
和last
来表示包含的范围 - 用
begin
和end
来表示排除范围 - 给布尔值命名:
is
、has
、can
、should
7. 语义相反的词汇要成对出现
正 | 反 |
---|---|
add | remove |
create | destory |
insert | delete |
get | set |
increment | decrement |
show | hide |
start | stop |
8. 其他命名小建议
- 计算限定符作为前缀或后缀(
Avg
、Sum
、Total
、Min
、Max
) - 变量名要能准确地表示事物的含义
- 用动名词命名函数名
- 变量名的缩写,尽量避免不常见的缩写
简化条件表达式
1. 分解条件表达式
有一个复杂的条件(if-elseif-else
)语句,从 if
、elseif
、else
三个段落中分别提炼出 独立函数。根据每个小块代码的用途,为分解而得到的 新函数 命名。对于 条件逻辑,可以 突出条件逻辑,更清楚地表明每个分支的作用和原因。
2. 合并条件表达式
将这些一系列 相关联 的条件表达式 合并 为一个,并将这个条件表达式提炼成为一个 独立的方法。
- 确定这些条件语句都没有副作用;
- 使用适当的逻辑操作符,将一系列相关条件表达式合并为一个;
- 对合并后的条件表达式实施进行方法抽取。
3. 合并重复的条件片段
在条件表达式的每个分支上有着一段 重复的代码,将这段重复代码搬移到条件表达式之外。
4. 以卫语句取代嵌套条件表达式
函数中的条件逻辑使人难以看清正常的执行路径。使用 卫语句 表现所有特殊情况。
如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时立刻从函数中返回。这样的单独检查常常被称为 “卫语句”(guard clauses)。
常常可以将 条件表达式反转,从而实以 卫语句 取代 嵌套条件表达式,写成更加 “线性” 的代码来避免 深嵌套。
变量与可读性
1. 内联临时变量
如果有一个临时变量,只是被简单表达式 赋值一次,而将所有对该变量的引用动作,替换为对它赋值的那个表达式自身。
2. 以查询取代临时变量
以一个临时变量保存某一表达式的运算结果,将这个表达式提炼到一个独立函数中。将这个临时变量的所有引用点替换为对新函数的调用。此后,新函数就可被其他函数使用。
3. 总结变量
接上条,如果该表达式比较复杂,建议通过一个总结变量名来代替一大块代码,这个名字会更容易管理和思考。
4. 引入解释性变量
将复杂表达式(或其中一部分)的结果放进一个 临时变量,以此 变量名称 来解释表达式用途。
在条件逻辑中,引入解释性变量特别有价值:可以将每个 条件子句 提炼出来,以一个良好命名的 临时变量 来解释对应条件子句的 意义。使用这项重构的另一种情况是,在较长算法中,可以运用 临时变量 来解释每一步运算的意义。
好处:
- 把巨大的表达式拆分成小段
- 通过用简单的名字描述子表达式来让代码文档化
- 帮助读者识别代码中的主要概念
5. 分解临时变量
程序有某个 临时变量 被赋值 超过一次,它既不是循环变量,也不是用于收集计算结果。针对每次赋值,创造一个独立、对应的临时变量。
临时变量有各种不同用途:
- 循环变量
- 结果收集变量(通过整个函数的运算,将构成的某个值收集起来)
如果临时变量承担多个责任,它就应该被替换(分解)为 多个临时变量,每个变量只承担一个责任。
6. 以字面常量取代 Magic Number
有一个字面值,带有特别含义。创造一个 常量,根据其意义为它 命名,并将上述的字面数值替换为这个常量。
7. 减少控制流变量
let done = false;
while (condition && !done) {
if (matchCondtion()) {
done = true;
continue;
}
}
像 done
这样的变量,称为 “控制流变量”。它们唯一的目的就是控制程序的执行,没有包含任何程序的数据。控制流变量通常可以通过更好地运用 结构化编程而消除。
while (condition) {
if (matchCondtion()) {
break;
}
}
如果有 多个嵌套循环,一个简单的 break
不够用,通常解决方案包括把代码挪到一个 新函数。
重新组织函数
一个函数尽量只做一件事情,这是程序 高内聚,低耦合 的基石。
1. 提炼函数
当一个过长的函数或者一段需要注释才能让人理解用途的代码,可以将这段代码放进一个 独立函数。
- 函数的粒度小,被 复用 的机会就很大;
- 函数的粒度小,覆写 也会更容易些。
一个函数过长才合适?长度 不是问题,关键在于 函数名称 和 函数本体 之间的 语义距离。
2. 代码块与缩进
函数的缩进层级不应该多于 一层 或 两层,对于 超过两层 的代码可以根据 重载 或函数的 具体语意 抽取的的函数。
3. 函数无副作用
每个函数应该只做 一件事,如果一个函数同时做了多件事,比如:
-
在 查询数据 的过程中对数据进行 修改,或者调用 第三方接口,那么这个函数是具有 二义性的。
-
一个函数用于校验数据或者异常,但是在校验过程没有统一 校验规范,存在同时 抛出异常 和 返回正常结果 的情况。那这个函数是不纯粹的,也是有夹带现象。
第一种情况可以考虑把函数进行拆分,拆分为 读数据函数 和 写数据函数;第二种情况应该将 校验逻辑 和 获取值 的逻辑抽离为两个函数,校验函数 前置于 获取值函数。同时保证校验函数尽量轻量级。
4. 函数参数优化
函数参数格式尽量避免超过 3
个。参数过多(类型相近)会导致代码 容错性降低,导致参数个数顺序传错等问题。如果函数的参数太多,可以考虑将参数进行 分组 和 归类,封装成 单独的对象。
5. 从函数中提前返回
可以通过马上处理 “特殊情况”,可以通过 卫语句 处理,从函数中 提前返回。
6. 重复代码抽取公共函数
应该避免纯粹的 copy-paste
,将程序中的 重复代码 抽取成公共的函数,这样的好处是避免 修改、删除 代码时出现遗忘或误判。
- 两个方法的 共性 提取到新方法中,新方法分解到另外的类里,从而提升其可见性
- 模板方法模式是消除重复的通用技巧
7. 拆分复杂的函数
如果有很难读的代码,尝试把它所做的 所有任务列出来。其中 一些任务 可以很容易地变成 单独的函数(或类)。其他的可以简单地成为一个函数中的逻辑 “段落”。
- 检查函数的 命名 是否 名副其实,梳理函数的思路,试图将 顶层函数 拆分成 多个子任务
- 将和任务相关的 代码段、变量生命 进行 聚类归拢,根据依赖调整 代码顺序
- 将 各个子任务 抽取成 单独的函数,减少 顶层函数 的复杂性
- 对于 逻辑仍然复杂 的 子任务,可以进一步细化,并利用以上原则(结合重载)继续剥离抽取
- 对于 代码复杂性 和 内聚性 本身比较高,代码可能 复用 的代码,抽取成单独的 类文件
- 对于单独抽取 类文件 或者 方法 后仍然复杂的代码,可以考虑引入 设计模式 进行 横向扩展 或 曲线救国。
整洁一致的格式
1. 垂直格式
- 文件长度:短文件 比 长文件 更易于理解,单个文件平均
200
行,最多不超过300
行 - 分隔:封包声明、导入声明、每个函数之间,可以使用 空白行 标识着新的 独立概念
- 归拢:紧密相关 的代码应该互相归拢
- 变量声明:变量 应尽可能靠近其 使用位置。
- 成员变量:应该放在类的顶部声明,不要四处放置
- 如果某个函数调用了另外一个,就应该把它们放在一起,调用者尽可能放在被调用者之上
2. 水平格式
- 一行代码不必死守
80
字符的上限,偶尔到达100
字符不超过120
字符即可 - 区隔与靠近:空格强调左右两边的分割,赋值运算符 两边加空格,函数名 与 左圆括号 之间不加空格
- 不必水平对齐:例如声明一堆成员变量时,各行不用每一个单词都对齐
- 短小的
if
、while
语句里最好也不要违反 缩进规则,不要这样if (xx == yy) z = 1;
对象和数据结构
1. 数据抽象
类的好处是 隐藏细节,所以尽量不要在标准的 数据对象 的 getter()
和 setter()
等函数内部进行 自定义扩展。
2. 数据、对象的反对称性
- 面向数据机构的代码:好处是不改动 现有数据结构 前提下增加 新的函数
- 面向对象的代码:好处是不改动 既有函数 的前提下增加 新的类(多态)
3. Demeter法则(最少知识原则)
- 模块不应该了解它 所操作对象 的 内部实现,更不应该了解 对象的对象 的内部实现
class
的方法只应该调用类本身方法,方法创建的对象,作为参数传递的方法,类所持有的对象- 方法不应调用由任何函数返回的对象的方法,
final String outputDir = ctxt.getOptions.getScratchDir.getAbsolutePath()
注意:对于 函数式编程 和 响应式编程,或者适用
Optional
和builder
模式的场景,Demeter法则并不是完全适用。除此之外的链式调用有可能会带来 空指针 等问题。
类
1. 类的结构组织
类成员定义的先后顺序:公共静态常量 -> 私有静态变量 -> 私有实体变量 -> 构造方法 -> 公共函数 -> 私有函数
2. 类应该短小
- 类的长度:对于 函数 我们计算 代码行数 衡量大小,对于类我们使用 职责 来衡量
- 类的命名:类的名称描述其 职责,类的命名是判断类长度的第一个手段,如果无法为某个类命以准确的名称,这个类就太长了,类名包含模糊的词汇,如
Processor
、Manager
、Super
,就说明有 不恰当 的职责聚集情况 - 类的单一职责: 类或者模块应该有一个职责,即只有一条修改的理由
- 类的个数:系统应该由许多短小精悍的类,而不是少量巨型的类组成
- 类的成员变量:类应该只有少量的实体变量,如果一个类中每个实体变量都被每个方法所使用,则说明该类具有最大的内聚性
3. 为修改而组织
- 类应当对扩展开放,对修改封闭,即 开放闭合原则
- 在理想系统中,通过扩展系统而非修改现有代码来添加新特性(可惜往往做不到)
4. 组合大于继承
合和继承各有优劣。这个准则的主要观点是,如果你潜意识地倾向于继承,试着想想组合是否能更好地给你的问题建模,在某些情况下可以。
什么时候应该使用继承?这取决于你面临的问题。以下场景使用继承更好:
- 继承代表的是
“is-a”
关系,而不是“has-a”
关系 (人 -> 动物vs
用户 -> 用户详情)。 - 可复用基类的代码,即有相同行为的代码 (人类可以像所有动物一样移动)。
- 希望通过更改 基类 对 派生类 进行 全局更改 (改变所有动物在运动时的热量消耗)。
错误处理
在处理程序异常时,常常会用到 try / catch
代码块,而 try / catch
代码块丑陋不堪,使用不慎容易 搞乱代码结构,把 错误处理 与 正常流程 混为一谈。
1. 使用异常而不是返回错误码
- 如果使用 错误码,调用者必须在函数返回时 立刻处理错误,但这很容易被忘记
- 错误码 通常会导致 嵌套判断,使代码结构不严谨
2. 先写try-catch-finally语句
- 当编写可能会抛异常的代码时,先写好
try-catch-finally
再往里堆逻辑
3. 根据业务场景定义不同的异常处理类
- 根据业务定义不同的 异常类,尽量避免直接使用
Throwable
、Exception
和RuntimeException
捕获 业务层面 的异常。
4. 特例模式,创建一个类来处理特例
- 定义一个配置或者对象来处理特殊情况,你处理了特殊情况后客户代码就不需要捕获异常了
5. 别返回null值
- 返回
null
值的地方都需要 重复的检查,只要一处没检查null
值,应用程序就会失败 - 当想返回
null
值的时候,可以试试 抛出异常,或者返回特例模式的对象 - 可以通过
Optional.ifPresent()
和Optional.map().orElseGet()
处理需要返回null
的场景
6. 别传递null值
- 在方法中传递
null
值是一种很危险的做法,应该尽量避免 - 在进行字符串比较时,要避免将可能为
null
的参数放在equals()
方法的左侧 - 在方法里用
if
或assert
过滤null
值参数,但是还是会出现运行时错误
编码原则
有必要熟知前人总结的一些经典的 编码原则,以此来改善我们既有的编码习惯,所谓 “站在巨人肩上编程”。
SOLID原则
SOLID 是面向对象设计(OOD)的五大基本原则的首字母缩写组合,由俗称“鲍勃大叔”的Robert C.Martin在《敏捷软件开发:原则、模式与实践》一书中提出来。
- S(Single Responsibility Principle):单一职责原则,简称
SRP
- O(Open Close Principle):开放封闭原则,简称
OCP
- L(Liskov Substitution Principle):里氏替换原则,简称
LSP
- I(Interface SegregationPrinciple):接口隔离原则,简称
ISP
- D(Dependence Inversion Principle):依赖倒置原则,简称
DIP
1. 单一职责原则
A class should have only one reason to change.
一个类应该有且仅有 一个原因 引起它的变更。通俗来讲:一个类只负责一项功能或一类相似的功能。当然这个 “一” 并不是绝对的,应该理解为一个类只负责尽可能独立的一项功能,尽可能少的职责。
如果一个类的 功能太多,修改了其中一处很难确定对代码库中其他 依赖模块 的影响。
优点:
- 功能单一,职责清晰。
- 增强可读性,方便维护。
缺点:
- 拆分得太详细,类的数量会急剧增加。
- 职责的度量没有统一的标准,需要根据项目实现情况而定。
这条定律同样适用于组织函数时的编码原则。
- 反例:
class UserSettings {
constructor(private readonly user: User) {
}
changeSettings(settings: UserSettings) {
if (this.verifyCredentials()) {
doChangeSettings()
}
}
verifyCredentials() {
doVerifyCredentials()
}
}
- 正例:
class UserAuth {
constructor(private readonly user: User) {
}
verifyCredentials() {
doVerifyCredentials()
}
}
class UserSettings {
private readonly auth: UserAuth;
constructor(private readonly user: User) {
this.auth = new UserAuth(user);
}
changeSettings(settings: UserSettings) {
if (this.auth.verifyCredentials()) {
doChangeSettings()
}
}
}
2. 开放封闭原则
Software entities (classes,modules,functions,etc.)should be open for extension, but closed for modification.
程序开发过程中(如类、模块、函数等)应该 对拓展开放,对修改封闭。换句话说,就是允许在不更改现有代码的情况下添加新功能。
- 反例
class AjaxAdapter extends Adapter {
constructor() {
super();
}
}
class NodeAdapter extends Adapter {
constructor() {
super();
}
}
class HttpRequester {
constructor(private readonly adapter: Adapter) {
}
async fetch<T>(url: string): Promise<T> {
if (this.adapter instanceof AjaxAdapter) {
const response = await makeAjaxCall<T>(url);
// transform response and return
} else if (this.adapter instanceof NodeAdapter) {
const response = await makeHttpCall<T>(url);
// transform response and return
}
}
}
function makeAjaxCall<T>(url: string): Promise<T> {
// request by ajax and return promise
}
function makeHttpCall<T>(url: string): Promise<T> {
// request by http and return promise
}
- 正例
abstract class Adapter {
abstract async request<T>(url: string): Promise<T>;
}
class AjaxAdapter extends Adapter {
constructor() {
super();
}
async request<T>(url: string): Promise<T>{
// request by ajax and return promise
}
}
class NodeAdapter extends Adapter {
constructor() {
super();
}
async request<T>(url: string): Promise<T>{
// request by http and return promise
}
}
class HttpRequester {
constructor(private readonly adapter: Adapter) {
}
async fetch<T>(url: string): Promise<T> {
const response = await this.adapter.request<T>(url);
// transform response and return
}
}
3. 里氏替换原则
Functions that use pointers to base classes must be able to use objects of derived classes without knowing it.
所有能引用 基类 的地方必须能透明地使用其 子类 的对象。
只要 父类 能出现的地方就可以用 子类 来替换它。反之,子类 不能替换 父类(子类拥有父类的 所有属性和行为,但 子类 拓展了更多的功能)。
- 反例:
class Rectangle {
constructor(
protected width: number = 0,
protected height: number = 0) {
}
render(area: number) {
}
setWidth(width: number) {
this.width = width;
}
setHeight(height: number) {
this.height = height;
}
getArea(): number {
return this.width * this.height;
}
}
class Square extends Rectangle {
setWidth(width: number) {
this.width = width;
this.height = width;
}
setHeight(height: number) {
this.width = height;
this.height = height;
}
}
function renderLargeRectangles(rectangles: Rectangle[]) {
rectangles.forEach((rectangle) => {
rectangle.setWidth(4);
rectangle.setHeight(5);
const area = rectangle.getArea(); // BAD: Returns 25 for Square. Should be 20.
rectangle.render(area);
});
}
const rectangles = [new Rectangle(), new Rectangle(), new Square()];
renderLargeRectangles(rectangles);
- 正例:
abstract class Shape {
setColor(color: string) {
}
render(area: number) {
}
abstract getArea(): number;
}
class Rectangle extends Shape {
constructor(private readonly width = 0,
private readonly height = 0) {
super();
}
getArea(): number {
return this.width * this.height;
}
}
class Square extends Shape {
constructor(private readonly length: number) {
super();
}
getArea(): number {
return this.length * this.length;
}
}
function renderLargeShapes(shapes: Shape[]) {
shapes.forEach((shape) => {
const area = shape.getArea();
shape.render(area);
});
}
const shapes = [new Rectangle(4, 5), new Rectangle(4, 5), new Square(5)];
renderLargeShapes(shapes);
4. 接口隔离原则
Clients should not be forced to depend upon interfaces that they don't use. Instead of one fat interface many small interfaces arepreferred based on groups of methods, each one serving one submodule.
客户端不应该依赖它不需要的接口。用 多个细粒度 的接口来替代由 多个方法 组成的 复杂接口,每一个接口服务于一个子模块。
接口尽量小,但是要有限度。当发现一个接口过于 臃肿 时,就要对这个接口进行适当的 拆分。但是如果 接口过小,则会造成 接口数量过多,使 设计复杂化。
- 反例:
interface ISmartPrinter {
print();
fax();
scan();
}
class AllInOnePrinter implements ISmartPrinter {
print() {
}
fax() {
}
scan() {
}
}
class EconomicPrinter implements ISmartPrinter {
print() {
}
fax() {
throw new Error('Fax not supported.');
}
scan() {
throw new Error('Scan not supported.');
}
}
- 正例:
interface IPrinter {
print();
}
interface IFax {
fax();
}
interface IScanner {
scan();
}
class AllInOnePrinter implements IPrinter, IFax, IScanner {
print() {
}
fax() {
}
scan() {
}
}
class EconomicPrinter implements IPrinter {
print() {
}
}
5. 依赖倒置原则
High level modules should not depend on low level modules; bothshould depend on abstractions. Abstractions should not depend ondetails. Details should depend upon abstractions.
这个原则有两个要点:
- 高层模块 不应该依赖于 低层模块,两者都应该依赖于 抽象。
- 抽象 不依赖 实现,实现 应依赖 抽象。
把具有 相同特征 或 相似功能 的类,抽象成 接口 或 抽象类,让具体的 实现类 继承这个 抽象类(或实现对应的接口)。抽象类(接口)负责定义统一的方法,实现类负责具体功能的实现。
- 反例:
import { readFile as readFileCb } from 'fs';
import { promisify } from 'util';
const readFile = promisify(readFileCb);
type ReportData = {
}
class XmlFormatter {
parse<T>(content: string): T {
// Converts an XML string to an object T
}
}
class ReportReader {
private readonly formatter = new XmlFormatter();
async read(path: string): Promise<ReportData> {
const text = await readFile(path, 'UTF8');
return this.formatter.parse<ReportData>(text);
}
}
const reader = new ReportReader();
await report = await reader.read('report.xml');
- 正例:
import { readFile as readFileCb } from 'fs';
import { promisify } from 'util';
const readFile = promisify(readFileCb);
type ReportData = {
// ..
}
interface Formatter {
parse<T>(content: string): T;
}
class XmlFormatter implements Formatter {
parse<T>(content: string): T {
// Converts an XML string to an object T
}
}
class JsonFormatter implements Formatter {
parse<T>(content: string): T {
// Converts a JSON string to an object T
}
}
class ReportReader {
constructor(private readonly formatter: Formatter){
}
async read(path: string): Promise<ReportData> {
const text = await readFile(path, 'UTF8');
return this.formatter.parse<ReportData>(text);
}
}
const reader = new ReportReader(new XmlFormatter());
await report = await reader.read('report.xml');
// or if we had to read a json report:
const reader = new ReportReader(new JsonFormatter());
await report = await reader.read('report.json');
是否一定要遵循这些设计原则
- 软件设计是一个逐步优化的过程
- 不是一定要遵循这些设计原则
没有充足的时间,或遵实现成本太大。在受限不能遵循 五大原则 来设计时,我们还可以遵循下面这些 更为简单、实用 的原则。
简单、实用的原则
1. LoD原则(Law of Demeter)
Each unit should have only limited knowledge about other units: only units "closely" related to the current unit. Only talk to your immediate friends, don't talk to strangers.
每一个逻辑单元应该对其他逻辑单元有最少的了解:也就是说只亲近当前的对象。只和直接(亲近)的朋友说话,不和陌生人说话。
这一原则又称为迪米特法则,简单地说就是:一个类对自己依赖的类知道的越少越好,这个类只需要和直接的对象进行交互,而不用在乎这个对象的内部组成结构。
例如,类A中有类B的对象,类B中有类C的对象,调用方有一个类A的对象a,这时如果要访问C对象的属性,不要采用类似下面的写法:
a.getB().getC().getProperties()
复制代码
而应该是:
a.getProperties()
复制代码
2. KISS原则(Keep It Simple and Stupid)
Keep It Simple and Stupid.
保持简单和愚蠢。
- “简单”就是要让你的程序能简单、快速地被实现;
- “愚蠢”是说你的设计要简单到任何人都能理解,即简单就是美!
3. DRY原则(Don't Repeat Yourself)
不要重复你的代码,即多次遇到同样的问题,应该抽象出一个 通用 的方法,不要重复开发同样的功能。也就是要尽可能地提高代码的 复用率。
要遵循 DRY
原则,实现的方式非常多:
- 函数级别的封装:把一些经常使用的、重复出现的功能封装成一个通用的函数。
- 类级别的抽象:把具有相似功能或行为的类进行抽象,抽象出一个基类,并把这几个类都有的方法提到基类去实现。
- 泛型设计:
Java
中可使用泛型,以实现通用功能类对多种数据类型的支持;C++
中可以使用类模板的方式,或宏定义的方式;Python
中可以使用装饰器来消除冗余的代码。
DRY
原则在单人开发时比较容易遵守和实现,但在团队开发时不太容易做好,特别是对于大团队的项目,关键还是团队内的沟通。
4. YAGNI原则(You Aren't Gonna Need It)
You aren't gonna need it, don't implement something until it is necessary.
你没必要那么着急去给你的类实现过多的功能,在你需要它的时候再去实现。
- 只考虑和设计必需的功能,避免 过度设计。
- 只实现目前需要的功能,在以后需要更多功能时,可以再进行添加。
- 如无必要,勿增加复杂性。
5. Rule Of Three原则
Rule of three
称为 “三次法则”
,指的是当某个功能 第三次 出现时,再进行 抽象化,即 事不过三,三则重构。
- 第一次实现一个功能时,就尽管大胆去做;
- 第二次做类似的功能设计时会产生反感,但是还得去做;
- 第三次还要实现类似的功能做同样的事情时,就应该去审视是否有必要做这些重复劳动了,这个时候就应该重构你的代码了,即把重复或相似功能的代码进行抽象,封装成一个通用的模块或接口。
6. CQS原则(Command-Query Separation)
- 查询(Query):当一个方法 返回一个值 来响应一个问题的时候,它就具有查询的性质;
- 命令(Command):当一个方法要 改变对象的状态 的时候,它就具有命令的性质。
严格保证方法的行为的方式是 命令 或者 查询,这样查询方法不会改变对象的状态,没有副作用;而会改变对象的状态的方法不可能有返回值。