本文在绿泡泡“狗哥琐话”首发于2024.12.27 <-关注不走丢。
最近看到一篇好文章,是6年前redis之父写的,虽然过了这么久,但是这些内容并没有过气。
标题《Writing system software: code comments》,链接是:http://antirez.com/news/124?continueFlag=372abd242aeafb5bbf6f...
这篇文讨论了代码中注释的重要性,以及好注释与坏注释的典型。
文章的核心的观点就是,写好注释特别重要。他对于代码写得足够好,就不需要注释
的这种观点持有反对意见。主要从两个点出发:
- 许多注释没有解释代码在做什么。它们仅从代码的功能中解释了你无法理解的内容。说到底就是没说明为什么这些代码要这么做。
- 很多人会觉得逐行加注释没用,因为读代码就可以懂,但编写可读代码的一个关键目标是减少读者在阅读某些代码时应该考虑的工作量和细节数量。因此,对我来说,注释可以成为降低读者认知负荷的工具。
他展示了一段redis的代码来佐证了第2个观点:
/* 初始栈:数组 */
lua_getglobal(lua, "table"); // 获取全局变量 "table" 并压入栈中
lua_pushstring(lua, "sort"); // 将字符串 "sort" 压入栈中
lua_gettable(lua, -2); // 使用栈顶的 "sort" 作为键,从 "table" 中获取值(即 table.sort 函数)并压入栈中
// 栈状态: 数组, 表, table.sort
lua_pushvalue(lua, -3); // 复制栈底的数组,并将其压入栈顶
// 栈状态: 数组, 表, table.sort, 数组
if (lua_pcall(lua, 1, 0, 0)) { // 调用 table.sort 函数来排序数组。如果调用失败(例如数组中有非数字或 nil 元素)
/* 栈状态: 数组, 表, 错误 */
/* 我们不关心错误的具体内容,我们假设问题是数组中包含 'false' 或其他不可比较的元素。
* 因此,我们尝试使用一个较慢但能够处理这种情况的函数,即:
* table.sort(table, __redis__compare_helper) */
lua_pop(lua, 1); // 移除错误信息
// 栈状态: 数组, 表
lua_pushstring(lua, "sort"); // 再次将字符串 "sort" 压入栈中
lua_gettable(lua, -2); // 再次获取 table.sort 函数
// 栈状态: 数组, 表, table.sort
lua_pushvalue(lua, -3); // 再次复制数组并压入栈顶
// 栈状态: 数组, 表, table.sort, 数组
lua_getglobal(lua, "__redis__compare_helper");
// 获取全局比较辅助函数 __redis__compare_helper 并压入栈中
// 栈状态: 数组, 表, table.sort, 数组, __redis__compare_helper
lua_call(lua, 2, 0); // 调用 table.sort 函数,传入数组和比较辅助函数。这里不需要返回值
}
这段代码的主要目的是对一个数组进行排序。
首先尝试使用标准的 table.sort
函数,如果数组中含有无法直接比较的元素(如 false
或 nil
)而导致排序失败,则会使用一个自定义的比较辅助函数来再次尝试排序。这样可以确保即使数组中有复杂的元素,也能够正确地进行排序。
然后作者对redis中的代码注释翻了个遍,对这些注释呢,做了共计9种的分类:
- Function comments(函数注释)
- Design comments(设计注释)
- Why comments(缘由注释)
- Teacher comments(教师式注释)
- Checklist comments(检查列表注释)
- Guide comments(教程式注释)
- Trivial comments(琐碎的注释)
- Debt comments (债务型注释)
- Backup comments(备份注释)
前6种都是不错的注释,但是后3种很不行。下面我们来一个个看:
1.函数注释
/* 在当前节点的子树中查找最大的键。当内存不足时返回0
,否则为1。这是一个与普通迭代函数有所不同的函数,代码如下。 */
int raxSeekGreatest(raxIterator *it) {
...
函数注释一般在函数声明的上面,也有可能在代码里面。它最重要的作用就是避免读者要去一行行读代码,了解它的作用。好的注释可以让读者认为这是个遵循一定规则的黑盒,读完直接回到TA正在阅读的主线分支。
作者还指出,这种方式也可以很好的让作者在变更代码时同时变更文档,避免文档更新的遗漏。
2.设计注释
设计型注释一般位于文件的开头,它会说明这些代码如何以及为什么使用某些算法、技术、以及技巧或实现等等。这种注释更多专注在高层次的概述。
* DESIGN
* ------
*
*设计很简单,我们有一个表示要执行的作业的结构
*每种作业类型都有不同的线程和作业队列。
*每个线程都在其队列中等待新作业,并按顺序处理每个作业。
...
3.缘由注释
这种注释更多在解释代码为什么要这么做,即使代码已经很清晰了。
以Redis副本同步的代码为例:
if (idle > server.repl_backlog_time_limit) {
/*当我们释放积压时,我们总是使用新的副本ID并清除ID2。当没有积压时这是必要的,master_repl_offset不会更新,但我们仍然会保留我们的副本ID,从而导致以下问题:
*
*1.我是一个master实例。
*2.我们的副本变成了master。它的repl-id-2将与我们的repl-id相同。
*3.作为master,我们会收到一些更新,这些更新不会增加master_repl_offset。
*4.稍后,我们变成了一个副本,连接到新的主机,该主机将通过第二个复制ID接受我们的PSYNC请求,但由于我们收到了写入,因此会出现数据不一致。
*/
changeReplicationId();
clearReplicationId2();
freeReplicationBacklog();
serverLog(LL_NOTICE,
"Replication backlog freed after %d seconds "
"without connected replicas.",
(int) server.repl_backlog_time_limit);
}
接下来可能会有人觉得,这种注释只有在比较复杂的代码中才需要,而然并不是,我们继续往下看:
for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
int expired;
redisDb *db = server.db+(current_db % server.dbnum);
//这样去增加ID,我们可以保证时间用完时,我们将从下一个数据库开始做一些事情
current_db++;
...
这段代码是让不同的实例密钥过期的,只要还有执行时间。但是它在循环中并没有直接递增数据库ID,而是在函数末尾。这种情况下,如果执行超时了(可能网络不通、机器不行),下次在外部调用时则会跳过这个数据库。
通过这样的注释,开发者就知道为什么要把递增写在这里,以及在编写类似代码时可以避免类似的问题。
4.教师式注释
教师型注释更多专注告诉你代码所在的领域知识(比如数学、图、统计学、复杂数据结构等),对于普通开发者可能比较陌生,或者是相关的太多的细节。
LOLWUT命令会在屏幕上显示旋转的方块。这里面到了一些三角函数,但为了防止大家没有数学背景,函数顶部加了注释。
/* 以指定的x、y坐标为中心,绘制一个具有指定旋转角度和大小的正方形。为了写出一个旋转的正方形,我们使用了参数方程
*
* x = sin(k)
* y = cos(k)
*
* 描述一个从0到2*PI的值的圆圈。所以我们从45度开始,即k=PI/4。然后我们找到其他三个点,将k增加PI/2(90度),我们就得到了正方形的点。为了旋转正方形,我们只需从k=PI/4+rotation_angle开始就行了。
*
* 当然,上面的普通方程式描述了一个
半径为1的圆,因此为了绘制更大的正方形,我们必须将获得的坐标相乘,然后平移它们。然而,这比实现2D形状的抽象概念然后执行旋转/平移变换要简单得多,对于LOLWUT来说,这是一种很好的方法。
*/
作者认为,虽然这些注释不包含代码的逻辑和技术细节,但是很有价值,它教给读者一些东西,让读者可以快速的入门这些代码。
5.检查列表注释
这种注释往往是疑问语言限制、设计问题以及系统中自然而然出现的复杂问题,而没法将给定的概念或接口集中在一个部分。所以注释例会告诉你代码在别的地方做的事:
/* 警告:如果你在这里添加了某种类型,那么请确保XX函数也一起修改了*/
6.教程式注释
作者承认在redis里是滥用这种注释的,而且这种注释大多数人认为是没用的,因为:
- 没有说清楚代码里晦涩的地方
- 没有任何设计相关的提示
作者认为,这种注释就是用来照顾读者的,来提供清晰的划分、节奏和介绍,帮助你来阅读里面的代码。简单来说就是在阅读代码时降低你的认知负荷。
/* 如果有,调用节点回调,
*如果回调返回true,则替换节点指针. */
if (it->node_cb && it->node_cb(&it->node))
memcpy(cp,&it->node,sizeof(it->node));
/*对于“下一步”,每次我们找到一个键时都要停止,因为该键在字典上比子节点中的键小*/
if (it->node->iskey) {
it->data = raxGetData(it->node);
return 1;
}
作者也说了,他认为这种注释的好,是很主观的。他相信大家觉得redis代码好读,一部分原因就是来自于这里的。
而且教程式注释可以把代码划分成独立的部分,增加可读性。从这点上来说,作者也是比较认可这类注释的。
你问我怎么看呢?这类注释对于老手来看是比较啰嗦的,但是的确对新人很友好。好了,你们又学会了一个防失业的技巧,写代码不写注释。
7.琐碎的注释
而琐碎的注释正是教程式注释退化而来的。举几个例子感受下:
array_len++; /* Increment the length of our array. */
这让我想起来我以前写代码的时候,有一个老哥让我补注释。就像这样:
// 订单服务类
public class OrderService{
}
8.债务型注释
债务型注释就是在源代码中的技术债务声明:
/*在这里,我们应该执行垃圾收集,
* 以防此时列表包中删除的条目太多*/
entries -= to_delete;
marked_deleted += to_delete;
if (entries + marked_deleted > 10 && marked_deleted > entries/2) {
/* TODO: perform a garbage collection. */
}
这种注释作者认为,更加适合写成设计型注释比较好。比如在这个例子中,解释为何没有GC。
但其实这类注释比较难避免——因为这样写进代码里,至少对应的问题不会被忘记。更好的方式则是定期让人收集起来,放到一个更好的地方,然后排期或立刻去解决它们。
9.备份注释
这种就是注释了代码,保存在源文件里。而不利用版本管理工具来做这件事。作者对这类注释感到很疑惑,尤其是git已经流行了这么长时间的情况下。
小结
在文章的最后,作者阐述了两个观点。
第一点:注释是一种很有效的分析工具。而且你写下的注释未来的代码读者是会去看的,在开源软件上搞不好就要被人吊,所以尽量做的体面一点吧,写点好的注释,为人为己。
第二个则是写好注释比写代码还难。因为写好注释意味着你能够在更深层次上理解你写的代码,而且这还会锻炼的你写作技巧。作者提到,他写代码是因为他很喜欢分享和交流,注释促进了代码的分享和交流,所以他写注释就和像写代码一样喜欢。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。