概述
美国童子军有一条简单的军规:让营地比你来时更干净。当梳理代码时,坚守此军规:每次 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 === '男')) {
// ...
} else {
// ...
}
复制代码
- 优化后
function isYoungGirl(age, gender) {
return (age < 20 && gender === '女'
}
function isOldMan(age, gender) {
return age > 60 && gender === '男'
}
if (isYoungGirl(age, gender) || isOldMan(age, gender)) {
// ...
} else {
// ...
}
复制代码
重构
重构一词有名词和动词上的理解。
- 名词:
对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
- 动词:
使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。
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. 用具体的名字代替抽象的名字
在给变量、函数或者其他元素命名时,要把它描述得更具体,而不是让人不明所以。
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. 拆分复杂的函数
如果有很难读的代码,尝试把它所做的 所有任务列出来。其中 一些任务 可以很容易地变成 单独的函数(或类)。其他的可以简单地成为一个函数中的逻辑 “段落”。
- 检查函数的 命名 是否 名副其实,梳理函数的思路,试图将顶层函数拆分成 多个子任务
- 将和任务相关的 代码段、变量生命 进行 聚类归拢,根据依赖调整 代码顺序
- 将 各个子任务 抽取成 单独的函数,减少 顶层函数 的复杂性
- 对于 逻辑仍然复杂 的 子任务,可以进一步细化,并利用以上原则(结合重载)继续剥离抽取
- 对于 代码复杂性 和 内聚性 本身比较高,代码可能 复用 的代码,抽取成单独的 类文件
- 对于单独抽取 类文件 或者 方法 后仍然复杂的代码,可以考虑引入 设计模式 进行 横向扩展 或 曲线救国。