法则05:补充注释
代码的一部分功能是给机器执行,另一部分就是供人阅读。注释则只是供人阅读,它是代码的必要补充。在一个大型软件系统的开发维护周期中,会存在同一时期有多个人维护同一个模块的情况,另外还会有不同人在不同时期维护同一个模块的情况。尤其是后者,软件设计师之间跨越时空的交流都依托代码和注释来完成。
忘记传统软件工程中所说的那些所谓文档吧,在我的职业生涯中从来没有仅仅通过学习某份文档就完全掌握某份代码的经历,而是通过不断地去“啃”代码才达到对其完全掌握的程度。一种新兴的软件理论也认为代码就是设计,代码就是文档,而注释也承载着这样的功能。
当代码发生变化时,如果要同时维护(修改)代码和文档两个地方,那这样的文档就是没有必要的,而要把相应内容放到代码里,以注释的形式存在。一份与代码不一致的文档会给人造成更大的麻烦,还不如没有。总之随时把握这一原则:一次需求变更只集中修改一个地方。当然文档并非完全一无是处,一些提纲挈领的UML类图和时序图也是必不可少的,它对代码阅读者掌握系统框架会有很大帮助。
以下是写注释时的一些注意事项。希望读者能有效利用好注释这种表达方式,向代码阅读者传递更为有效的信息。
● 不用注释描述代码在“做什么”
我曾经参与过的一个项目要求注释率不得低于30%,这样的要求简直令人沮丧。项目代码中类似下面这样令人哭笑不得的注释屡见不鲜。
实际上项目组做这个要求的出发点是好的,就是希望提高代码可读性。然而当对某一项指标进行考核时,往往这样的考核就会变质。注释是代码的必要补充,但它们之间并没有一个必然的量化关系。正如写文章一样,有的人行文啰唆,一件事情翻来覆去说了一大堆;有的人则言简意赅。总之能把一件事情说清楚就行。
新的编程观点也在强调代码能“自注释”,这可以避免冗余注释,提高代码的可读性。当不得不用注释来说明一段代码在“做什么”时,应该考虑将这些代码封装为一个新的函数,并用函数名说明其在“做什么”。下面这个例子摘自Linux内核,在Linux-2.6.10版本中,函数copy_page_range的代码是这么写的(实际上从Linux-2.4.0版本开始这个函数的结构一直是这样)。
该函数的功能是在两个进程间复制一段内存页面。Linux采用分页式内存管理,因此其步骤为分别对页面目录、中间目录、页面表项的处理。在每一块代码上方,都有说明这部分代码是“做什么”的注释。当然,这些代码还存在另外一些问题:嵌套过深,函数过长。
到了Linux-2.6.11版本,我们看到这些代码被重构了。这些注释说明的部分已经被封装为函数,代码也变得简洁,说明代码是“做什么”的注释自然也不再需要,同时嵌套过深,函数过长的问题也一并解决了(在“法则 23:控制函数规模”一节会对该重构方法做进一步说明)。
● 用注释说明“为什么”
代码的自注释已经能描述其在“做什么”,但是作者在写代码时是基于什么样的考虑,为什么要把代码写成这样?这一类的表达通过代码本身是无法实现的。注释就成了一个有益的补充,用其来说明“为什么”这么写,可以帮助维护人员准确理解这部分代码。
还是举一个Linux内核中的例子,以下代码摘自Linux-2.4.0版本内核。
ll_rw_block尝试将一些记录块(对应一块存储介质的内容)写入设备驱动,其中调用了test_and_set_bit,根据代码本身就能知道这里是要将记录块加锁,加锁成功就继续后续的处理,加锁失败就continue。test_and_set_bit的功能是先判断记录块是否加锁,如果已经加锁就返回1,说明其他线程已经对该记录块进行加锁;如果没有加锁,则对其进行加锁并返回0。但为什么要将记录块加锁?恐怕没有设备驱动开发背景的人就不太明白,因此代码上方做出了“为什么”的解释:对于同一个记录块,只能有一个线程对其进行submit(submit是指后面将会调用的submit_bh函数将记录块提交给设备驱动层)。这样一来,阅读者就很容易理解代码的意图了。
● 避免冗余注释
冗余注释和冗余代码一样,在一次修改时要同步修改多个地方,也增加了阅读成本。下面这个例子是某个项目对函数头注释的格式要求。
要填的信息较多,但多数都没有必要。首先看“函数名称”,实际上这段注释放在哪个函数的上方,那肯定是对这个函数的描述。如果出现不一致,那肯定是复制粘贴以后没有修改完全,因此这一项是冗余的。
函数名的自注释主要就是描述其功能,但是也不排除有的时候只靠函数名可能会描述不准确,因此“功能描述”这一项可以保留,当函数名自注释描述不完全的时候再进行补充说明。
“输入参数”和“输出参数”这两项完全没有必要。一个函数哪个是入参(输入参数)哪个是出参(输出参数),完全可以通过代码辨别。另外一个更好的方法是运用编程语言的语法来描述这个信息,比如C++可以用const,Java可以用final来表示一个参数是入参。另外对于参数的说明是有必要进行注释的,但建议不要放在函数头,而是直接放在参数的旁边。注释应该放在其所描述的代码附近,最好代码和注释都能在同一屏中显示。如果距离较远可能不容易被人读到,而且修改时容易遗漏。
至于“返回值”则分两种情况:如果该函数是一个对外接口,则有必要详细描述,比如什么情况返回成功,什么情况返回失败等;如果该函数是模块内部的一个函数,则只需要进行简单说明,或者通过函数名的自注释就能知道其返回值的信息。
“其他说明”可以改为“注意事项”,主要是给其他人看的。同一项目的其他人员可能会调用该函数,调用前有哪些预置条件,调用后需要注意什么善后工作等都可以在这里进行描述。
至于“修改日期”“版本号”这些信息放在这里太过烦琐,直接在代码改动的地方注释即可。另外这个注释风格不够简洁,用了很多的“*”号,可以进行简化。最终我们得到的函数头注释如下所示。
这已经到了极简模式。把“功能描述”“返回值”“注意事项”这些信息中较为重要的说清楚即可。另外千万不要模板化地写成如下样式。
这样容易导致冗余信息、导致很多复制粘贴、导致很多修改遗漏。
● 不要的代码先注(注释)掉,别着急删
在小的迭代开发过程中,写代码时做加法是一个较好的方式。删除代码时将其注掉并写明原因,而不要直接将代码删除。因为这样能够让你的每一次修改都留有痕迹。比如下面的例子要删除对do_some_thing的调用,先将这个地方注掉。
这样做的好处是当你回顾代码时,能帮助你回忆之前修改代码的过程。
另外还有一个优点。在一些大型项目中往往采用多级代码管理的方式,整个项目被划分成多个子系统,每个子系统由对应的小组进行开发维护。代码首先提交到本小组的开发分支,做好自测及持续集成后,再由专人统一合入主干。在合并代码时,如果在代码比较工具(如Beyond Compare)中出现图1-3所示的代码片段,合并代码的人就会产生疑惑。对于这样没有任何注释的删除,合并人员不太敢轻易向主干提交,往往还需要找开发当事人进行确认。
图1-3 缺少注释的代码删除
此外还有可能一个模块同时被多个版本使用,出现需要同时维护多个分支的情况。当某个分支的修改需要同步到其他分支时,在进行代码比较时能更加清楚地知道修改的内容,因此图1-4所示的方式更为清晰。
图1-4 补充了注释的代码删除
当然,这种方法长期下来会积累很多垃圾代码。通常的做法是在项目进行到一定阶段时,再统一对这些垃圾代码进行清理(直接删除)。
● 强调修改时的注意事项
能为将来的维护者着想是一个优秀软件设计师的职业素养。一个大项目中某个模块可能会经历几代开发者,如果模块中存在某些“机关”,而后来的人没能完全掌握的话,很可能会“踩雷”,代码稍作修改就引入问题。这是非常令人沮丧的。
在代码中难免会存在一些特定的约束或者一些“坑”,修改时容易导致错误。前任开发者需要把这些注意事项都以醒目的方式写在注释中,以提醒后继的开发者,避免引入问题。
下面用Linux-2.4.0中的代码进行举例。
请留意filp_open和open_namei函数头注释中的黑体部分。这两个函数存在调用关系,前者会调用后者,而且两个函数都有一个入参flag。但是flag在两个函数中的含义是不一样的,为避免混淆,开发者在两个函数头的注释里都做了详细的说明。第一个flag来自系统调用sys_open,其取值为:00—只读、01—只写、10—读写、11—特殊值。而open_namei是内部函数,其flag的取值为:00—不需要权限、01—需要读权限、10—需要写权限、11—需要读写权限。两个参数都叫flag但是含义不一样,而且又存在直接调用关系,容易引起混淆。因此开发者在这里用大量的篇幅进行了注释说明。
当然在实际的编程工作中,应该避免这类混淆,因为注释难免被人忽略,用注释只是在不得已的时候。我们很高兴地看到在Linux-3.11.0内核中,这个问题得到了很好的解决。代码如下。
用open_to_namei_flags将两个参数进行转换,并且使用了一个新的局部变量open_flag。这样有效地避免了混淆,也无须再花费大量的注释说明注意事项。毕竟注释是一种约束性相对比较“弱”的表达方式,编译和运行程序都无法发现注释里的错误,只在不得已的时候才使用。那么还有没有其他更好的方法呢?答案是:有。
方法一:如果所在的项目有持续集成进行单元测试,那么应编写单元测试用例来保证代码功能的正确性,如果有其他人修改代码引入错误,测试用例就会报错,提示修改人检查并修正错误。
方法二:增加代码看护。此方法将在“法则 20:代码看护”一节中进行介绍。
● 用中文还是用英文
我刚到H公司时一位老员工建议我用英文写注释,理由是将来外籍研发人员可能看不懂中文注释。这令我感到很惶恐,因为我对自己的英文水平没有信心。不过好在项目组没有强制地要求,因此我仍一直用中文写注释并平安地渡过了几年。直到离开H公司时也没有等来一位外籍研发人员。
但时至今日,从H公司开源的鸿蒙操作系统源码中看到,其注释已经全都使用英文,因为该操作系统已经成为一个面向全世界开放的系统。比如下面这段注释描述得非常详细。
在_los_attach_file中要关联一个文件时,需要使用如下方式进行文件加锁。
并用了大量篇幅描述为什么不能用下面这样简单的if判断。
注释是一种表达方式,表达就要考虑读者能否理解,如同白居易的诗。目前国内绝大部分软件项目都是国内的研发人员在开发,如果没有特殊的要求,建议还是使用中文进行注释,因为使用母语表达更加得心应手。但如果有类似鸿蒙系统这样面向全世界的开源项目,则需要使用英文,这样能更好地让外籍研发人员参与进来。
[1] [美]Martin Fowler著Refactoring: Improving the Design of Existing Code。
[2] [美]Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides著Design Patterns。
[3] 在“代码资源”一章的“花样泡泡龙”一节中会对其进行详细介绍,并公开了完整源代码。
[4] Charles Petzold所著的 Programming Windows被誉为Windows编程的“圣经”。