在代码中找到一段不合时宜且毫无用处的注释是不是很好玩?

这是一个很容易犯的错误:你修改了代码却忘了删除或更新注释。糟糕的注释并不会弄坏你的代码,但你能想象这对调试代码造成的影响吗?你读了注释。它说的是一套,而代码做的是另一套。你将不得不花费时间来搞清楚到底发生了什么,在最坏的情况下,你甚至可能被它误导。

但是编写没有任何注释的代码也是不现实的。在我超过15年的编程经验中,我还从没见过完全不需要注释的代码。

然而,还是可以通过某些途径减少对注释的依赖。我们可以利用编程语言的特性以及某些编码技巧来使得我们的代码更清晰。

这不仅会使我们的代码更容易被理解,还会从整体上帮助我们优化程序的设计。

我们往往称这种类型的代码是自文档的。让我告诉你如何用这种方法来编码。虽然在本文中使用的例子是JavaScript版的,但大部分的技巧也适用于其它语言。

技巧概览

有些程序员会把注释作为自文档代码的一部分。但在本文中,我只会关注代码部分。注释是很重要,但它是一个很大的话题需要单独的一篇文章去讨论。

我们可以把编写自文档代码的技巧归为三大类:

  • 结构相关的, 利用代码和目录的结构表明意图
  • 命名相关的, 例如函数和变量的命名
  • 语法相关的, 通过使用(或避免)某些语言特性来澄清代码意图

其中许多技巧在字面上看起来很简单。难就难在如何判断何时该使用何种技巧。当我讲到每一个技巧时我都会给你一些实用的例子。

结构

首先让我们看看与结构相关的技巧。结构变化是指通过移动代码来达到增加代码清晰度的目的。

把代码放到一个函数中

这和“抽取函数”的重构方式一致 —— 表示把现有代码移动到一个新创建的函数中去:把代码“抽取”到新函数中。

例如,请试着猜猜下面的代码在做什么:

var width = (value - 0.5) * 16;复制代码

并不是很清楚;在这加一个注释似乎是很有用的。或者我们可以把它抽取成一个函数来使它成为自文档的:

var width = emToPixels(value);

function emToPixels(ems) {
    return (ems - 0.5) * 16;
}复制代码

唯一的改变就是我把它移动到一个函数中去了。函数的名字描述了它的功能,因此也就不需要添加注释来澄清了。作为额外的好处,我们还得到了一个可以在任何地方调用的辅助函数,它还帮助降低了代码的重复性。

把条件语句替换为函数

带有多个条件的if语句在没有注释的情况下往往很难理解。我们可以使用一种类似抽取函数的方式来改善这种情况:

if(!el.offsetWidth || !el.offsetHeight) {
}复制代码

上面的条件判断是什么意思呢?

function isVisible(el) {
    return el.offsetWidth && el.offsetHeight;
}

if(!isVisible(el)) {
}复制代码

再一次的,在我们把代码移到一个函数中之后,代码立即变得容易理解了。

使用变量替换表达式

使用变量替换某些部分和把代码抽取到函数是类似的,不同点在于这里使用的是一个变量而不是函数。

让我们再看看if语句的例子:

if(!el.offsetWidth || !el.offsetHeight) {
}复制代码

除了抽取函数以外,我们还可以通过引入一个变量来简化代码:

var isVisible = el.offsetWidth && el.offsetHeight;
if(!isVisible) {
}复制代码

有时这会是比抽取函数更好的选择 —— 例如,你要澄清的逻辑只在某个特定的地方使用了。

这个技巧最常见的使用场景就是替换数学表达式:

return a * b + (c / d);复制代码

我们可以通过拆分上边的表达式来达到澄清意图的目的:

var multiplier = a * b;
var divisor = c / d;
return multiplier + divisor;复制代码

由于我的数学很糟糕,你就假设上边的例子是一些有意义的算法吧。不管在何种情况下,重点在于使用有意义的变量替换掉复杂,不易理解的表达式。

类和模块的接口

一个类或模块的接口 —— 也就是公共的方法和属性 —— 可以作为它自身用法的文档。

让我们看一个例子:

class Box {
    setState(state) {
        this.state = state;
    }

    getState() {
        return this.state;
    }
}复制代码

这个类当然也可以包含其它的代码。我有意使这个例子保持简单以演示公共接口是如何作为文档的。

你能看出如何使用这个类吗?也许花一点精力能搞明白,但它并不明显。

两个方法都有合理的命名:从名字就能看出它们能做什么。但除此之外,你并不是很清楚该怎样使用它们。最有可能的结果是你需要阅读更多的代码或是文档才能搞清楚。

如果我们把它改成这样呢:

class Box {
    open() {
        this.state = 'open';
    }

    close() {
        this.state = 'closed';
    }

    isOpen() {
        return this.state === 'open';
    }
}复制代码

搞清楚用法变得简单多了,不是吗?注意我们只是改变了公共接口;内部状态还是使用的this.state属性。

现在你可以一眼看明白如何使用Box类了。这个例子说明即使在第一个版本中每个函数都被很好的命名,但整体上还是让人迷惑的;通过像这样的一些小改动,代码得到了极大的改善。你总是要考虑大局。

代码聚合

把不同部分的代码聚合在一起也是某种形式的文档。

例如,你应该总是尽可能的在靠近使用变量的地方声明变量,并设法把对变量的使用放在一起。

这有助于表明不同代码片段之间的关系,从而帮助今后修改代码的人搞清楚都需要修改哪些相关联的代码。

考虑下面的例子:

var foo = 1;

blah()
xyz();

bar(foo);
baz(1337);
quux(foo);复制代码

你能一眼看出foo被使用了多少次吗?再比较一下下面的版本:

var foo = 1;
bar(foo);
quux(foo);

blah()
xyz();

baz(1337);复制代码

通过把使用foo的代码聚合在一起,我们可以清楚的看出哪些代码对它有依赖。

使用纯粹函数

纯粹函数比那些依赖外部状态的函数更容易被理解。

什么是纯粹函数呢?以相同的参数调用同一个函数,如果它始终输出相同的结果,那它很可能就是所谓的“纯粹”函数。这意味着函数不能有任何副作用或是依赖外部状态:例如时间,对象属性,Ajax等等。

由于所有影响结果的值都作为参数被明确的传入,这类函数往往更容易被理解。你不再需要到处找哪个值是从哪里来的或是哪些因素影响了结果。

纯粹函数有助于代码自文档化的另一个原因是你可以信任它们的输出。不论何种情况,函数的输出只取决于调用时传入的参数。它也不会造成任何外部影响,你可以信任它不会带来任何副作用。

非纯粹函数问题的一个典型例子就是document.write()。有经验的JS开发者知道你不应该使用它,但很多新手会被他绊倒。有时它可以正常工作 —— 但有时,在某些特定场景,它可以把你整个页面清空。这就是所谓的副作用!

要了解更多关于纯粹函数的信息,请参考函数式编程:纯粹函数

目录和文件结构

命名文件或目录时,请遵循项目中的命名规范。如果项目中没有明确的规范,那么就遵循你使用的编程语言的标准。

例如,如果你要添加新的UI相关的代码,你应该找找类似功能的代码保存在项目中的哪个目录。如果和UI相关的代码都在src/ui/下,那么你也应该这样做。

在你已经了解了项目中的其他代码段之后,你可以很容易的找到代码并了解它的功能。毕竟所有和UI相关的代码都在这里,那么它肯定也和UI相关了。

命名

关于计算机科学里的两个难题有一句很流行的名言:

计算机科学中只有2个难题: 缓存失效和命名. — Phil Karlton

那么让我们看看如何通过命名来使我们的代码自文档化吧。

重命名函数

给函数命名通常并不困难,但还是有一些可以遵循的简单规则:

  • 避免使用模糊的单词,例如“handle”或是“manage”。handleLinks(), manageObjects() 你能看出它们的功能吗?
  • 使用主动动词:cutGrass(), sendFile() —— 对于那些主动执行操作的函数。
  • 预示返回值:getMagicBullet(), readFile()。该方法并不适用于所有场景,但在合适的地方它是很有帮助的。
  • 强类型语言也可以使用类型签名来预示返回值的信息。

重命名变量

对于变量,这里有两条经验:

  • 指明单位:对于数值类型的参数,你可以在命名中包含期望的单位。例如,使用widthPx而不是width来表明变量的单位是像素。
  • 不要使用简写:a或b是不能接受的,除非是作为循环体中的计数器。

遵循已经建立的命名规范

在代码中要保持一致的命名规范。例如针对某个类型的对象,你应该使用一致的命名:

var element = getElement();复制代码

不要又突然叫它node:

var node = getElement();复制代码

如果你在代码中遵循一致的命名规范,任何人在阅读代码时都可以根据某个对象在其它位置的含义来猜测它在当前位置的含义。

使用有意义的错误

Undefined不是对象!

它是每个人的最爱。让我们不要遵循JavaScript的例子,并确保我们代码中抛出的任何错误都包含了有意义的信息。

是什么让一条错误信息有意义呢?

  • 它应该说明问题是什么
  • 如果可能的话,它应该包含导致错误的变量值或是对象
  • 关键:它应该能帮助我们找到哪里出问题了 —— 就相当于作为该功能如何工作的文档

语法

和语法相关的技巧会更贴近于某种具体的语言。例如,Ruby和Perl允许你使用各种奇怪的语法技巧,通常这是需要被避免的。

让我们看一看和JavaScript有关的部分。

不要使用语法技巧

不要使用奇怪的技巧。下面是迷惑人的一个好方法:

imTricky && doMagic();复制代码

它等同于下面这段看起来更完整的代码:

if(imTricky) {
    doMagic();
}复制代码

始终使用后面一种形式。语法技巧并不会给你带来任何好处。

使用命名的常量,避免魔法数字

如果你的代码中包含特殊的值 —— 比如数字或是字符串 —— 考虑使用一个常量替换。即使现在看起来很清楚,往往过一段时间之后就没人知道为什么一个特殊的数字会出现在这里了。

const MEANING_OF_LIFE = 42;复制代码

(如果你没有使用ES6,你可以使用一个var,它一样可以很好的工作)

避免布尔标志

布尔标志可能导致难以理解的代码。考虑下面这段代码:

myThing.setData({ x: 1 }, true);复制代码

这里的true是什么意思?除非去setData()的源码里找答案,否则你只能是一头雾水。

事实上你可以添加一个函数,或是重命名一个现有的函数:

myThing.mergeData({ x: 1 });复制代码

现在你立马就能看明白了。

利用语言特性

我们还可以利用语言的特性来编写更具表达力的代码。

JavaScript中一个很好的例子就是数组循环方法:

var ids = [];
for(var i = 0; i < things.length; i++) {
  ids.push(things[i].id);
}复制代码

上边的代码把一系列ID收集到数组中。然而,为了弄明白它的功能,你需要阅读整个循环体的代码。把它和下面使用了map()的方案做对比:

var ids = things.map(function(thing) {
  return thing.id;
});复制代码

在这个版本中,由于使用了map()我们一眼就能知道它会返回一个数组。当你的循环逻辑更复杂时,这会很有用。MDN上有一份数组遍历函数的列表.

JavaScript的另一个例子是const关键字。

你经常会定义一些值永远都不会改变的变量。一个常见的例子就是加载CommonJS模块:

var async = require('async');复制代码

我们可以把这个永远不会改变的意图表达的更明显:

const async = require('async');复制代码

作为附加的好处,如果某人不小心要修改这个值,代码会抛出错误。

反模式

合理使用以上提到的这些方法,你可以做的很好。然而有一些事情,你应该小心...

为了使用短函数而抽取函数

有些人主张使用微小的函数,如果你把所有的代码都抽取成函数,那就是你会得到的效果。然而这可能会对代码的易读性造成不好的影响。

假设你正在debug一段代码。你进入a()函数,发现它调用了b(),接着又根据调用关系找到了c(),依此类推。

虽然短函数可以是容易理解的,但如果某个函数只在某一个地方使用到了,你应该考虑使用“利用变量替换表达式”的技巧进行优化。

不要强求

和其他事情一样,编写自文档的代码并没有绝对正确的方式。因此,如果某个改动看起来不是一个好主意,那么就不要强求。

总结

使你的代码自文档化是一种可以长期改变代码可维护性的方法。每一段注释都是不得不维护的包袱,因此消除任何不必要的注释都是件好事。

然而,自文档的代码并不会完全取代文档或是注释。例如,代码在表达意图方面的能力有限,因此你还需要好的注释。同时,对于代码库来说API文档也是很重要的,因为除非代码量很小,阅读源码通常是不可行的。