Writing system software: code comments.

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 原始碼的不同部分,以檢視註解在不同脈絡下是否有用、以及為何有用。很快就發現,註解基於非常不同的原因而有用,因為它們在功能、寫作風格、長度與更新頻率上往往差異很大。最終,我將這項工作轉化為一個分類任務。

在研究過程中,我歸納出九種類型的註解:

  • 函式註解
  • 設計註解
  • 原因註解
  • 教學註解
  • 檢查清單註解
  • 導引註解
  • 瑣碎註解
  • 技術債註解
  • 備份註解

在我看來,前六種大多是非常正面的註解形式,而最後三種則有些爭議。在接下來的各節中,每一種類型都將搭配 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.
     ...

原因註解

原因註解解釋了程式碼為何要做某件事的原因,即使程式碼在做什麼已經非常清楚。請看以下來自 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 的型別可以用「object type」結構來表示,每個物件都可以連結到其所屬的型別,因此你可以這樣做:

    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.

檢查清單註解在類似於使用某些「原因註解」的脈絡中也很有用:當某段程式碼為何必須在某個特定位置、在某件事之前或之後執行並不明顯時。但是,雖然原因註解可能會告訴你某個陳述式為何存在於那裡,在同樣情況下使用的檢查清單註解則更偏向於告訴你,如果你想修改它,應該遵循什麼規則(在這個例子中,規則是遵循特定的順序),而不會破壞程式碼的行為。

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% 的項目,串流就會開始碎片化,由「macro nodes」組成。項目僅被標記為已刪除,但只有在某個 macro node 中的所有項目都被釋放後才會被回收。因此,你的大量刪除將會改變 streams 的記憶體行為。

目前,這看起來不是什麼問題,因為我預期使用者不會刪除串流中的大部分歷史紀錄。然而,未來我們可能會想引入垃圾回收:一旦已刪除項目與現有項目的比例達到一定水準,就可以壓縮 macro node。此外,在垃圾回收之後,相鄰的節點可能會被黏合在一起。我有點擔心之後會不再記得進行垃圾回收的進入點,所以我加入了 TODO 註解,甚至寫下了觸發條件。

這可能不是很好的做法。更好的想法反而是在檔案頂端的設計註解中,寫明我們目前為何不執行 GC,以及如果日後想加入時,GC 的進入點是什麼。

FIXME、TODO、XXX、「This is a hack」都是技術債註解的形式。一般來說它們都不太好,我試著避免它們,但並非總是可行,而且有時與其永遠忘記某個問題,我寧願在原始碼中留個節點。至少應該定期 grep 搜尋這類註解,看看是否可能將這些筆記放到更好的地方,或該問題是否已不再相關、或可以立即修正。

備份註解

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

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

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

將註解作為分析工具

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

這與撰寫文件時發生的過程相同:撰寫者試圖提供某段程式碼的功能要旨、其保證與副作用。這往往是尋找錯誤的機會。在描述某件事物時,很容易發現它有漏洞……你無法完整描述它,因為你對某個行為不太確定:該行為只是從複雜性中隨機浮現出來的。你絕對不想要那樣,因此你會回頭將一切修正好。我認為這是撰寫註解的一個絕佳理由。

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

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

我寫程式碼是因為我有一種迫切想要分享與溝通的感覺,勝過其他一切。註解協助程式碼、輔助它、描述我們的努力,而且畢竟,我熱愛撰寫它們的程度,就如同我熱愛撰寫程式碼本身一樣。

(感謝 Michel Martens(米歇爾·馬坦斯)在撰寫本篇部落格文章期間提供的回饋)

原文由 Salvatore Sanfilippo 發布

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