分類彙整:代碼質量

代碼質量你問我答

  這幾天承蒙eoe.cn(http://www.eoe.cn/)的靳總之邀,在網站開了個博客(http://my.eoe.cn/jeffreylee)。可以看出,靳先生是勤奮耕耘於技術社區的人,而且很久以前我還經常去這家網站學習。後來工作稍顯忙碌,就去得少一些。既然開了博客,按理說應該寫一些帖子,不過說來慚愧,個人博客上現有的那些都是很早以前的舊帖子了,所以爲了給eoe.cn移動開發者社區做點小貢獻,特開一個問答貼。關於代碼質量的問題,請大家盡情發問,只要是我知道的,一定奉上答案。

  請訪問http://my.eoe.cn/915740/archive/2396.html

  大家也可在此發問,有代表性的問題我會同步到eoe博客。

廣告

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

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

代码质量随想录(五)注得多不如注得巧

  写代码也流行注水了么?不是不是,我说的是注释。其实注释这个东西,历史久远。我们可以宽泛一点儿说,《春秋》就是要配上左传的注解,才能兴发其“微言大义”嘛!注释有很多种,如果按照注释者与原文作者是不是同一个人来分,可以划分成自注和他注。在程序员这个行当内,一般来说,还是自注多一些,自己写代码,自己加注。有的时候进行代码审查或者复用遗留代码时,才可能会有必要对他人写的代码加注。

  从代码质量的角度看,注释写得应不应该,写得好不好,应该从它是否有助于加深代码读者及代码使用者对程序的理解这一标准来判断。按照《The Art of Readable Code》作者的说法,注释的目标,就是让读者尽量明白代码作者的编程意图

  那么,具体到代码书写层面,究竟怎么注释才算好呢?这个问题得展开来谈。这一篇文章先谈谈注释的时机问题,下一篇再来研究注释的内容。

1.显而易见的代码别注释

  写注释经常会遭遇两种极端态度,一种是绝对不写注释,一种是写废话连篇的注释。对于持第一种态度的人,小翔希望看完讲注释的这两篇文章之后,能够适当转变一下态度,稍稍缓释惜墨如金的执念,多为大家带来一些精彩的注释。有很多理由都会被拿来为不写注释做辩护,这在后文会一一讲到,我在这里主要是想先说说口水型注释的害处。从我个人的工作经历来看,不写注释的人一旦能够理性地认识到注释的好处,那么他们很有可能养成在编码的同时自发地为代码精准加注的好习惯,然而没话找话型的程序员,则很难写出优雅简洁的注释来,对这些人来说,先要消解注释泡沫才行。

  比如,代码本身就含有的题中之义就不宜再以注释的形式重复了。

// Account类的定义。
class Account {
  // 构造器
  public Account(){...}

  // 将profit字段设定为新指定的值
  public void setProfit(double profit){...}

  // 获取本Account对象的profit字段值
  public double getProfit(){...}
}

  以上几行注释的内容完全是在重述代码,意义不大

2.注释要尽量阐发被注标识符无法容纳的意思,比如操作的同步性、工作流程、参数的范围、返回值、异常等有价值的信息

  形成上例这种情况,也许还有一个原因,那就是有些公司或者团队会对注释形成一种强制要求,比如在Java语言中要求公有和保护级别的API必须写Javadoc。这种规范是好的,不过要定出具体细则来,比如类的总结部分怎么写,构建子怎么写注释,简单的setter/getter方法怎么写注释。

  针对上述这些问题,我觉得在制定开发团队的注释规范时,要明确指出:注释应该尽量阐明被注标识符无法容纳的义涵。例如,针对本类字段的简单存取方法,如果其中有特殊之处,比如setter方法参数的取值范围、参数非法时是否会造成异常、设置的新值是否立刻生效等等问题,那么这些情况就应当明确标注。例如:

/** 
 * 将profit字段设定为新指定的值。设置动作有可能不会立即生效,要根据该账户对象的修改策略
 * 所允许的单位时段内最大修改次数来定。如果修改策略是“延时生效”,则超过修改次数限制的   
 * 修改动作会在下个时间段生效.
 * @param profit 新的收益率,必须在[0.0d, 1.0d]之间
 * @throws IllegalArgumentException  如果收益率不在合法区间内
 * @throws IllegalOperationException 如果本次设置已在修改策略容许次数之外,
 *                                   且修改策略是“立即生效”
 */
public void setProfit(double profit){...}

  虽然有点儿啰嗦(我写注释的毛病,哈哈),不过比起上例来说,毕竟还是带来了一些新内容。而且一旦通过注释把这些隐晦的东西挑明了,那么还可以由此引发新的讨论,以促进团队成员对代码的理解,进而触发重构。比如大家可以尽情吐槽:这个方法名怎么能简简单单地叫成setProfit呢?这样怎么能体现出它还受制于“账户修改策略”这个事实?参数怎么能叫成profit?为什么不写成profitBetweenZeroAndOne?如果设置无法立刻生效的话,那为什么不提供通知机制?不然客户代码怎么知道什么时候才能设置生效?等等等等……这些质疑未必各个都有道理,不过可以由此让我们重新审视该方法,甚至是整个类,看看它设计得是不是有问题,对下游开发者是否友好。

  再看getProfit方法,可就有点儿尴尬了,因为不管怎么写注释,貌似都很无力。这时咱们就可以很有自信地无视它了。不过使用Eclipse的开发者可能会遇到一些小障碍,比如在设定里面设置好了强制要求所有protected、public的API都要写Javadoc注释,那么略去这种getProfit方法不注,可能会有警告或者错误。这种小麻烦,恐怕就需要一些变通办法了,大家如果有好办法,也请告诉我。

  如果代码读者和下游开发者有必要适当地瞭解工作流程和返回值详情,那么这些信息就要注释,比如:

// 在子树中寻找某个深度范围内,具有给定名称的节点。
public Node findNodeInSubtree(Node subtree, string name, int depth){...}

  就应该改为:

// 找寻具有指定名称的节点,找不到则返回null。
// 如果深度值小于等于0,则整个子树都将被查找。
// 如果大于0,则只在N级深度范围内查找。
public Node findNodeInSubtree(Node subtree, string name, int depth){...}

3.如果编程意图不够明显,则可以适当地加些注释。此种情况的根本解决办法还是通过重构来理顺复杂的代码,使之清晰、直观

# 移除第二个'*'字符及其后内容
name = '*'.join(line.split('*')[:2])

  ARC作者可能认为以上这句大家看到之后第一眼有点搞不清楚状况,所以建议加上那行注释。小翔倒是觉得,不妨对上面的代码进行重构,将“切割、数组切片、拼合”这个大操作拆解成三个小操作,并且封装起来,这样更符合迪米特原则(又叫得墨忒耳定律、最少知识原则),而且看上去代码会更加清晰,不需加注即可明白。

String name=truncateFromDelimiter(line,'*',2);
...
private String truncateFromDelimiter(String input, char delimiter, 
                                     int groupIndexToDropFrom){...}

4.再好的注释也无法彻底掩饰坏名称

// 确保回覆对象的内容符合请求对象中关于条目数量、总字节数等规格的限定。
public void cleanReply(Request request, Reply reply){...}

  以上注释中的“确保”(Enforce)、“限定”(Limit)等词应该直接纳入方法名称中。不妨改成:

// 经请求对象所限定的规格包括“条目数量”、“总字节数”等指标。
public void enforceLimitsFromRequest(Request request, Reply reply){...}

  这样不仅注释内容变简单了,而且方法名称所表达的意思也比原来精确许多,让人更易理解。关于这一点,我在做项目时体会特别深刻,千万不要试图用注释去粉饰糟糕的名字,而应该直接修正不当的命名

// 释放主键所指向的注册表操作句柄。该方法并不修改实际的注册表内容。
public void deleteRegistry(RegistryKey key);

  既然“并不修改实际的注册表内容”,那么名称中delete何谓?用注释无法掩饰这个矛盾。莫如去掉注释,直书其意,这样不需要注释大家也能从方法名称中准确判断出该操作的效果仅仅是释放句柄:

public void releaseRegistryHandle(RegistryKey key);

5.能够对代码读者起到警示、启发或备忘作用的注释值得去写

  有时需要警告同组开发者,不要进行仓促的优化:

// 在处理该数据时,使用二叉树比哈希表要快40%,计算哈希码的开销比进行左右比较的开销要大。

  有时则要避免开发者在无关紧要的问题上浪费时间:

// 这种试探法可能会漏掉一些词语,不过不影响使用,100%解决这个问题很难。

  有时陈述将来可改观之处:

// 这个类很乱,也许应该创建一个ResourceNode子类来下移一部分代码。
// TODO:应该使用更快的算法

  有时要陈述不完备的功能:

// TODO: 除了JPEG之外,还得处理其他格式。

  上述最后两种情况要特别注意,也就是在注释待改进或者功能不完备的代码时,强烈建议使用特殊的前导标识符来标明注释行。这样可以藉助文本统计或者IDE提供的待办任务视图来立刻检索到项目中存在的隐患,促进开发者之间对代码现状的理解,以便发现问题及时沟通。这种注释其实扮演了“待办任务”或“待办事项”的角色。咱们业内通用的标注法按照紧急程度从低到高排列如下,新入行的小朋友们可以学习一下:

// TODO: 可改观或不完备的功能。
// HACK:  用来应急的杂技代码,稍后必须纠正。
// FIXME: 代码有错,需要修正。
// XXX:     代码大误,即行修正!

6.关乎代码逻辑的常量,如其名称不足以描述其包含的重要信息,则必须加注

  必须具备某种特性,方能使程序正常运转的常量应该加注,例如:

/** 只要不小于处理器数量的2倍就好. */
public static final int NUM_THREADS = 8;

  翔按:ARC作者在说明此种情况应当加注时,举了上面这个例子。其实,这里不妨补以// TODO: 提示信息,因为这种“不小于处理器数量的2倍”的特性可能会随着运行环境的改变而无法满足。仅凭这个注释,程序员未必能在出问题时第一时间就定位到该常量。大家可以在遇到这种情况时,补以提示性注释,例如“// TODO: 在后续版本改进过程中,应使用系统硬件信息来初始化此常量值,不宜手工指定”。

  随意选取数值的限定常量亦应加注,以便后续版本要对其进行可定制的功能扩展时参考(注意TODO后面的话):

// TODO: 如果将来要由客户自行指定订阅点上限,则可把此值改为变量。
/** 最大的RSS订阅点数量。这么多订阅点足以应对客户当前的需求了. */
public static final int MAX_RSS_SUBSCRIPTIONS = 1000;

  精心调优后的常量应加注,避免误调

// 使用0.72作为质量参数,可以在画质与占用空间之间取得良好平衡。
public static final double IMAGE_QUALITY = 0.72d;

  其实这一条原则的三个小分支,都与上一条所述的“能够对代码读者起到警示、启发或备忘作用的注释值得去写”这一原则有重复。之所以要单列出来,是因为常量的设置尤为微妙,经常会暗含无法用标识符全面涵盖的细微特征,应当适时地辅以注释。

7.提高注释质量所奉行的原则之一与提高代码质量的大原则一致:用局外人的视点来审读代码

  这一点,我在日常编码中曾一再对身边同事强调,此时不妨再啰嗦几句。那就是要从当前代码中跳出来,“冷眼看程序,热心挑毛病”

  大部分人不甚明瞭的微妙语言细节应该加注,例如:

struct Recorder {
  vector data;
  ...
  void Clear() {
    vector().swap(data);
  }
};

  如果谁突然闯进来看到上面的代码,肯定第一个就要问:为什么不直接调用data.clean()函数呢?与其让读者陷入猜测与不解之中,咱们不如直接用注释把隐晦的细节说明白了:

// 在vector对象上进行强制内存回收,参见“STL容器的swap技巧”(STL swap trick)
vector().swap(data);

  好久没做C++的项目了,刚Google了一下,这个技巧问的人还蛮多,我想起当时Scott Meyers在《Effective STL》一书里面讲过,Stack Overflow上面有人说是条目17,大家可以去复习一下。我觉得,如果真是像本例这种情况,某段代码使用了一个不成文的高端技巧或者某权威著作中深入讲述的代码惯用法,那么不如在注释中直接给出明确的参考源,例如“参阅网址:……;参考书目或文章:……”

  可能会导致客户代码出状况的API要加注。例如:

// 调用外部程序投递邮件(有可能耗时长达1分钟,若届时还未完成,则算超时)
public void sendEmail(String to, String subject, String body){...}

// 算法的时间复杂度是O(标签数量*平均标签深度),若输入数据含有大量嵌套错误,可能会相当耗时。
public void fixBrokenHtml(String html){...}

  类之间的互动、整个系统数据流、程序的入口点等宏观信息应该加注。讲到这个问题时,ARC的作者让我们假想一下,如果某个程序狼(或者程序娘,原文按照英语惯例,写的是her)突然闯入团队里面,你怎么以代码的方式向他解释整个项目的架构,使他尽速融入开发过程中呢?这个时候就必须有一些全局性的注释了,通过阅读这些注释,新人就可以迅速把握住整个项目的大方向、大节奏。例如:

// 在业务逻辑与数据库层之间的粘合代码,应用程序不直接使用它。
// 该类内部逻辑稍显复杂,不过仅仅扮演智能缓存池的角色。它并不依赖于系统的其他部分。

  在Java项目中,我们通常以包注释或类概览的Javadoc形式来提供宏观注释。

/**
 * 为便于访问与文件操作有关的功能而提供的工具类。其内部会处理与操作权限等事项相关的细节问题。
 */
public class FileMiscellaneousUtility{...}

8.以注释将长段代码分为小段,使读者快速掌握程序流程

  在上一篇文章中举过一个类似的例子,那次是编写一个社交软件中的潜在友人推荐功能。那个例子其实只有8行有效代码。所以只需分段,不用注释,读者就可以清晰地理解它。然而有的时候,如果某方法内部包含数十甚至上百行代码,而因为效率或复杂度等原因无法立刻进行代码整理的话,那么可以先写一些注释来厘清程序流程,这样也便于后续的维护。例如:

public void  generateUserReport{
  // 获取配给该用户的锁
  ...
  // 从数据源读入用户信息
  ...
  // 将信息写入文件
  ...
  // 释放用户锁
  ...
}

  本来上述方法的四段应该分别被重构提取到四个不同的小方法之内,不过如果由于内部逻辑过于复杂,提取小方法的时候需要提取过多的参数以配合程序流程,那么在短期内无法进行有效重构的情况下,方法内部的适当注释可以起到“起、承、转、合”之目的,也可以为稍后进行重构的人厘清思路。

  嗯,这一篇讲的心得有点多,可以小小总结一下。有一种传统的说法,那就是“只注释写代码的原因(why),不要注释代码具体内容(what)以及代码的算法(how)”。不过看了上述这些例子之后,我想大家应该明白,有些时候,代码的具体细节以及算法等内容,如果与代码的理解紧密相关,那么就应该毫不吝惜地注释。

  巧妙的注释,好就好在它能促进代码理解这一点上。不仅能让读者快速抓住代码的意图,而且还能为将来潜在的重构打开思路,同时还利于项目的维护,再有就是方便下游开发者进行二次开发。相反,对代码理解毫无益处的注释,就显得笨拙、累赘,应该删去。所以嘛,我想大家可以稍微修正一下上述说法了:只要有助于代码的理解,“做什么、为什么做、怎么做“这几方面都应加注。

  最后说一个小问题,那就是“注释恐惧症”。本文开头说道,有些人不愿意写注释,原因有很多种。其中有一种就是注释恐惧症,一旦形成这个习惯,同时又没有督促因素的话,则很难改正。此时如果通过团队注释规范强迫开发者去写注释的话,那么在没有养成良好注释习惯的情况下,就很可能会立刻走入另一个极端,为了应付差事而写出毫无意义甚至刻意掩盖代码隐患的注释来。对于如何克服注释恐惧症的问题,ARC的作者说了一个方法,我转述给大家听听。他们二位建议,将自己的第一感觉以“原生态”的方式写出来,例如:

// 额滴神啊,如果列表中有重复元素的话,这家伙就玩儿不转了。
// (其实,ARC这本书的原文是这样的:)
// Oh crap, this stuff will get tricky if there are ever duplicates in this list.

  上面这种话我估计人人都会写吧。好,写完了之后,用具体的、精确的词语代替模糊的、情绪化的描述。

  • “额滴神啊”这几个字,其实是想说“这里有必须要注意的状况发生”。
  • “这家伙”其实指的是“处理输入数据的代码”。
  • “玩儿不转了”意思是“这种情况下的算法很难实现”。

  所以,上述注释经过美化之后,就变成了:

// 注意:这段代码并不能处理含有重复元素的列表,因为那种情况下的算法太难实现了。
// (ARC的原文是:)
// Careful: this code doesn't handle duplicates in the list
// (because that's hard to do)

  不知道上面这个顽皮搞笑的过程能不能克服注释恐惧症,如果不能的话,大家也可以跟帖想想办法。

  这段时间一直没有写文章,一来由于工作繁忙,二来是晚上想贪玩看看比赛,三嘛,你别说,还真有可能是写作恐惧症呢!其实这更像是写作倦怠症。好了,不管怎么说,这次写开了,就不倦怠了。这一篇讲的是注释的时机问题,也就是什么时候应该注释,什么时候不该注释,下一篇来讲讲内容问题,也就是说,如果要写注释的话,怎么写才算好。

爱飞翔
2012年6月16日至17日

本文使用Creative Commons BY-NC-ND 3.0协议(创作共用 自由转载-保持署名-非商业使用-禁止衍生)发布。

原文网址:http://agilemobidev.net/eastarlee/code-quality/think_in_code_quality_5_judicious_comments_zh_cn/

代碼質量隨想錄(五)注得多不如注得巧

  寫代碼也流行注水了麼?不是不是,我說的是註釋。其實註釋這個東西,歷史久遠。我們可以寬泛一點兒說,《春秋》就是要配上左傳的註解,才能興發其“微言大義”嘛!註釋有很多種,如果按照註釋者與原文作者是不是同一個人來分,可以劃分成自注和他注。在程序員這個行當內,一般來說,還是自注多一些,自己寫代碼,自己加注。有的時候進行代碼審查或者復用遺留代碼時,纔可能會有必要對他人寫的代碼加註。

  從代碼質量的角度看,註釋寫得應不應該,寫得好不好,應該從它是否有助於加深代碼讀者及代碼使用者對程序的理解這一標準來判斷。按照《The Art of Readable Code》作者的說法,註釋的目標,就是讓讀者儘量明白代碼作者的編程意圖

  那麼,具體到代碼書寫層面,究竟怎麼註釋纔算好呢?這個問題得展開來談。這一篇文章先談談註釋的時機問題,下一篇再來研究註釋的內容。

1.顯而易見的代碼別註釋

  寫註釋經常會遭遇兩種極端態度,一種是絕對不寫註釋,一種是寫廢話連篇的註釋。對於持第一種態度的人,小翔希望看完講註釋的這兩篇文章之後,能夠適當轉變一下態度,稍稍緩釋惜墨如金的執念,多爲大家帶來一些精彩的註釋。有很多理由都會被拿來爲不寫註釋做辯護,這在後文會一一講到,我在這裏主要是想先說說口水型註釋的害處。從我個人的工作經歷來看,不寫註釋的人一旦能夠理性地認識到註釋的好處,那麼他們很有可能養成在編碼的同時自發地爲代碼精準加註的好習慣,然而沒話找話型的程序員,則很難寫出優雅簡潔的註釋來,對這些人來說,先要消解註釋泡沫才行。

  比如,代碼本身就含有的題中之義就不宜再以註釋的形式重複了。

// Account類的定義。
class Account {
  // 構造器
  public Account(){...}

  // 將profit字段設定爲新指定的值
  public void setProfit(double profit){...}

  // 獲取本Account對象的profit字段值
  public double getProfit(){...}
}

  以上幾行註釋的內容完全是在重述代碼,意義不大

2.註釋要儘量闡發被注標識符無法容納的意思,比如操作的同步性、工作流程、參數的範圍、返回值、異常等有价值的信息

  形成上例這種情況,也許還有一個原因,那就是有些公司或者團隊會對註釋形成一種強制要求,比如在Java語言中要求公有和保護級別的API必須寫Javadoc。這種規範是好的,不過要定出具體細則來,比如類的總結部分怎麼寫,構建子怎麼寫註釋,簡單的setter/getter方法怎麼寫註釋。

  針對上述這些問題,我覺得在制定開發團隊的註釋規範時,要明確指出:註釋應該儘量闡明被注標識符無法容納的義涵。例如,針對本類字段的簡單存取方法,如果其中有特殊之處,比如setter方法參數的取值範圍、參數非法時是否會造成異常、設置的新值是否立刻生效等等問題,那麼這些情況就應當明確標註。例如:

/** 
 * 將profit字段設定爲新指定的值。設置動作有可能不會立即生效,要根據該賬戶對象的修改策略
 * 所允許的單位時段內最大修改次數來定。如果修改策略是“延時生效”,則超過修改次數限制的   
 * 修改動作會在下個時間段生效.
 * @param profit 新的收益率,必須在[0.0d, 1.0d]之間
 * @throws IllegalArgumentException  如果收益率不在合法區間內
 * @throws IllegalOperationException 如果本次設置已在修改策略容許次數之外,
 *                                   且修改策略是“立即生效”
 */
public void setProfit(double profit){...}

  雖然有點兒囉嗦(我寫註釋的毛病,哈哈),不過比起上例來說,畢竟還是帶來了一些新內容。而且一旦通過註釋把這些隱晦的東西挑明了,那麼還可以由此引發新的討論,以促進團隊成員對代碼的理解,進而觸發重構。比如大家可以盡情吐槽:這個方法名怎麼能簡簡單單地叫成setProfit呢?這樣怎麼能體現出它還受制於“賬戶修改策略”這個事實?參數怎麼能叫成profit?爲什麼不寫成profitBetweenZeroAndOne?如果設置無法立刻生效的話,那爲什麼不提供通知機制?不然客戶代碼怎麼知道什麼時候才能設置生效?等等等等……這些質疑未必各個都有道理,不過可以由此讓我們重新審視該方法,甚至是整個類,看看它設計得是不是有問題,對下游開發者是否友好。

  再看getProfit方法,可就有點兒尷尬了,因爲不管怎麼寫註釋,貌似都很無力。這時咱們就可以很有自信地無視它了。不過使用Eclipse的開發者可能會遇到一些小障礙,比如在設定裏面設置好了強制要求所有protected、public的API都要寫Javadoc註釋,那麼略去這種getProfit方法不注,可能會有警告或者錯誤。這種小麻煩,恐怕就需要一些變通辦法了,大家如果有好辦法,也請告訴我。

  如果代碼讀者和下游開發者有必要適當地瞭解工作流程和返回值詳情,那麼這些信息就要註釋,比如:

// 在子樹中尋找某個深度範圍內,具有給定名稱的節點。
public Node findNodeInSubtree(Node subtree, string name, int depth){...}

  就應該改爲:

// 找尋具有指定名稱的節點,找不到則返回null。
// 如果深度值小於等於0,則整個子樹都將被查找。
// 如果大於0,則只在N級深度範圍內查找。
public Node findNodeInSubtree(Node subtree, string name, int depth){...}

3.如果編程意圖不夠明顯,則可以適當地加些註釋。此種情況的根本解決辦法還是通過重構來理順複雜的代碼,使之清晰、直觀

# 移除第二個'*'字符及其後內容
name = '*'.join(line.split('*')[:2])

  ARC作者可能認爲以上這句大家看到之後第一眼有點搞不清楚狀況,所以建議加上那行註釋。小翔倒是覺得,不妨對上面的代碼進行重構,將“切割、數組切片、拼合”這個大操作拆解成三個小操作,並且封裝起來,這樣更符合迪米特原則(又叫得墨忒耳定律、最少知識原則),而且看上去代碼會更加清晰,不需加註即可明白。

String name=truncateFromDelimiter(line,'*',2);
...
private String truncateFromDelimiter(String input, char delimiter, 
                                     int groupIndexToDropFrom){...}

4.再好的註釋也無法徹底掩飾壞名稱

// 確保回覆對象的內容符合請求對象中關於條目數量、總字節數等規格的限定。
public void cleanReply(Request request, Reply reply){...}

  以上註釋中的“確保”(Enforce)、“限定”(Limit)等詞應該直接納入方法名稱中。不妨改成:

// 經請求對象所限定的規格包括“條目數量”、“總字節數”等指標。
public void enforceLimitsFromRequest(Request request, Reply reply){...}

  這樣不僅註釋內容變簡單了,而且方法名稱所表達的意思也比原來精確許多,讓人更易理解。關於這一點,我在做項目時體會特別深刻,千萬不要試圖用註釋去粉飾糟糕的名字,而應該直接修正不當的命名

// 釋放主鍵所指向的註冊表操作句柄。該方法並不修改實際的註冊表內容。
public void deleteRegistry(RegistryKey key);

  既然“並不修改實際的註冊表內容”,那麼名稱中delete何謂?用註釋無法掩飾這個矛盾。莫如去掉註釋,直書其意,這樣不需要註釋大家也能從方法名稱中準確判斷出該操作的效果僅僅是釋放句柄:

public void releaseRegistryHandle(RegistryKey key);

5.能夠對代碼讀者起到警示、啓發或備忘作用的註釋值得去寫

  有時需要警告同組開發者,不要進行倉促的優化:

// 在處理該數據時,使用二叉樹比哈希表要快40%,計算哈希碼的開銷比進行左右比較的開銷要大。

  有時則要避免開發者在無關緊要的問題上浪費時間:

// 這種試探法可能會漏掉一些詞語,不過不影響使用,100%解決這個問題很難。

  有時陳述將來可改觀之處:

// 這個類很亂,也許應該創建一個ResourceNode子類來下移一部分代碼。
// TODO:應該使用更快的算法

  有時要陳述不完備的功能:

// TODO: 除了JPEG之外,還得處理其他格式。

  上述最後兩種情況要特別注意,也就是在註釋待改進或者功能不完備的代碼時,強烈建議使用特殊的前導標識符來標明註釋行。這樣可以藉助文本統計或者IDE提供的待辦任務視圖來立刻檢索到項目中存在的隱患,促進開發者之間對代碼現狀的理解,以便發現問題及時溝通。這種註釋其實扮演了“待辦任務”或“待辦事項”的角色。咱們業內通用的標註法按照緊急程度從低到高排列如下,新入行的小朋友們可以學習一下:

// TODO: 可改觀或不完備的功能。
// HACK:  用來應急的雜技代碼,稍後必須糾正。
// FIXME: 代碼有錯,需要修正。
// XXX:     代碼大誤,即行修正!

6.關乎代碼邏輯的常量,如其名稱不足以描述其包含的重要信息,則必須加註

  必須具備某種特性,方能使程序正常運轉的常量應該加註,例如:

/** 只要不小於處理器數量的2倍就好. */
public static final int NUM_THREADS = 8;

  翔按:ARC作者在說明此種情況應當加註時,舉了上面這個例子。其實,這裏不妨補以// TODO: 提示信息,因爲這種“不小於處理器數量的2倍”的特性可能會隨着運行環境的改變而無法滿足。僅憑這個註釋,程序員未必能在出問題時第一時間就定位到該常量。大家可以在遇到這種情況時,補以提示性註釋,例如“// TODO: 在後續版本改進過程中,應使用系統硬件信息來初始化此常量值,不宜手工指定”。

  隨意選取數值的限定常量亦應加註,以便後續版本要對其進行可定製的功能擴展時參考(注意TODO後面的話):

// TODO: 如果將來要由客戶自行指定訂閱點上限,則可把此值改爲變量。
/** 最大的RSS訂閱點數量。這麼多訂閱點足以應對客戶當前的需求了. */
public static final int MAX_RSS_SUBSCRIPTIONS = 1000;

  精心調優後的常量應加註,避免誤調

// 使用0.72作爲質量參數,可以在畫質與佔用空間之間取得良好平衡。
public static final double IMAGE_QUALITY = 0.72d;

  其實這一條原則的三個小分支,都與上一條所述的“能夠對代碼讀者起到警示、啓發或備忘作用的註釋值得去寫”這一原則有重複。之所以要單列出來,是因爲常量的設置尤爲微妙,經常會暗含無法用標識符全面涵蓋的細微特徵,應當適時地輔以註釋。

7.提高註釋質量所奉行的原則之一與提高代碼質量的大原則一致:用局外人的視點來審讀代碼

  這一點,我在日常編碼中曾一再對身邊同事強調,此時不妨再囉嗦幾句。那就是要從當前代碼中跳出來,“冷眼看程序,熱心挑毛病”

  大部分人不甚明瞭的微妙語言細節應該加註,例如:

struct Recorder {
  vector data;
  ...
  void Clear() {
    vector().swap(data);
  }
};

  如果誰突然闖進來看到上面的代碼,肯定第一個就要問:爲什麼不直接調用data.clean()函數呢?與其讓讀者陷入猜測與不解之中,咱們不如直接用註釋把隱晦的細節說明白了:

// 在vector對象上進行強制內存回收,參見“STL容器的swap技巧”(STL swap trick)
vector().swap(data);

  好久沒做C++的項目了,剛Google了一下,這個技巧問的人還蠻多,我想起當時Scott Meyers在《Effective STL》一書裏面講過,Stack Overflow上面有人說是條目17,大家可以去複習一下。我覺得,如果真是像本例這種情況,某段代碼使用了一個不成文的高端技巧或者某權威著作中深入講述的代碼慣用法,那麼不如在註釋中直接給出明確的參考源,例如“參閱網址:……;參考書目或文章:……”

  可能會導致客戶代碼出狀況的API要加註。例如:

// 調用外部程序投遞郵件(有可能耗時長達1分鐘,若屆時還未完成,則算超時)
public void sendEmail(String to, String subject, String body){...}

// 算法的時間複雜度是O(標籤數量*平均標籤深度),若輸入數據含有大量嵌套錯誤,可能會相當耗時。
public void fixBrokenHtml(String html){...}

  類之間的互動、整個系統數據流、程序的入口點等宏觀信息應該加註。講到這個問題時,ARC的作者讓我們假想一下,如果某個程序狼(或者程序娘,原文按照英語慣例,寫的是her)突然闖入團隊裏面,你怎麼以代碼的方式向他解釋整個項目的架構,使他儘速融入開發過程中呢?這個時候就必須有一些全局性的註釋了,通過閱讀這些註釋,新人就可以迅速把握住整個項目的大方向、大節奏。例如:

// 在業務邏輯與數據庫層之間的粘合代碼,應用程序不直接使用它。
// 該類內部邏輯稍顯複雜,不過僅僅扮演智能緩存池的角色。它並不依賴於系統的其他部分。

  在Java項目中,我們通常以包註釋或類概覽的Javadoc形式來提供宏觀註釋。

/**
 * 爲便於訪問與文件操作有關的功能而提供的工具類。其內部會處理與操作權限等事項相關的細節問題。
 */
public class FileMiscellaneousUtility{...}

8.以註釋將長段代碼分爲小段,使讀者快速掌握程序流程

  在上一篇文章中舉過一個類似的例子,那次是編寫一個社交軟件中的潛在友人推薦功能。那個例子其實只有8行有效代碼。所以只需分段,不用註釋,讀者就可以清晰地理解它。然而有的時候,如果某方法內部包含數十甚至上百行代碼,而因爲效率或複雜度等原因無法立刻進行代碼整理的話,那麼可以先寫一些註釋來釐清程序流程,這樣也便於後續的維護。例如:

public void  generateUserReport{
  // 獲取配給該用戶的鎖
  ...
  // 從數據源讀入用戶信息
  ...
  // 將信息寫入文件
  ...
  // 釋放用戶鎖
  ...
}

  本來上述方法的四段應該分別被重構提取到四個不同的小方法之內,不過如果由於內部邏輯過於複雜,提取小方法的時候需要提取過多的參數以配合程序流程,那麼在短期內無法進行有效重構的情況下,方法內部的適當註釋可以起到“起、承、轉、合”之目的,也可以爲稍後進行重構的人釐清思路。

  嗯,這一篇講的心得有點多,可以小小總結一下。有一種傳統的說法,那就是“只註釋寫代碼的原因(why),不要註釋代碼具體內容(what)以及代碼的算法(how)”。不過看了上述這些例子之後,我想大家應該明白,有些時候,代碼的具體細節以及算法等內容,如果與代碼的理解緊密相關,那麼就應該毫不吝惜地註釋。

  巧妙的註釋,好就好在它能促進代碼理解這一點上。不僅能讓讀者快速抓住代碼的意圖,而且還能爲將來潛在的重構打開思路,同時還利於項目的維護,再有就是方便下游開發者進行二次開發。相反,對代碼理解毫無益處的註釋,就顯得笨拙、累贅,應該刪去。所以嘛,我想大家可以稍微修正一下上述說法了:只要有助於代碼的理解,“做什麼、爲什麼做、怎麼做“這幾方面都應加註。

  最後說一個小問題,那就是“註釋恐懼症”。本文開頭說道,有些人不願意寫註釋,原因有很多種。其中有一種就是註釋恐懼症,一旦形成這個習慣,同時又沒有督促因素的話,則很難改正。此時如果通過團隊註釋規範強迫開發者去寫註釋的話,那麼在沒有養成良好註釋習慣的情況下,就很可能會立刻走入另一個極端,爲了應付差事而寫出毫無意義甚至刻意掩蓋代碼隱患的註釋來。對於如何克服註釋恐懼症的問題,ARC的作者說了一個方法,我轉述給大家聽聽。他們二位建議,將自己的第一感覺以“原生態”的方式寫出來,例如:

// 額滴神啊,如果列表中有重復元素的話,這傢伙就玩兒不轉了。
// (其實,ARC這本書的原文是這樣的:)
// Oh crap, this stuff will get tricky if there are ever duplicates in this list.

  上面這種話我估計人人都會寫吧。好,寫完了之後,用具體的、精確的詞語代替模糊的、情緒化的描述。

  • “額滴神啊”這幾個字,其實是想說“這裏有必須要注意的狀況發生”。
  • “這傢伙”其實指的是“處理輸入數據的代碼”。
  • “玩兒不轉了”意思是“這種情況下的算法很難實現”。

  所以,上述註釋經過美化之後,就變成了:

// 注意:這段代碼並不能處理含有重復元素的列表,因爲那種情況下的算法太難實現了。
// (ARC的原文是:)
// Careful: this code doesn't handle duplicates in the list
// (because that's hard to do)

  不知道上面這個頑皮搞笑的過程能不能克服註釋恐懼症,如果不能的話,大家也可以跟帖想想辦法。

  這段時間一直沒有寫文章,一來由於工作繁忙,二來是晚上想貪玩看看比賽,三嘛,你別說,還真有可能是寫作恐懼症呢!其實這更像是寫作倦怠症。好了,不管怎麼說,這次寫開了,就不倦怠了。這一篇講的是註釋的時機問題,也就是什麼時候應該註釋,什麼時候不該註釋,下一篇來講講內容問題,也就是說,如果要寫註釋的話,怎麼寫纔算好。

愛飛翔
2012年6月16日至17日

本文使用Creative Commons BY-NC-ND 3.0協議(創作共用 自由轉載-保持署名-非商業使用-禁止衍生)發佈。

原文網址:http://agilemobidev.net/eastarlee/code-quality/think_in_code_quality_5_judicious_comments_zh_tw/

代码质量随想录(四)排版,不只是为了漂亮

  写了前三篇之后,发现比我预想的效果要好。关注代码质量的朋友还蛮多的,而且很多意见和建议也很有益,指出了我文章中的一些问题。

  我这种家庭妇男型的自由职业者来说,在平常写代码的时候可以多停下来,思考一些代码质量与软件设计方面的问题。当然啦,由于具体的工作环境、关注领域、自身阅历等原因,小翔在文中提出的许多观点难免书生之见,请诸位多包涵。

  针对排版这个问题,不同的公司、团队都有自己的一套方案,有时网络上也能下载到很多大型的权威代码规范,其中亦含有程序排版相关的规则,我也经常与众友人一起讨论某个项目所用的排版约定。在看到《The Art of Readable Code》一书中有关此话题的章节时,我的感觉是,很难总结出一套万用的“宇宙排版律”来,多半要根据自身环境、团队和项目的特点来拟定,所给出的建议仅仅是参考,并不能强行照搬。

1.功能相似的代码,版式也应相似

public class PerformanceTester {
  public static final TcpConnectionSimulator wifi = 
    new TcpConnectionSimulator(
      500   /* 以Kbps计量的吞吐量 */, 
      80    /* 以毫秒计的网络延迟 */, 
      200   /* 包抖动 */, 
      1     /* 丢包百分比 */);

  public static final TcpConnectionSimulator t3Fiber =
    new TcpConnectionSimulator(
      45000 /* 以Kbps计量的吞吐量 */, 
      10    /* 以毫秒计的网络延迟 */, 
      0     /* 包抖动 */, 
      0     /* 丢包百分比 */);

  public static final TcpConnectionSimulator cell = 
    new TcpConnectionSimulator(
      100   /* 以Kbps计量的吞吐量 */, 
      400   /* 以毫秒计的网络延迟 */, 
      250   /* 包抖动 */, 
      5     /* 丢包百分比 */);
}

  上面这个例子是ARC书中所举的,我认为很恰当。该类的三个静态字段功能类似,都指代某种环境下的网络模拟器,所以排版也应该相似。每行都只写一个实参,而且后面用行内注释的形式解释该实参的意思。在垂直方向上的对齐做得也很好:字段申明前面空2格,实例化语句前面空4格,各实参前面空6格(以上数字非实指,仅是举例而已)。这样要修改某个参数,很快就能定位到它,而且以后如果增加类似的字段,如badWIFI,也可以比照这个格式来,便于维护。

  由以上范例还可引出一个问题,那就是在实例化或方法调用中,经常会遇到一些孤立的魔法数字(magic number),如果确有必要为它起名,那么不妨执行一个小的重构,以常量来代替它。反之,如果是大段的硬数值,则不一定非要为每个值都起一个名字,例如:

TcpConnectionSimulator wifi = 
  new TcpConnectionSimulator(
    WIFI_KBPS_THROUGHPUT,
    WIFI_LATENCY,
    WIFI_JITTER,
    WIFI_PACKET_LOSS_PERCENT);

  这样反而显得累赘。不妨像上例那样采用行内注释的办法来解释这些硬值的意思。

  承上,ARC的作者又推导出一条建议,就是将相似的方法调用参数注释提取到一处,例如:

public class PerformanceTester {
  // TcpConnectionSimulator(throughput, latency, jitter, packet_loss)
  //                        [Kbps]      [ms]     [ms]    [percent]

  public static final TcpConnectionSimulator wifi = 
    new TcpConnectionSimulator(500,   80,  200, 1);

  public static final TcpConnectionSimulator t3Fiber =
    new TcpConnectionSimulator(45000, 10,  0,   0);

  public static final TcpConnectionSimulator cell = 
    new TcpConnectionSimulator(100,   400, 250, 5);
}

  说实在的,以前在工作中还没太重视这个问题,一来是觉得我在写Javadoc时一贯非常完备,出现这种情况时只需靠鼠标悬停就可知道某个方法或构造器的具体信息了;二来嘛,也是想着如果使用大量数值的调用代码多到无法管控,我可能会祭出配置文件这个大旗来,将它们全部纳入配置中了事。所以关于以上例子中谈到的这些问题,我觉得还是根据大家的具体实践来理解为好,不要机械地寻求一致。

2.将大量相似的嵌套式、接续式调用逻辑整合到共用方法之中,即利于排版,又可凸显重要数据

  在测试用例等代码中,经常会出现类似下面这种状况:

// 某受测类中:
// 将类似"Doug Adams"这样的不完整称呼进行补全,扩展为"Mr. Douglas Adams"的形式。
// 如若不能(查不到数据或无法补完),则于error参数中填充错误信息并返回空串。
// 此方法会置空错误信息接收参数。
public String expandToFullName(DatabaseConnection   conn, 
                               String               partialName,
                               ErrorMessageReceiver error){...}

// 某测试方法中:
  DatabaseConnection connection=...;
  ErrorMessageReceiver error=...;
  assertEquals(expandToFullName(connection,"Doug Adams" ,error) , 
               "Mr. Douglas Adams");
  assertEquals(error.getMessage() , "");
  assertEquals(expandToFullName(connection,"Jake Brown" ,error) , 
               "Mr. Jacob Brown III");
  assertEquals(error.getMessage() , "");
  assertEquals(expandToFullName(connection,"No Such Guy“,error) , 
               "");
  assertEquals(error.getMessage() , "no match found");
  assertEquals(expandToFullName(connection,"John“,       error) , 
               "");
  assertEquals(error.getMessage() , "more than one result");

  这符合上面所说的“量大”、“形似”、“嵌套”等特征,而且诸如输入字串、预期结果、预期错误消息等重要的数据,被埋没于connection、error、getMessage()等技术细节之中。所以可以借由美化版式之机进行重构:

  checkPartialToFull("Doug Adams" , "Mr. Douglas Adams" , "");
  checkPartialToFull("Jake Brown" , "Mr. Jake Brown III", "");
  checkPartialToFull("No Such Guy", ""                  , "no match found");
  checkPartialToFull("John"       , ""                  , "more than one result");

private void checkPartialToFull(String partialName, 
                                String expectedFullName,
                                String expectedErrorMessage) {
  // connection已被提取为测试固件类的成员变量
  ErrorMessageReceiver error=...;
  String actualFullName = expandToFullName(connection, partialName, error);
  assertEquals(expectedErrorMessage, error.getMessage());
  assertEquals(expectedFullName    , actualFullName);
}

  如此一来一举三得:既消除了重复代码,同时美化了版式,凸显了输入字串、预期结果、预期错误消息等重要数据,顺带着还方便了后续测试数据的维护。这种藉由版式整理带来的重构,我看可以有!

3.明智地使用纵向对齐来减少拼写错误、厘清大量同组数据。

  我觉得这一条和第1条有重复,其实也属于类似功能的代码应具类似版式之意,不过既然ARC作者将它单列,我想可能是为了强调纵向对齐的好处吧。

// 将POST参数中的属性分别提取至各个局部变量中
ServletRequest request=...;

String details  = request.getParameter("details");
String location = request.getParameter("location");
String hone     = request.getParameter("phon");
String email    = request.getParameter("email");
String url      = request.getParameter("url");

  经由纵向对齐,很容易看出第三个局部变量这行的错误:将变量名“phone”误写为“hone”,参数名的“phone”则错成了”phon“。

  另外,在进行结构体数据、数组成员等这种同组数据排列时,也可以充分利用版式来厘清每个元素的意义。ARC的作者就大赞wget这个命令行工具在指定参数结构体时,代码排列地很工整。

// 非原文,小翔以Java形式改写
Object[][] commands = {
  //参数名         , 默认值           , 类型
  { "timeout",      null,             TIMEOUT    },
  { "timestamping", defOpt.timestamp, BOOLEAN    },
  { "tries",        defOpt.tryCount,  NUMBER     },
  { "useproxy",     defOpt.useProxy,  BOOLEAN    },
  { "useragent",    null,             USER_AGENT }
};

  这一条建议如果与第1条合并起来说,那就是:任务相似的代码块应该具有相似的轮廓(ARC的作者叫它silhouette),如行数、缩进、纵向对齐等。

4.使用适当空行与注释,将代码按功能分段

  有时候经常在考虑代码与散文或诗的联系,如果从隐喻(metaphor)的观点来看,的确有相似性:都是信息的载体,都可以用一定的段落来整合文意。要说区别嘛,前者服务于软件需求,后者服务于社会关系。前者为了向更低阶的执行机制去接合,所以更加注重语法格式。我可不是第一个进行这种思维比拟的人,记得台湾的技术畅销书作者侯捷先生(侯俊杰)就曾写过一本《左手程序右手诗》的书。

class FrontendServer {
public:
  FrontendServer();
  void ViewProfile(HttpRequest* request);
  void OpenDatabase(string location, string user);
  void SaveProfile(HttpRequest* request);
  string ExtractQueryParam(HttpRequest* request, string param);
  void ReplyOK(HttpRequest* request, string html);
  void FindFriends(HttpRequest* request);
  void ReplyNotFound(HttpRequest* request, string error);
  void CloseDatabase(string location);
  ~FrontendServer();
};

  上面的代码挺蜗居的,如果加上适当的空行与说明,就显得清晰多了。

class FrontendServer {
public:
  FrontendServer();
  ~FrontendServer();

  // 与用户配置相关的处理函数
  void ViewProfile(HttpRequest* request);
  void SaveProfile(HttpRequest* request);
  void FindFriends(HttpRequest* request);

  // 回覆及应答工具函数
  string ExtractQueryParam(HttpRequest* request, string param);
  void   ReplyOK(HttpRequest* request, string html);
  void   ReplyNotFound(HttpRequest* request, string error);

  // 数据库操作工具函数
  void OpenDatabase(string location, string user);
  void CloseDatabase(string location);
};

  上述类将声明区按照构建子/析构子、社交功能函数、工具函数这个标准划分为的三大思维区段,工具函数区又按题材划分为消息操作与数据库操作两小段。这样一来,以后再要维护这份声明代码就会很清爽了。同理,如果声明一个集合类的接口,也应该按照“增、删、改、查”等概念来将API划分为若干小组,以便帮助代码阅读者理顺思路。

  就算是在流水式的业务代码中,也可以用段落来衬托出逻辑的“起、承、转、合”。

// 导入用户电子邮件账户中联系人,同本产品中已有的联系人相比对。
// 然后展示正在使用本产品但未与用户建立朋友关系的联络人列表。
public ListDataModel suggestNewFriends(User user,Password emailPassword){
  SocialCircle friends = user.friends();
  Emails friendEmails = friend.dumpAllEmails();
  Contacts contacts = importContacts(user.email, emailPassword);
  Emails contactEmails = contacts.extractAllEmails();
  Emails productUserEmails = UserDataCenter.selectEmails(contactEmails);
  Emails suggestedFriends = productUserEmails.subtract(friendEmails);
  ListDataModel displayModel = new ListDataModel(user,friends,suggestedFriends);
  return displayModel;
}

  上面的代码给人的压迫感很强列,没有思维喘息的机会。不如把注释拆解,按其逻辑将代码分成小段,为每一段冠以简短标题。

public ListDataModel suggestNewFriends(User user,Password emailPassword){
  // 取得当前用户全部朋友的邮件地址
  SocialCircle friends = user.friends();
  Emails friendEmails  = friend.dumpAllEmails();

  // 引入当前用户电子邮件账户中的联系人
  Contacts contacts    = importContacts(user.email, emailPassword);
  Emails contactEmails = contacts.extractAllEmails();

  // 找出正在使用本产品但尚未与本用户建立朋友关系的联系人
  Emails productUserEmails = UserDataCenter.selectEmails(contactEmails);
  Emails suggestedFriends  = productUserEmails.subtract(friendEmails);

  // 返回待显示列表的数据模型
  ListDataModel displayModel = new ListDataModel(user,friends,suggestedFriends);
  return displayModel;
}

  欣赏一下上面这段代码吧,每小段以一句概括性的注释引领,然后是两句实现代码,排列得非常整齐,代码的阅读者根据此的版式,很容易就能抓住代码的思维走向:“2(获取朋友列表)-2(获取联系人邮箱)-2(找出潜在友人)-2(返回数据模型)”。怎么样,是不是有点儿“起、承、转、合”的意思了?照这样写下去,可以山寨一个小的Google+了吧?我这个SocialCircle类比谷加的还厉害,它那个只能是同级别的平行关系,我这个还能像组合体模式那样,互相嵌套呢!(大误)

  由上例可见,适当地进行代码分段并通过注释来充当代码段的概括语,有助于梳理代码阅读者的思路,也有助于代码修改、维护和后续查错。比如想做一个“向未使用本社交网站的电邮联络人发送邀请”的功能,扫一眼上述这段清晰排版的代码,大家立刻就能看出,只需要写好测试用例,复制一份suggestNewFriends的代码,把selectEmails改成excludeEmails,就能找到这些潜在的被邀请人了。给新的方法起个名字,叫inviteContacts,删去多余的程序,然后通过重构提取一下共用代码,再确保测试无误,就可以收工了。思路顺了,编码的过程自然也就更加流畅了。

  好了,小小总结一下吧。其实代码排版这种略带个人化的东西,不仅仅是让代码看起来更漂亮,其根本目的还是着眼于代码的可读性,要有助于代码的理解、维护、纠错。具体到执行层面,除了可以参考上述4条建议外,还要注意两方面的问题。

  第一个问题,ARC的作者也提到了,那就是很多朋友对代码排版有排斥心理,不愿意认真排版。有一部分原因是怕浪费时间,还有就是担心代码管理系统会将排版后的代码与排版之前的代码判定为两份截然不同的程序,在版本比对时导致满屏的diff,非常难看。其实,在现有的成熟IDE之中(抑或各位Geek们惯用的文本编辑器之中)已经有非常完备的功能来支援代码版式的调整了。比如Eclipse、Netbeans等开发环境,都可以把版式定义文件导出为xml等数据格式,到了陌生的环境时,只需导入即可。而且代码排版一旦确定,就可以一次性地更改所有项目源码的版式然后提交,这样就可以避免在版本比对时显示过多的修改提示了。

  第二个问题就是应该在必要的范围内保持代码排版的一致性。虽然我刚也说了,代码排版没有绝对的真理,不过,它却应该有一个相对的底线。在公司与公司之间、团队与团队之间,的确没有必要强行要求一致的版式。例如我们不宜妄自菲薄,说I记或G社的代码排得如何如何漂亮,同时也不能过分地自高自大,说自己团队的版式是天下最美观、最养眼的。但是,如果具体到某个项目,尤其是中小型项目里面,那么就要想方设法达成一致的版式规范了,否则将会给代码的阅读、理解与维护造成不必要的障碍。为此,项目组的成员应该富有的妥协精神,在坚持个人风格这个问题上稍作让步,以求达成大家对代码版式的共识。比如,小翔在个人项目或由我带队的项目中,通常使用以下版式:

public class MyArrayList extends MyAbstractList implements MyCollection{
  // 静态部分在前:
  // 静态内部类型区。同区成员按存取级别排序,高者在前。
  /** 
   * 列表容量参数。
   */
  public static class CapacityOptions{
    /** 初始容量。 */
    private final int initialElementCount;
    /** 扩容时新增的容量与扩容前容量之比。 */
    private final int expandRatio;
  }
  ...

  // 静态初始化块与静态字段区。
  private static final Map commonCapacityOptions=...;
  ...

  static{
    commonCapacityOptions.put("normal" , new CapacityOptions(12,1));
    ...;
  }
  ...

  // 静态方法区。
  /**
   * 从既有数组中构建列表。
   * @param elements 用以构建的数组,不能为null。
   * @return 构建好的列表
   */
  public static List create(Object[] elements){
    ...;
  }
  ...

  // 动态部分在后:
  // 动态内部类型区。
  public class MyIterator{
    public Object next(){
      ...;
    }
  }

  // 动态初始化块与实例成员变量区。
  {
    ...;
  }

  private int count;
  ...

  // 构造器区。
  public MyList(){
    ...;
  }
  ...

  // 实例方法区。
  // 先写本类方法。
  void expand(){
    ...;
  }

  // 其次,从直接超类开始,层层追溯至Object,将各层级上的覆写方法列出。
  @Override
  public boolean add(Object e){
    ...;
  }

  // 然后按由进至远的顺序,实现接口中的方法。
  // 同等层级的接口,按其出现在implements、extends子句中的先后顺序,依次实现其方法。
  @Override
  public void clear(){
    ...;
  }

  // 最后覆写Object类中的方法。
  @Override
  public String toString(){
    ...;
  }

  //准析构方法区。
  protected void finalize() throws Throwable{
    ...;
  }
}

  上述这个“3+5式分段法”(静态部分:静态内部类型、静态初始化块与字段、静态方法;动态部分:动态内部类性、动态初始化块与实例成员变量、构造器、实例方法、准析构方法),小翔在六年多的工作中一直用着,我觉得它对于代码阅读来说,还算满流畅的,在此也分享给大家。不过,如果现有项目大多数成员要求将左花括号放于新行之首,并要求动态部分出现在静态部分的前边,那么我就会在这个项目中按照大家喜欢的格式来办(并不是随意放弃自己认为合理的版式,而是在某个项目的具体语境下为了求得共识而妥协),同理,类似新行符是n、r还是nr,空白符是空格还是制表符之类问题,我觉得只要大家认为合适,就没有必要过分争执,定出一个项目内部易于统一管理的规范就好。

  再多说一句吧,有同学可能会问,既然Eclipse等IDE中已经可以通过类结构导览视图来显示类代码中的各种成员,那么为何还要如此在乎代码版式呢?因为不管具体代码怎么排列,视图中都可以调整显示顺序呀。对于这个问题,我想有时我们不仅仅要通过导览视图来看宏观结构,还需要进行微观的具体代码审读与维护,所以微观代码的排列终究还是为了易读。当然啦,排列方法可以商量,比如你可以说不必按照“静态、动态”那样分,而是按照“内部类、变量、方法”这样来分。

  从下午开始写,断断续续到了深夜,微笑地浏览了一遍之后,顿时觉得这一篇文章讲的话题有点儿文艺了。嗯,接下来,将和大家聊聊代码注释。

爱飞翔

2012年6月6日至7日

本文使用Creative Commons BY-NC-ND 3.0协议(创作共用 自由转载-保持署名-非商业使用-禁止衍生)发布。

原文网址:http://agilemobidev.net/eastarlee/code-quality/think_in_code_quality_4_layout_zh_cn/

代碼質量隨想錄(四)排版,不只是爲了漂亮

  寫了前三篇之後,發現比我預想的效果要好。關注代碼質量的朋友還蠻多的,而且很多意見和建議也很有益,指出了我文章中的一些問題。

  我這種家庭婦男型的自由職業者來說,在平常寫代碼的時候可以多停下來,思考一些代碼質量與軟件設計方面的問題。當然啦,由於具體的工作環境、關注領域、自身閱歷等原因,小翔在文中提出的許多觀點難免書生之見,請諸位多包涵。

  針對排版這個問題,不同的公司、團隊都有自己的一套方案,有時網絡上也能下載到很多大型的權威代碼規範,其中亦含有程序排版相關的規則,我也經常與衆友人一起討論某個項目所用的排版約定。在看到《The Art of Readable Code》一書中有關此話題的章節時,我的感覺是,很難總結出一套萬用的“宇宙排版律”來,多半要根據自身環境、團隊和項目的特點來擬定,所給出的建議僅僅是參考,並不能強行照搬。

1.功能相似的代碼,版式也應相似

public class PerformanceTester {
  public static final TcpConnectionSimulator wifi = 
    new TcpConnectionSimulator(
      500   /* 以Kbps計量的吞吐量 */, 
      80    /* 以毫秒計的網絡延遲 */, 
      200   /* 包抖動 */, 
      1     /* 丟包百分比 */);

  public static final TcpConnectionSimulator t3Fiber =
    new TcpConnectionSimulator(
      45000 /* 以Kbps計量的吞吐量 */, 
      10    /* 以毫秒計的網絡延遲 */, 
      0     /* 包抖動 */, 
      0     /* 丟包百分比 */);

  public static final TcpConnectionSimulator cell = 
    new TcpConnectionSimulator(
      100   /* 以Kbps計量的吞吐量 */, 
      400   /* 以毫秒計的網絡延遲 */, 
      250   /* 包抖動 */, 
      5     /* 丟包百分比 */);
}

  上面這個例子是ARC書中所舉的,我認爲很恰當。該類的三個靜態字段功能類似,都指代某種環境下的網絡模擬器,所以排版也應該相似。每行都只寫一個實參,而且後面用行內註釋的形式解釋該實參的意思。在垂直方向上的對齊做得也很好:字段申明前面空2格,實例化語句前面空4格,各實參前面空6格(以上數字非實指,僅是舉例而已)。這樣要修改某個參數,很快就能定位到它,而且以後如果增加類似的字段,如badWIFI,也可以比照這個格式來,便於維護。

  由以上範例還可引出一個問題,那就是在實例化或方法調用中,經常會遇到一些孤立的魔法數字(magic number),如果確有必要爲它起名,那麼不妨執行一個小的重構,以常量來代替它。反之,如果是大段的硬数值,則不一定非要爲每個值都起一個名字,例如:

TcpConnectionSimulator wifi = 
  new TcpConnectionSimulator(
    WIFI_KBPS_THROUGHPUT,
    WIFI_LATENCY,
    WIFI_JITTER,
    WIFI_PACKET_LOSS_PERCENT);

  這樣反而顯得累贅。不妨像上例那樣採用行內註釋的辦法來解釋這些硬值的意思。

  承上,ARC的作者又推導出一條建議,就是將相似的方法調用參數註釋提取到一處,例如:

public class PerformanceTester {
  // TcpConnectionSimulator(throughput, latency, jitter, packet_loss)
  //                        [Kbps]      [ms]     [ms]    [percent]

  public static final TcpConnectionSimulator wifi = 
    new TcpConnectionSimulator(500,   80,  200, 1);

  public static final TcpConnectionSimulator t3Fiber =
    new TcpConnectionSimulator(45000, 10,  0,   0);

  public static final TcpConnectionSimulator cell = 
    new TcpConnectionSimulator(100,   400, 250, 5);
}

  說實在的,以前在工作中還沒太重視這個問題,一來是覺得我在寫Javadoc時一貫非常完備,出現這種情況時只需靠鼠標懸停就可知道某個方法或構造器的具體信息了;二來嘛,也是想着如果使用大量數值的調用代碼多到無法管控,我可能會祭出配置文件這個大旗來,將它們全部納入配置中了事。所以關於以上例子中談到的這些問題,我覺得還是根據大家的具體實踐來理解爲好,不要機械地尋求一致。

2.將大量相似的嵌套式、接續式調用邏輯整合到共用方法之中,即利於排版,又可凸顯重要數據

  在測試用例等代碼中,經常會出現類似下面這種狀況:

// 某受測類中:
// 將類似"Doug Adams"這樣的不完整稱呼進行補全,擴展爲"Mr. Douglas Adams"的形式。
// 如若不能(查不到數據或無法補完),則於error參數中填充錯誤信息並返回空串。
// 此方法會置空錯誤信息接收參數。
public String expandToFullName(DatabaseConnection   conn, 
                               String               partialName,
                               ErrorMessageReceiver error){...}

// 某測試方法中:
  DatabaseConnection connection=...;
  ErrorMessageReceiver error=...;
  assertEquals(expandToFullName(connection,"Doug Adams" ,error) , 
               "Mr. Douglas Adams");
  assertEquals(error.getMessage() , "");
  assertEquals(expandToFullName(connection,"Jake Brown" ,error) , 
               "Mr. Jacob Brown III");
  assertEquals(error.getMessage() , "");
  assertEquals(expandToFullName(connection,"No Such Guy“,error) , 
               "");
  assertEquals(error.getMessage() , "no match found");
  assertEquals(expandToFullName(connection,"John“,       error) , 
               "");
  assertEquals(error.getMessage() , "more than one result");

  這符合上面所說的“量大”、“形似”、“嵌套”等特徵,而且諸如輸入字串、預期結果、預期錯誤消息等重要的數據,被埋沒於connection、error、getMessage()等技術細節之中。所以可以借由美化版式之機進行重構:

  checkPartialToFull("Doug Adams" , "Mr. Douglas Adams" , "");
  checkPartialToFull("Jake Brown" , "Mr. Jake Brown III", "");
  checkPartialToFull("No Such Guy", ""                  , "no match found");
  checkPartialToFull("John"       , ""                  , "more than one result");

private void checkPartialToFull(String partialName, 
                                String expectedFullName,
                                String expectedErrorMessage) {
  // connection已被提取爲測試固件類的成員變量
  ErrorMessageReceiver error=...;
  String actualFullName = expandToFullName(connection, partialName, error);
  assertEquals(expectedErrorMessage, error.getMessage());
  assertEquals(expectedFullName    , actualFullName);
}

  如此一來一舉三得:既消除了重複代碼,同時美化了版式,凸顯了輸入字串、預期結果、預期錯誤消息等重要數據,順帶着還方便了後續測試數據的維護。這種藉由版式整理帶來的重構,我看可以有!

3.明智地使用縱向對齊來減少拼寫錯誤、釐清大量同組數據。

  我覺得這一條和第1條有重複,其實也屬於類似功能的代碼應具類似版式之意,不過既然ARC作者將它單列,我想可能是爲了強調縱向對齊的好處吧。

// 將POST參數中的屬性分別提取至各個局部變量中
ServletRequest request=...;

String details  = request.getParameter("details");
String location = request.getParameter("location");
String hone     = request.getParameter("phon");
String email    = request.getParameter("email");
String url      = request.getParameter("url");

  經由縱向對齊,很容易看出第三個局部變量這行的錯誤:將變量名“phone”誤寫爲“hone”,參數名的“phone”則錯成了”phon“。

  另外,在進行結構體數據、數組成員等這種同組數據排列時,也可以充分利用版式來釐清每個元素的意義。ARC的作者就大讚wget這個命令行工具在指定參數結構體時,代碼排列地很工整。

// 非原文,小翔以Java形式改寫
Object[][] commands = {
  //參數名         , 默認值           , 類型
  { "timeout",      null,             TIMEOUT    },
  { "timestamping", defOpt.timestamp, BOOLEAN    },
  { "tries",        defOpt.tryCount,  NUMBER     },
  { "useproxy",     defOpt.useProxy,  BOOLEAN    },
  { "useragent",    null,             USER_AGENT }
};

  這一條建議如果與第1條合並起來說,那就是:任務相似的代碼塊應該具有相似的輪廓(ARC的作者叫它silhouette),如行數、縮進、縱向對齊等。

4.使用適當空行與註釋,將代碼按功能分段

  有時候經常在考慮代碼與散文或詩的聯繫,如果從隱喻(metaphor)的觀點來看,的確有相似性:都是信息的載體,都可以用一定的段落來整合文意。要說區別嘛,前者服務於軟件需求,後者服務於社會關係。前者爲了向更低階的執行機制去接合,所以更加注重語法格式。我可不是第一個進行這種思維比擬的人,記得臺灣的技術暢銷書作者侯捷先生(侯俊傑)就曾寫過一本《左手程序右手詩》的書。

class FrontendServer {
public:
  FrontendServer();
  void ViewProfile(HttpRequest* request);
  void OpenDatabase(string location, string user);
  void SaveProfile(HttpRequest* request);
  string ExtractQueryParam(HttpRequest* request, string param);
  void ReplyOK(HttpRequest* request, string html);
  void FindFriends(HttpRequest* request);
  void ReplyNotFound(HttpRequest* request, string error);
  void CloseDatabase(string location);
  ~FrontendServer();
};

  上面的代碼挺蝸居的,如果加上適當的空行與說明,就顯得清晰多了。

class FrontendServer {
public:
  FrontendServer();
  ~FrontendServer();

  // 與用戶配置相關的處理函數
  void ViewProfile(HttpRequest* request);
  void SaveProfile(HttpRequest* request);
  void FindFriends(HttpRequest* request);

  // 回覆及應答工具函數
  string ExtractQueryParam(HttpRequest* request, string param);
  void   ReplyOK(HttpRequest* request, string html);
  void   ReplyNotFound(HttpRequest* request, string error);

  // 數據庫操作工具函數
  void OpenDatabase(string location, string user);
  void CloseDatabase(string location);
};

  上述類將聲明區按照構建子/析構子、社交功能函數、工具函數這個標準劃分爲的三大思維區段,工具函數區又按題材劃分爲消息操作與數據庫操作兩小段。這樣一來,以後再要維護這份聲明代碼就會很清爽了。同理,如果聲明一個集合類的接口,也應該按照“增、刪、改、查”等概念來將API劃分爲若干小組,以便幫助代碼閱讀者理順思路。

  就算是在流水式的業務代碼中,也可以用段落來襯托出邏輯的“起、承、轉、合”。

// 導入用戶電子郵件賬戶中聯繫人,同本產品中已有的聯繫人相比對。
// 然後展示正在使用本產品但未與用戶建立朋友關係的聯絡人列表。
public ListDataModel suggestNewFriends(User user,Password emailPassword){
  SocialCircle friends = user.friends();
  Emails friendEmails = friend.dumpAllEmails();
  Contacts contacts = importContacts(user.email, emailPassword);
  Emails contactEmails = contacts.extractAllEmails();
  Emails productUserEmails = UserDataCenter.selectEmails(contactEmails);
  Emails suggestedFriends = productUserEmails.subtract(friendEmails);
  ListDataModel displayModel = new ListDataModel(user,friends,suggestedFriends);
  return displayModel;
}

  上面的代碼給人的壓迫感很強列,沒有思維喘息的機會。不如把註釋拆解,按其邏輯將代碼分成小段,爲每一段冠以簡短標題。

public ListDataModel suggestNewFriends(User user,Password emailPassword){
  // 取得當前用戶全部朋友的郵件地址
  SocialCircle friends = user.friends();
  Emails friendEmails  = friend.dumpAllEmails();

  // 引入當前用戶電子郵件賬戶中的聯繫人
  Contacts contacts    = importContacts(user.email, emailPassword);
  Emails contactEmails = contacts.extractAllEmails();

  // 找出正在使用本產品但尚未與本用戶建立朋友關係的聯繫人
  Emails productUserEmails = UserDataCenter.selectEmails(contactEmails);
  Emails suggestedFriends  = productUserEmails.subtract(friendEmails);

  // 返回待顯示列表的數據模型
  ListDataModel displayModel = new ListDataModel(user,friends,suggestedFriends);
  return displayModel;
}

  欣賞一下上面這段代碼吧,每小段以一句概括性的註釋引領,然後是兩句實現代碼,排列得非常整齊,代碼的閱讀者根據此的版式,很容易就能抓住代碼的思維走向:“2(獲取朋友列表)-2(獲取聯繫人郵箱)-2(找出潛在友人)-2(返回數據模型)”。怎麼樣,是不是有點兒“起、承、轉、合”的意思了?照這樣寫下去,可以山寨一個小的Google+了吧?我這個SocialCircle類比谷加的還厲害,它那個只能是同級別的平行關係,我這個還能像組合體模式那樣,互相嵌套呢!(大誤)

  由上例可見,適當地進行代碼分段並通過註釋來充當代碼段的概括語,有助於梳理代碼閱讀者的思路,也有助於代碼修改、維護和後續查錯。比如想做一個“向未使用本社交網站的電郵聯絡人發送邀請”的功能,掃一眼上述這段清晰排版的代碼,大家立刻就能看出,只需要寫好測試用例,複製一份suggestNewFriends的代碼,把selectEmails改成excludeEmails,就能找到這些潛在的被邀請人了。給新的方法起個名字,叫inviteContacts,刪去多餘的程序,然後通過重構提取一下共用代碼,再確保測試無誤,就可以收工了。思路順了,編碼的過程自然也就更加流暢了。

  好了,小小總結一下吧。其實代碼排版這種略帶個人化的東西,不僅僅是讓代碼看起來更漂亮,其根本目的還是着眼於代碼的可讀性,要有助於代碼的理解、維護、糾錯。具體到執行層面,除了可以參考上述4條建議外,還要注意兩方面的問題。

  第一個問題,ARC的作者也提到了,那就是很多朋友對代碼排版有排斥心理,不願意認真排版。有一部分原因是怕浪費時間,還有就是擔心代碼管理系統會將排版後的代碼與排版之前的代碼判定爲兩份截然不同的程序,在版本比對時導致滿屏的diff,非常難看。其實,在現有的成熟IDE之中(抑或各位Geek們慣用的文本編輯器之中)已經有非常完備的功能來支援代碼版式的調整了。比如Eclipse、Netbeans等開發環境,都可以把版式定義文件導出爲xml等數據格式,到了陌生的環境時,只需導入即可。而且代碼排版一旦確定,就可以一次性地更改所有項目源碼的版式然後提交,這樣就可以避免在版本比對時顯示過多的修改提示了。

  第二個問題就是應該在必要的範圍內保持代碼排版的一致性。雖然我剛也說了,代碼排版沒有絕對的真理,不過,它卻應該有一個相對的底線。在公司與公司之間、團隊與團隊之間,的確沒有必要強行要求一致的版式。例如我們不宜妄自菲薄,說I記或G社的代碼排得如何如何漂亮,同時也不能過分地自高自大,說自己團隊的版式是天下最美觀、最養眼的。但是,如果具體到某個項目,尤其是中小型項目裏面,那麼就要想方設法達成一致的版式規範了,否則將會給代碼的閱讀、理解與維護造成不必要的障礙。爲此,項目組的成員應該富有的妥協精神,在堅持個人風格這個問題上稍作讓步,以求達成大家對代碼版式的共識。比如,小翔在個人項目或由我帶隊的項目中,通常使用以下版式:

public class MyArrayList extends MyAbstractList implements MyCollection{
  // 靜態部分在前:
  // 靜態內部類型區。同區成員按存取級別排序,高者在前。
  /** 
   * 列表容量參數。
   */
  public static class CapacityOptions{
    /** 初始容量。 */
    private final int initialElementCount;
    /** 擴容時新增的容量與擴容前容量之比。 */
    private final int expandRatio;
  }
  ...

  // 靜態初始化塊與靜態字段區。
  private static final Map commonCapacityOptions=...;
  ...

  static{
    commonCapacityOptions.put("normal" , new CapacityOptions(12,1));
    ...;
  }
  ...

  // 靜態方法區。
  /**
   * 從既有數組中構建列表。
   * @param elements 用以構建的數組,不能爲null。
   * @return 構建好的列表
   */
  public static List create(Object[] elements){
    ...;
  }
  ...

  // 動態部分在後:
  // 動態內部類型區。
  public class MyIterator{
    public Object next(){
      ...;
    }
  }

  // 動態初始化塊與實例成員變量區。
  {
    ...;
  }

  private int count;
  ...

  // 構造器區。
  public MyList(){
    ...;
  }
  ...

  // 實例方法區。
  // 先寫本類方法。
  void expand(){
    ...;
  }

  // 其次,從直接超類開始,層層追溯至Object,將各層級上的覆寫方法列出。
  @Override
  public boolean add(Object e){
    ...;
  }

  // 然後按由進至遠的順序,實現接口中的方法。
  // 同等層級的接口,按其出現在implements、extends子句中的先後順序,依次實現其方法。
  @Override
  public void clear(){
    ...;
  }

  // 最後覆寫Object類中的方法。
  @Override
  public String toString(){
    ...;
  }

  //準析構方法區。
  protected void finalize() throws Throwable{
    ...;
  }
}

  上述這個“3+5式分段法”(靜態部分:靜態內部類型、靜態初始化塊與字段、靜態方法;動態部分:動態內部類性、動態初始化塊與實例成員變量、構造器、實例方法、準析構方法),小翔在六年多的工作中一直用着,我覺得它對於代碼閱讀來說,還算滿流暢的,在此也分享給大家。不過,如果現有項目大多數成員要求將左花括號放於新行之首,並要求動態部分出現在靜態部分的前邊,那麼我就會在這個項目中按照大家喜歡的格式來辦(並不是隨意放棄自己認爲合理的版式,而是在某個項目的具體語境下爲了求得共識而妥協),同理,類似新行符是n、r還是nr,空白符是空格還是製表符之類問題,我覺得只要大家認爲合適,就沒有必要過分爭執,定出一個項目內部易於統一管理的規範就好。

  再多說一句吧,有同學可能會問,既然Eclipse等IDE中已經可以通過類結構導覽視圖來顯示類代碼中的各種成員,那麼爲何還要如此在乎代碼版式呢?因爲不管具體代碼怎麼排列,視圖中都可以調整顯示順序呀。對於這個問題,我想有時我們不僅僅要通過導覽視圖來看宏觀結構,還需要進行微觀的具體代碼審讀與維護,所以微觀代碼的排列終究還是爲了易讀。當然啦,排列方法可以商量,比如你可以說不必按照“靜態、動態”那樣分,而是按照“內部類、變量、方法”這樣來分。

  從下午開始寫,斷斷續續到了深夜,微笑地瀏覽了一遍之後,頓時覺得這一篇文章講的話題有點兒文藝了。嗯,接下來,將和大家聊聊代碼註釋。

愛飛翔

2012年6月6日至7日

本文使用Creative Commons BY-NC-ND 3.0協議(創作共用 自由轉載-保持署名-非商業使用-禁止衍生)發佈。

原文網址:http://agilemobidev.net/eastarlee/code-quality/think_in_code_quality_4_layout_zh_tw/

代码质量随想录(三)名字好,误会少

  写完前两篇之后,有点小倦怠,因为一方面要整理读书笔记,一方面还要结合自己的思路加以重新表述,颇费周张。不过前两日看到有小朋友过来赞我的文章,说对实际代码有所帮助,还是满欣慰的,本系列随想录的目的之一,就是要营造一个努力改良代码质量的思维环境。

  要想让标识符的名称更易理解,就应该多考虑考虑此名称是否会被误读。

  先看两个很容易误读的例子。

Object[] results = Database.getAllObjects().filter("year <= 2011");

  到底是要选出year小于等于2011的那部分对象,还是选出year大于2011的那部分呢?filter到底是排除(exclude),还是遴选(select)呢?我自己在日常编码中也爱用filter,多半由于习惯。现在自己思量,是得改正了。
再看

public String clip(String text,int length); //裁掉文本的末尾

  clip方法有歧义:到底是去掉文本后的length个字符,还是从头开始截取最大length个字符呢?比如clip("Java",2);到底是"va"还是"Ja"?如果是前者应该叫removeLast,如果是后者则应叫truncate。而且length也有毛病,到底以什么为单位?字节?字符?还是词语?如果是字符,应该是truncate(String text,int maxCharCount)。

  归纳起来说,以下几种情形应格外注重选取避免误解的名称。

1.以常量表示包含端点的上限或下限时,应分别用MAX与MIN做前缀。

  例如CART_TOO_BIT_LIMIT=10到底是说购物车中最多放10件商品还是11件?抑或是9件?改为MAX_ITEMS_IN_CART = 10则很清楚:最多10件。

2.在表达包含左右端点的区间时,应用first及last。

public void printIntegerInRange(int start,int stop){...}
...
printIntegerInRange(2,6);

  到底打印[2,3,4,5]还是[2,3,4,5,6]?如果是后者,应该是printIntegerInRange(int first,int last)。

3.在表达包含左端点而不含右端点的区间时,应当使用begin与end。

  英文中没有哪个常用词的字面意思能表示“区段内最后一个值的紧下一个值”这个意思,所以使用end只是约定成俗而已,并不精确。例如public void printEventsInRange(String begin,String end),可以使用如下参数来调用:printEventsInRange(“OCT 16 00:00″, “OCT 17 00:00″),这样的话,一般人都能理解右端点("OCT 17 00:00″)不含在范围内。如果用public void printEventsInRange(String first,String last),则是printEventsInRange(“OCT 16 00:00″, “OCT 16 23:59″)。

4.使用判断词来消除boolean变量的歧义。

  为boolean变量起名时一定注意是否有歧义:

bool readPassword = true;

  到底是当前需要读取密码,还是密码已经被读取过了?前者应是needPassword,后者应是userIsAuthenticated。

  使用is、has、can、should等词汇来让boolean变量与方法的意图更加清晰,尤其是在那些不需要申明方法或函数返回类型的编程语言中。例如:spaceLeft()到底是返回剩下的空间大小,还是返回是否有剩余空间?根据是简单获取还是复杂计算,前者应命名为getLeftSpaceInPixel()或calcLeftSpacePx(),分别指示轻量级(get)和重量级(calculate或compute)的两种获取办法;而后者则应是hasSpaceLeft(),只说有没有剩余空间,不谈具体的量。

5.避免在boolean命名中使用否定形式。

  例如:

bool disableSSL = false;

  不如下面这种命名方式清晰:

bool useSSL = true;

6.不要同约定成俗的命名方式相违逆。

  例如getXXX()格式的方法一般有两个隐含意义:1.该操作为轻量级。2.该操作返回所在类的某个成员。

  如下统计算数平均数的方法名称即为不宜:

public class SampleCollector {
  public void add(double sample) { ... }

  public double getMean() {
    ... // 叠加所有采样值并返回“总和/样本数”
  }
...
}

  getMean()并非轻量级操作,且不返回本类某个成员。不如叫它computeMean()更好,compute会引人联想该操作是不是稍为复杂一些,耗时一些。如果非要用getMean做名称的话,那么mean应被纳入缓存机制。例如:

private boolean meanCached;//计算完样本后置为true,样本改动时置为false
...

public double getMean() {
  if(!meanCached){
    ... // 叠加所有采样值
    mean=sampleSum/sampleCount;
    meanCached=true;
  }
  return mean;
}

void ShrinkList(list& list, int max_size) {
  while (list.size() > max_size) {
    FreeNode(list.back());
    list.pop_back();
  }
}

  size()操作的时间复杂度为O(1)应是大多数人的共识,可是恰恰list的size()是时间复杂度为O(n)的操作,这导致整个函数的复杂度变为O(n2)。按理说size()应该叫为countSize() 或countElements(),以体现其重量级运算的特质来,不过,为了和其余容器类相符合,还是叫成size了。所幸新版C++规范强制要求size操作的时间复杂度为O(1)了(ARC书的作者这么说的,我未查证。大家帮忙在C++11规范中查证此事。原有规范只是“建议”它应具有常数时间复杂度,并未强制)。

  小翔以为,如果某个抽象接口定义了一个貌似轻量级的简单操作,如Collection的size(),则子类对象在实现时应该尽量降低时间复杂度。实在不能时甚至可以考虑抛出异常或对客户提出警告。根本的解决办法还是学习C++规范那样,给出一个建议的时间复杂度来。

7.在多个候选名称中取舍时应该仔细质询其可能带来的歧义。

  例如有两份相似的服务器配置参数文件:

config_id: 100
description: "increase font size to 14pt"
traffic_fraction: 5%
...

config_id: 101
description: "increase font size to 13pt"
[其余参数与前一份相同]

  我们现在想通过某个机制复用整套参数,例如这样:

config_id: 101
想要复用的配置文件id: 100
[其余参数与前一份相同]

  那么,这个“想要复用的配置文件id”,应该怎么起名呢?备选关键词有:template、reuse、copy和inherit。

  template很模糊:“template: 100”到底是说自己是一份名叫“100”模板,还是说使用一个名叫“100”的模板作为其基础参数?况且模板这个概念太过抽象,给人感觉需要以具体内容填充它。

  “reuse: 100”到底是说这份参数最多可以使用100次,还是说复用名为“100”的那份配置文件中的参数?

  “copy: 100”是第100份拷贝吗?还是说拷贝自编号为“100”的那套配置?后者不如叫copy_config_from更好。

  “inherit: 100”,inherit这个词,大多数程序员很熟悉,且与日常生活的“财产继承”概念可相比拟,所以引起的误解相对较少。可以扩充为inherit_config_from来更精确地阐明这个意思。

  综上,copy_config_from或inherit_config_from应为最终中选名称。

  总之,好的标识符名称可以尽量消除代码阅读者的误解,提高代码可读性与可维护性,亦能促进业务交流。所以应当仔细考究,尽量选取免于误会的名称,尤其是遇到“filter、length和limit”这些模棱两可的词语时。此外,区间与上下限含不含端点、boolean类型的标识符会不会引起误解、方法名称所隐含的意义是否符合常识,这些问题也应该在起名时反覆考量。

  用了两篇文章才讲完给标识符起名的事情,可见其的确关乎代码质量的提升。下一篇我们谈谈代码的排版问题。

爱飞翔
2012年6月4日

本文使用Creative Commons BY-NC-ND 3.0协议(创作共用 自由转载-保持署名-非商业使用-禁止衍生)发布。
原文网址:http://agilemobidev.net/eastarlee/code-quality/think_in_code_quality_3_good_name_zh_cn/

代碼質量隨想錄(三)名字好,誤會少

  寫完前兩篇之後,有點小倦怠,因爲一方面要整理讀書筆記,一方面還要結合自己的思路加以重新表述,頗費周張。不過前兩日看到有小朋友過來讚我的文章,說對實際代碼有所幫助,還是滿欣慰的,本系列隨想錄的目的之一,就是要營造一個努力改良代碼質量的思維環境。

  要想讓標識符的名稱更易理解,就應該多考慮考慮此名稱是否會被誤讀。

  先看兩個很容易誤讀的例子。

Object[] results = Database.getAllObjects().filter("year <= 2011");

  到底是要選出year小於等於2011的那部分對象,還是選出year大於2011的那部分呢?filter到底是排除(exclude),還是遴選(select)呢?我自己在日常編碼中也愛用filter,多半由於習慣。現在自己思量,是得改正了。
再看

public String clip(String text,int length); //裁掉文本的末尾

  clip方法有歧義:到底是去掉文本後的length個字符,還是從頭開始截取最大length個字符呢?比如clip("Java",2);到底是"va"還是"Ja"?如果是前者應該叫removeLast,如果是後者則應叫truncate。而且length也有毛病,到底以什麼爲單位?字節?字符?還是詞語?如果是字符,應該是truncate(String text,int maxCharCount)。

  歸納起來說,以下幾種情形應格外注重選取避免誤解的名稱。

1.以常量表示包含端點的上限或下限時,應分別用MAX與MIN做前綴。

  例如CART_TOO_BIT_LIMIT=10到底是說購物車中最多放10件商品還是11件?抑或是9件?改爲MAX_ITEMS_IN_CART = 10則很清楚:最多10件。

2.在表達包含左右端點的區間時,應用first及last。

public void printIntegerInRange(int start,int stop){...}
...
printIntegerInRange(2,6);

  到底打印[2,3,4,5]還是[2,3,4,5,6]?如果是後者,應該是printIntegerInRange(int first,int last)。

3.在表達包含左端點而不含右端點的區間時,應當使用begin與end。

  英文中沒有哪個常用詞的字面意思能表示“區段內最後一個值的緊下一個值”這個意思,所以使用end只是約定成俗而已,並不精確。例如public void printEventsInRange(String begin,String end),可以使用如下參數來調用:printEventsInRange(“OCT 16 00:00″, “OCT 17 00:00″),這樣的話,一般人都能理解右端點("OCT 17 00:00″)不含在範圍內。如果用public void printEventsInRange(String first,String last),則是printEventsInRange(“OCT 16 00:00″, “OCT 16 23:59″)。

4.使用判斷詞來消除boolean變量的歧義。

  爲boolean變量起名時一定注意是否有歧義:

bool readPassword = true;

  到底是當前需要讀取密碼,還是密碼已經被讀取過了?前者應是needPassword,後者應是userIsAuthenticated。

  使用is、has、can、should等詞彙來讓boolean變量與方法的意圖更加清晰,尤其是在那些不需要申明方法或函數返回類型的編程語言中。例如:spaceLeft()到底是返回剩下的空間大小,還是返回是否有剩餘空間?根據是簡單獲取還是複雜計算,前者應命名爲getLeftSpaceInPixel()或calcLeftSpacePx(),分別指示輕量級(get)和重量級(calculate或compute)的兩種獲取辦法;而後者則應是hasSpaceLeft(),只說有沒有剩餘空間,不談具體的量。

5.避免在boolean命名中使用否定形式。

  例如:

bool disableSSL = false;

  不如下面這種命名方式清晰:

bool useSSL = true;

6.不要同約定成俗的命名方式相違逆。

  例如getXXX()格式的方法一般有兩個隱含意義:1.該操作爲輕量級。2.該操作返回所在類的某個成員。

  如下統計算數平均數的方法名稱即爲不宜:

public class SampleCollector {
  public void add(double sample) { ... }

  public double getMean() {
    ... // 疊加所有採樣值並返回“總和/樣本數”
  }
...
}

  getMean()並非輕量級操作,且不返回本類某個成員。不如叫它computeMean()更好,compute會引人聯想該操作是不是稍爲複雜一些,耗時一些。如果非要用getMean做名稱的話,那麼mean應被納入緩存機制。例如:

private boolean meanCached;//計算完樣本後置爲true,樣本改動時置爲false
...

public double getMean() {
  if(!meanCached){
    ... // 疊加所有採樣值
    mean=sampleSum/sampleCount;
    meanCached=true;
  }
  return mean;
}

void ShrinkList(list& list, int max_size) {
  while (list.size() > max_size) {
    FreeNode(list.back());
    list.pop_back();
  }
}

  size()操作的時間複雜度爲O(1)應是大多數人的共識,可是恰恰list的size()是時間複雜度爲O(n)的操作,這導致整個函數的複雜度變爲O(n2)。按理說size()應該叫爲countSize() 或countElements(),以體現其重量級運算的特質來,不過,爲了和其餘容器類相符合,還是叫成size了。所幸新版C++規範強制要求size操作的時間複雜度爲O(1)了(ARC書的作者這麼說的,我未查證。大家幫忙在C++11規範中查證此事。原有規範只是“建議”它應具有常數時間複雜度,並未強制)。

  小翔以爲,如果某個抽象接口定義了一個貌似輕量級的簡單操作,如Collection的size(),則子類對象在實現時應該儘量降低時間複雜度。實在不能時甚至可以考慮拋出異常或對客戶提出警告。根本的解決辦法還是學習C++規範那樣,給出一個建議的時間複雜度來。

7.在多個候選名稱中取捨時應該仔細質詢其可能帶來的歧義。

  例如有兩份相似的服務器配置參數文件:

config_id: 100
description: "increase font size to 14pt"
traffic_fraction: 5%
...

config_id: 101
description: "increase font size to 13pt"
[其餘參數與前一份相同]

  我們現在想通過某個機制復用整套參數,例如這樣:

config_id: 101
想要復用的配置文件id: 100
[其餘參數與前一份相同]

  那麼,這個“想要復用的配置文件id”,應該怎麼起名呢?備選關鍵詞有:template、reuse、copy和inherit。

  template很模糊:“template: 100”到底是說自己是一份名叫“100”模板,還是說使用一個名叫“100”的模板作爲其基礎參數?況且模板這個概念太過抽象,給人感覺需要以具體內容填充它。

  “reuse: 100”到底是說這份參數最多可以使用100次,還是說復用名爲“100”的那份配置文件中的參數?

  “copy: 100”是第100份拷貝嗎?還是說拷貝自編號爲“100”的那套配置?後者不如叫copy_config_from更好。

  “inherit: 100”,inherit這個詞,大多數程序員很熟悉,且與日常生活的“財產繼承”概念可相比擬,所以引起的誤解相對較少。可以擴充爲inherit_config_from來更精確地闡明這個意思。

  綜上,copy_config_from或inherit_config_from應爲最終中選名稱。

  總之,好的標識符名稱可以儘量消除代碼閱讀者的誤解,提高代碼可讀性與可維護性,亦能促進業務交流。所以應當仔細考究,儘量選取免於誤會的名稱,尤其是遇到“filter、length和limit”這些模棱兩可的詞語時。此外,區間與上下限含不含端點、boolean類型的標識符會不會引起誤解、方法名稱所隱含的意義是否符合常識,這些問題也應該在起名時反覆考量。

  用了兩篇文章才講完給標識符起名的事情,可見其的確關乎代碼質量的提升。下一篇我們談談代碼的排版問題。

愛飛翔
2012年6月4日

本文使用Creative Commons BY-NC-ND 3.0協議(創作共用 自由轉載-保持署名-非商業使用-禁止衍生)發佈。
原文网址:http://agilemobidev.net/eastarlee/code-quality/think_in_code_quality_3_good_name_zh_tw/

代码质量随想录(二)必也正名乎

  不必被我的标题吓到哈,孔老夫子时代没有电脑。如果有,估计诸子百家们还得针对软件工程抒发一系列代码质量伦理学的教条。

  上回文章说到,代码品质改进应该在三个层面上展开,其中最微观的就是代码段的质量考究了。很多时候我在针对一些项目做工程分析和大规模重构之前,首先希望对大概的工作原理有些瞭解,这个时候就要深入核心模块的文件之中,挑选代码来阅读,以求理顺思路了。根据个人的经验来说,微观的改进往往能够激发大规模的结构重组。所以一连几篇文章,分别会谈到“好名称”、“好格式”、“好注释”三个微观的表层质量改进问题。

  深入到函数或方法内部的代码之后,就要面对一行行具体的代码了。此时最应该关注的首先就是标识符的命名问题。这个问题基本上是讲重构或代码质量的书所必谈的话题之一。记得马叔叔曾经在《Clean Code》中说,给标识符起名时,应该像给你们家小朋友起名字一样认真(大意,并非原文)。当时我看到此话不禁微笑了一下。是哇,很多时候我在代码评审中遇到的思维不顺都是源于名字问题。

  一直以来,朋友和同事都偶尔会拿整个项目或是代码片段来和我讨论,对于企业级开发领域,我看的代码不多,对代码质量不便妄言,不过具体到和我关系比较密切的移动开发领域,可就真的是令我非常头疼了。由于移动软件或游戏的开发经常周期很短,而且重结果,轻过程,更不讲求后续的版本更新、维护与复用。所以经常在开发过程中程序员容易在工期的压力下过于随心所欲,导致项目的代码理解起来大费周折。有时候我越是急于理解,就越是摸不着头绪。后来想想,很多困难都源于具体的标识符名称。必须理解了它们,才有可能理解更高层级的内容。

  通过阅读《The Art of Readable Code》以及其他相关的书,我渐渐把原来学到的一些代码质量知识总结起来了。ARC这本书的好处之一就是,它讲的东西不见得多新,很多都是Clean Code或者类似的书中讲了又讲的话题,不过,它善于把这些零散的知识点按照一定的框架整合起来,让我能够更系统地归纳并巩固这些知识。

  简单的说,好的标识符名称,必须封装恰当的信息,同时不致误解。

  至于如何封装恰当的信息,这个问题要看个人的把握,有几条能够作为指导的建议,不妨梳理给大家来看。

1.选择更具表达力词语

  我自己在代码中就经常忽视这一点,用惯了get和size之后,遇到什么情况,不管具体细节,一律使用getXXX或size作为方法名称。今天就看到了几个反例。例如

class BinaryTree{

  public int size(){...}

}

  这个size到底获取的是高度,节点数还是占据的内存字节数?这三种情况应该分别用更为特定的height、nodeCount或occupiedMemoryBytes来表示,而不是空泛的size。

  说到这个问题,我觉得增加个人的词汇量是非常有好处的。可以经常翻看英英词典来瞭解各个词语之间的细微差别。例如用“deliver, dispatch, announce, distribute, route”(投递、派发、播报、分配、按指定线路发送,就是路由)之中的某个词代替send(送),用“search, extract, locate, recover”(搜索、提取、定位、重新找回)代替find(找)等等。

  有一个问题,就是命名含义丰富了会不会影响以后的修改。有同学可能会说,我故意放一个朦胧且暧昧的size来代替height、nodeCount或occupiedMemoryBytes,这样将来万一内部的逻辑有变化,我直接修改具体代码就行了,连size这个方法名都不用修改,岂不是更符合“针对接口而非实现来编程”的面向对象设计理论么?一开始我也有这个想法,后来想想后果十分可怕,这样做根本就没有明确表述出该接口的具体意图:一旦将表示height的size方法之中的算法改为返回nodeCount,而保留size方法名不做修改,那么这会害苦了该API的客户代码编写者们。你的同事仍然以为size返回的是二叉树的高度,殊不知现在它返回的是节点数目了。一旦出现这样的bug,除非两人紧密配合,否则调试很费时,而且随着时间的推移更为难办。反之如果方法名从height改为nodeCount,那么下游开发者在源码管理系统中更新代码时立刻就看出其中的差别,从而能够很从容地修改已有的逻辑,避免了频繁调试。总之,我同意ARC作者的看法:应该选择更具表现力、含义更为丰富的词语。

  当然,特定不等于标新立异或者耸人听闻。友人goldlion曾经在学习NDK开发时被Android的诗意文档所苦。当时我看到“punch a hole”这个表述(参见这里,类的概览部分,第二段首句),就笑得三分钟没停下来,是有点可爱。文档可爱一点还好,如果具体的函数就麻烦了,比如ARC作者所提到的PHP的explode()函数。初看莫名其妙,定神想了想才明白可能是用于打散字符串用的。如果温柔一点儿,应该叫做split或者delimit。而且更有趣的则是新支持的第三个参数。

array explode ( string $delimiter , string $string [, int $limit ] )

  这个参数如果取负值,则最后的-limit组小字符串会被丢弃,例如

explode('|', 'one|two|three|four', -1)

  只会返回“one、two、three”三个子串所合成的数组。这种一鱼两吃的豪爽颇有古典程序员的遗风。不过我还是建议在工作代码中将这种特定的处理命名为splitButLast(char delimiter, String str, int thrownCount)更清爽,这样一来写的人和看的人都不累。

2.避免空泛的名称

  tmp(temp)和retVal(returnValue、result)是十大空泛名称排行榜上的前两名(其余请读者补充)

public double euclideanNorm(int values){
  double result=0.0;
  for(int i=0,count<values.length;i<count;i++)
    result+=values[i]*values[i];
  }
  return Math.sqrt(result);
}

  这种命名不当我也常犯,第一句不假思索就用result了。上述代码的result应该被squareSum代替,这样一旦将for循环中的代码误写为squareSum+=values[i](忘记求平方了,直接加),立刻就能看出错误来。因为sum前面的square已经明示了+=运算符后面必须是平方形式。

  temp这种名字也不是不能用。如果某个变量唯一存在目的就是交换数据的暂存空间,那么也很贴切。

if (right < left) {
  temp = right;
  right = left;
  left = temp;
}

  反之如果是

String temp = user.name();
temp += " " + user.phoneNumber();
temp += " " + user.email();
...
template.set("user_info", temp);

  那么以上代码的temp就明显是userInfo的偷懒写法了,必须纠正。

  有时可以使用temp修饰另一个中心词,将此偏正短语作为标识符,倒也恰当,比如:

tempFile = namedTemporaryFile();
...
saveData(tempFile, ...);

  temp修饰了File,如果仅用saveData(temp, …),人们要去猜temp到底是临时文件本身,还是临时文件名,又或是被写入的临时数据?

  在循环语句所使用的迭代变量中,尤其要注意命名问题。空泛的i、j、k有时合适,有时则不行。尤其是会导致下标错乱的情况下,更要注意循环变量的起名。例如:

for (int i = 0; i < clubs.size(); i++)
  for (int j = 0; j < clubs[i].members.size(); j++)
    for (int k = 0; k < users.size(); k++)
      if (clubs[i].members[k] == users[j])
        System.out.println("user[" + j + "] is in club[" + i + "]");

  很难注意到其中的bug,如果写成

if (clubs[ci].members[ui] == users[mi])

  一下子就看到问题所在了。members数组的下标居然是ui(user index),users的下标居然是mi(member index),很明显,这两个写反了。

3.名称对内容的描述要具体而准确

  比如经常会定义如下的宏来防止生成默认的拷贝构造器与复制操作符。

#define DISALLOW_EVIL_CONSTRUCTORS(ClassName) 
ClassName(const ClassName&); 
void operator=(const ClassName&);

  这个evil constructors就太过感情化,不具体(怎么evil了?),而且不甚准确(operator=并不是一个构建子)。所以莫如更为精确的好:

#define DISALLOW_COPY_AND_ASSIGN(ClassName) ...

  上文一望即知:禁止提供拷贝构造器和赋值操作符。

  正交性也是考量准确度的一个标准。比如在设计参数选项时,经常会犯这样的错误:有时候我们开发的某个手机程序需要打印调试信息到手机屏幕,同时需要屏蔽内嵌的程序广告,有些小朋友以为,开发的时候总是用模拟器来运行程序,所以就把这两个功能强行塞入一个对应的选项中,并命名为on_emulator。这样的话有时候需要在真机上运行程序,而且要看调试信息,那么不得不把on_emulator选项设定为true。这看起来很容易造成误解,而且一旦这样设计,如果在真机上即要打印调试信息,同时还要显示内嵌广告,那么on_emulator便怎么设置都不对了。所以常犯的错误就是:根据表面现象,将两个毫不相关或可以各自独立存在的功能强行塞入一个选项中,既造成了误解,又丧失了使用的灵活度。上述这种情况莫如分别设计成print_debug_on_screen和show_ads比较好。

4.将重要信息纳入名称中

  如果某个附加信息,代码使用者非得知道它,才能正确地使用代码的话,那它就得被纳入标识符的名称当中了。比如:

String id; // 使用范例: "af84ef845cd8"

  如果id一定要用十六进制字符串,否则后续程序无法正常执行的话,那么这个信息必须让大家知道。所以最好将代码改成:

String hexID;

  这样的话,大家看到了hex前缀,都会明白代码作者的本意:非使用十六进制字符串不可。

  除了进制信息,计量的单位也应该被纳入命名之中。
  例如:

long start = (new Date()).getTime(); 
...
long elapsed = (new Date()).getTime() - start;
System.out.println("Load time was: " + elapsed + " seconds");

  上面这段代码很容易出错,因为elapsed并没有指明计时单位,是微秒?毫秒?秒?还是分钟?小时?如果加上了计量单位:

long startMs = (new Date()).getTime(); 
...
long elapsedMs = (new Date()).getTime() - start;
System.out.println("Load time was: " + elapsedMs/1000 + " seconds");

  这样的代码一目瞭然。而且有了错误也非常好查找。万一把“elapsedMs/1000”错写成“elapsedMs”,那么一眼就能看到:明明后面是“seconds”,前面却是“Ms”,单位明显不统一,当即知道漏掉了“/1000”。

  根据以上这个例子,我们建议将左边的参数改为右边的式样:

public void start(int delay ){...}; //delay改为delaySecs
public void createCache(int size){...}; //size改为sizeMB
public void throttleDownload(float limit){...}; //limit 改为maxKBPS
public void rotate(float angle){...}; //angle改为degreesClockwise

  上面之中的第4条最为严重。angle既没说是角度还是弧度,又没说是顺时针还是逆时针,如果不配合详细的Javadoc说明文档,很难一眼读透该方法所要表达的意思。

  除了计量单位之外,其余代码读者或代码使用者必须注意的信息也要纳入命名之中。这样以后该部分若有变动,可以在重构时及时更动变量名及使用它的其他语句,以维护代码语义的一致性。例如:明文密码应该叫plaintextPassword,以提醒使用者加密后方可使用,不宜直接叫做password。以后如果决定将初始的代码由明文变为已经加密好的,那么只需要使用开发环境的重构功能将plaintextPassword变为encryptedPassword即可,然后藉助开发工具找出所有使用encryptedPassword的地方,一一对照,如有逻辑不符,即行修改——这样就维护了代码逻辑的一致性,不会因为是否加密而导致bug或程序行为改变。同理,用户提供的注释里面可能包含需要进行转义处理的字符,此时应叫unescapedComment而非comment;已经转换为UTF-8格式的html字节序应叫htmlUTF8而非html;经由URL编码形式传入的数据应叫dataURLEnc而非data。

  很久以前,我也是一名Win32的API研究爱好者,当然忘不了匈牙利命名法了,那么“将重要信息纳入名称中“与”匈牙利命名法“有何区别呢?(这里主要讲的是系统匈牙利命名法,另外一种叫匈牙利应用命名法——感谢网友李先生在原文评论中指出此问题)它们的区别是,后者是一套正规的强制规范,纳入名称中的一般是指针(p)、映射表(m)、零终结字符串(sz)、计数(c)等特定属性,而前者则无此强制属性规定,凡对用户重要的属性均可纳入。可以仿称其为“要素命名法”("Essential Factor Notation")。(ARC的作者用“English Notation”来命名它,小翔觉之不确)

5.标识符的长短应符合其作用域的大小。

if (debug) {
  Map m=...;
  ...
  print(m);
}

  变量m的作用域很小,所以短命称不会带来问题。但是如果是在一个很大的作用域中,比如有上千行代码的类中:

public class PhoneBook{
  private Map m=...;
  ... //几千行代码之后
  public void someFun(){
    ...
    print(m); // m是啥咪东东呀?
    ...
  }
  ... //还有数千行代码
}

  那么m这样的短名显然不太合适。现在的编辑环境一般都有自动补完功能,按下某个组合键就好了,比如常见的几种编辑器:

编辑器/开发环境 自动补完快捷键
Vi Ctrl-p
Emacs Meta-/
Eclipse Alt-/
IntelliJ IDEA Alt-/
TextMate ESC

  我常用的是eclipse,其余的欢迎大家补充。

  当然啦,将不必要的词汇省略是好的。例如convertToString()简称toString(),doServerLoop()简称serverLoop()。翔以为主要是将不言自明的动词(比如convert,do等)省去。

6.使用格式来传达信息

  使用特殊的符号来表示特殊的对象,同其他普通对象区隔开来。例如在JavaScript中,用$为前缀来表示经由jQuery的$(“…")选择子而选中的一系列具有某名称的DOM节点。(小翔对JS不是很熟悉,因为日常工作是单机的手机应用/游戏开发。目前正在学习中,这部分代码有错误还望朋友们赐教)

var $all_images = $("img"); // $all_images是jQuery对象
var height = 250;//而height则是普通变量

  每种特殊标识符都用一套特殊命名法来区隔。例如HTML/CSS中,id与class都是特殊属性,所以分别采用下划线与连字符来命名这两种标识符。(再次捂脸:HTML/CSS苦手飘过,仍然是在努力学习这项技术之中)例如:

...

  嗯,写了这么多,休息一下吧。轻松地总结一下啦:

  ”以语句行为单位的微观代码管控如何入手呢?”“必也正名乎!”——将信息纳入名称,使读者通过名字就能领会到其中的含义。

  特定技巧:

  1. 使用更具表达力词语:例如以在BinaryTree类的设计中以height或nodeCount代替size。
  2. 避免空泛名称:tmp、retval、i、j、k等,除非确有必要,否则不用。
  3. 使用具体而准确的名称:描述更多细节的CanListenPort()优于ServerCanStart()。
  4. 附加重要属性:将Ms缀于以毫秒计时的值名称之后,将Raw缀于未经处理的数据名称之前。
  5. 大作用域用长名:不要把一两个字符的名称用在一大段代码中,短的代码可以有短名。
  6. 特殊名称用特殊格式:类成员可以_结尾,以与局部变量相区隔。$符号、大写或下划线等特殊格式可以区隔特殊的名称。

  嗯,这篇文章写了好几个小时,休息一下。正名大业分为上下两部分,这一篇主要是从正面给大家总结一些标识符命名的建议,下一篇则将从反面讲解何种名称会给人带来误解。

爱飞翔
2012年6月1日至2日

本文使用Creative Commons BY-NC-ND 3.0协议(创作共用 自由转载-保持署名-非商业使用-禁止衍生)发布。
原文网址:http://agilemobidev.net/eastarlee/code-quality/think_in_code_quality_2_name_zh_cn/