代码质量随想录(五):注得多不如注得巧
写代码也流行注水了么?不是不是,我说的是注释。其实注释这个东西,历史久远。我们可以宽泛一点儿说,《春秋》就是要配上左传的注解,才能兴发其“微言大义”嘛!注释有很多种,如果按照注释者与原文作者是不是同一个人来分,可以划分成自注和他注。在程序员这个行当内,一般来说,还是自注多一些,自己写代码,自己加注。有的时候进行代码审查或者复用遗留代码时,才可能会有必要对他人写的代码加注。
从代码质量的角度看,注释写得应不应该,写得好不好,应该从它是否有助于加深代码读者及代码使用者对程序的理解这一标准来判断。按照《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<float> data; ... void Clear() { vector<float>().swap(data); } };
如果谁突然闯进来看到上面的代码,肯定第一个就要问:为什么不直接调用data.clean()函数呢?与其让读者陷入猜测与不解之中,咱们不如直接用注释把隐晦的细节说明白了:
// 在vector对象上进行强制内存回收,参见“STL容器的swap技巧”(STL swap trick) vector<float>().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)
不知道上面这个顽皮搞笑的过程能不能克服注释恐惧症,如果不能的话,大家也可以跟帖想想办法。
这段时间一直没有写文章,一来由于工作繁忙,二来是晚上想贪玩看看比赛,三嘛,你别说,还真有可能是写作恐惧症呢!其实这更像是写作倦怠症。好了,不管怎么说,这次写开了,就不倦怠了。这一篇讲的是注释的时机问题,也就是什么时候应该注释,什么时候不该注释,下一篇来讲讲内容问题,也就是说,如果要写注释的话,怎么写才算好。