《编写可读代码的艺术》干货笔记

569 阅读8分钟

这是我参与更文挑战的第16天,活动详情查看:更文挑战


本文为阅读《编写可读代码的艺术》后整理的重点笔记,其中很多观点既实用又清晰,对日常代码良好习惯的养成很有帮助。

首先我们需要明确,什么是「可读性」?作者认为可读性的基本定理是:

代码的写法应当使别人理解它所需的时间最小化。

即使一个项目中不存在「别人」,只有开发者自己,但这个「别人」可能就是六个月后的自己。我想很多人都有过隔段时间回头看自己的代码,就已经觉得很陌生了。而且我们永远也不知道,以后会不会有其他人加入项目。

所以编写有可读性的代码,是必备的基本功,也是程序员的良好职业素养。

(P.S. 书中的部分例子本文改写成了 JavaScript)

一、表面层次的改进

1. 把信息装到名字里

  • 选择专业/更有表现力的词
单词更多选择
GetFetch, Download
StopKill, Pause
SendDeliver, Dispatch, Announce, Distribute, Route
FindSearch, Extract, Locate, Recover
StartLaunch, Create, Begin, Open
MakeCreate, SetUp, Build, Generate, Compose, Add, New
  • 避免使用像tmpretval这样泛泛的名字

    不要滥用tmp,它只应该用于短期存在、提供临时存储的情况:

if (right > left) {
    tmp = right;
    right = left;
    left = tmp;
}

retval含义太空泛,应该选择更确切的名称:

// 👎 我是一个返回值
retval += v[i] * v[i];

// 👍 我是一个平方和
sumSquares += v[i] * v[i];
  • 更精确的循环迭代器命名

    通常我们为了简洁,在循环时会用i j k 来给迭代器命名,但在一些比较复杂的循环中,更精确的命名会更清晰,并且容易发现错误。

for(let i = 0; i < orders.length; i++)
    for(let j = 0; j < orders[i].items.length; j++)
        for(let k = 0; k < services.length; k++)
            if (orders[i].items[k].services == services[j])

在这个例子里,最后把jk写错了,但不容易一眼找到问题。如果我们用oi(orders_i)、ii(items_i)、si(services_i)来命名,可读性会更强,也不容易出错。

for (left oi = 0; oi < orders.length; oi++)
    for(let ii = 0; ii < orders[oi].items.length; ii++)
        for(let si = 0; si < services.length; si++)
            if (orders[oi].items[si].services == services[ii])
  • 为名字附带更多信息
var start = (new Date()).getTime(); 
...
var elapsed = (new Date()).getTime() - start;
document.writeIn("Load time was: " + elapsed + " seconds";

这是一个计算页面加载时间的例子,因为没有带单位,导致容易把秒和毫秒弄错。在命名时加上单位就能避免这个问题:

var start_ms = (new Date()).getTime(); 
...
var elapsed_ms = (new Date()).getTime() - start_ms;
document.writeIn("Load time was: " + elapsed_ms / 1000 + " seconds";
  • 名字应该有多长

有时候为了表达清楚含义,会把命名写得很长,但如果长到这种程度可能会很头疼:

newNavigationControllerWrappingViewContrllerForDataSourceOfClass

再例如一个变量,可以有以下三种命名:

  • d
  • days
  • daysSinceLastUpdate

哪一种长度才是合适的呢?

  • 在小的作用域里可以使用短的名字,大的作用域最好使用包含足够信息的名字,否则含义会很不清楚。

    例如: 作用域在10行内的变量,可以清晰看到它的类型、初值等,用简单的命名也不会产生误解。但如果是全局变量就会理解困难。

  • 少使用不熟悉的缩写,原则可以参考:团队的新成员是否能理解这个名字的含义。

    例如: doc 代替 document 容易理解, BEManager 代替 BackEndManager 可能有点困难。

  • 当驼峰命名中的单元超过了3个之后,就会很影响阅读体验

    例如: userFriendsInfoModel memoryCacheCalculateTool

  • 丢掉没用的词:有时名字中的某些单词可以拿掉而不会损失任何信息。

    例如: convertToString可以省略为toString

  • 有目的地使用大小写、下划线等

    例如: $Content代表Jquery变量, _getLast()表示私有属性不可被外部调用,CONSTANT_NAME表示常量

2. 不会误解的名字

怎样才能取一个不会被误解的名字呢?

  • 要多问自己几遍:“这个名字会被别人解读成其他的意义吗?”
  • 推荐用minmax来表示(包含)极限,优于使用limit
  • 推荐用firstlast来表示包含的范围,优于使用startstop
  • 推荐用beginend来表示包含/排除范围
  • 使用ishascan这样的词来明确表示它是布尔值,避免使用反义的词
  • 与使用者的期望相匹配,例如get通常被认为是轻量级访问器

3. 审美

在保持代码的美感方面,也需要注意一些地方。

三条原则
  • 使用一致的布局,让读者很快就习惯这种风格
  • 让相似的代码看上去相似
  • 把相关的代码行分组,形成代码块
方法
  • 重新安排换行来保持一致和紧凑
  • 用方法来整理不规则的东西
  • 在需要时使用列对齐
nameLabel.text    = model.name;
sexLabel.text     = model.sex;
addressLabel.text = model.address;
  • 选一个有意义的顺序,始终一致的使用它:

    • 让变量的顺序与对应的HTML表单中字段的顺序相匹配
    • 从“最重要”到“最不重要”排序
    • 按字母顺序排序
  • 把声明按块组织起来,把代码分成“段落”

BigFunction {
    // step1: xxx
    ...
    // step2: xxx
    ...
    // step3: xxx
    ...
}
  • 个人风格与一致性: 一致的风格比“正确”的风格更重要

4. 该写什么样的注释

关键思想: 注释的目的是尽量帮助读者了解得和作者一样多。

什么不需要注释
  • 不要为那些从代码本身就能快速推断的事实写注释
  • 不要为了注释而注释
  • 不要给不好的名字加注释(拐杖式注释)—— 应该把名字改好(好代码 > 坏代码 + 好注释
什么应该作为注释
  • 记录你对代码有价值的见解

    很多时候代码本身却无法将这些思考表达出来,所以你就可能有必要通过注释的方式来呈现你的思考,让阅读代码的人知道这段代码是哪些思考的结晶,从而也让读者理解了这段代码为什么这么写。

标记通常的含义
TODO:待处理的事情
FIXME:已知的有问题的代码
HACK:对一个问题采取的粗糙的临时方案
XXX:危险!这里有重要的问题
  • 为代码中的瑕疵写注释

    临时方案,写出缺陷,暂时没有想到替代方案,或者想到了却因为时间或能力无法实现。

// 该方案有一个很容易忽略的陷阱: xxx
// 该方案存在性能瓶颈,在xx函数中
// 该方案的性能可能并不是最好的,如果使用某某算法可能会好很多
  • 给常量加注释
image_quality = 0.72; // 最佳的size / quanlity 比率
retry_limit = 4; // 服务器性能所允许的请求重试上限
  • 「全局观」注释:

    比如组织架构,类与类之间如何交互,数据如何保存,如何流动,以及模块的入口点等等。可以想象成带新人熟悉代码的场景。

// 这个文件包含了一些辅助函数,为某项业务提供了便利的接口
注释的规范(详见Airbnb规范)
  • 让注释保持紧凑,精确的描述函数的行为
  • 输入 / 输出例子来说明特别的情况
  • 使用 /** ... */ 作为多行注释。包含描述、指定所有参数和返回值的类型和值。

image.png

二、简化循环和逻辑

1. 把控制流变得易读

把条件、循环以及其他对控制流的改变做得越“自然”越好

运用一种方式使读者不用停下来重读你的代码

条件语句中参数的顺序

对于条件语句中参数的先后顺序,有以下几种情况:

// code 1
if(length > 10)

// code 2
if(10 < length)

// code 3
if(received_number < standard_number)

// code 4
if(standard_number < received_number)

更推荐使用第一种和第三种顺序,原则是:

  • 比较式的左侧: 通常放「被询问的」表达式,它的值一般是在不断变化的;
  • 比较式的右侧: 通常是用来「做比较」的表达式,它的值更倾向于常量。
if/else 语句块的顺序
  • 首先处理正逻辑而不是负逻辑的情况
  • 先处理掉简单的情况
  • 先处理有趣的或者是可疑的情况
?:条件表达式
  • 默认情况下用if/else更清晰,条件表达式只有在最简单的情况下使用。
var a.b = new c(a.d ? a.e(1) : a.f(1));

if (a.d) {
    var a.b = new c(a.e(1));
} else {
    var a.b = new c(a.f(0));
}
避免 do/while 循环(逻辑颠倒不易读)
从函数中提前返回(处理异常情况、减少嵌套)
适当增加小括号(逻辑更清晰)
// 容易被 && 和 || 的优先级迷惑
(a + b > c && a - b < c || a > b > c)

// 优化顺序后,可读性更强
((a + b > c) && ((a - b < c) || (a > b > c))

2. 拆分超长的表达式

超长的表达式常常是不容易理解的,我们需要把一些变量拆分出来,下列类型的变量更适合单独拆分:

用作解释的变量

拆分表达式最简单的方法就是引入一个额外的变量,让它来表示一个小一点的子表达式。

// 👎 超长表达式难以理解
if (ListeningStateChangedEvent.split(':')[0].strip() === 'root') { }

// 👍 按照含义拆分username
username = line.split(':')[0].strip();
if (username === "root") { }
总结变量

总结变量的目的只是用一个短很多的名字来代替一大块代码,这个名字会更容易管理和思考。

// 👎
if (request.user.id === document.owner_id) {
  // user can edit this document...
}

// 👍
let user_owns_document = (request.user.id === document.owner_id);
if (user_owns_document) {
  // user can edit this document...
}
使用德摩根定理

德摩根定理的原则是:「分别取反,转换与或」。有时你可以使用这个法则来让布尔表达式更具可读性。

not(a or b or c) 等价于 (not a) and (not b) and (not c)

not(a and b and c) 等价于 (not a) or (not b) or (not c)

// 使用德摩根定理转换以前
if (!(file_exists && !is_protected)) {}

// 使用德摩根定理转换以后
if (!file_exists || is_protected) { }

3. 变量与可读性

减少变量
  • 没有价值的临时变量:没有拆分任何复杂的表达式、没有做更多的澄清、只用过一次
  • 减少中间结果:通过让代码提前返回来处理中间用来保存临时结果的变量
缩小变量的作用域
  • 大文件拆分成小文件,大函数拆分成小函数,并且相互独立
  • 把只有一个函数会用到的全局变量变成私有变量
  • 对所有的引用使用 const ,使用 let 代替 var

三、重新组织代码

1. 抽取不相关的子问题

  • 抽取纯工具代码
  • 抽取多处复用的小模块
  • 创建大量通用代码:例如util中的代码
  • 项目专有的功能:把业务中特殊的代码单独处理
  • 简化已有接口:例如document.cookie提供了字符串,可以通过函数简化读取
这样做的好处:
  • 提高代码的可读性:

将函数的调用与原来复杂的实现进行替换,让阅读代码的人很快能了解到该子逻辑的目的,让他们把注意力放在更高层的主逻辑上,而不会被子逻辑的实现(往往是复杂无味的)所影响。

  • 便于修改和调试:

因为一个项目中可能会多次调用该子逻辑(计算距离,计算汇率,保留小数点),当业务需求发生改变的时候只需要改变这一处就可以了,而且调试起来也非常容易。

  • 便于测试:

同理,也是因为可以被多次调用,在进行测试的时候就比较有针对性。

2. 一次只做一件事

  • 列出代码所做的所有“任务”: 任务可以很小,比如检查数据格式。

  • 尽量把这件任务拆分到不同的函数中,或者至少是代码中不同的段落中。

这样做的好处:
  • 我们可以清晰地看到这个功能是如何一步一步完成的,而且拆分出来的小的函数或许也可以用在其他的地方。

  • 如果你遇到了比较难读懂的代码,可以尝试将它所做的所有任务列出来。可能马上你就会发现这其中有些任务可以转化成单独的函数或者类。而其他的部分可以简单的成为函数中的一个逻辑段落。

3. 把想法变成代码

“如果你不能把一件事解释给你祖母听的话说明你还没有真正理解它。”

在设计一个解决方案之前,如果你能够用自然语言把问题说清楚会对整个设计非常有帮助。因为如果直接从大脑中的想法转化为代码,可能会露掉一些东西。但是如果你可以将整个问题和想法滴水不漏地说出来,就可能会发现一些之前没有想到的问题。这样可以不断完善你的思路和设计。

可以尝试以下方法:

  • 想对着一个同事一样用自然语言描述代码要做什么
  • 注意描述中所使用的关键词和短语
  • 写出与描述所匹配的代码

4. 少写代码

  • 质疑和拆分你的需求

    不是所有的程序都需要运行得快,100%准确,并且能处理所有的输入。如果你真的仔细检查你的需求,有时你可以把它削减成一个简单的问题,只需要较少的代码。

  • 保持小代码库

    创建越多越好的“工具”代码来减少重复代码

    让你的项目保持分开的子项目状态

  • 熟悉你周边的库

    每隔一段时间,花15分钟来阅读标准库中的所有函数/模块/类型的名字。

参考内容:

《编写可读代码的艺术》

《编写高质量代码:改善JavaScript程序的188个建议》

《Airbnb JavaScript 代码规范(ES6)》