如何编写更加自解释的代码

2016-09-08 W.Y. 更多博文 » 博客 » GitHub »

Document Quality

原文链接 https://bubkoo.github.io/2016/09/08/15-ways-to-write-self-documenting-javascript/
注:以下为加速网络访问所做的原文缓存,经过重新格式化,可能存在格式方面的问题,或偶有遗漏信息,请以原文为准。


当你发现代码中的某些注释完全无用时你会怎么办?

我们经常会犯一个错误:当我们更新代码时,却忘记更新相应的注释。不友好的注释并不会影响代码的执行,但使我们的调试和阅读带来极大困扰,注释描述的是一种逻辑,而代码确是另外一种,结果会浪费我们大量时间来搞懂这段代码的意思,更糟糕的是这样的注释很可能误导我们。

这并不是说注释完全没有必要,优秀的代码有具有相应优秀的注释。我们可以利用某些编程技术来减少我们的注释,使我们的代码更加自解释。这不仅仅使我们的代码更加容易理解,还有助于改善项目的整体设计。

这样的代码通常被称为自解释的代码,下面我将介绍一些编写自解释代码的方法。

<!--more-->

概览

一些程序猿将注释也作为自解释代码的一部分,注释很重要,可以用很大的篇幅单独讨论。在本文中,我们只讨论代码。

我先将要讨论的技术分为三大类:

  • 代码结构,清晰的代码和目录结构能更好地表达我们的意图;
  • 命名相关,比如方法和变量命名;
  • 语法相关,使用(不使用)某些语法特性可以使代码更清晰。

这几个点看起来都很简单,难点在于在合适地方选择合适的技术,下面我将用分别用实例讲解如何使用这些技术。

代码结构

改善现有代码的结构来增加项目整体的清晰度。

提取帮助函数

将一些通用的代码提取为帮助函数。例如,很难想到下面代码是什么意思:

var width = (value - 0.5) * 16;

可以在这里添加注释,或者将其提取成为一个函数:

var width = emToPixels(value);

function emToPixels(ems) {
    return (ems - 0.5) * 16;
}

唯一的变化就是我们将计算过程移到一个函数中,通过函数名使其自解释,同时我们还得到一个可以复用的帮助函数,减少了代码冗余。

将条件表达式提取为函数

一个包含多个条件判断的表达式在没有注释的情况下很难理解,看下面的代码:

if(!el.offsetWidth || !el.offsetHeight) {
}

是不是很难理解,我们可以将条件判断部分提取为一个函数,是不是瞬间就变得很好理解:

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

if(!isVisible(el)) {
}

用变量替换表达式

这和上个方法很像,这里只是将表达式的计算结果放在一个变量中,看上面讨论过的那个例子:

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';
    }
}

是不是更加容易理解和使用?现在,你可以一眼就看出来如何使用 Box 类。这里我们仅仅改变了公共接口,在内部仍然使用 this.state 属性来表示。

代码分组

将不同的代码分组也可以作为“文档”的一部分,例如,我们应该保证变量的声明位置尽量靠近变量的使用位置,而且尽可能按组使用这些变量。

分组可以暗示代码内部之间的关系,将来其让人修改你的代码时也可以更快找到该修改的位置。

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请求等)。

这类函数更加容易理解,函数的输出结果仅由输入参数决定,你不必纠结这个结果到底是如何得到的,会不会有其他因素影响的了结果,你可以完全信任这类函数的返回值,更不会影响函数外部的状态。

文件和目录结构

在同一个项目中保持相同的命名约定,如果项目中没有明确的命名约定,可以遵循你选择的语言的标准。

比如,你正在添加 UI 相关的代码,可以先在项目中找到这类代码的位置,如果 UI 相关的代码放在 src/ui/ 下面,那么请将你的代码也放在这里。

命名相关

先看一个名言:

在计算机领域只有两个难题:缓存失效和命名。-- Phil Karlton

下面我们就来看看如何通过命名来使我们的代码自解释。

函数命名

函数命名并不复杂,但有几个原则可以遵循:

  • 避免使用语义模糊的动词,比如“handle”或“manage”:handleLinks(), manageObjects() 我们很难理解这些方法到底是用来干什么的?
  • 使用主动动词:cutGrass(), sendFile()
  • 暗示返回值:getMagicBullet(), readFile()
  • 对于强类型的语言,还可以使用方法签名来暗示函数的返回值。

变量命名

对于变量命名有两个经验法则:

  • 暗示数值的单位:对于数值类型的变量,我们可以通过更好的命名来暗示该值对于的单位,例如,使用 widthPx 代替 width 可以让我们更清晰地知道该值的单位是像素;
  • 不要使用简写:ab 是不规范的变量命名,循环中的计数器除外。

遵循现有的命名规范

尽量遵循现有项目中的命名规范。例如,对于特殊类型的对象,请保持相同的命名:

var element = getElement();

请不要突然命名为:

var node = getElement();

使用更有意义的错误提示

Undefined is not an object!

这个错误我们经常可以看到,这是一个反例,我们应该确保我们的代码中抛出的任何错误都有一个有意义的错误消息。

如何做呢?

  • 应该描述清楚具体的问题;
  • 如果可能,尽可能包含导致该错误的变量或数据;
  • 关键点:错误信息应该帮助我们找到错误所在,应该作为文档告知我们函数应该如果工作。

语法相关

不要使用某些语法技巧

看下面示例:

imTricky && doMagic();

下面的方式更加一目了然:

if(imTricky) {
    doMagic();
}

请总是使用后面这种方式,前一种语法技巧不会给任何人带来任何好处。

使用命名的常量

如果在代码中有一个特殊的数字或字符串字面量,请将其声明为一个常量。如果在代码中直接使用一个特殊的数字字面量,现在可能很好理解其意义,但在一两个月之后,没人会理解这个数字的具体意义。

const MEANING_OF_LIFE = 42;

避免使用 Boolean 字面量

使用 Boolean 字面量可能导致一些不好理解的代码:

myThing.setData({ x: 1 }, true);

我们压根不知道这里的 true 是什么含义,除非阅读 setData() 的源码。我们可以添加另外一个方法来完成相同的功能:

myThing.mergeData({ x: 1 });

充分利用语言特性

一个很好的例子是循环代码:

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;
});

另一个实例是 JavaScript 的 const 关键字。通常,我们可能会定义一些永远都不会改变值的变量,一个非常常见的例子是实用 CommonJS 加载一个模块:

var async = require('async');

我们可以直接将其声明为一个常量,使其意义更加清晰:

const async = require('async');

结论

编写自解释的代码能够提高系统的可维护性,每一段注释都需要额外的维护精力,所以应该尽可能减少注释的数量。然而,自解释的代码并不能完全替代注释,有必要在适当的位置保留某些关键的注释,而且 API 文档也非常必要,除非你的代码库非常轻量级 -- 开发人员可以直接阅读你的代码。