Writing system software: code comments.

Salvatore Sanfilippo

撰寫系統軟體:程式碼註解

原文由 Salvatore Sanfilippo 發布,訂閱此部落格

有好一段時間,我一直想為我在 YouTube 上的「writing system software」系列錄製一支談論程式碼註解的新影片。不過仔細想過之後,我發現這個主題更適合寫成部落格文章,於是就有了這一篇。在這篇文章裡,我會分析 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 的程式碼),而不需要去讀函式、類別、巨集或任何其他實作細節。

在所有類型的註解中,這是最被廣大程式社群普遍接受、認為有必要的類型。唯一值得探討的是,把 largely 屬於 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 實作。Radix tree 是一種結構精細的資料結構。Redis 的實作在實作過程中,重新闡述了整個資料結構的理論,展示了不同的情況以及演算法如何合併或分割節點等等。在每一段註解之後,緊接著就是實作前面所述內容的程式碼。在好幾個月沒碰過實作 radix tree 的檔案後,我能夠打開它,在幾分鐘內修掉一個錯誤,然後繼續做別的事。完全不需要重新研究 radix tree 是如何運作的,因為說明本身就與程式碼混在一起,兩者合而為一。

這些註解太長了,所以我只擷取部分片段來展示。

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

並非所有東西都需要這種程度的註解,但像 radix tree 這類東西確實充滿了各種小細節與邊界情況。這些細節很難記住,而且某些細節是特定於某個實作的。當然,為一個鏈結串列做這種事就沒什麼意義。這需要個人的敏感度來判斷何時值得這麼做。

檢查清單註解

這是一種非常常見卻也有點奇怪的類型:有時候由於語言的限制、設計上的問題,或僅僅是系統中自然產生的複雜度,無法將某個概念或介面集中在一處,因此程式碼中會有些地方提醒你,記得要在程式碼的其他地方做某些事。一般的概念是:

    /* 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 就會開始碎片化,由「macro nodes」所組成。條目只是被標記為已刪除,只有當某個 macro node 中的所有條目都被釋放後,才會真正回收。因此,你的大量刪除會改變 streams 的記憶體行為。

目前這看起來不是什麼問題,因為我預期使用者不會刪除 stream 中的大部分歷史資料。然而,未來我們有可能會想引入垃圾回收(garbage collection):當已刪除條目與現有條目的比例達到一定水準時,就可以壓縮 macro node。此外,在垃圾回收之後,相鄰的節點還可以再黏合在一起。我有點擔心以後會忘記要在哪裡切入做垃圾回收,所以留下了 TODO 註解,甚至寫下了觸發條件。

這大概不是什麼好做法。更好的想法應該是在檔案頂端的設計註解中,說明我們目前為何不執行 GC,以及如果未來想加入 GC,應該從哪些進入點著手。

FIXME、TODO、XXX、「This is a hack」都是技術債註解的形式。一般來說它們都不是很好,我會盡量避免,但並非總是做得到,而且有時候與其永遠忘記某個問題,我寧願在原始碼中留下一個節點。至少應該定期 grep 這些註解,看看是否有可能把這些備註移到更好的地方,或是問題已經不再相關、可以立刻著手修復。

備份註解

最後,備份註解指的是開發者把某個程式碼區塊或甚至整個函式的舊版本註解起來,因為他或她對新版本所做的變更感到不安。令人費解的是,這種事在我們已經有了 Git 的現在竟然還會發生。我猜人們對於在某個多年前的提交中遺失那段被認為更合理或更穩定的程式碼片段,感到不安。

但原始碼不是用來做備份的。如果你想保留某個函式或程式碼片段的舊版本,表示你的工作還沒完成,還不能提交。要嘛就確保新的函式比舊的好,要嘛就先留在你自己的開發分支中,直到你確定為止。

備份註解為我的分類劃下句點。接下來試著做個結論。

把註解當作分析工具

註解就像是加強版的橡皮鴨除錯法,只不過你不是在對一隻橡皮鴨說話,而是在對未來閱讀程式碼的人說話,這比橡皮鴨更讓人敬畏,而且對方還會用 Twitter。所以在這個過程中,你會真的試著去理解自己所陳述的內容是否是可以接受的、體面的、夠好的。如果不夠好,你就會回去做功課,想出一個更像樣的東西。

寫好註解比寫好程式碼更難

你可能會認為寫註解是一種比較不高尚的工作。畢竟你可是會寫程式的!然而請想想:程式碼是一組陳述與函式呼叫,或無論你的程式設計範式是什麼。有時候老實說,如果程式碼寫得不好,這些陳述根本沒什麼道理可言。註解則總是需要持續進行某種設計過程,並對你正在撰寫的程式碼有更深層的理解。除此之外,為了寫出好的註解,你還必須培養寫作能力。同樣的寫作能力也會幫助你撰寫電子郵件、文件、設計文件、部落格文章以及提交訊息。

我寫程式,是因為我有一種迫切想要分享與溝通的渴望,勝過其他一切。註解輔助著程式碼、協助它、描述我們的努力,歸根究柢,我熱愛寫註解的程度,就跟熱愛寫程式碼本身一樣。

(感謝 Michel Martens 在撰寫這篇部落格文章期間提供的回饋)

本文章由 muse-spark-1.2-contributor 進行翻譯

留言