Redis作者談如何編寫系統軟體的程式碼註釋

banq發表於2018-10-08
頂頂大名的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);
    }

<p class="indent">

上面是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){
    ...
<p class="indent">


函式註釋實際上是一種內聯API文件。如果函式註釋編寫得足夠好,那麼大多數時間使用者應該能夠直接閱讀文件,而無需閱讀函式,類,宏的具體實現。

那麼,在程式碼本身中放置API參考文件的註釋是否是一個好主意?對我來說答案很簡單:我希望API文件與程式碼完全匹配。隨著程式碼的更改,應該更改文件。

出於這個原因,在函式程式碼前加入使用這個函式的註釋使API文件更接近程式碼,三個好處:

1. 隨著程式碼的更改,文件可以同時輕鬆更改,而不會使API參考過時。

2. 這種方法說明程式碼更改的作者也應是API文件更改的作者。

3. 閱讀程式碼非常方便,能直接找到函式或方法的文件,這樣程式碼讀者就會只關注程式碼,而不是在程式碼和文件之間的上下文切換。


設計註釋

雖然“函式註釋”通常位於函式的開頭,但設計註釋通常位於檔案的開頭。設計註釋基本上說明了當前程式碼的使用某些演算法,技術,技巧和實現的方式和原因。它是對程式碼中實現內容的更高階別概述。有了這樣的背景,閱讀程式碼會更簡單。此外,當我找到設計說明時,意味著可能有很多的程式碼。至少我知道在某些時候,在開發過程中發生了某種明確的設計階段。

根據我的經驗,設計註釋對於說明也非常有用,如果實現提出的解決方案看起來有點過於微不足道,那麼競爭的另外一個解決方案是什麼以及為什麼不採取另外一個?一般採取一個非常簡單的解決方案就足以滿足當前的要求。如果設計是正確的,閱讀者會說服自己當前的解決方案是合適的,這種簡單性來自一個過程,而不是懶惰或只知道如何編寫基本的東西。

bio.c:
     *設計
     * ------
     *
     *設計很簡單,我們有一個代表要執行作業的結構
     *以及的不同執行緒和代表每種不同作業型別的作業佇列。
     *每個執行緒都在等待佇列中的新作業,並順序處理每個作業
     *。
     ...
<p class="indent">


為什麼註釋

“為什麼註釋”解釋了程式碼執行某些操作的原因,即使程式碼執行的操作非常明確也要進行說明。請參閱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);
    }
<p class="indent">

如果我只檢查函式呼叫,那麼很少有人想知道:如果達到超時,請更改主複製ID,清除輔助ID,最後釋放複製積壓backlog。但是,我們需要在釋放backlog時更改複製ID,這一點並不十分清楚。

現在,一旦達到某個複雜程度,這是軟體中不斷髮生的事情。無論涉及哪些程式碼,複製協議本身都有一定程度的複雜性,因此我們需要做些事情以確保不良問題不會發生。

在某種程度上,這些註釋可能幫助推理系統的邏輯,並檢查是否有改進的機會,如果能夠改進了,這些註釋也許不再需要,但是,改進措施可能會使事情變得更簡單,也可能會使其他事情變得更難或者根本不可行,或者會破壞向後相容性。

...

老師註釋
教師註釋不會試圖解釋程式碼本身或我們應該注意的某些副作用。他們傳達程式碼有關領域知識(例如數學,計算機圖形學,網路,統計,複雜的資料結構),這可能是讀者技能組合之外的一個,或者只是太多的細節無法記住所有。

...

檢查清單註釋
這是一個非常常見和奇怪的問題:有時由於語言限制,設計問題,或者僅僅因為系統中出現的自然複雜性,不可能將所有包含的概念或介面集中在一個程式碼部分呈現,因此程式碼散落在各個地方,需要告訴讀者記住在當前程式碼的其他地方也有相關程式碼。一般概念是:

/ *警告:如果在此處新增型別ID,請務必修改
*函式getTypeNameByID()也是如此。* /

指南註釋
Redis中的大多數註釋都是指南註釋。指南註釋正是大多數人認為完全無用的註釋。

指南註釋做了一件事:他們給讀者指路,在處理原始碼中的內容時協助他,幫助提供明確的劃分、節奏,並介紹需要要閱讀的內容。(類似老師註釋?)

指南註釋存在的唯一理由是降低程式設計師閱讀某些程式碼的認知負擔。

細節註釋
指南註釋是非常主觀的工具。你可能喜歡或不喜歡他們。我愛他們。然而,指南註釋可能會退化為一個非常糟糕的註釋:它很容易變成“細節註釋”。一個細節註釋應該也是一種指南註釋,其閱讀註釋的認知負荷與閱讀相關程式碼相同或更高。以下是一種細節註釋,很多書籍要求你避免的。

array_len ++; / *增加陣列的長度。* /

因此,如果你寫指南註釋,請確保你避免寫得太瑣碎,變成細節註釋。

債務註釋
如果原始碼本身內部有些硬編碼,那麼未來需要修正(嘗還債務),類似TODO,FIXME,XXX,“這裡一種駭客處理手法”,這些都是債務註釋的形式。

它們一般都不是很好,我試圖避免它們,但避免並不總是可能的,有時希望不要永遠忘記一個問題,我更喜歡在原始碼中放置一個標識。至少有一個人應該定期檢視這些註釋,看看是否可以將註釋放在更好的位置,或者該問題是否已不再相關或可以立即解決。

備份註釋
最後,備份註釋是開發人員註釋某些程式碼塊的舊版本甚至是整個函式的註釋,因為他或她對在新版本中執行的新更改感到不安全。令人費解的是,現在我們已經擁有Git卻仍然會發生這種情況。我想這是人們對丟失程式碼片段總是有一種不安的感覺,在一些多年的提交commit活動中,這種做法被認為更加理智或穩定。

總結
註釋可以作為分析工具
註釋能提供程式碼片段的作用、確保它是什麼,有什麼副作用等要點。這通常是一個尋找錯誤的機會。在描述某些東西時很容易發現它有漏洞......如果你無法真正描述它,其實是因為你不能確定其行為:這種行為只是從複雜性中隨機出現。但是如果你真的不想出現這種情況,那麼你可以修復這個Bug。我覺得這是寫註釋的一個很好的理由。


#編寫好的註釋比編寫好的程式碼更難
編寫註釋總要進行一些設計過程,並從更深層次的角度理解你正在編寫的程式碼。最重要的是,為了寫出好的註釋,你必須培養你的寫作技巧。相同的寫作技巧將幫助您編寫電子郵件,文件,設計文件,部落格文章和提交訊息。

Writing system software: code comments. - <antirez

相關文章