代码质量随想录(六)用心写好注释

  上个月工作一直很忙,于是就很久没有更新博客了。今天早晨51CTO的博客管理员同学问了我一下,我也觉得是该继续写文章了。

  我要先说说对待注释的态度问题。有一种不写注释的理由,叫做“代码是最好的注释”或是“好的代码应该是自解释型的”。这两个观点其实我都非常赞同,只不过,它们容易被人误用为不写注释的藉口。我们有理由质疑,那种胡乱拼凑瞎写出来的代码能当作“最好的注释”来用吗?即便是非常注重质量的代码,也可能会有程序本身所传达不尽的意思,这个时候,需要些微的注释来提点一下。其实这种心态和随意涂鸦式的注释风格所存在的毛病是一样的,就是靠“不写注释”或者“狂写注释”来回避、掩盖领域模型的缺陷,以及测试用例的不完备。

  所以我标题中的“用心”就是这个意思,用正确的心态去写注释,须知注释能够发挥作用的前提是对领域模型有着正确的理解,以及对产品代码有着合适的测试覆盖度。在满足这两个前提的情况下,我们才会用注释来提升代码的可读性。否则,就是“宽严皆误”:如果没满足刚说的那两个前提,你写注释,就是掩盖错误;不写,就是逃避错误,实质是一样的。如果你发现注释不好写,写着不顺,你别怪注释,多半是业务模型或测试本身先出了问题。

  上一篇文章主要讲的是注释的重要性,这一篇则来谈谈注释的具体写作技巧。标题中所谓“写好”注释,除了能够通过注释来阐明代码的未尽之意,还有就是要让注释充当我们打磨领域模型与提升测试覆盖度的催化剂。有的时候我们先写了一定量的注释,然后在代码审查中发现可以由此来找出业务逻辑中存在的问题,从而完善标识符的命名,同时删减原有注释(ARC作者没有太强调这一点,我下面的例子补充了一些自己的看法)。这样一来,它在很多场合,其实是提高代码质量的一种中间过程和手段。

  ARC一书的作者总结说,好的注释就是要“精准”(precise)和“简洁“(compact),也就是有高的“信息/空间比”(information-to-space ratio)。通俗一点说,就是注释要写得言简义赅、微言大义,尽量用最少的文句精准地表达代码意图

1.尽量减少注释所用的词语,同时考虑以更加精准的名称来取代注释。

  比如:

// int表示CategoryType。
// 内部数值对的第一个浮点数是“分数”,
// 第二个是“重量”。
typedef hash_map<int, pair > ScoreMap;

  上一段代码的注释太长了,不如直接以映射关系来解释它:

// CategoryType -> (score, weight)
typedef hash_map<int, pair > ScoreMap;

  ARC的作者觉得修改之后的注释将3行减少至1行,很合适。不过我却觉得,做到这一步并没有结束,既然写出了“CategoryType -> (score, weight)”这个映射关系,那么是不是应该考虑将“(score, weight)”这个数值对封装起来呢?如果不是在程序级别进行封装,那么至少应该在语义上进行封装,比如,如果它表示一个重量查询表,那么,ScoreMap这个变量就应该命名为CategoryToWeightQueryTable或TypeToScoreWeightPair。

  如果从业务领域的角度看,前者好,毕竟根据Score来查Weight,应该是业务模型的一部分,该模型应该在某个类或包的总结说明处深入阐释过了,不需要再重复解释了,提一下WeightQueryTable这个领域模型的名字,就足够了。但如果从直白度看,则后者好。如果这段代码没有对领域进行深入建模,那么类似后者这种“傻瓜式”的表达则是减少阅读难度所必须的命名技巧。

  小翔以上提出的两种命名方法,虽然长了一些,但是该说的却都说了,而且省去了维护注释的麻烦。所以我说,“精简注释”与“琢磨命名”之间,并不矛盾,而且针对前者所做的努力,往往会激发后者。有些时候,我们精简了注释之后,发现可以用精准的命名来取代精简之后的注释;还有些时候,我们则发现,导致注释啰嗦,无法精简的根源,其实在于命名不当。或者更深入地看,有些情况下是由于对领域模型的不瞭解或者分析错误所致。通过“精简注释“这个工作流程,我们可以求得更好的命名,也可以厘清对领域模型的误解,所以我说,“精简注释“这一步,其实可以看作编写高质量代码的催化剂。

2.减少有歧义的表述方式。

  例如:

// 将数据插入缓存,但要先检查它是否过大。

  到底是检查谁的大小?数据还是缓存?如果是数据,应该写成:

// 将数据插入缓存,但要先检查数据是否过大。

  或者更简洁些:

// 若数据足够小,则将其插入缓存。

3.澄清模糊的用词。

  其实注释的书写与标识符的命名一样,都要竭力避免歧义与模糊表述。比如在某个网络爬虫程序中:

# 根据原来是否收录过该URL,对其赋予不同的优先级。

  优先度的高低到底怎么个“不同”法?没抓取过的网址优先度高,还是已经收录过的网址优先度高?应该用更为清晰的表述来阐明这个问题:

# 给未曾收录的URL赋予更高的优先级。

4.可能引发不同理解的情况,可以注释,但仍应尽力将其纳入标识符中。

  例如:

// 返回文件的行数。
public int countLines(String filename) { ... }

  那么,"hellonr crueln worldr"到底是几行?如果只考虑’n’,就是3行;如果只考虑"nr",就是两行。还有,如果文件内容是"hellon",那么考虑’n’之后的空字符串""吗?算的话就是两行,不算就是1行。如果类似Unix的wc程序那样,无视一切特例,只认’n’为标准,那么就应在注释中写明:

// 根据新行符'n'计算文件行数。
public int countLines(String filename) { ... }

  ARC作者举的这个例子并不错,不过小翔以为这种情况最好还想办法把可能消除理解歧义的因子纳入到标识符之中。比如我可能会直接将方法命名为:

/**
  * 以“新行符数加1”为标准统计文件内容所占行数。
  * (其余的参数、返回值及异常说明)
  */
public int countLinesByNewline(String filename) { ... }

  这样一来就可以省掉刚才那行注释了,而把宝贵的信息空间留给更有用的内容。而且,我心里还有个小算盘:万一将来更改了统计标准,那么标识符中赫然在目的“Newline”不得不引起修改者的注意,逼着其将它重构为符合新算法的名称,比如countLinesByCarriageReturn;反之,如果单单将它放到了注释中,那么很容易就会被忽视了。如果谁在修改统计标准时居然无视“精心”(也可以说是“矫情”)嵌入的那个“Newline”,我想你可以罚这个人请你吃顿汉堡王了。

  这里要多说两句。至少小爱我认为:

  第一,注释,尤其是Javadoc这样的API注释,是特别需要字斟句酌的。在表达清晰不致引发歧义的前提下,应该尽量简省。这样代码拥有者维护起来也方便,类库使用者运用起来也方便,对双方都有好处。

  第二,简省下来的空间应当更加着墨于那种不能或者不便纳入标识符的问题,这包括:可接受的参数范围、返回值以及各种可能发生的异常情况。切忌将本来可以纳入API标识符中的意涵放到注释里面大说特说,这样注释本应强调的其他信息就会被淡化,而且代码也没有充分利用标识符来容纳重要信息。

5.应该用注释来精确地描述那些微妙的函数行为,如边界状况、特殊输入值等。

  有些函数行为仅仅通过其标识符是无法判断的,如果硬要将这些可能引发理解问题的要素全部都纳入标识符中,恐怕很难有人能记住那么长的符号。这个时候最好是请注释帮忙,来厘清那些微妙之处。

  在程序开发中,最精准和简洁的文句其实还是用例,有的时候只要举出例子来,代码阅读者就可以依此来理解程序的意图了。

  例如:

// 从'src'输入源中移除包含'chars'的前后缀。
public String strip(String src, String chars) { ... }

  到底是将chars中指明的所有字符无差别地移除,还是精确地按照chars中字符的排列顺序来移除。如果前后缀中出现了多份匹配数据,那么是移除一份还是全部移除?

  要想澄清上述疑问,还是举特例吧:

// 例如,strip("abba/a/ba", "ab")将返回"/a/"。
public String strip(String src, String chars) { ... }

  上述这个特例举得就很好,首先它说清了第一个问题,应该是无差别地移除,而不是精确匹配,不然的话,返回的就是"ba/a/ba"了。而且还说清楚了第二个问题,应该是尽可能地移除多份前后缀,而不是仅移除最外部的一份,不然的话,返回值就是了"ba/a/"了。

  所举的例子必须具备特殊性。太过简单的说明不了问题:

// 例如,strip("ab", "a")将返回"b"。

  上面这个例子既没说清第一个疑问,也没说请第二个问题。其实,如果你和小翔一样,在代码质量这个问题上,是个“普瑞坦派”(Puritan,清教徒。至于为什么鸡毛蒜皮的小事都要分成这派那派的,请参考历史魔幻题材动漫巨著《银魂》),那么我低调地建议可以将上面这两个招人疑惑的问题通过标识符来澄清:

/**
  * 移除所有与无序字符组相匹配之前后缀。
  * ……(其余的参数、返回值及异常说明)
  */
public String stripAllPrefixesAndSuffixes(String src, 
        String unorderedCharsToRemove) { ... }

  这个颇有些自鸣得意的修改,和前面那个例子一样,可以将更多的文字空间留给那些更加“说不清”的事情,像是参数范围、返回值、异常等。

  所以我还是那句老话:不管怎么说,对注释的提炼毕竟还是很有可能促成对标识符甚至领域模型的完善。所以,注释这东西,确实是“写写更健康”。(本来我这广告专业的老毛病又犯了,想用这五个字作为本文的标题,后来想想那太过做作了,于是就小清新了一把)

  对于这个问题,ARC一书举的第二个例子倒真的是很恰当:

// 重排l中元素的位置,使小于pivot的元素出现在大于等于它的元素之前。
// 然后返回小于pivot的元素中下标最大者之下标,若无此种元素,则返回-1。
public int partition(List l, int pivot){...}

  如果举出特例,则可以厘清几个疑惑:

// ……
// partition([8 5 9 8 2], 8)将会把l重排为[5 2 | 8 9 8],并返回1。
public int partition(List l, int pivot){...}

  这个特例说明了好几个问题:

  1. 重排所参考的标竿元素"8″恰好与列表中的元素有重复,直接写出运行结果可以澄清该方法在边界状况的行为。
  2. 由举例可知该方法可以接纳含有重复元素的列表。
  3. 排列后的两段是各自无序的。
  4. 返回值1不在列表元素之中,厘清了误解:该方法返回的是“小于指标值的元素所具有之最大下标”,而不是“小于指标值的最大元素”(那样的话返回的是5),也不是“左方区域下标最大者所对应的集合元素”(那样的话是2)。

  如果没有这个“多功能”的例子,那么上面这四个问题很容易惹人疑惑。毕竟写得再好的文字注释还是会有人视而不见,此时只能通过这种华丽的例子来吸引这些程序员的眼球了。

6.遇到文档不完备等带有缺陷的第三方库时,应该编写学习型测试用例来掌握其用法,并进行适当封装。

  本来这一条是我阅读刚才那个例子想到的,但是鉴于它一来非常重要,二来有些程序员又有那么一种侥幸心理,对于含有明显缺陷的第三方库,希望通过马马虎虎的几行程序来随意应付过去,所以我必须将这个问题单独列出来说明。

  如果第三方代码的注释本身不是很清晰,需要如何来厘清误解呢?例如:

/* 依照pivot将l划为两个区段。 */
public static int partition(List l, int pivot) {...}

  如果你遇到了这样的遗留项目或者第三方库,那么不要犹豫,赶紧编写学习型测试用例来厘清它的用法。比如针对上一条中提出的几个疑问,假设我们的业务要求大者在前,同时与指标值相等的值应该归为大者区,那么我们可以这么编写学习型测试(伪代码,架空语言):

@Test(repeat=LEARNING_TEST_RUN_COUNT, exception=none)
public void testPartitionEnsureBiggerPartComeFirst(){
  // setup
  int listSize=createRandomInt(MAX_LIST_SIZE);
  int duplicatedElementsRate=createRandomDouble(MIN_REPEAT_RATE, 
                MAX_REPEAT_RATE);
  List data=createRandomList(listSize, duplicatedElementsRate);
  int pivot=getRandomElement(l);

  // call
  int result=TesteeClass.partition(data,pivot);

  // assert
  List BigNumbers=data.range(0,result);
  List smallNumbers=data.slice(result+1);
  assertMoreThanOrEqualTo(BigNumbers,pivot);
  assertLessThan(smallNumbers,pivot);

  // teardown
  ...
}

  这里我偷了个小懒。按照标准的测试用例书写规范,每一个受测的问题都应该单独采用一个测试方法来写。为了节省篇幅,我就将这几个受测内容合并到一个测试里面了。大家写工作代码时一定要分开。而且,还是要严格按照我刚给出的“设置”、“调用”、“断言”、“拆卸“这个步骤来,不能顺序错乱。

  以上这个测试用例可以让我们瞭解到这个第三方库的三个特性:是否接纳有重复元素的列表;“大于等于指标值的部分”是否排列在“小于其值的部分”之前;在指标值恰好等于列表中某个元素的特殊情况下,还能否得到正确的结果。如果用例能够通过,那么满足这三个特性的可能性就大大提高了。至于每个小区段内部的数据是否排列有序,这个只能通过查看源码或者根据输出值来统计分析了,很难通过单元测试反映。为了下游使用者的方便,我们还需要将它封装得更为精致一些,如果第三方库有缺陷,比如不能正确处理含有重复元素的列表,不能正确处理边界值,返回值不合要求等等,我们可以在封装内部对其进行调整:

public int rearrange(List l, int pivot){
  int result=-1;
  try{
    result=partition(l,pivot);
  }catch(...){
    ... // 如果异常合乎业务逻辑,则将其翻译为本领域中的异常。
    ... // 如果异常是由于第三方库的缺陷导致的,修正之。
  }
  ... // 如果第三方库的返回结果有缺陷,修正之。
  return result;
}

7.注释有时可充当提示语,在代码审阅时能帮助纠正逻辑错误。

  例如:

public void display(List products) {
  products.sort(priceComparator);

  // 按照从低至高的顺序显示产品价格。
  for (Product item: products)
    print(item->price);
  ...
}

  如果有“好心人”将priceComparator实现为逆序排列,那么代码出了Bug后,“按照从高至低的顺序显示产品价格。”这句注释可以帮助代码审阅者发现问题。

  当然啦,光靠注释还不行,综合我前面几条说过的,可以在标识符命名上做做文章。我们应该在代码中应该直接把priceComparator叫成AscPriceComparator,加了一个Asc前缀,这就明确了低价应该在前,从而能让很多“聪明人”和“粗心人”少犯点错误(至于犯错后的惩戒方法,请参考第4条)。

  要想尽可能地杜绝逻辑错误,最为根本的解决方案,还是完备的测试用例。有了testDisplayEnsureCheapestProductShownFirst这样的测试方法,能给咱们开发者省却好多心力。

8.适当地对函数参数注释,可以纠正第三方库的不足。

  Python等语言支持按照参数名来调用函数,例如:

def Connect(timeout, use_encryption): ...

# 使用命名参数来呼叫函数。
Connect(timeout = 10, use_encryption = False)

  C++和Java等语言不行,不过可以使用行内参数注释。例如:

public void connect(int timeout, bool useEncryption) { ... }

connect(/* timeout = */ 10, /* useEncryption= */ false);

  ARC作者强调的这一条,我有保留地同意。其实,有了完善的API文档,再搭配IDE的鼠标悬停,完全可以弥补Java这样不支持“按参数名调用”的语言所带有的缺点。调用时之所以需要注释,根本原因还是API的设计问题。好的函数或方法,在恰当名称的引领下,其参数的个数与意义应当不言自明,配合重载与可变参数列表机制,应该能让用户不假思索地使用它。例如,绘制矩形的函数drawRect(),自然让人想到需要4个参数,起点横纵坐标与宽高。如果要按照对角线两个端点进行绘制呢?提供一个drawRectByPoints()即可。用户在开发环境中敲入drawRect这几个字符之后,各种前导名称相同的方法以及它们的重载版本就都会自动列出来了,旁边还有说明文档,我们可以在这些候选方法中选择自己需要的来调用。

  所以,提供丰富的方法族(drawRect()、drawRectByPoints())与重载方法(drawRect(int, int, int, int)、drawRect(Point, int, int)、drawRect(Point, Size)),可以在很大程度上取代按参数调用。像刚才的connect方法那样,确实很难通过其他手段来揭示参数意义时,可以使用行内参数注释。另外,这个小技巧,还能够纠正第三方库在参数命名方面的不足,因为有时我们需要直接调用第三方库,同时又不便修改其参数,只能通过行内注释对它稍作说明

9.不要长篇累牍地注释业已约定成俗的范式。直书范式名称即可,必要时可辅以英文或参考网址。

  例如:

// 本类含有大量成员,其存储的信息与数据库中的相同。
// 存于此处是为了快速访问。此对象在接受查询时,
// 先判断所查数据是否存在,若是则返回;
// 否则将从数据库中读取其值并保存以备下次使用。

  不如直书:

// 该类充当数据库的缓存层(caching layer)。

  代码的读者如果知道缓存层是什么,那么立刻就明白该类的用途了,要是不知道的话,也可以询问他人或从网上得知caching layer的具体原理。

  同理,我们在编写游戏时,也要多用专有名词来代替解释,例如,不要详细解释卡马克卷轴算法是怎么起源的,有几个变种,用多少个缓冲区,每个缓冲区多大,按照什么顺序绘制缓冲区,怎么更新缓冲区来应对地图移动等等等等……而是直接写明:

/**
  * 使用卡马克卷轴(Carmack Scroll)算法,
  * 参考网址:http://en.wikipedia.org/wiki/Adaptive_tile_refresh。
  */
public void draw(Graphics g) {...}

  专有名词甚至可以直接纳入标识符中,比如,可以直接删去上例中的注释,而把方法名改为drawWithCarmackScroll。

  除了专有名词外,也有很多词汇用于总结这种约定成俗的范式,比如:试探法(heuristic)、蛮力法或暴力法(brute force)、笨办法(naive solution)等。

  这篇文章看起来有点儿长,是要好好总结一下了。

  • 首先,在注释中要使用精准简洁的词语,避免模糊或有歧义的表达。(第1、2、3条)
  • 然后,根据提炼之后的注释,尽量将可能引发误会的要素直接纳入标识符中。确有必要时,可举几个能够说明边界状况与特殊值的例子做注释,以促进理解。(第4、5条)
  • 还要注意,好的注释可以充当提示语,帮助我们在代码评审中发现逻辑错误、澄清某些不易理解的参数、快速掌握代码中所用的专有技术。(第7、8、9条)

  但是,最后小翔必须将自己的一点原创心得分享给大家:注释所要阐明的问题,其根本解决方案还在于对“领域模型的准确把握”以及“对业务流程的完备测试”。有了对领域模型的准确把握,我们就可以将很大一部分问题融入标识符之中,使代码阅读者立刻就能抓住程序所要解决的核心问题,能够流畅地读完并理解全部代码。我一直对朋友们说我想要像读一本引人入胜的小说那样阅读一段“引人入胜”的代码,说的就是上面这个意思。在另一方面,如果有了完备的测试用例,我们就能够获得它所带来的原动力,让它督促我们写出可读性好的高质量代码来。毕竟,要靠注释才能够阐明的那些个隐晦问题,其实在负责任的程序员看来,早就应该通过测试用例将其覆盖了。完备的测试用例,能够推动你去阐明那些你不愿去面对,想要侥幸逃避的棘手问题。

  写了这么多年的程序,我后来才明白一个道理,那就是领域模型和测试用例其实都是注释,一个是思维型注释,一个是代码型注释。它们两个都有文字注释所无法取代的重要职能。这三者并不矛盾,有了前两者,再加上画龙点睛式的注释,这才真正算得上高质量的代码!看完了这两篇讲注释的文章之后,我想请大家思考一下,那些想通过大段注释来极力掩盖的问题,是不是由于领域模型的抽象不准确或是测试用例的编写不完备所导致的,同时,号称从来不写注释的朋友们,你们是不是真的将那部分精力投入到业务模型的提炼以及测试用例的编写上去了呢?写出来的代码有没有经过反覆评审、多次重构,有没有达到“代码就是最好的注释”这种境地呢?

  所谓“用心写好注释”,就是我们应该用正确的态度去编写注释,既不能拿它来掩盖问题,又不能在需要写它的时候找藉口逃避。用好的注释来促进读者对代码的理解,用好的注释来激发对代码可读性的提升,这,才是它真正发光的地方。

  下面几篇文章将会关注稍微高一级的程序组织单元,那就是循环与逻辑控制流。

爱飞翔
2012年8月3日至4日

欢迎转载,请标明作者与原文网址。如需商用,请与本人联系。

原文网址:http://agilemobidev.com/eastarlee/code-quality/thinking_in_code_quality_6_write_elegant_comments_zh_cn/

廣告

代码质量随想录(六)用心写好注释” 有 4 則迴響

發表迴響

在下方填入你的資料或按右方圖示以社群網站登入:

WordPress.com 標誌

您的留言將使用 WordPress.com 帳號。 登出 /  變更 )

Google+ photo

您的留言將使用 Google+ 帳號。 登出 /  變更 )

Twitter picture

您的留言將使用 Twitter 帳號。 登出 /  變更 )

Facebook照片

您的留言將使用 Facebook 帳號。 登出 /  變更 )

連結到 %s