Redis作者谈如何编写系统软件的代码注释

18-10-08 banq
                   

顶顶大名的Redis作者谈如何在Redis这样系统软件上进行代码文档注释,以下是九种注释类型的大意说明:

很长一段时间以来,我一直想在YouTube上发布一段“如何对系统软件文档注释”的新视频,讨论如何进行代码注释,然而,经过一番思考后,我意识到这个主题更适合博客文章。在这篇文章中,我分析了Redis的文档注释,试图对它们进行分类。在此过程中,我试图说明为什么编写注释对于生成良好的代码是至关重要,从长远来看,这些代码是可维护的,并且在修改和调试期间可由其他人和作者自己理解。

并不是每个人都这么想,许多人认为,如果代码足够扎实,代码具有自明性,无需文档注释了。这个想法前提是,需要一切都设计得很完美,代码本身会有文档注释的作用,因此再加上代码注释是多余的。

我不同意这个观点有两个主要原因:

1. 许多注释并不是解释代码的作用,而是解释*为什么*代码执行这个操作,或者为什么它正在做一些清晰的事情,但却不是感觉更自然的事情?注释是解释一些你无法理解的东西。(banq注:根据海德格尔存在主义哲学观点,注释是解释代码的存在意义,如果注释时说明代码作用,那是在说明代码的存在方式,代码的功能作用是代码的存在方式,不是存在意义,存在意义与编写者动机和阅读者的理解有关,与其上下文场景有关)

2.虽然一行一行地记录代码做些什么通常没有用,因为通过阅读代码本身也是可以理解的,编写可读代码的关键目标是减少工作量和细节数量。但是应该考虑其他阅读者在阅读一些代码时他们的思考角度和进入门槛的难易程度。因此,对我而言,文档注释可以成为降低阅读者认知负担的工具。

以下代码片段是上面第二点的一个很好的例子。请注意,此博客文章中的所有代码段都是从Redis源代码中获取的。


scripting.c:
/ *初始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 * /

/ *我们对错误不感兴趣,我们假设如果
*数组中有'false'元素,那么我们就再试验
*使用较慢的功能来处理这种情况,这个功能是
*是: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使用基于堆栈Stack的API。

假设的场景是:有一个代码阅读者会跟随在上面的函数中的每个调用,同时手上也有一个Lua API参考,将能够根据每一行注释中stack的阵列布局在心中重现Stack堆栈布局.

(banq注:所谓stack布局就是注释行中一个个Stack:
/* Stack: array, table */
/* Stack: array, table, sort */
这两行区别就是Stack中多了一个sort。

)。

但为什么要强迫阅读者做这样的想象努力呢?因为在编写代码时,原始作者就是这么想象的:在每次调用后想象一下当前堆栈里的情况。这样大家阅读这样代码才会想象一致,显得非常轻松,也无需考虑Lua API本身的难易程度了。

注释是可以作为提供阅读源代码时无法清晰获得的上下文背景的工具。

#注释分类

我随机阅读Redis源代码时开始分类工作的,这样检查注释在不同的上下文中是否有用,以及为什么在这个上下文中有用。很快呈现的是注释对于不同的动机原因有不同作用,它们在功能、写作风格、长度和更新频率方面表现的用处往往非常不同。

我最终进行了分类,在我的研究期间,我确定了九种注释:

*函数注释
*设计注释
*为什么注释
*老师注释
*清单注释
*指南注释
*细节注释
*债务注释
*备份注释

在我看来,前六个主要是非常积极的注释形式,而最后三个有点值得怀疑。在接下来的部分中,将使用Redis源代码中的示例分析每种类型。

函数注释

函数注释的目标是防止读者首先阅读代码。在阅读注释之后,阅读者应该可以将一些代码视为应遵守某些功能规则的黑盒子。通常,函数注释位于函数定义的顶部,但它们可能位于其他位置,记录类、宏或与其他函数隔离的代码块,这些代码块定义了某些接口。


rax.c:

/* 在当前节点的子树中寻找最新的密钥。
*内存不足返回0,否则返回1.对于下面的迭代函数这是不同的helper函数
* /
int raxSeekGreatest(raxIterator * it){
...


函数注释实际上是一种内联API文档。如果函数注释编写得足够好,那么大多数时间用户应该能够直接阅读文档,而无需阅读函数,类,宏的具体实现。

那么,在代码本身中放置API参考文档的注释是否是一个好主意?对我来说答案很简单:我希望API文档与代码完全匹配。随着代码的更改,应该更改文档。

出于这个原因,在函数代码前加入使用这个函数的注释使API文档更接近代码,三个好处:

1. 随着代码的更改,文档可以同时轻松更改,而不会使API参考过时。

2. 这种方法说明代码更改的作者也应是API文档更改的作者。

3. 阅读代码非常方便,能直接找到函数或方法的文档,这样代码读者就会只关注代码,而不是在代码和文档之间的上下文切换。


设计注释

虽然“函数注释”通常位于函数的开头,但设计注释通常位于文件的开头。设计注释基本上说明了当前代码的使用某些算法,技术,技巧和实现的方式和原因。它是对代码中实现内容的更高级别概述。有了这样的背景,阅读代码会更简单。此外,当我找到设计说明时,意味着可能有很多的代码。至少我知道在某些时候,在开发过程中发生了某种明确的设计阶段。

根据我的经验,设计注释对于说明也非常有用,如果实现提出的解决方案看起来有点过于微不足道,那么竞争的另外一个解决方案是什么以及为什么不采取另外一个?一般采取一个非常简单的解决方案就足以满足当前的要求。如果设计是正确的,阅读者会说服自己当前的解决方案是合适的,这种简单性来自一个过程,而不是懒惰或只知道如何编写基本的东西。


bio.c:
*设计
* ------
*
*设计很简单,我们有一个代表要执行作业的结构
*以及的不同线程和代表每种不同作业类型的作业队列。
*每个线程都在等待队列中的新作业,并顺序处理每个作业
*。
...


为什么注释

“为什么注释”解释了代码执行某些操作的原因,即使代码执行的操作非常明确也要进行说明。请参阅Redis复制代码中的以下示例。

replication.c:

if(idle> server.repl_backlog_time_limit){
/ *当我们释放积压backlog时,我们总是使用新的
*复制ID并清除ID2。这是必要的
*因为如果没有积压,master_repl_offset
*不会更新,但我们仍会保留我们的复制
* ID,导致以下问题:
*
* 1.我们是一个主节点实例。
* 2.我们的副节点会被提升为主人。它是repl-id-2,
*会与我们的repl-id相同。
* 3.我们作为主节点,会收到一些更新,但不会
*增加master_repl_offset。
* 4.稍后我们将变成副节点,连接到新的
*主节点,它通过复制ID将接受我们的PSYNC请求
*但会有数据不一致
*因为我们收到了写操作。* /
changeReplicationId();
clearReplicationId2();
freeReplicationBacklog();
SERVERLOG(LL_NOTICE,
“%d秒后释放复制backlog”
“没有连接复制品。”,
(int)server.repl_backlog_time_limit);
}

如果我只检查函数调用,那么很少有人想知道:如果达到超时,请更改主复制ID,清除辅助ID,最后释放复制积压backlog。但是,我们需要在释放backlog时更改复制ID,这一点并不十分清楚。

现在,一旦达到某个复杂程度,这是软件中不断发生的事情。无论涉及哪些代码,复制协议本身都有一定程度的复杂性,因此我们需要做些事情以确保不良问题不会发生。

在某种程度上,这些注释可能帮助推理系统的逻辑,并检查是否有改进的机会,如果能够改进了,这些注释也许不再需要,但是,改进措施可能会使事情变得更简单,也可能会使其他事情变得更难或者根本不可行,或者会破坏向后兼容性。

...

老师注释
教师注释不会试图解释代码本身或我们应该注意的某些副作用。他们传达代码有关领域知识(例如数学,计算机图形学,网络,统计,复杂的数据结构),这可能是读者技能组合之外的一个,或者只是太多的细节无法记住所有。

...

检查清单注释
这是一个非常常见和奇怪的问题:有时由于语言限制,设计问题,或者仅仅因为系统中出现的自然复杂性,不可能将所有包含的概念或界面集中在一个代码部分呈现,因此代码散落在各个地方,需要告诉读者记住在当前代码的其他地方也有相关代码。一般概念是:

/ *警告:如果在此处添加类型ID,请务必修改
*函数getTypeNameByID()也是如此。* /

指南注释
Redis中的大多数注释都是指南注释。指南注释正是大多数人认为完全无用的注释。

指南注释做了一件事:他们给读者指路,在处理源代码中的内容时协助他,帮助提供明确的划分、节奏,并介绍需要要阅读的内容。(类似老师注释?)

指南注释存在的唯一理由是降低程序员阅读某些代码的认知负担。

细节注释
指南注释是非常主观的工具。你可能喜欢或不喜欢他们。我爱他们。然而,指南注释可能会退化为一个非常糟糕的注释:它很容易变成“细节注释”。一个细节注释应该也是一种指南注释,其阅读注释的认知负荷与阅读相关代码相同或更高。以下是一种细节注释,很多书籍要求你避免的。

array_len ++; / *增加数组的长度。* /

因此,如果你写指南注释,请确保你避免写得太琐碎,变成细节注释。

债务注释
如果源代码本身内部有些硬编码,那么未来需要修正(尝还债务),类似TODO,FIXME,XXX,“这里一种黑客处理手法”,这些都是债务注释的形式。

它们一般都不是很好,我试图避免它们,但避免并不总是可能的,有时希望不要永远忘记一个问题,我更喜欢在源代码中放置一个标识。至少有一个人应该定期查看这些注释,看看是否可以将注释放在更好的位置,或者该问题是否已不再相关或可以立即解决。

备份注释
最后,备份注释是开发人员注释某些代码块的旧版本甚至是整个函数的注释,因为他或她对在新版本中运行的新更改感到不安全。令人费解的是,现在我们已经拥有Git却仍然会发生这种情况。我想这是人们对丢失代码片段总是有一种不安的感觉,在一些多年的提交commit活动中,这种做法被认为更加理智或稳定。

总结
#注释可以作为分析工具。
注释能提供代码片段的作用、确保它是什么,有什么副作用等要点。这通常是一个寻找错误的机会。在描述某些东西时很容易发现它有漏洞......如果你无法真正描述它,其实是因为你不能确定其行为:这种行为只是从复杂性中随机出现。但是如果你真的不想出现这种情况,那么你可以修复这个Bug。我觉得这是写注释的一个很好的理由。


#编写好的注释比编写好的代码更难
编写注释总要进行一些设计过程,并从更深层次的角度理解你正在编写的代码。最重要的是,为了写出好的注释,你必须培养你的写作技巧。相同的写作技巧将帮助您编写电子邮件,文档,设计文档,博客文章和提交消息。

Writing system software: code comments. - <antirez

                   

2