程序是写给人读的,只是偶尔让计算机执行一下。 —— Donald Ervin Knuth
每次 review 过往写的代码,总有一种不忍直视的感觉。想提高编码能力,故阅读了一些相关书籍及博文,并有所感悟,今将一些读书笔记及个人心得感悟梳理出来。抛转引玉,希望这砖能抛得起来。
开始阅读之前,大家可以快速思考一下,大家脑海里的好代码和坏代码都是怎么样的“形象”呢?
如果看到这一段代码,如何评价呢?
if (a && d || b && c && !d || (!a || !b) && c) { // ... } else { // ... } 复制代码
上面这段代码,尽管是特意为举例而写的,要是真实遇到这种代码,想必大家都“一言难尽”吧。大家多多少少都有一些坏味道的代码的“印象”,坏味道的代码总有一些共性:
那坏味道的代码是怎样形成的呢?
对坏味道的代码有一个大概的了解后,或许读者心中有一个疑问:代码的好坏有没有一些量化的标准去评判呢?答案是肯定的。
接下来,通过了解圈复杂度去衡量我们写的代码。然而当代码的坏味道已经“弥漫”到处都是了,这时我们应该了解一下重构。代码到了我们手里,不能继续“发散”坏味道,这时应该了解如何编写 clean code。此外,我们还应该掌握一些编码原则及设计模式,这样才能做到有的放矢。
圈复杂度(Cyclomatic complexity,简写CC)也称为条件复杂度,是一种代码复杂度的衡量标准。由托马斯·J·麦凯布(Thomas J. McCabe, Sr.)于1976年提出,用来表示程序的复杂度。
圈复杂度可以用来衡量一个模块判定结构的复杂程度,数量上表现为独立现行路径条数,也可理解为覆盖所有的可能情况最少使用的测试用例数。
圈复杂度可以通过程序控制流图计算,公式为:
V(G) = e + 2 - n
有一个简单的计算方法:圈复杂度实际上就是等于判定节点的数量再加上1。
注:
if else
、switch case
、for循环
、三元运算符
、||
、&&
等,都属于一个判定节点。
代码复杂度低,代码不一定好,但代码复杂度高,代码一定不好。
圈复杂度 | 代码状况 | 可测性 | 维护成本 |
---|---|---|---|
1 - 10 | 清晰、结构化 | 高 | 低 |
10 - 20 | 复杂 | 中 | 中 |
20 - 30 | 非常复杂 | 低 | 高 |
>30 | 不可读 | 不可测 | 非常高 |
ESLint 提供了检测代码圈复杂度的 rules。开启 rules 中的 complexity 规则,并将圈复杂度大于 0 的代码的 rule severity 设置为 warn 或 error 。
rules: { complexity: [ 'warn', { max: 0 } ] } 复制代码
借助 ESLint 的 CLIEngine ,在本地使用自定义的 ESLint 规则扫描代码,并获取扫描结果输出。
很多情况下,降低圈复杂度就能提高代码的可读性了。针对圈复杂度,结合例子给出一些改善的建议:
通过抽象配置将复杂的逻辑判断进行简化。
before:
// ... if (type === '扫描') { scan(args) } else if (type === '删除') { delete(args) } else if (type === '设置') { set(args) } else { // ... } 复制代码
after:
const ACTION_TYPE = { 扫描: scan, 删除: delete, 设置: set } ACTION_TYPE[type](args) 复制代码
将代码中的逻辑进行抽象提炼成单独的函数,有利于降低代码复杂度和降低维护成本。尤其是当一个函数的代码很长,读起来很费力的时候,就应该思考能否提炼成多个函数。
before:
function example(val) { if (val > MAX_VAL) { val = MAX_VAL } for (let i = 0; i < val; i++) { doSomething(i) } // ... } 复制代码
after:
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)) } 复制代码
某些复杂的条件判断可能逆向思考后会变的更简单,还能减少嵌套。
before:
function checkAuth(user){ if (user.auth) { if (user.name === 'admin') { // ... } else if (user.name === 'root') { // ... } } } 复制代码
after:
function checkAuth(user){ if (!user.auth) return if (user.name === 'admin') { // ... } else if (user.name === 'root') { // ... } } 复制代码
将冗余的条件合并,然后再进行判断。
before:
if (fruit === 'apple') { return true } else if (fruit === 'cherry') { return true } else if (fruit === 'peach') { return true } else { return true } 复制代码
after:
const redFruits = ['apple', 'cherry', 'peach'] if (redFruits.includes(fruit) { return true } 复制代码
对复杂难懂的条件进行提取并语义化。
before:
if ((age < 20 && gender === '女') || (age > 60 && gender === '男')) { // ... } else { // ... } 复制代码
after:
function isYoungGirl(age, gender) { return (age < 20 && gender === '女' } function isOldMan(age, gender) { return age > 60 && gender === '男' } if (isYoungGirl(age, gender) || isOldMan(age, gender)) { // ... } else { // ... } 复制代码
后文有简化条件表达式更全面的总结。
重构一词有名词和动词上的理解。名词:
对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
动词:
使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。
如果遇到以下的情况,可能就要思考是否需要重构了:
为何重构,不外乎以下几点:
本文讨论的内容只涉及第一点,仅限代码级别的重构。
第一次做某件事时只管去做;第二次做类似的事会产生反感,但无论如何还是可以去做;第三次再做类似的事,你就应该重构。
关键思想:一致的风格比“正确”的风格更重要。
原则:
注释的目的是尽量帮助读者了解得和作者一样多。因此注释应当有很高的信息/空间
率。
标记 | 通常的意义 |
---|---|
TODO: | 还没处理的事情 |
FIXME: | 已知的无法运行的代码 |
HACK: | 对一个问题不得不采用的比价粗糙的解决方案 |
关键思想:把信息装入名字中。
良好的命名是一种以“低代价”取得代码高可读性的途径。
“把信息装入名字中”包括要选择非常专业的词,并且避免使用“空洞”的词。
单词 | 更多选择 |
---|---|
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 |
在给变量、函数或者其他元素命名时,要把它描述得更具体而不是更抽象。
如果关于一个变量有什么重要事情的读者必须知道,那么是值得把额外的“词”添加到名字中的。
正 | 反 |
---|---|
add | remove |
create | destory |
insert | delete |
get | set |
increment | decrement |
show | hide |
start | stop |
有一个复杂的条件(if-then-else)语句,从if、then、else三个段落中分别提炼出独立函数。根据每个小块代码的用途,为分解而得到的新函数命名,并将原函数中对应的代码改为调用新建函数,从而更清楚地表达自己的意图。对于条件逻辑,可以突出条件逻辑,更清楚地表明每个分支的作用,并且突出每个分支的原因。
有一系列条件测试,都得到相同结果。将这些测试合并为一个条件表达式,并将这个条件表达式提炼成为一个独立函数。
在条件表达式的每个分支上有着相同的一段代码,将这段重复代码搬移到条件表达式之外。
函数中的条件逻辑使人难以看清正常的执行路径。使用卫语句表现所有特殊情况。
如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时立刻从函数中返回。这样的单独检查常常被称为“卫语句”(guard clauses)。
常常可以将条件表达式反转,从而实以卫语句取代嵌套条件表达式,写成更加“线性”的代码来避免深嵌套。
变量存在的问题:
如果有一个临时变量,只是被简单表达式赋值一次,而将所有对该变量的引用动作,替换为对它赋值的那个表达式自身。
以一个临时变量保存某一表达式的运算结果,将这个表达式提炼到一个独立函数中。将这个临时变量的所有引用点替换为对新函数的调用。此后,新函数就可被其他函数使用。
接上条,如果该表达式比较复杂,建议通过一个总结变量名来代替一大块代码,这个名字会更容易管理和思考。
将复杂表达式(或其中一部分)的结果放进一个临时变量,以此变量名称来解释表达式用途。
在条件逻辑中,引入解释性变量特别有价值:可以将每个条件子句提炼出来,以一个良好命名的临时变量来解释对应条件子句的意义。使用这项重构的另一种情况是,在较长算法中,可以运用临时变量来解释每一步运算的意义。
好处:
程序有某个临时变量被赋值超过一次,它既不是循环变量,也不是用于收集计算结果。针对每次赋值,创造一个独立、对应的临时变量。
临时变量有各种不同用途:
如果临时变量承担多个责任,它就应该被替换(分解)为多个临时变量,每个变量只承担一个责任。
有一个字面值,带有特别含义。创造一个常量,根据其意义为它命名,并将上述的字面数值替换为这个常量。
let done = false; while (condition && !done) { if (...) { done = true; continue; } } 复制代码
像done这样的变量,称为“控制流变量”。它们唯一的目的就是控制程序的执行,没有包含任何程序的数据。控制流变量通常可以通过更好地运用结构化编程而消除。
while (condition) { if (...) { break; } } 复制代码
如果有多个嵌套循环,一个简单的break不够用,通常解决方案包括把代码挪到一个新函数中。
当一个过长的函数或者一段需要注释才能让人理解用途的代码,可以将这段代码放进一个独立函数中。
一个函数过长才合适?长度不是问题,关键在于函数名称和函数本体之间的语义距离。
某个函数既返回对象状态值,又修改对象状态。建立两个不同的函数,其中一个负责查询,另一个负责修改。
有一个函数,其中完全取决于参数值而采取不同行为。针对该参数的每一个可能值,建立一个独立函数。
某些参数总是很自然地同时出现,以一个对象取代这些参数。
可以通过马上处理“特殊情况”,并从函数中提前返回。
如果有很难读的代码,尝试把它所做的所有任务列出来。其中一些任务可以很容易地变成单独的函数(或类)。其他的可以简单地成为一个函数中的逻辑“段落”。
在循环中,与提早返回类似的技术是continue
。与if...return;
在函数中所扮演的保护语句一样,if...continue;
语句是循环中的保护语句。(注意:JavaScript 中 forEach 的特殊性)
对于一个布尔表达式,有两种等价写法:
可以使用这些法则让布尔表达式更具有可读性。例如:
// before if (!(file_exitsts && !is_protected)) Error("sorry, could not read file.") // after if (!file_exists || is_protected) Error("sorry, could not read file.") 复制代码
使用相关定律能优化开始举例的那段代码:
// before if (a && d || b && c && !d || (!a || !b) && c) { // ... } else { // ... } // after if (a && d || c) { // ... } else { // ... } 复制代码
具体简化过程及涉及相关定律可以参考这篇推文:你写这样的代码,不怕同事打你嘛?
所谓工程学就是关于把大问题拆分成小问题再把这些问题的解决方案放回一起。
把这条原则应用于代码会使代码更健壮并且更容易读。
积极地发现并抽取不相关的子逻辑,是指:
如果你不能把一件事解释给你祖母听的话说明你还没真正理解它。 --阿尔伯特·爱因斯坦
步骤如下:
有必要熟知前人总结的一些经典的编码原则及涉及模式,以此来改善我们既有的编码习惯,所谓“站在巨人肩上编程”。
SOLID 是面向对象设计(OOD)的五大基本原则的首字母缩写组合,由俗称“鲍勃大叔”的Robert C.Martin在《敏捷软件开发:原则、模式与实践》一书中提出来。
A class should have only one reason to change.
一个类应该有且仅有一个原因引起它的变更。
通俗来讲:一个类只负责一项功能或一类相似的功能。当然这个“一”并不是绝对的,应该理解为一个类只负责尽可能独立的一项功能,尽可能少的职责。
优点:
缺点:
这条定律同样适用于组织函数时的编码原则。
Software entities (classes,modules,functions,etc.)should be openfor extension,but closed for modification.
软件实体(如类、模块、函数等)应该对拓展开放,对修改封闭。
在一个软件产品的生命周期内,不可避免会有一些业务和需求的变化,我们在设计代码的时候应该尽可能地考虑这些变化。在增加一个功能时,应当尽可能地不去改动已有的代码;当修改一个模块时不应该影响到其他模块。
const makeSound = function( animal ) { animal.sound(); }; const Duck = function(){}; Duck.prototype.sound = function(){ console.log( '嘎嘎嘎' ); }; const Chicken = function(){}; Chicken.prototype.sound = function(){ console.log( '咯咯咯' ); }; makeSound( new Duck() ); // 嘎嘎嘎 makeSound( new Chicken() ); // 咯咯咯 复制代码
Functions that use pointers to base classes must be able to useobjects of derived classes without knowing it.
所有能引用基类的地方必须能透明地使用其子类的对象。
只要父类能出现的地方子类就能出现(就可以用子类来替换它)。反之,子类能出现的地方父类不一定能出现(子类拥有父类的所有属性和行为,但子类拓展了更多的功能)。
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 onesubmodule.
客户端不应该依赖它不需要的接口。用多个细粒度的接口来替代由多个方法组成的复杂接口,每一个接口服务于一个子模块。
接口尽量小,但是要有限度。当发现一个接口过于臃肿时,就要对这个接口进行适当的拆分。但是如果接口过小,则会造成接口数量过多,使设计复杂化。
High level modules should not depend on low level modules; bothshould depend on abstractions.Abstractions should not depend ondetails.Details should depend upon abstractions.
高层模块不应该依赖低层模块,二者都该依赖其抽象。抽象不应该依赖细节,细节应该依赖抽象。
把具有相同特征或相似功能的类,抽象成接口或抽象类,让具体的实现类继承这个抽象类(或实现对应的接口)。抽象类(接口)负责定义统一的方法,实现类负责具体功能的实现。
没有这么充足的时间遵循这些原则去设计,或遵循这些原则设计的实现成本太大。在受现实条件所限不能遵循五大原则来设计时,我们还可以遵循下面这些更为简单、实用的原则。
Each unit should have only limited knowledge about other units: onlyunits "closely"related to the current unit.Only talk to your immediatefriends,don't talk to strangers.
每一个逻辑单元应该对其他逻辑单元有最少的了解:也就是说只亲近当前的对象。只和直接(亲近)的朋友说话,不和陌生人说话。
这一原则又称为迪米特法则,简单地说就是:一个类对自己依赖的类知道的越少越好,这个类只需要和直接的对象进行交互,而不用在乎这个对象的内部组成结构。
例如,类A中有类B的对象,类B中有类C的对象,调用方有一个类A的对象a,这时如果要访问C对象的属性,不要采用类似下面的写法:
a.getB().getC().getProperties() 复制代码
而应该是:
a.getProperties() 复制代码
Keep It Simple and Stupid.
保持简单和愚蠢。
DRY原则(Don't Repeat Yourself)
不要重复自己。
不要重复你的代码,即多次遇到同样的问题,应该抽象出一个共同的解决方法,不要重复开发同样的功能。也就是要尽可能地提高代码的复用率。
要遵循DRY原则,实现的方式非常多:
DRY原则在单人开发时比较容易遵守和实现,但在团队开发时不太容易做好,特别是对于大团队的项目,关键还是团队内的沟通。
You aren't gonna need it,don't implement something until it isnecessary.
你没必要那么着急,不要给你的类实现过多的功能,直到你需要它的时候再去实现。
Rule of three 称为“三次法则”,指的是当某个功能第三次出现时,再进行抽象化,即事不过三,三则重构。
保证方法的行为严格的是命令或者查询,这样查询方法不会改变对象的状态,没有副作用;而会改变对象的状态的方法不可能有返回值。
设计模式的开山鼻祖 GoF 在《设计模式:可复用面向对象软件的基础》一书中提出的23种经典设计模式被分成了三类,分别是创建型模式、结构型模式和行为型模式。
在面向对象软件设计过程中针对特定问题的简洁而优雅的解决方案。
常用的设计模式有:策略模式、发布—订阅模式、职责链模式等。
比如策略模式使用的场景:
策略模式:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。
if (account === null || account === '') { alert('手机号不能为空'); return false; } if (pwd === null || pwd === '') { alert('密码不能为空'); return false; } if (!/(^1[3|4|5|7|8][0-9]{9}$)/.test(account)) { alert('手机号格式错误'); return false; } if(pwd.length<6){ alert('密码不能小于六位'); return false; } 复制代码
使用策略模式:
const strategies = { isNonEmpty: function (value, errorMsg) { if (value === '' || value === null) { return errorMsg; } }, isMobile: function (value, errorMsg) { // 手机号码格式 if (!/(^1[3|4|5|7|8][0-9]{9}$)/.test(value)) { return errorMsg; } }, minLength: function (value, length, errorMsg) { if (value.length < length) { return errorMsg; } } }; const accountIsMobile = strategies.isMobile(account,'手机号格式错误'); const pwdMinLength = strategies.minLength(pwd,8,'密码不能小于8位'); const errorMsg = accountIsMobile || pwdMinLength; if (errorMsg) { alert(errorMsg); return false; } 复制代码
又比如,发布—订阅模式具有的特点:
既可以用在异步编程中,也可以帮助我们完成更松耦合的代码编写。
如果大家需要了解设计模式更多知识,建议另外找资料学习。
宋代禅宗大师青原行思提出参禅的三重境界:
参禅之初,看山是山,看水是水;禅有悟时,看山不是山,看水不是水;禅中彻悟,看山仍是山,看水仍是水。
同理,编程同样存在境界:编程的一重境界是照葫芦画瓢,二重境界是可以灵活运用,三重境界则是心中无模式。唯有多实践,多感悟,方能突破一重又一重的境界。
最后,愿大家终将能写出自己不再讨厌的代码。
真的是最后了,有空会补上上述的示例代码,欢迎大家 Star & PR 呀:你所需要知道的代码整洁之道