按月彙整:八月 2012

無論何時何地,都要保持年少時的心!

看完了《少年同盟》第2季共13集,更加覺得少年情誼之可貴。無論何時何地,都要保持年少時的心。頑張!

《少年同盟》第2季結束畫面

廣告

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

  上个月工作一直很忙,于是就很久没有更新博客了。今天早晨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/

代碼質量隨想錄(六)用心寫好注釋

  上個月工作一直很忙,于是就很久沒有更新博客了。今天早晨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_tw/