【译】编写系统软件:代码注释
作者:antirez(Redis之父)
原文: https://antirez.com/news/124
很长一段时间以来,我一直想为YouTube上的“编写系统软件”系列录制一个关于代码注释的新视频。但经过一番思考后,我意识到这个主题更适合写成博客,于是就有了这篇文章。在本文中,我将分析Redis的代码注释并尝试对其进行分类,同时试图说明为什么在我看来,编写注释对于产出优秀代码至关重要——这些注释能让代码在长期内易于维护,也能让其他人(包括开发者自己)在修改和调试时轻松理解。
并非所有人都认同这一点。许多人认为,如果代码足够简洁,注释就毫无用处。他们的观点是,当一切设计合理时,代码本身就能说明其功能,因此代码注释是多余的。我不认同这种观点,主要有两个原因:
-
许多注释不解释代码在做什么,而是解释仅从代码行为无法理解的内容。这些缺失的信息通常是代码为何要执行某个操作,或者为何选择看似清晰但实际比其他方案更复杂的实现方式。
-
虽然逐行注释代码功能通常没有必要(因为读代码即可理解),但编写易读代码的核心目标之一是降低读者理解代码所需的精力和信息量。因此对我而言,注释是减轻读者认知负担的工具。
以下代码片段很好地体现了第二点(所有示例均来自Redis源代码,代码片段前标注了文件名,基于当前哈希值为32e0d237的“unstable”分支):
scripting.c:
/* 初始栈状态:array */
lua_getglobal(lua,"table");
lua_pushstring(lua,"sort");
lua_gettable(lua,-2); /* 栈状态:array, table, table.sort */
lua_pushvalue(lua,-3); /* 栈状态:array, table, table.sort, array */
if (lua_pcall(lua,1,0,0)) {
/* 栈状态:array, table, error */
/* 我们不关心错误,假设问题出在数组中存在'false'元素,
* 因此尝试使用更慢但能处理这种情况的函数:table.sort(table, __redis__compare_helper) */
lua_pop(lua,1); /* 栈状态:array, table */
lua_pushstring(lua,"sort"); /* 栈状态:array, table, sort */
lua_gettable(lua,-2); /* 栈状态:array, table, table.sort */
lua_pushvalue(lua,-3); /* 栈状态:array, table, table.sort, array */
lua_getglobal(lua,"__redis__compare_helper");
/* 栈状态:array, table, table.sort, array, __redis__compare_helper */
lua_call(lua,2,0);
}
Lua使用基于栈的API。即使读者手头有Lua API文档,逐行跟踪上述函数调用时也需要在脑海中重建每个时刻的栈状态。但为什么要让读者费这个劲呢?开发者在编写代码时已经完成了这种思维过程,而这里的注释只是将每次调用后的栈状态记录下来。现在,即使Lua API本身较难理解,阅读这段代码也变得轻松了。
我的目标不仅是阐述注释作为补充背景信息工具的作用(这些背景信息无法从局部代码中直接获取),还想证明那些传统上被认为无用甚至危险的注释(即仅说明代码“做什么”而非“为什么”的注释)的价值。
注释的分类
我通过阅读Redis源代码的随机片段来研究注释在不同场景中的作用,很快发现注释因功能、写作风格、长度和更新频率的不同而差异显著,最终将其归纳为九种类型:
- 函数注释(Function comments)
- 设计注释(Design comments)
- 原因注释(Why comments)
- 知识注释(Teacher comments)
- 清单注释(Checklist comments)
- 引导注释(Guide comments)
- 琐碎注释(Trivial comments)
- 技术债注释(Debt comments)
- 备份注释(Backup comments)
前六种注释在我看来大多是积极有效的,后三种则存在争议。以下结合Redis源代码示例逐一分析。
一、函数注释(Function Comments)
目标:避免读者直接阅读代码,通过注释将代码视为遵循特定规则的黑盒。通常位于函数定义顶部,也可能用于文档化类、宏或其他功能独立的代码块。
示例(rax.c):
/* 在当前节点的子树中查找最大键。内存不足时返回0,否则返回1。
* 这是供下方不同迭代函数使用的辅助函数。 */
int raxSeekGreatest(raxIterator *it) {
...
}
函数注释本质上是内联的API文档。优秀的函数注释能让读者在调用API时无需阅读实现细节,直接跳转回调用逻辑。这类注释是编程社区普遍认可的必要注释,唯一需要探讨的是:将API文档嵌入代码是否合理?我的答案很明确:我希望API文档与代码完全同步。代码变更时,文档也应随之修改。将函数注释作为函数(或其他代码元素)的序言,可实现三个目标:
- 代码与文档同步修改:避免API文档过时。
- 确保文档由最懂代码的人维护:代码修改者通常最了解变更,更可能正确更新文档。
- 文档与代码一体化:读者可直接在定义处查看函数文档,无需在代码和外部文档间切换。
二、设计注释(Design Comments)
特点:通常位于文件开头,概述代码使用的算法、技术和实现逻辑,提供高层设计视角。阅读设计注释后,代码会更易理解,同时也能体现开发过程中经过了明确的设计阶段。
示例(bio.c):
/* 设计
* ------
* 设计很简单:我们用一个结构体表示待执行的任务,
* 每种任务类型对应独立的线程和任务队列。
* 每个线程在其队列中等待新任务,并按顺序处理每个任务。
*/
...
设计注释尤其适合解释看似简单但存在潜在替代方案的实现。例如,若代码采用极简方案,设计注释可说明为何放弃复杂方案,让读者相信这种简洁性源于深思熟虑,而非偷懒或能力局限。
三、原因注释(Why Comments)
作用:解释代码为何执行某操作,即使操作本身一目了然。这类注释常用于复杂协议或系统交互场景,揭示代码背后的逻辑因果。
示例1(replication.c):
if (idle > server.repl_backlog_time_limit) {
/* 释放积压日志时,必须使用新的复制ID并清空ID2。这是因为:
* 1. 当没有积压日志时,master_repl_offset不会更新,
* 但我们仍会保留复制ID,导致以下问题:
* - 主节点A的复制ID为R1,从节点B提升为主节点后,其repl-id-2仍为R1。
* - 原主节点A(现为从节点)接收新写入,不会更新master_repl_offset。
* - 当A重新连接到新主节点B时,B会通过第二个复制ID接受PSYNC请求,
* 但此时A的写入会导致数据不一致。 */
changeReplicationId();
clearReplicationId2();
freeReplicationBacklog();
...
}
仅看函数调用(修改复制ID、清空备份、释放积压日志)很容易,但为何在释放积压日志时必须修改复制ID?注释揭示了分布式系统中复制协议的复杂性和潜在风险。
示例2(expire.c):
for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
redisDb *db = server.db+(current_db % server.dbnum);
/* 立即递增当前数据库ID,确保若本次处理超时,
* 下次从下一个数据库开始。这能将时间均匀分配到各个数据库。 */
current_db++;
...
}
代码看似简单:选择数据库后立即递增ID。但为何不在循环结束时递增?注释指出,若超时后从同一数据库重启,会导致其他数据库的过期键积压。这种注释不仅解释行为,还提醒后续修改者保留这一逻辑(该逻辑源于一次修复,注释相当于“回归说明”)。
四、知识注释(Teacher Comments)
功能:不解释代码细节,而是传授代码所依赖的领域知识(如数学、算法、网络协议等),帮助读者理解代码背后的理论基础。
示例(lolwut5.c):
/* 在指定坐标(x,y)绘制一个指定旋转角度和大小的正方形。
* 为绘制旋转正方形,我们利用以下数学原理:
* 参数方程 x = sin(k), y = cos(k) 描述了一个半径为1的圆(k从0到2π)。
* 因此,若从45度(k=π/4)开始取点,每隔π/2(90度)取一个点,
* 即可得到正方形的四个顶点。若要旋转正方形,只需将起始角设为π/4+旋转角度。
* 当然,上述方程描述的是单位圆内的正方形,
* 绘制更大的正方形时需缩放坐标并平移。
* 尽管这比实现二维图形的旋转/平移变换更简单,但对LOLWUT功能而言已足够。 */
这段注释与函数代码无关,仅解释所使用的数学原理。许多程序员可能缺乏数学背景,知识注释降低了理解门槛,使更多人能参与代码维护。类似地,Redis的基数树(radix tree)实现中,注释详细描述了数据结构的理论和各种操作场景,即使数月未接触该代码,也能通过注释快速定位和修复问题(查看完整代码)。
五、清单注释(Checklist Comments)
场景:因语言限制、设计缺陷或系统复杂性,无法将某个概念集中实现时,通过注释提醒开发者在修改某处代码时同步调整其他相关部分。
示例1(blocked.c):
/* 实现新的阻塞操作类型时,必须修改unblockClient()和replyToBlockedClientTimedOut()
* 以处理这两个函数的特定行为。若阻塞操作等待某些键变更状态,
* 还需更新clusterRedirectBlockedClientIfNeeded()函数。 */
示例2(cluster.c):
/* 更新我们对已服务槽的信息。
* 注意:这必须在更新主从状态后执行,以确保设置CLUSTER_NODE_MASTER标志。 */
清单注释常见于Linux内核等对操作顺序要求极高的场景,本质是一种防御性注释,确保代码修改时遵循既定规则,避免隐性bug。
六、引导注释(Guide Comments)
特点:被认为是“最主观”的注释类型,仅用于辅助读者阅读代码,通过清晰的分段和提示降低认知负担。Redis代码中大量存在此类注释。
示例(networking.c):
/* 记录与从节点的连接断开 */
if ((c->flags & CLIENT_SLAVE) && !(c->flags & CLIENT_MONITOR)) {
...
}
/* 释放查询缓冲区 */
sdsfree(c->querybuf);
...
/* 释放阻塞操作相关结构 */
if (c->flags & CLIENT_BLOCKED) unblockClient(c);
...
/* 取消WATCH所有键 */
unwatchAllKeys(c);
...
引导注释看似冗余,但能将代码划分为逻辑清晰的段落,确保新增代码被正确归类,同时通过简短说明避免读者反复跳转查看函数实现(如unlinkClient()
前的注释)。尽管有人认为其无用,但Redis的可读性在一定程度上得益于这类注释。
七、琐碎注释(Trivial Comments)
定义:注释内容与代码完全重复,或理解注释所需的认知成本不低于直接阅读代码。这是传统编程书籍中明确反对的注释类型。
反例:
array_len++; /* 增加数组长度 */
引导注释与琐碎注释的界限在于:前者是否真正降低了理解成本。编写引导注释时需避免陷入琐碎。
八、技术债注释(Debt Comments)
形式:以TODO
、FIXME
、XXX
等标记硬编码在代码中的技术债声明,用于记录未完成的优化或待解决的问题。
示例(t_stream.c):
if (entries + marked_deleted > 10 && marked_deleted > entries/2) {
/* TODO: 执行垃圾回收 */
}
技术债注释本质上是临时解决方案,理想情况下应将其迁移至设计文档或项目管理工具,并定期清理。尽管不推荐滥用,但在防止遗忘问题方面具有一定价值。
九、备份注释(Backup Comments)
问题:开发者因对代码变更缺乏信心,将旧版本代码直接注释保留在文件中。在版本控制系统普及的今天,这种做法已完全过时(代码不是备份工具,旧版本应通过Git等工具管理)。
反例:
/*
// 旧版本代码
for (int i=0; i<n; i++) {
...
}
*/
// 新版本代码
for (int i=n-1; i>=0; i--) {
...
}
备份注释会污染代码,应通过版本控制解决变更疑虑。
注释:代码分析的利器
编写注释的过程类似“橡皮鸭调试法”的强化版——你需要向未来的代码读者(而非橡皮鸭)解释逻辑,这迫使你深入思考代码的合理性。如同编写文档,注释能暴露代码设计中的漏洞:当无法清晰描述某个行为时,往往意味着该行为存在隐性缺陷或逻辑漏洞,需进一步修复。从这个角度看,注释是发现和预防bug的有效工具。
写好注释比写好代码更难
有人认为写注释不如写代码“高级”,但事实上:
- 代码是具体的语句和逻辑,而注释需要提炼设计思路,要求开发者对代码有更深刻的理解。
- 优秀的注释需要良好的写作能力,而这种能力同样适用于邮件、文档、设计方案等场景。
我热爱编写代码,也同样热爱编写注释。注释不是代码的附属品,而是开发者思考过程的具象化,是跨越时间传递编程智慧的桥梁。
(感谢Michel Martens在本文写作过程中提供的反馈)
如本文对你有些许帮助,欢迎大佬支持我一下(点赞+收藏+关注、关注公众号等),您的支持是我持续创作的竭动力
支持我的方式