Writing system software: code comments.

Salvatore Sanfilippo

编写系统软件:代码注释

原文由 Salvatore Sanfilippo 发布,订阅该博客

很长时间以来,我一直想为 YouTube 上的“编写系统软件”系列录一期谈代码注释的新视频。不过,仔细想了想之后,我觉得这个话题更适合写成一篇博客,于是就有了这篇文章。在这篇文章里,我会分析 Redis 中的注释,并尝试对它们进行分类。在此过程中,我想说明的是,在我看来,写好注释对于产出高质量的代码至关重要——这样的代码才能长期可维护,也才能让他人以及作者本人在后续的修改和调试中易于理解。

并非所有人都这么想。很多人认为,只要代码写得足够扎实,注释就是多余的。他们的想法是,如果一切都设计得当,代码本身就能说明自己在做什么,因此代码注释是画蛇添足。我不同意这种看法,主要有两点原因:

  1. 许多注释解释的并不是代码在做什么。它们解释的是光看代码本身无法理解的东西。很多时候,这些缺失的信息是代码为什么要执行某个操作,或者为什么要以一种明确的方式去做某件事,而不是采用另一种更自然的做法。
  2. 虽然逐行说明代码在做什么通常没什么用,因为光读代码就能看懂,但编写可读代码的一个关键目标是,降低读者在阅读代码时需要装进脑子里、需要处理的信息量和细节数量。因此对我而言,注释是一种降低读者认知负担的工具。

下面的代码片段就是上述第二点的一个很好的例子。需要说明的是,本文中所有的代码片段都取自 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 本身并不那么直观易懂。

我在这里的目标,不只是想阐述我对注释有用性的看法——即注释是一种工具,能提供那些仅靠阅读局部源码无法直接获得的背景信息。同时,我也想为另一类注释的有用性提供一些证据,这类注释历来被认为是无用甚至有害的,也就是那些说明代码“在做什么”而非“为什么这么做”的注释。

注释的分类

我开展这项工作的起点,是随机阅读 Redis 源码的不同部分,去观察注释在不同上下文中是否有用、以及为什么有用。很快我就发现,注释有用的原因大不相同,因为它们在功能、写作风格、长度和更新频率上都差异很大。最终,我把这项工作变成了一次分类尝试。

在研究过程中,我归纳出九种类型的注释:

  • 函数注释
  • 设计注释
  • Why 注释
  • 教学型注释
  • 清单式注释
  • 引导性注释
  • 冗余注释
  • 欠账注释
  • 备份式注释

在我看来,前六种大多是非常积极的注释形式,而后三种则多少有些问题。在接下来的章节中,我将结合 Redis 源码中的例子,逐一分析每种类型。

函数注释

函数注释的目标,是让读者根本不需要去读代码。读完注释后,读者应该就能把某段代码当作一个遵守特定规则的黑盒来对待。通常,函数注释位于函数定义的顶部,但也可能出现在其他地方,用于说明类、宏或其他定义了某种接口的功能性独立代码块。

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 的代码),而无需再去阅读某个函数、类、宏或其他实现的细节。

在所有类型的注释中,这是被广大编程社区最普遍认可为必需的一种。唯一值得讨论的是,把大量作为 API 参考文档的注释直接放在代码里是否是个好主意。对我来说答案很简单:我希望 API 文档与代码完全一致。代码变了,文档也应该跟着变。因此,把函数注释作为函数或其他元素的序言,让 API 文档紧贴代码,可以带来三个好处:

  • 代码修改时,文档可以同时轻松更新,不用担心 API 参考会过时。
  • 这种方式最大程度地确保了最理解这次改动的人——也就是改动的作者——同时也会去更新 API 文档。
  • 阅读代码时,可以直接在函数或方法定义的地方找到文档,读者就能专注于代码本身,而不必在代码和文档之间来回切换。

设计注释

如果说“函数注释”通常位于函数的开头,那么设计注释则更常出现在文件的开头。设计注释基本上会说明某段代码如何以及为何使用特定的算法、技术、技巧和实现。它是对代码中所实现内容更高层次的概览。有了这样的背景,阅读代码会更简单。而且,我往往更信任那些能找到设计说明的代码——至少我知道,在开发过程中的某个时刻,确实经历过一个明确的设计阶段。

依我的经验,设计注释还有一个很重要的作用:当实现所提出的方案看起来过于简单时,用来说明曾考虑过哪些备选方案,以及为什么认为一个非常简单的方案就足以应对当前情况。如果设计是正确的,读者就会相信这个方案是恰当的,并且这种简单是经过推敲的结果,而不是因为偷懒或只会写些基础代码。

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 注释

Why 注释解释的是代码为什么要做某件事,即使代码本身在做什么已经非常清楚。请看下面这个来自 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:
	 *
	 * 1. We are a master instance.
	 * 2. Our replica is promoted to master. It's repl-id-2 will
	 *    be the same as our repl-id.
	 * 3. We, yet as master, receive some updates, that will not
	 *    increment the master_repl_offset.
	 * 4. 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,最后释放复制积压缓冲区。然而,不太清楚的是,为什么在释放积压缓冲区时需要更改复制 ID。

这类事情在软件达到一定复杂度后会不断出现。无论涉及哪段代码,复制协议本身就有一定的复杂性,因此我们需要做某些事情来确保不会引发其他问题。也许,这类注释在某种程度上也是反思系统、审视是否应该改进系统的机会,从而让这种复杂性不再必要,注释本身也就可以去掉了。然而,很多时候让一处变简单,可能会让另一处变难,或者根本不可行,又或者需要未来进行会破坏向后兼容性的工作。

再看一个例子。

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 文件期间新连入的其他副本。这就是我们为什么要这么做。我们做的事本身很简单:有待发送的回复?就报错。如果没有注释,其背后的原因就相当晦涩了。

有人可能会认为,只有在描述像复制这样复杂的协议和交互时才需要这类注释。真是这样吗?我们完全换一个文件、换一个目标,依然到处都能看到这类注释。

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。这样一来,如果函数因为单次调用耗时过多而提前结束,我们就不会遇到又从同一个数据库重新开始的问题,从而避免因一直盯着同一个数据库处理,而让其他数据库中本应过期的键不断堆积。

有了这样的注释,我们既解释了为什么要在那个位置递增,也提醒下一个要修改代码的人应该保留这一特性。请注意,如果没有这条注释,这段代码看起来完全无害:选中、递增、然后去干活。没有任何明显的理由说明为什么不能把递增移到循环末尾那个看起来更自然的位置。

题外话:在最初的代码中,循环的递增确实是在末尾的。后来在一次修复中才被移到现在的位置,同时加上了这条注释。所以可以说,这有点像一条“回归注释”。

教学型注释

教学型注释并不试图解释代码本身或我们应该注意的某些副作用。相反,它们讲解的是代码所处领域(例如数学、计算机图形学、网络、统计学、复杂数据结构)中的知识,这些领域可能超出了读者的技能范围,或者细节太多,无法全凭记忆回忆起来。

5.0 版本中的 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. */

这条注释没有包含任何与函数本身的代码、副作用或技术细节相关的内容。描述仅限于函数为达成特定目标而使用的数学概念。

我认为教学型注释价值巨大。它们能在读者不了解相关概念时教会对方,至少也能为进一步研究提供起点。但这反过来也意味着,教学型注释能让更多程序员读懂某段代码路径:让代码能被更多程序员读懂是我的一个主要目标。有些开发者可能不擅长数学,却是非常扎实的程序员,他们也能贡献出色的修复或优化。总的来说,代码不仅要被执行,更要被人阅读,因为它是人写给人看的。

有些情况下,要写出像样的代码,几乎不可能避免教学型注释。一个很好的例子是 Redis 的基数树实现。基数树是一种结构复杂的数据结构。Redis 的实现在实现的同时,重新阐述了整个数据结构的理论,展示了不同情况以及算法如何合并或分裂节点等。每段注释之后,紧跟着的就是实现前文所述内容的代码。在数月没有碰过实现基数树的文件后,我依然能够打开它,在几分钟内修复一个 bug,然后继续做别的事。完全不需要重新学习基数树是如何工作的,因为解释本身就和代码混在一起,二者如同一体。

这些注释太长了,所以我只展示其中一些片段。

rax.c:

    /* If the node we stopped at is a compressed node, we need to
     * split it before to continue.
     *
     * Splitting a compressed node have a few possible cases.
     * Imagine that the node 'h' we are currently at is a compressed
     * node contaning the string "ANNIBALE" (it means that it represents
     * nodes A -> N -> N -> I -> B -> A -> L -> E with the only child
     * pointer of this node pointing at the 'E' node, because remember that
     * we have characters at the edges of the graph, not inside the nodes
     * themselves.
     *
     * In order to show a real case imagine our node to also point to
     * another compressed node, that finally points at the node without
     * children, representing 'O':
     *
     *     "ANNIBALE" -> "SCO" -> []

     ... snip ...

     * 3a. IF $SPLITPOS == 0:
     *     Replace the old node with the split node, by copying the auxiliary
     *     data if any. Fix parent's reference. Free old node eventually
     *     (we still need its data for the next steps of the algorithm).
     *
     * 3b. IF $SPLITPOS != 0:
     *     Trim the compressed node (reallocating it as well) in order to
     *     contain $splitpos characters. Change chilid pointer in order to link
     *     to the split node. If new compressed node len is just 1, set
     *     iscompr to 0 (layout is the same). Fix parent's reference.

     ... snip ...

        if (j == 0) {
            /* 3a: Replace the old node with the split node. */
            if (h->iskey) {
                void *ndata = raxGetData(h);
                raxSetData(splitnode,ndata);
            }
            memcpy(parentlink,&splitnode,sizeof(splitnode));
        } else {
            /* 3b: Trim the compressed node. */
            trimmed->size = j;
            memcpy(trimmed->data,h->data,j);
            trimmed->iscompr = j > 1 ? 1 : 0;
            trimmed->iskey = h->iskey;
            trimmed->isnull = h->isnull;
            if (h->iskey && !h->isnull) {
                void *ndata = raxGetData(h);
                raxSetData(trimmed,ndata);
            }
            raxNode **cp = raxNodeLastChildPtr(trimmed);
        ...

如你所见,注释中的描述与代码中相同的标签一一对应。很难在这里以这种形式完整展示,所以如果你想了解全貌,请直接查看完整文件:

https://github.com/antirez/redis/blob/unstable/src/rax.c

并非所有代码都需要这种程度的注释,但像基数树这样的东西确实充满了各种细节和边界情况。它们很难凭记忆回忆起来,某些细节又是特定于某个实现的。当然,给链表做这种注释就没有太大意义。何时值得这么做,取决于个人的判断力。

清单式注释

这是一种非常常见却又有点奇怪的类型:有时由于语言限制、设计问题,或者仅仅是因为系统中自然产生的复杂性,无法将某个概念或接口集中在一处,因此代码中会有一些地方提醒你,记得要在代码的另一处做些事情。大致的概念是这样的:

    /* Warning: if you add a type ID here, make sure to modify the
     * function getTypeNameByID() as well. */

在理想世界里,这本不应该出现,但在实践中,有时却无可避免。例如,Redis 的类型本可以用一个“对象类型”结构来表示,每个对象都可以链接到它所属的类型,这样你就可以这样做:

    printf("Type is %s\n", myobject->type->name);

但结果呢?对我们来说这样太耗费资源了,因为 Redis 对象的表示是这样的:

    typedef struct redisObject {
        unsigned type:4;
        unsigned encoding:4;
        unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                                * LFU data (least significant 8 bits frequency
                                * and most significant 16 bits access time). */
        int refcount;
        void *ptr;
    } robj;

我们用 4 位而不是 64 位来表示类型。这只是为了说明,为什么有时事情无法像理想中那样集中和自然。当情况如此时,有时能帮上忙的做法就是使用防御性注释,以确保当某段代码被触及时,能提醒你也要去修改代码的其他部分。具体来说,清单式注释会做以下一件事或两件事:

  • 告诉你当某处被修改时,需要执行的一系列操作。
  • 提醒你进行某些改动时应该遵循的方式。

另一个例子在 blocked.c 中,当引入一种新的阻塞类型时:

blocked.c:

     * When implementing a new type of blocking opeation, the implementation
     * should modify unblockClient() and replyToBlockedClientTimedOut() in order
     * to handle the btype-specific behavior of this two functions.
     * If the blocking operation waits for certain keys to change state, the
     * clusterRedirectBlockedClientIfNeeded() function should also be updated.

清单式注释在与某些“Why 注释”类似的上下文中也很有用:即当某段代码为什么必须在某个特定位置、在某件事之前或之后执行并不明显时。但 Why 注释可能会告诉你某条语句为什么在那里,而在同样情况下使用的清单式注释,则更侧重于告诉你如果想修改它应该遵循什么规则(在这种情况下,规则就是遵循特定的顺序),以免破坏代码的行为。

cluster.c:

    /* Update our info about served slots.
     *
     * Note: this MUST happen after we update the master/replica state
     * so that CLUSTER_NODE_MASTER flag will be set. */

清单式注释在 Linux 内核中非常常见,在那里,某些操作的顺序极其重要。

引导性注释

我对引导性注释的使用到了近乎滥用的程度,很可能 Redis 中的大多数注释都是引导性注释。而且,引导性注释恰恰就是大多数人认为完全无用的那种注释。

  • 它们没有说明代码中不清楚的地方。
  • 引导性注释中没有任何设计提示。

引导性注释只做一件事:它们像保姆一样带着读者,通过提供清晰的划分、节奏感,并预告你接下来要读到的内容,来协助读者处理源码中的内容。

引导性注释存在的唯一理由,就是降低阅读代码的程序员的认知负担。

rax.c:

    /* Call the node callback if any, and replace the node pointer
     * if the callback returns true. */
    if (it->node_cb && it->node_cb(&it->node))
	memcpy(cp,&it->node,sizeof(it->node));

    /* For "next" step, stop every time we find a key along the
     * way, since the key is lexicographically smaller compared to
     * what follows in the sub-children. */
    if (it->node->iskey) {
	it->data = raxGetData(it->node);

	return 1;
    }

上面的注释并没有为代码增加任何新信息。上面的引导性注释会帮助你阅读代码,而且还会让你确认自己确实理解正确了。再看更多例子。

networking.c:

    /* Log link disconnection with replica */
    if ((c->flags & CLIENT_SLAVE) && !(c->flags & CLIENT_MONITOR)) {
        serverLog(LL_WARNING,"Connection with replica %s lost.",
            replicationGetSlaveName(c));
    }

    /* Free the query buffer */
    sdsfree(c->querybuf);
    sdsfree(c->pending_querybuf);
    c->querybuf = NULL;

    /* Deallocate structures used to block on blocking ops. */
    if (c->flags & CLIENT_BLOCKED) unblockClient(c);
    dictRelease(c->bpop.keys);

    /* UNWATCH all the keys */
    unwatchAllKeys(c);
    listRelease(c->watched_keys);

    /* Unsubscribe from all the pubsub channels */
    pubsubUnsubscribeAllChannels(c,0);
    pubsubUnsubscribeAllPatterns(c,0);
    dictRelease(c->pubsub_channels);
    listRelease(c->pubsub_patterns);

    /* Free data structures. */
    listRelease(c->reply);
    freeClientArgv(c);

    /* Unlink the client: this will close the socket, remove the I/O
     * handlers, and remove references of the client from different
     * places where active clients may be referenced. */
    unlinkClient(c);

Redis 代码里可以说遍布引导性注释,所以基本上你打开的每个文件都会包含大量这类注释。何必如此呢?在这篇博客中分析过的所有注释类型里,我承认这绝对是最主观的一种。我并不认为没有这类注释的代码就更差,但我坚信,如果人们认为 Redis 代码可读,其中一部分原因正是这些引导性注释。

引导性注释除了上述作用外,还有其他用处。由于它们把代码清晰地划分为独立的区块,新增的代码很可能会被插入到恰当的区块中,而不是落在某个随机的角落。让相关的语句彼此靠近,对可读性来说是一个很大的提升。

另外,别忘了留意 unlinkClient() 函数调用前的那条引导性注释。这条注释简要地告诉读者该函数将要做什么,如果你只关心整体脉络,就无需再跳转到函数内部去查看。

冗余注释

引导性注释是非常主观的工具。你可能喜欢,也可能不喜欢。我很喜欢它们。然而,引导性注释也可能退化成一种非常糟糕的注释:它很容易变成“冗余注释”。冗余注释是指阅读注释的认知负担与直接阅读相关代码相当甚至更高的引导性注释。下面这种形式的冗余注释,正是许多书会告诉你要避免的。

    array_len++;	/* Increment the length of our array. */

所以,如果你要写引导性注释,一定要避免写成冗余注释。

欠账注释

欠账注释是直接硬编码在源码中的技术债声明:

t_stream.c:

    /* Here we should perform garbage collection in case at this point
     * there are too many entries deleted inside the listpack. */
    entries -= to_delete;
    marked_deleted += to_delete;
    if (entries + marked_deleted > 10 && marked_deleted > entries/2) {
	/* TODO: perform a garbage collection. */
    }

上面的片段取自 Redis streams 的实现。Redis streams 允许使用 XDEL 命令从中间删除元素。这在不同场景下都可能有用,尤其是在隐私法规的背景下——无论你使用什么数据结构或系统,某些数据都不能被保留。对于一个基本只追加的数据结构来说,这是一个非常另类的用例,但如果用户开始从中间删除超过 50% 的条目,stream 就会开始碎片化,由“宏节点”组成。条目只是被标记为已删除,只有当某个宏节点中的所有条目都被释放后,才会被真正回收。因此,你的大量删除操作会改变 stream 的内存行为。

目前来看,这似乎不是什么问题,因为我预计用户不会删除 stream 中的大部分历史记录。不过,未来我们可能会想引入垃圾回收:一旦已删除条目与现有条目的比例达到某个阈值,就可以对宏节点进行压缩。而且,在垃圾回收之后,还可能将相邻的节点合并在一起。我有点担心以后会忘记垃圾回收的入口在哪里,所以就留下了 TODO 注释,甚至写下了触发条件。

这可能不是个好主意。更好的做法是在文件顶部的设计注释中,说明我们目前为什么不做 GC,以及如果以后想加入 GC,入口点在哪里。

FIXME、TODO、XXX、“This is a hack”都是欠账注释的形式。总体来说它们都不太好,我尽量避免使用,但并不总是能做到,有时与其永远忘记某个问题,我更愿意在源码中留个标记。至少应该定期 grep 这些注释,看看是否可以把这些说明移到更合适的地方,或者问题是否已经不再相关、可以立即修复。

备份式注释

最后,备份式注释是指开发者把某段代码块甚至整个函数的旧版本注释掉,因为他或她对新改动不太有信心。令人费解的是,这种情况在我们已经有了 Git 的今天依然会发生。我猜人们对于在几年前的某个提交中丢失那段被认为更合理或更稳定的代码片段,会有一种不安感。

但源码不是用来做备份的。如果你想保留某个函数或某段代码的旧版本,说明你的工作还没完成,还不能提交。要么确保新函数比旧的更好,要么就先把它留在你的开发分支里,直到你确信为止。

备份式注释为我的分类画上了句号。接下来试着做个总结。

注释作为分析工具

注释就像是加强版的小黄鸭调试,只不过你面对的不是一只小黄鸭,而是未来阅读代码的人——这可比小黄鸭让人紧张多了,而且对方还会用 Twitter。所以在这个过程中,你会真正去审视自己所陈述的东西是否可接受、是否体面、是否足够好。如果不是,你就会去做功课,想出一个更像样的方案。

这和写文档时发生的过程是一样的:作者试图概括某段代码做了什么、提供了哪些保证、会带来哪些副作用。这往往是一个捉 bug 的机会。在描述某件事时,很容易发现其中的漏洞……你无法完整地描述它,因为你对某个行为并不确定:这种行为只是从复杂性中随机涌现出来的。你肯定不想要这样,于是就会回去把一切修正好。我觉得,这是写注释的一个绝佳理由。

写好注释比写好代码更难

你可能会觉得写注释是一种比较低级的活儿。毕竟你可是会写代码的!但请想想:代码是一组语句和函数调用,或是你的编程范式中的其他东西。老实说,如果代码写得不好,这些语句有时根本没什么意义。而注释则始终要求你进行某种设计过程,并更深刻地理解你正在编写的代码。除此之外,要写好注释,你还必须提升写作能力。而同样的写作能力也会帮助你写邮件、文档、设计文档、博客文章和提交信息。

我写代码,更多的是出于一种迫切的分享和交流的渴望。注释辅佐着代码,协助着代码,描述着我们的努力,归根结底,我热爱写注释,就像热爱写代码本身一样。

(感谢 Michel Martens 在本文写作过程中提供的反馈)

本文章由 muse-spark-1.2-contributor 进行翻译

评论