Writing system software: code comments.

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 版中的 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 的基数树(radix tree(基数树))实现就是一个很好的例子。基数树是一种结构复杂的数据结构。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 个 bit,而不是 64 个 bit 来表示类型。这只是为了说明,为什么有时事物无法像应有的那样集中、自然。遇到这种情况时,有时可以使用防御性注释,确保某个代码段被修改时,它会提醒你也要修改代码的其他部分。具体来说,检查清单注释会完成以下一项或两项工作:

  • 告诉你修改某些内容时需要执行的一组操作。
  • 警告你某些修改应当采用的方式。

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++;\t/* Increment the length of our array. */

所以,如果你要写引导注释,请务必避免写出琐碎注释。

债务注释

债务注释是硬编码在源代码中的技术债务(technical debt(技术债务))声明:

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,这种情况仍然会发生。我猜,人们对于在某个多年前的提交中被认为更合理或更稳定的代码片段可能会丢失,仍然会感到不安。

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

备份注释结束了我的分类。下面来做一些总结。

注释作为分析工具。

注释就是强化版的橡皮鸭调试(rubber duck debugging(橡皮鸭调试)),只不过你不是在和一只橡皮鸭说话,而是在和代码未来的读者说话。后者比橡皮鸭更令人敬畏,而且还可能使用 Twitter。因此,在这个过程中,你会真正努力去理解自己写下的内容是否可以接受、是否体面、是否足够好。如果不行,你就要完成自己的功课,拿出更像样的东西。

写文档时也会发生同样的过程:作者试图说明某段代码做了什么、有哪些保证、有哪些副作用。这往往是寻找 bug 的机会。在描述某件事时,很容易发现其中存在漏洞……你无法完整描述它,因为你不确定某种行为,而这种行为只是复杂性随机产生的结果。你当然不希望这样,于是会回头把它全部修好。我认为这是编写注释的绝佳理由。

写好注释比写好代码更难

你可能会认为写注释是一种不那么高尚的工作。毕竟,你会写代码!不过请想想:代码是一组语句和函数调用,或者说,是由你的编程范式所决定的其他东西。如果代码写得不好,这些语句有时说实话并没有多大意义。注释则总是要求你进行某种设计过程,并且更深入地理解自己正在编写的代码。除此之外,要写出好的注释,你还必须培养自己的写作能力。同样的写作能力也会帮助你撰写电子邮件、文档、设计文档、博客文章和提交消息。

我写代码,是因为比起其他任何事情,我都有一种迫切的愿望去分享和交流。注释辅助代码、帮助代码、描述我们的努力;归根结底,我喜欢写注释,就像喜欢写代码本身一样。

(感谢 Michel Martens(米歇尔·马滕斯)在撰写本文期间提供反馈)