antirez on code comments: a nine-part taxonomy from Redis
antirez works through the Redis source (unstable branch, 32e0d237) to argue that comments are not a crutch for weak code. He sorts comments into nine kinds, namely function, design, why, teacher, checklist, guide, trivial, debt and backup, judging the first six useful and the last three suspect. His two reasons: many comments carry information the code cannot express, such as why a statement is there instead of a more natural alternative, and comments lower the reader's cognitive load, as when scripting.c annotates the Lua stack layout after every call. Each category comes with real examples: the replication code that swaps replication IDs before freeing the backlog, the expire.c loop that increments current_db early, the trigonometry behind LOLWUT, the checklist duty created by Redis's 4-bit type field, and the TODO left in t_stream.c. Reading and writing comments, he argues, is bug hunting and design review in disguise.
antirez 2899 days ago. 443150 views. For quite some time I’ve wanted to record a new video talking about code comments for my "writing system software" series on YouTube. However, after giving it some thought, I realized that the topic was better suited for a blog post, so here we are. In this post I analyze Redis comments, trying to categorize them. Along the way I try to show why, in my opinion, writing comments is of paramount importance in order to produce good code, that is maintainable in the long run and understandable by others and by the authors during modifications and debugging activities.
antirez 2899 天前。443150 次浏览。有很长一段时间,我都想为 YouTube 上的“writing system software”系列录制一个新视频,聊聊代码注释。但仔细想过之后,我意识到这个主题更适合写成博客文章,于是就有了本文。在这篇文章里,我分析 Redis 的注释,尝试对它们进行分类。同时,我也会说明为什么在我看来,写注释对于产出好代码至关重要——这样的代码才能长期可维护,才能被他人以及作者自己在修改和调试时理解。
Not everybody thinks likewise. Many believe that comments are useless if the code is solid enough. The idea is that when everything is well designed, the code itself documents what the code is doing, hence code comments are superfluous. I disagree with that vision for two main reasons:
-
Many comments don't explain what the code is doing. They explain what you can't understand just from what the code does. Often this missing information is why the code is doing a certain action, or why it’s doing something that is clear instead of something else that would feel more natural.
-
While it is not generally useful to document, line by line, what the code is doing, because it is understandable just by reading it, a key goal in writing readable code is to lower the amount of effort and the number of details the reader should take into her or his head while reading some code. So comments can be, for me, a tool for lowering the cognitive load of the reader.
不是所有人都这么看。许多人认为,只要代码足够扎实,注释就是没用的。他们的想法是:当一切设计良好时,代码本身就能说明它在做什么,因此代码注释是多余的。我不同意这种观点,主要有两个原因:
-
很多注释并不解释代码在做什么。它们解释的是你无法仅从代码行为中理解的东西。通常,这些缺失的信息是代码为什么要执行某个操作,或者为什么它做了这件清楚的事,而不是做另一件看似更自然的事。
-
虽然逐行记录代码在做什么通常没什么用——因为一读就懂——但写可读代码的一个关键目标,是减少读者在阅读代码时需要记在脑子里的细节和精力。所以对我来说,注释可以是一种降低读者认知负荷的工具。
The following code snippet is a good example of the second point above. Note that all the code snippets in this blog post are obtained from the Redis source code. Every code snipped is presented prefixed by the file name it was extracted from. The branch used is the current "unstable" with hash 32e0d237.
scripting.c: /* Initial Stack: array / lua_getglobal(lua,"table"); lua_pushstring(lua,"sort"); lua_gettable(lua,-2); / Stack: array, table, table.sort / lua_pushvalue(lua,-3); / Stack: array, table, table.sort, array / if (lua_pcall(lua,1,0,0)) { / Stack: array, table, error */
/* We are not interested in the error, we assume that the problem is
- that there are 'false' elements inside the array, so we try
- again with a slower function but able to handle this case, that
- is: table.sort(table, __redis__compare_helper) / lua_pop(lua,1); / Stack: array, table / lua_pushstring(lua,"sort"); / Stack: array, table, sort / lua_gettable(lua,-2); / Stack: array, table, table.sort / lua_pushvalue(lua,-3); / Stack: array, table, table.sort, array / lua_getglobal(lua,"__redis__compare_helper"); / Stack: array, table, table.sort, array, __redis__compare_helper */ lua_call(lua,2,0); }
Lua uses a stack based API. A reader following each call in the function above, having also a Lua API reference at hand, will be able to mentally reconstruct the stack layout at every given moment. But why to force the reader to do such effort? While writing the code, the original author had to do that mental effort anyway. What I did there was just to annotate every line with the current stack layout after every call. Reading this code is now trivial, regardless of the fact the Lua API is otherwise non trivial to follow.
下面的代码片段很好地说明了第二点。请注意,本文所有代码片段都取自 Redis 源码。每个代码片段前面都会标出它来自哪个文件。使用的分支是当前的 “unstable”,哈希为 32e0d237。
scripting.c: /* Initial Stack: array / lua_getglobal(lua,"table"); lua_pushstring(lua,"sort"); lua_gettable(lua,-2); / Stack: array, table, table.sort / lua_pushvalue(lua,-3); / Stack: array, table, table.sort, array / if (lua_pcall(lua,1,0,0)) { / Stack: array, table, error */
/* We are not interested in the error, we assume that the problem is
- that there are 'false' elements inside the array, so we try
- again with a slower function but able to handle this case, that
- is: table.sort(table, __redis__compare_helper) / lua_pop(lua,1); / Stack: array, table / lua_pushstring(lua,"sort"); / Stack: array, table, sort / lua_gettable(lua,-2); / Stack: array, table, table.sort / lua_pushvalue(lua,-3); / Stack: array, table, table.sort, array / lua_getglobal(lua,"__redis__compare_helper"); / Stack: array, table, table.sort, array, __redis__compare_helper */ lua_call(lua,2,0); }
Lua 使用基于栈的 API。读者如果对照上面的函数逐行跟踪每个调用,手边再放一份 Lua API 参考,就能在脑海中重建每一刻的栈布局。但为什么要强迫读者费这个劲呢?写代码时,原作者无论如何都得做这种脑力劳动。我当时所做的,只是在每个调用后面标注当前栈布局。现在读这段代码就变得轻而易举了,尽管 Lua API 本身并不好跟。
My goal here is not just to offer my point of view on the usefulness of comments as a tool to provide a background that is not clearly available reading a local section of the source code. But also to also provide some evidence about the usefulness of the kind of comments that are historically considered useless or even dangerous, that is, comments stating what the code is doing, and not why.
我在这里的目标,不仅是分享我对注释有用性的看法——注释可以作为一种工具,提供阅读源码局部时无法清晰获得的背景。同时,我也想提供一些证据,说明那类历史上被认为无用甚至危险的注释也有其价值,也就是那些说明代码在做什么、而非为什么这么做的注释。
Classification of comments
The way I started this work was by reading random parts of the Redis source code, to check if and why comments were useful in different contexts. What quickly emerged was that comments are useful for very different reasons, since they tend to be very different in function, writing style, length and update frequency. I eventually turned the work into a classification task.
During my research I identified nine types of comments:
- Function comments
- Design comments
- Why comments
- Teacher comments
- Checklist comments
- Guide comments
- Trivial comments
- Debt comments
- Backup comments
The first six are, in my opinion, mostly very positive forms of commenting, while the final three are somewhat questionable. In the next sections each type will be analyzed with examples from the Redis source code.
评论的分类
我这项工作的起点,是随机阅读 Redis 源码的各个部分,检查注释在不同语境下是否有用、为什么有用。很快我就发现,注释之所以有用,原因各不相同,因为它们在功能、写作风格、长度和更新频率上往往差异很大。最终,我把这项工作变成了一项分类任务。
在研究过程中,我识别出了九类注释:
- 函数注释(Function comments)
- 设计注释(Design comments)
- 原因注释(Why comments)
- 教师注释(Teacher comments)
- 清单注释(Checklist comments)
- 引导注释(Guide comments)
- 琐碎注释(Trivial comments)
- 债务注释(Debt comments)
- 备份注释(Backup comments)
在我看来,前六类大多是相当正面的注释形式,而后三类则有些可疑。接下来,我会用 Redis 源码中的例子逐一分析每一类。
FUNCTION COMMENTS
The goal of a function comment is to prevent the reader from reading code in the first place. Instead, after reading the comment, it should be possible to consider some code as a black box that should obey certain rules. Normally function comments are at the top of functions definitions, but they may be at other places, documenting classes, macros, or other functionally isolated blocks of code that define some interface.
rax.c:
/* Seek the grestest key in the subtree at the current node. Return 0 on
- out of memory, otherwise 1. This is an helper function for different
- iteration functions below. */ int raxSeekGreatest(raxIterator *it) { ...
Function comments are actually a form of in-line API documentation. If the function comment is written well enough, the user should be able most of the times to jump back to what she was reading (reading the code calling such API) without having to read the implementation of a function, a class, a macro, or whatever.
函数注释
函数注释的目标,是让读者从一开始就不必阅读代码。相反,读完注释后,就应该能把某段代码当作一个应遵守某些规则的黑盒。函数注释通常位于函数定义的开头,但也可能出现在其他地方,用于说明类、宏或其他定义了某种接口、功能上独立的代码块。
rax.c:
/* Seek the grestest key in the subtree at the current node. Return 0 on
- out of memory, otherwise 1. This is an helper function for different
- iteration functions below. */ int raxSeekGreatest(raxIterator *it) { ...
函数注释其实是一种内联的 API 文档。如果函数注释写得足够好,用户大多数时候就能跳回自己之前读的地方(正在读调用该 API 的代码),而不必去读函数、类、宏或其他东西的实现。
Among all the kinds of comments, these are the ones most widely accepted by the programming community at large as needed. The only point to analyze is if it is a good idea to place comments that are largely API reference documentation inside the code itself. For me the answer is simple: I want the API documentation to exactly match the code. As the code is changed, the documentation should be changed. For this reason, by using function comments as a prologue of functions or other elements, we make the API documentation close to the code, accomplishing three results:
-
As the code is changed, the documentation can be easily changed at the same time, without the risk of making the API reference stale.
-
This approach maximizes the probability that the author of the change, that should be the one better understanding the change, will also be the author of the API documentation change.
-
Reading the code is handy to find the documentation of functions or methods directly where they are defined, so that the reader of the code can focus solely on the code, instead of context switching between code and documentation.
在各类注释中,函数注释是编程社区最普遍认可其必要性的一种。唯一值得分析的是:把大部分属于 API 参考文档的注释放在代码本身里,是不是个好主意?对我来说答案很简单:我希望 API 文档与代码完全一致。代码变了,文档就该跟着变。因此,把函数注释作为函数或其他元素的开场白,可以让 API 文档紧贴代码,从而达成三个结果:
-
代码变更时,文档可以同时轻松修改,不会让 API 参考变得过时。
-
这种做法最大程度地保证了:改代码的人——本应是最理解这次改动的人——也会是修改 API 文档的人。
-
阅读代码时,可以直接在函数或方法定义的地方找到文档,这样读代码的人就能专注于代码,而不必在代码和文档之间来回切换。
DESIGN COMMENTS
While a "function comment" is usually located at the start of a function, a design comment is more often located at the start of a file. The design comment basically states how and why a given piece of code uses certain algorithms, techniques, tricks, and implementation. It is an higher level overview of what you'll see implemented in the code. With such background, reading the code will be simpler. Moreover I tend to trust more code where I can find design notes. At least I know that some kind of explicit design phase happened, at some point, during the development process.
In my experience design comments are also very useful in order to state, in case the solution proposed by the implementation looks a bit too trivial, what were the competing solutions and why a very simple solution was considered to be enough for the case at hand. If the design is correct, the reader will convince herself that the solution is appropriate and that such simplicity comes from a process, not from being lazy or only knowing how to code basic things.
bio.c:
- DESIGN
-
- The design is trivial, we have a structure representing a job to perform
- and a different thread and job queue for every job type.
- Every thread waits for new jobs in its queue, and process every job
- sequentially. ...
设计注释
“函数注释”通常位于函数开头,而设计注释更常出现在文件开头。设计注释主要说明某段代码如何使用特定的算法、技术、技巧和实现,以及为什么这样做。它是对代码中将看到的内容的高层概览。有了这样的背景,阅读代码会更简单。而且,我倾向于更信任那些能找到设计说明的代码。至少我知道,在开发过程中的某个时刻,确实有过某种明确的设计阶段。
根据我的经验,设计注释还非常适合用来说明:如果实现给出的方案看起来过于简单,那么当时有哪些竞争方案,以及为什么一个非常简单的方案被认为足以应对当前情况。如果设计是正确的,读者就会相信这个方案是合适的,这种简单来自一个过程,而不是因为懒惰或只会写基本代码。
bio.c:
- DESIGN
-
- The design is trivial, we have a structure representing a job to perform
- and a different thread and job queue for every job type.
- Every thread waits for new jobs in its queue, and process every job
- sequentially. ...
WHY COMMENTS
Why comments explain the reason why the code is doing something, even if what the code is doing is crystal clear. See the following example from the Redis replication code.
replication.c:
if (idle > server.repl_backlog_time_limit) { /* When we free the backlog, we always use a new
- replication ID and clear the ID2. This is needed
- because when there is no backlog, the master_repl_offset
- is not updated, but we would still retain our replication
- ID, leading to the following problem:
-
- We are a master instance.
-
- Our replica is promoted to master. It's repl-id-2 will
- be the same as our repl-id.
-
- We, yet as master, receive some updates, that will not
- increment the master_repl_offset.
-
- Later we are turned into a replica, connect to the new
- master that will accept our PSYNC request by second
- replication ID, but there will be data inconsistency
- because we received writes. */ changeReplicationId(); clearReplicationId2(); freeReplicationBacklog(); serverLog(LL_NOTICE, "Replication backlog freed after %d seconds " "without connected replicas.", (int) server.repl_backlog_time_limit); }
If I check just the function calls there is very little to wonder: if a timeout is reached, change the main replication ID, clear the secondary ID, and finally free the replication backlog. However what is not exactly clear is why we need to change the replication IDs when freeing the backlog.
Now this is the kind of thing that happens continuously in software once it has reached a given level of complexity. Regardless of the code involved, the replication protocol has some level of complexity itself, so we need to do certain things in order to make sure that other bad things can't happen. Probably these kind of comments are, in some way, opportunities to reason about the system and check if it should be improved, so that such complexity is no longer needed, hence also the comment can be removed. However often making something simpler may make something else harder or is simply not viable, or requires future work breaking backward compatibility.
原因注释
原因注释解释代码为什么要做某件事,即使代码在做什么已经一清二楚。看看下面这个来自 Redis 复制代码的例子。
replication.c:
if (idle > server.repl_backlog_time_limit) { /* When we free the backlog, we always use a new
- replication ID and clear the ID2. This is needed
- because when there is no backlog, the master_repl_offset
- is not updated, but we would still retain our replication
- ID, leading to the following problem:
-
- We are a master instance.
-
- Our replica is promoted to master. It's repl-id-2 will
- be the same as our repl-id.
-
- We, yet as master, receive some updates, that will not
- increment the master_repl_offset.
-
- Later we are turned into a replica, connect to the new
- master that will accept our PSYNC request by second
- replication ID, but there will be data inconsistency
- because we received writes. */ changeReplicationId(); clearReplicationId2(); freeReplicationBacklog(); serverLog(LL_NOTICE, "Replication backlog freed after %d seconds " "without connected replicas.", (int) server.repl_backlog_time_limit); }
如果只看函数调用,几乎没什么可疑惑的:一旦超时,就更改主复制 ID,清除次要 ID,最后释放复制 backlog。然而,不太清楚的是:为什么释放 backlog 时需要更改复制 ID。
如今,软件一旦达到某种复杂度,这类事情就会不断发生。不管涉及什么代码,复制协议本身就有一定复杂度,所以我们必须做某些事,以确保其他坏事不会发生。这类注释或许在某种程度上是反思系统的机会,检查系统是否应该改进,从而不再需要这种复杂度,注释也就可以删掉。但很多时候,把事情变简单会让别的事情变难,或者根本不可行,又或者需要未来做破坏向后兼容的工作。
Here is another one.
replication.c:
/* SYNC can't be issued when the server has pending data to send to
- the client about already issued commands. We need a fresh reply
- buffer registering the differences between the BGSAVE and the current
- dataset, so that we can copy to other replicas if needed. */ if (clientHasPendingReplies(c)) { addReplyError(c,"SYNC and PSYNC are invalid with pending output"); return; }
If you run SYNC while there is still pending output (from a past command) to send to the client, the command should fail because during the replication handshake the output buffer of the client is used to accumulate changes, and may be later duplicated to serve other replicas connecting while we are already creating the RDB file for the full sync with the first replica. This is the why we do that. What we do is trivial. Pending replies? Emit an error. Why is rather obscure without the comment.
再看一个例子。
replication.c:
/* SYNC can't be issued when the server has pending data to send to
- the client about already issued commands. We need a fresh reply
- buffer registering the differences between the BGSAVE and the current
- dataset, so that we can copy to other replicas if needed. */ if (clientHasPendingReplies(c)) { addReplyError(c,"SYNC and PSYNC are invalid with pending output"); return; }
如果在仍有待发送输出(来自之前的命令)时运行 SYNC,命令应该失败,因为在复制握手期间,客户端的输出缓冲区被用来累积变更,稍后可能被复制,以服务其他正在连接的副本——而此时我们已经在为第一个副本创建全量同步的 RDB 文件。这就是我们这样做的原因。我们做的事情很简单:有待发送回复?那就报错。没有注释的话,原因相当晦涩。
One may think that such comments are needed only when describing complex protocols and interactions, like in the case of replication. Is that the case? Let's change completely file and goals, and we see still such comments everywhere.
expire.c:
for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) { int expired; redisDb *db = server.db+(current_db % server.dbnum);
/* Increment the DB now so we are sure if we run out of time
- in the current DB we'll restart from the next. This allows to
- distribute the time evenly across DBs. */ current_db++; ...
That's an interesting one. We want to expire keys from different DBs, as long as we have some time. However instead of incrementing the “database ID” to process next at the end of the loop processing the current database, we do it differently: we select the current DB in the db variable, but then we immediately increment the ID of the next database to process (at the next call of this function). This way if the function terminates because too much effort was spent in a single call, we don't have the problem of restarting again from the same database, letting logically expired keys accumulating in the other databases since we are too focused in processing the same database again and again.
With such comment we both explain why we increment at that stage, and that the next person going to modify the code, should preserve such quality. Note that without the comment the code looks completely harmless. Select, increment, go to do some work. There is no evident reason for not relocating the increment at the end of the loop where it could look more natural.
Trivia: the loop increment was indeed at the end in the original code. It was moved there during a fix: at the same time the comment was added. So let's say this is kinda of a "regression comment".
有人可能会认为,这类注释只在描述复杂协议和交互时才需要,比如复制的情况。真是这样吗?让我们换一个完全不同的文件和目标,你会发现这类注释依然无处不在。
expire.c:
for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) { int expired; redisDb *db = server.db+(current_db % server.dbnum);
/* Increment the DB now so we are sure if we run out of time
- in the current DB we'll restart from the next. This allows to
- distribute the time evenly across DBs. */ current_db++; ...
这个例子很有意思。我们想从不同的数据库中过期键,只要还有时间。但是,我们并不是在处理完当前数据库后、在循环末尾才递增下一个要处理的“数据库 ID”,而是换了一种做法:我们在 db 变量中选中当前数据库,但紧接着就立即递增下一个要处理的数据库 ID(供下次调用本函数时使用)。这样,如果函数因为单次调用花费太多精力而终止,我们就不会重新从同一个数据库开始,避免因为过于专注反复处理同一个数据库,而让其他数据库里逻辑上已过期的键越积越多。
有了这条注释,我们既解释了为什么在那个阶段递增,也告诉下一位修改代码的人应当保留这种质量。注意,没有注释的话,这段代码看起来完全无害:选中、递增、去干点活。没有明显理由反对把递增挪到循环末尾——那里看起来更自然。
趣闻:在最初的代码中,循环递增确实在末尾。它是在一次修复中被移到那里的,同时加上了注释。所以可以说,这算是一种“回归注释”。
TEACHER COMMENTS
Teacher comments don't try to explain the code itself or certain side effects we should be aware of. They teach instead the domain (for example math, computer graphics, networking, statistics, complex data structures) in which the code is operating, that may be one outside of the reader skills set, or is simply too full of details to recall all them from memory.
The LOLWUT command in version 5 needs to display rotated squares on the screen (http://antirez.com/news/123). In order to do so it uses some basic trigonometry: despite the fact that the math used is simple, many programmers reading the Redis source code may not have any math background, so the comment at the top of the function explains what's going to happen inside the function itself.
lolwut5.c:
/* Draw a square centered at the specified x,y coordinates, with the specified
- rotation angle and size. In order to write a rotated square, we use the
- trivial fact that the parametric equation:
- x = sin(k)
- y = cos(k)
- Describes a circle for values going from 0 to 2*PI. So basically if we start
- at 45 degrees, that is k = PI/4, with the first point, and then we find
- the other three points incrementing K by PI/2 (90 degrees), we'll have the
- points of the square. In order to rotate the square, we just start with
- k = PI/4 + rotation_angle, and we are done.
- Of course the vanilla equations above will describe the square inside a
- circle of radius 1, so in order to draw larger squares we'll have to
- multiply the obtained coordinates, and then translate them. However this
- is much simpler than implementing the abstract concept of 2D shape and then
- performing the rotation/translation transformation, so for LOLWUT it's
- a good approach. */
The comment does not contain anything that is related to the code of the function itself, or its side effects, or the technical details related to the function. The description is only limited to the mathematical concept that is used inside the function in order to reach a given goal.
教师注释
教师注释并不试图解释代码本身,或我们应该注意的某些副作用。它们教的是代码所处的领域(比如数学、计算机图形学、网络、统计学、复杂数据结构),这些领域可能超出读者的技能范围,或者细节太多,无法全部凭记忆回想起来。
版本 5 中的 LOLWUT 命令需要在屏幕上显示旋转的正方形(http://antirez.com/news/123)。为此它用了一些基本的三角学:尽管所用的数学很简单,但许多阅读 Redis 源码的程序员可能没有数学背景,所以函数顶部的注释解释了函数内部将要发生什么。
lolwut5.c:
/* Draw a square centered at the specified x,y coordinates, with the specified
- rotation angle and size. In order to write a rotated square, we use the
- trivial fact that the parametric equation:
- x = sin(k)
- y = cos(k)
- Describes a circle for values going from 0 to 2*PI. So basically if we start
- at 45 degrees, that is k = PI/4, with the first point, and then we find
- the other three points incrementing K by PI/2 (90 degrees), we'll have the
- points of the square. In order to rotate the square, we just start with
- k = PI/4 + rotation_angle, and we are done.
- Of course the vanilla equations above will describe the square inside a
- circle of radius 1, so in order to draw larger squares we'll have to
- multiply the obtained coordinates, and then translate them. However this
- is much simpler than implementing the abstract concept of 2D shape and then
- performing the rotation/translation transformation, so for LOLWUT it's
- a good approach. */
注释里没有任何与函数代码本身、其副作用或函数相关技术细节有关的内容。描述仅限于函数内部为达成某个目标而使用的数学概念。