如何写好注释-转
许多人认为,如果代码写得足够扎实,注释就没什么用了。在他们看来,当一切都设计妥当时,代码本身会记录其作用,因此代码注释是多余的。我对此持不同意见,主要出于两个原因:
- 许多注释并未起到解释代码的作用。
- 注释使读者不必凭空想象太多细枝末节,帮助读者降低认知负担。
注释的分类
我的工作始于随机地阅读Redis源代码,以检查注释是否以及为什么在不同的上下文中起作用。我很快发现,注释的作用来源于多方面:它们在功能,编程风格,长度和更新频率方面往往非常不同。我最终转向了注释分类。
在研究期间,我确定了九种注释类别:
函数注释 Function comments
设计注释 Design comments
原因注释 Why comments
教学注释 Teacher comments
清单注释 Checklist comments
引导注释 Guide comments
琐碎注释 Trivial comments
(代码)负债注释 Debt comments
备份注释 Backup comments
在我看来,前六个主要是非常积极的注释形式,而最后三个有点值得怀疑。在接下来的部分中,我将使用Redis源代码中的示例分析每种注释类型。
函数注释
函数注释的目标是防止读者直接阅读代码。
在阅读注释之后,读者应该可以将一些代码视为应遵守某些规则的黑箱子。通常情况下,函数注释位于函数定义的顶部。
rax.c
1 | / * 在当前节点的子树中寻找最大的key。 |
2 | 如果内存不足返回0,否则 返回1. * / |
3 | int raxSeekGreatest(raxIterator * it){ |
4 | ... |
5 | } |
函数注释实际上是一种内联API文档。如果函数注释编写得好,那么用户在大多数时候能跳回到她正在阅读的内容(如阅读调用此类API的代码),而无需阅读函数(function),类(class),宏(macro)等的实现过程。
在所有注释类型中,函数注释被整个编程界广泛接受和需要。要分析的唯一一点是:在代码内部放置以API参考文档为主的注释是否是件好事。
对我来说答案很简单:我希望API文档与代码完全匹配。随着代码的更改,文档也得到更改。出于这个原因,我们将函数注释用作函数或其他元素的序言,使API文档接近代码,完成三个任务:
随着代码的更改,我们可以轻松更改文档,API参考也不会有过时的风险。
这种方法使得更改者(理应是最清楚更改目的的人)在最大限度上成为API文档的更改者。
读者能通过阅读代码直接找到函数或方法(method)的文档,以便阅读代码的读者只关注代码,而不是代码和文档之间的上下文切换。
设计注释
“函数注释”通常位于函数的开头,而设计注释通常位于文件的开头。
设计注释一般说明了给定代码片段使用某些算法、技术、技巧和具体实现的方式和原因,对代码中实现的内容进行了更高级别的概述。在这样的背景下,阅读代码会更简单一些。
bio.c
1 | *设计 |
2 | * ------ |
3 | * |
4 | *设计很简单,我们用一个结构代表要执行的一项 Job |
5 | *每种Job类型有不同的线程和Job队列。 |
6 | *每个线程都在等待队列中的新Job,并按照顺序处理 |
7 | *每个Job。 |
8 | ... |
原因注释
原因注释解释了代码执行某些操作的原因——即使代码执行的操作非常明确。请看以下来自Redis replication的代码 的示例。
replication.c
1 | if(idle> server.repl_backlog_time_limit){ |
2 | |
3 | /* 当我们释放 backlog时,我们总是使用新的 |
4 | * replication ID并清除ID2。这是 |
5 | * 因为在没有backlog时,master_repl_offset |
6 | * 未更新,但我们仍会保留我们的 |
7 | * replication ID,由此导致以下问题: |
8 | * |
9 | * 1.我们是一个主实例(master instance)。 |
10 | * 2.我们的副本成为主服务器(Master)。repl-id-2将会 |
11 | * 与我们的repl-id相同。 |
12 | * 3.我们作为主服务器,收到了一些更新命令,但不会 |
13 | * 增加master_repl_offset。 |
14 | * 4.稍后我们将变成副本,连接到新的 |
15 | * 主服务器,它将接受我们第二个副本ID的 |
16 | * PSYNC请求,但会有数据不一致的情况 |
17 | * 因为我们接受了写命令。* / |
18 | |
19 | changeReplicationId(); |
20 | clearReplicationId2(); |
21 | freeReplicationBacklog(); |
22 | serverLog(LL_NOTICE, |
23 | "Replication backlog freed after %d seconds " |
24 | "without connected replicas.", |
25 | (int) server.repl_backlog_time_limit); |
26 | } |
如果我只检查函数调用,就没什么需要纠结的:如果超时了就更改主replication ID,清除辅助ID,最后释放replication backlog。
教学注释
教学注释不会试图解释代码本身或我们应该注意的某些副作用。教学注释教授的是代码运行的“领域”(例如数学,计算机图形学,网络系统,统计,复杂的数据结构等),这些信息可能超出了读者的认知范围,或者细节多到难以回忆。
版本5中的LOLWUT命令需要在屏幕上显示旋转的方块。为了做到这一点,它使用了一些基本的三角函数:尽管涉及的数学内容很简单,但许多阅读Redis源代码的程序员可能没有任何数学背景知识,因此函数顶部的注释解释了该函数的原理。
1 | /* |
2 | * 绘制一个以指定的x,y坐标为中心的正方形 |
3 | * 旋转角度和大小已定。为了写出旋转方块的代码,我们使用了 |
4 | * 参数方程: |
5 | * |
6 | * x = sin(k) |
7 | * y = cos(k) |
8 | * |
9 | * 绘制一个圆(0-2*PI)。然后,如果我们从45度 |
10 | * 开始,即k = PI / 4,以此作为第一个点,然后我们发现 |
11 | * 其他三个点的K值以PI / 2(90度)递增,于是我们得到 |
12 | * 了构成一个圆的点。为了旋转方块,我们从 |
13 | * k = PI / 4 + rotation_angle开始,然后我们就完事儿了。 |
14 | * ...... |
15 | * / |
注释不包含任何与函数本身的代码,或其副作用,或与函数相关的技术细节等内容。注释描述的部分仅限于函数内部使用以达到给定目标的数学概念。
清单注释
这是一个非常常见且奇怪的问题:有时由于语言限制,设计问题,或者仅仅因为系统内部固有的复杂性,我们无法将某个概念或界面集中在一个代码片段中,因此代码中有一些部分能提醒你在代码的某个部分做某件事。一般概念是:
1 | / * 警告:如果你在此处添加类型ID,请务必修改 |
2 | |
3 | \* getTypeNameByID()函数。* / |
在一个完美世界中,我们永远不需要添加这类注释;但在实践中有时没法省略这一步。
在这种情况下,防御性注释有时能起作用:如果你修改了某节代码,它会提醒你修改代码的其他相关部分。具体而言,清单注释会发挥以下一种作用(或者两种兼而有之):
告诉你在修改某些内容时要执行的一系列操作。
警告你应该如何进行某些更改。
引导注释
我滥用引导注释到这种程度:Redis中的大多数注释都是引导注释。然而,引导注释正是大多数人认知中那类完全无用的注释:
- 他们没有说明代码中不甚明了的内容。
- 指导注释不提供有关设计方面的提示。
引导注释只做了一件事:他们照顾了读者的需求,在读者处理源代码中的内容时提供明确的划分(division)和节奏(rhythm),并介绍接下来需要阅读的内容。
rax.c
1 | / *调用节点回调(如果有的话),如果回调返回true |
2 | *则替换节点指标* / |
3 | if (it->node_cb && it->node_cb(&it->node)) |
4 | memcpy(cp,&it->node,sizeof(it->node)); |
5 | |
6 | /*对于“下一步”,每次找到一个键就停止 |
7 | *一次,因为相比较后面子节点分支中的内容 |
8 | *键本身字典序较小。* / |
9 | if (it->node->iskey) { |
10 | it->data = raxGetData(it->node); |
11 | return 1; |
12 | } |
Redis内“实际上”充满了引导注释,所以基本上你打开的每个文件都会包含很多引导注释。为什么要费这个力气呢?在这篇博客文章中所分析的所有注释类型中,这绝对是最主观的一种。我并不觉得没有引导注释的代码就不是好代码。但我坚信,如果人们认为Redis代码是可读的,部分原因就在于其中的引导注释。
引导注释还有一些别的用处。因为它们明确地将代码划分为独立的部分,所以我们能在合适的位置插入新代码,而不是随便加在其他代码后面。在代码附近设置相关语句能大大提高可读性。
引导注释能简要地告诉读者函数将要执行什么操作,所以如果你只对大框架感兴趣,则无需回过头去阅读函数。
琐碎注释
引导注释是非常主观的工具。不管你喜不喜欢,我反正超爱引导注释。
然而,引导注释可能会退化为极其糟糕的注释:它很容易变成“琐碎注释”(trivial comment)。
琐碎注释这种引导注释所带来的认知负荷和仅阅读相关代码比起来相差无几,甚至可能更高。以下这种琐碎注释正是许多书籍规劝你避免的。
array_len ++; / 增加数组的长度。 /
因此,如果你写引导注释的话,请避免写琐碎注释。
###(代码)负债注释
负债注释是源代码内部硬编码的技术债务语句:
1 | entries -= to_delete; |
2 | marked_deleted += to_delete; |
3 | if (entries + marked_deleted > 10 && marked_deleted > entries/2) { |
4 | / * TODO:执行垃圾收集操作。* / |
5 | } |
FIXME,TODO,XXX,“这是一个黑客”,这些都是负债注释。总的来说这些注释不算好,我试图避免使用它们,但看起来不太可能。有时候,比起永远忘记一个问题,我更喜欢在源代码中放置一个节点。程序员至少应该定期查看这些注释,看看是否可以能改进一下表述,或者这些问题是否已不再相关或可以立即解决。
备份注释
备份注释是开发人员对某些代码块的旧版本甚至是整个函数做出的注释,因为他/她对新版本中运行的更改放不下心。令人费解的是,现在有了Git,人们却还在使用这类注释。我想人们对于丢失代码片段有一种不安全感,过去提交代码时,使用备份注释会显得更加理智可靠。
但源代码并不是用来备份的。如果你需要保存旧版本的函数或代码,说明你的工作尚未完成,也无法提交。要么确保新函数比过去的更好,要么只在开发树(development tree)中使用它,直到你确定为止。
备份注释是我分类中的最后一项。我们来做个总结。
总结
注释是和未来的代码读者聊天,读者们还能在Twitter上评价你的注释。所以在这个过程中,你真心地在审视自己所注释的内容是否“能让人接受”,看自己写得是否足够体面、足够好。如果不是,你就勤勤恳恳地再做一遍,拿出更好的注释来。
你可能认为编写注释不是个高端工作。毕竟你“会写代码”!但请考虑这一点:代码是一组语句和函数调用(或者你做的其他编程范例也一样)。如果代码写得不好,这些语句就没有多大意义。注释常常要求你进行一些设计过程,并从更深层次来理解你正在编写的代码。
最重要的是,为了写出好的注释,你必须培养自己的写作能力。这种写作技巧能帮你更好地编写电子邮件、文案、设计文档、博客文章和提交文件。
我写代码是因为我迫切想要与他人沟通交流、分享想法。注释能够为代码提供帮助,把作者的心血表现出来。说到底,我喜欢写注释,就像我喜欢写代码一样。