有些人以为注释写的多就可以让代码更加可读,然而通过大量的实践证明,事实并非如此。

有些时候注释不但没能让代码变得可读,反而由于大量的注释充斥在代码中间,让程序变得障眼难读,而且代码的逻辑一旦修改,对应的注释也应该一起修改,修改注释花费大量时间不说,更严重的问题是,最初添加注释的人与后来修改代码逻辑的人往往不是同一个人,或是因为编码习惯不一致,或是因为懒得修改注释,此时就很容易造成注释内容与代码逻辑版本不匹配,如果再有第三个人来读这段代码,结果可想而知,因为大量过期的注释让代码更加难以理解。

实际上,真正优雅的代码,是几乎不需要注释的。

当我们发现自己写的代码必须要很多注释才能让别人理解时,那么此时的代码肯定是含糊其辞,逻辑不清的。其实,程序语言相比自然语言,是更加强大而严谨的,它其实具有自然语言最主要的元素:主语,谓语,宾语,名词,动词,如果,那么,否则,是,不是,…… 所以如果我们充分利用了程序语言的表达能力,则完全可以用程序本身来表达它到底在干什么,而不需要自然语言的辅助。

有少数的时候,我们也许会为了绕过其他一些代码的设计问题,采用一些违反直觉的作法。这时候我们应该使用尽可能短的注释,说明为什么要写成这个奇怪的鸟样子。这样的情况应该很少很少,否则就意味着整个代码的设计有问题。

下面总结一下怎样才能写出可读性良好的代码:

(1)使用有意义的函数和变量名字。

如果你的函数和变量的名字,能够切实的描述它们的逻辑,那么你就不需要写注释来解释它在干什么。比如:

// put elephant1 into fridge2
put(elephant1, fridge2);

由于我的函数名put,加上两个有意义的变量名elephant1和fridge2,已经说明了这是在干什么(把大象放进冰箱),所以上面那句注释完全没有必要。

(2)局部变量应该尽量接近使用它的地方

有些人喜欢在函数最开头定义很多局部变量,然后在下面很远的地方使用它,例如:

void foo() {
  int index = ...;
  ...
  ...
  bar(index);
  ...
}

由于这中间都没有使用过index,也没有改变过它所依赖的数据,所以这个变量定义,其实可以挪到接近使用它的地方:

void foo() {
  ...
  ...
  int index = ...;
  bar(index);
  ...
}

这样读代码的人看到bar(index),不需要向上看很远就能发现index是如何算出来的。而且这种短距离,可以加强读者对于这里的“计算顺序”的理解。否则如果index在顶上,读代码的人可能会怀疑,它其实保存了某种会变化的数据,或者它后来又被修改过。如果index放在使用它的地方,我们就清楚的知道,index并不是保存了什么可变的值,而且它算出来之后就没变过。

局部变量的本质——就是电路里的导线,变量定义离用的地方越近,导线的长度就越短,我们不需要摸着一根导线,绕来绕去找很远,就能发现接收它的端口,这样的电路更容易理解。

(3)局部变量名字应该简短

这貌似跟第(1)点相冲突,简短的变量名怎么可能有意义呢?

注意这里说的是局部变量,因为它们处于局部,再加上第(2)点已经把它放到离使用位置尽量近的地方,所以根据上下文我们就会容易知道它的意思:

比如,你有一个局部变量,表示一个操作是否成功:

boolean successInDeleteFile = deleteFile("foo.txt");
if (successInDeleteFile) {
  ...
} else {
  ...
}

这个局部变量successInDeleteFile大可不必这么啰嗦。因为它只用过一次,而且用它的地方就在下面一行,所以我们可以轻松发现它是deleteFile返回的结果。如果我们把它改名为success,其实读代码的人根据一点上下文,也知道它表示”success in deleteFile”。所以我们可以把它改成这样:

boolean success = deleteFile("foo.txt");
if (success) {
  ...
} else {
  ...
}

这样的写法不但没漏掉任何有用的语义信息,而且更加易读。successInDeleteFile这种“camelCase”,如果超过了三个单词连在一起,其实是很碍眼的东西。所以如果我们能用一个单词表示同样的意义,那当然更好。

(4)不要重用局部变量

很多人写代码不喜欢定义新的局部变量,而喜欢重用同一个局部变量,通过反复对它们进行赋值,来表示完全不同意思。比如这样写:

String msg;
if (...) {
  msg = "succeed";
  log.info(msg);
} else {
  msg = "failed";
  log.info(msg);
}

虽然这样在逻辑上是没有问题的,然而却不易理解,容易混淆。变量msg两次被赋值,表示完全不同的两个值。它们立即被log.info使用,没有传递到其它地方去。这种赋值的做法,把局部变量的作用域增大,容易让人以为它在以后会改变,或者以为它会在其它地方被使用。

if (...) {
  String msg = "succeed";
  log.info(msg);
} else {
  String msg = "failed";
  log.info(msg);
}

由于这两个msg变量的作用域仅限于它们所处的if语句分支,我们可以很清楚的看到这两个msg被使用的范围,而且知道它们之间没有任何关系。

(5)把复杂的逻辑提取出去,做成工具函数

有些人写的函数很长,以至于看不清楚里面的语句在干什么,所以他们误以为需要写注释。如果仔细观察这些代码,就会发现不清晰的那片代码,往往可以提取出去,做成一个函数,然后在原来的地方调用。由于函数有一个名字,这样我们就可以使用有意义的函数名来代替注释。例如:

...
// put elephant1 into fridge2
openDoor(fridge2);
if (elephant1.alive()) {
    ...
} else {
    ...
}
closeDoor(fridge2);
...

如果把这片代码提出去定义成一个函数:

void put(Elephant elephant, Fridge fridge) {
  openDoor(fridge);
  if (elephant.alive()) {
    ...
  } else {
     ...
  }
  closeDoor(fridge);
}

这样原来的代码就可以改成:

...
put(elephant1, fridge2);
...

更加清晰,而且注释也没必要了。

(6)把复杂的表达式提取出去,做成中间变量

有些人听说“函数式编程”不错,也不理解它的真正含义,就在代码里大量使用嵌套的函数。像这样:

Pizza pizza = makePizza(crust(salt(), butter()),
   topping(onion(), tomato(), sausage()));

这样的代码一行太长,而且嵌套太多,非常不清晰。其实一枚合格的函数式程序员,都知道中间变量的好处,不会盲目的使用嵌套的函数。他们会把这代码变成这样:

Crust crust = crust(salt(), butter());
Topping topping = topping(onion(), tomato(), sausage());
Pizza pizza = makePizza(crust, topping);

这样写,不但有效地控制了单行代码的长度,而且由于引入的中间变量具有“意义”,步骤清晰,变得很容易理解。

(7)在合理的地方换行

对于绝大部分的程序语言,代码的逻辑是和空白字符无关的,所以可以在几乎任何地方换行,也可以不换行。这样的语言设计是个好东西,因为这能够让程序员自由控制代码格式。然而,它也引起了一些问题,因为很多人不知道如何合理的换行。

有些人喜欢利用IDE的自动换行机制,编辑之后用一个热键把整个代码重新格式化一遍,IDE就会把超过行宽限制的代码自动折行。可是这种自动这行,往往没有根据代码的逻辑来进行,不能帮助理解代码。自动换行之后可能产生这样的代码:

   if (someLongCondition1() && someLongCondition2() && someLongCondition3() &&
     someLongCondition4()) {
     ...
   }

由于someLongCondition4()超过了行宽限制,被编辑器自动换到了下面一行。虽然满足了行宽限制,换行的位置却是相当任意的,它并不能帮助我们理解这代码的逻辑。这几个boolean表达式,全都用&&连接,所以它们其实处于平等的地位。为了表达这一点,当需要折行的时候,应该把每一个表达式都放到新的一行,就像这个样子:

   if (someLongCondition1() &&
       someLongCondition2() &&
       someLongCondition3() &&
       someLongCondition4()) {
     ...
   }

这样每一个条件都对齐,里面的逻辑就很清楚了。再举个例子:

   log.info("failed to find file {} for command {}, with exception {}", file, command,
     exception);

这行因为太长,被自动折行成这个样子。file,command和exception本来是同一类东西,却有两个留在了第一行,最后一个被折到第二行。它就不如手动换行成这个样子:

   log.info("failed to find file {} for command {}, with exception {}",
     file, command, exception);

把格式字符串单独放在一行,而把它的参数一并放在另外一行,这样逻辑就更加清晰。

为了避免IDE把这些手动调整好的换行弄乱,很多IDE(比如IntelliJ)的自动格式化设定里都有“保留原来的换行符”的设定。如果发现IDE的换行不符合逻辑,我们可以修改这些设定,然后在某些地方保留我们自己的手动换行。

说到这里,需要多啰嗦依据,这里所说的“不需注释,让代码自己解释自己”,并不是说要让代码看起来像某种自然语言。有个叫Chai的JavaScript测试工具,可以这样写代码:

expect(foo).to.be.a('string');
expect(foo).to.equal('bar');
expect(foo).to.have.length(3);
expect(tea).to.have.property('flavors').with.length(3);

这种做法是极其错误的。程序语言本来就比自然语言简单清晰,这种写法让它看起来像自然语言的样子,反而变得复杂难懂了。

本系列其他文章:

添加新评论