本文在綠泡泡“狗哥瑣話”首發於2024.12.27 <-關注不走丟。
最近看到一篇好文章,是6年前redis之父寫的,雖然過了這麼久,但是這些內容並沒有過氣。
標題《Writing system software: code comments》,鏈接是:http://antirez.com/news/124?continueFlag=372abd242aeafb5bbf6f...
這篇文討論了代碼中註釋的重要性,以及好註釋與壞註釋的典型。
文章的核心的觀點就是,寫好註釋特別重要。他對於代碼寫得足夠好,就不需要註釋的這種觀點持有反對意見。主要從兩個點出發:
- 許多註釋沒有解釋代碼在做什麼。它們僅從代碼的功能中解釋了你無法理解的內容。説到底就是沒説明為什麼這些代碼要這麼做。
- 很多人會覺得逐行加註釋沒用,因為讀代碼就可以懂,但編寫可讀代碼的一個關鍵目標是減少讀者在閲讀某些代碼時應該考慮的工作量和細節數量。因此,對我來説,註釋可以成為降低讀者認知負荷的工具。
他展示了一段redis的代碼來佐證了第2個觀點:
/* 初始棧:數組 */
lua_getglobal(lua, "table"); // 獲取全局變量 "table" 並壓入棧中
lua_pushstring(lua, "sort"); // 將字符串 "sort" 壓入棧中
lua_gettable(lua, -2); // 使用棧頂的 "sort" 作為鍵,從 "table" 中獲取值(即 table.sort 函數)並壓入棧中
// 棧狀態: 數組, 表, table.sort
lua_pushvalue(lua, -3); // 複製棧底的數組,並將其壓入棧頂
// 棧狀態: 數組, 表, table.sort, 數組
if (lua_pcall(lua, 1, 0, 0)) { // 調用 table.sort 函數來排序數組。如果調用失敗(例如數組中有非數字或 nil 元素)
/* 棧狀態: 數組, 表, 錯誤 */
/* 我們不關心錯誤的具體內容,我們假設問題是數組中包含 'false' 或其他不可比較的元素。
* 因此,我們嘗試使用一個較慢但能夠處理這種情況的函數,即:
* table.sort(table, __redis__compare_helper) */
lua_pop(lua, 1); // 移除錯誤信息
// 棧狀態: 數組, 表
lua_pushstring(lua, "sort"); // 再次將字符串 "sort" 壓入棧中
lua_gettable(lua, -2); // 再次獲取 table.sort 函數
// 棧狀態: 數組, 表, table.sort
lua_pushvalue(lua, -3); // 再次複製數組並壓入棧頂
// 棧狀態: 數組, 表, table.sort, 數組
lua_getglobal(lua, "__redis__compare_helper");
// 獲取全局比較輔助函數 __redis__compare_helper 並壓入棧中
// 棧狀態: 數組, 表, table.sort, 數組, __redis__compare_helper
lua_call(lua, 2, 0); // 調用 table.sort 函數,傳入數組和比較輔助函數。這裏不需要返回值
}
這段代碼的主要目的是對一個數組進行排序。
首先嚐試使用標準的 table.sort 函數,如果數組中含有無法直接比較的元素(如 false 或 nil)而導致排序失敗,則會使用一個自定義的比較輔助函數來再次嘗試排序。這樣可以確保即使數組中有複雜的元素,也能夠正確地進行排序。
然後作者對redis中的代碼註釋翻了個遍,對這些註釋呢,做了共計9種的分類:
- Function comments(函數註釋)
- Design comments(設計註釋)
- Why comments(緣由註釋)
- Teacher comments(教師式註釋)
- Checklist comments(檢查列表註釋)
- Guide comments(教程式註釋)
- Trivial comments(瑣碎的註釋)
- Debt comments (債務型註釋)
- Backup comments(備份註釋)
前6種都是不錯的註釋,但是後3種很不行。下面我們來一個個看:
1.函數註釋
/* 在當前節點的子樹中查找最大的鍵。當內存不足時返回0
,否則為1。這是一個與普通迭代函數有所不同的函數,代碼如下。 */
int raxSeekGreatest(raxIterator *it) {
...
函數註釋一般在函數聲明的上面,也有可能在代碼裏面。它最重要的作用就是避免讀者要去一行行讀代碼,瞭解它的作用。好的註釋可以讓讀者認為這是個遵循一定規則的黑盒,讀完直接回到TA正在閲讀的主線分支。
作者還指出,這種方式也可以很好的讓作者在變更代碼時同時變更文檔,避免文檔更新的遺漏。
2.設計註釋
設計型註釋一般位於文件的開頭,它會説明這些代碼如何以及為什麼使用某些算法、技術、以及技巧或實現等等。這種註釋更多專注在高層次的概述。
* DESIGN
* ------
*
*設計很簡單,我們有一個表示要執行的作業的結構
*每種作業類型都有不同的線程和作業隊列。
*每個線程都在其隊列中等待新作業,並按順序處理每個作業。
...
3.緣由註釋
這種註釋更多在解釋代碼為什麼要這麼做,即使代碼已經很清晰了。
以Redis副本同步的代碼為例:
if (idle > server.repl_backlog_time_limit) {
/*當我們釋放積壓時,我們總是使用新的副本ID並清除ID2。當沒有積壓時這是必要的,master_repl_offset不會更新,但我們仍然會保留我們的副本ID,從而導致以下問題:
*
*1.我是一個master實例。
*2.我們的副本變成了master。它的repl-id-2將與我們的repl-id相同。
*3.作為master,我們會收到一些更新,這些更新不會增加master_repl_offset。
*4.稍後,我們變成了一個副本,連接到新的主機,該主機將通過第二個複製ID接受我們的PSYNC請求,但由於我們收到了寫入,因此會出現數據不一致。
*/
changeReplicationId();
clearReplicationId2();
freeReplicationBacklog();
serverLog(LL_NOTICE,
"Replication backlog freed after %d seconds "
"without connected replicas.",
(int) server.repl_backlog_time_limit);
}
接下來可能會有人覺得,這種註釋只有在比較複雜的代碼中才需要,而然並不是,我們繼續往下看:
for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
int expired;
redisDb *db = server.db+(current_db % server.dbnum);
//這樣去增加ID,我們可以保證時間用完時,我們將從下一個數據庫開始做一些事情
current_db++;
...
這段代碼是讓不同的實例密鑰過期的,只要還有執行時間。但是它在循環中並沒有直接遞增數據庫ID,而是在函數末尾。這種情況下,如果執行超時了(可能網絡不通、機器不行),下次在外部調用時則會跳過這個數據庫。
通過這樣的註釋,開發者就知道為什麼要把遞增寫在這裏,以及在編寫類似代碼時可以避免類似的問題。
4.教師式註釋
教師型註釋更多專注告訴你代碼所在的領域知識(比如數學、圖、統計學、複雜數據結構等),對於普通開發者可能比較陌生,或者是相關的太多的細節。
LOLWUT命令會在屏幕上顯示旋轉的方塊。這裏面到了一些三角函數,但為了防止大家沒有數學背景,函數頂部加了註釋。
/* 以指定的x、y座標為中心,繪製一個具有指定旋轉角度和大小的正方形。為了寫出一個旋轉的正方形,我們使用了參數方程
*
* x = sin(k)
* y = cos(k)
*
* 描述一個從0到2*PI的值的圓圈。所以我們從45度開始,即k=PI/4。然後我們找到其他三個點,將k增加PI/2(90度),我們就得到了正方形的點。為了旋轉正方形,我們只需從k=PI/4+rotation_angle開始就行了。
*
* 當然,上面的普通方程式描述了一個
半徑為1的圓,因此為了繪製更大的正方形,我們必須將獲得的座標相乘,然後平移它們。然而,這比實現2D形狀的抽象概念然後執行旋轉/平移變換要簡單得多,對於LOLWUT來説,這是一種很好的方法。
*/
作者認為,雖然這些註釋不包含代碼的邏輯和技術細節,但是很有價值,它教給讀者一些東西,讓讀者可以快速的入門這些代碼。
5.檢查列表註釋
這種註釋往往是疑問語言限制、設計問題以及系統中自然而然出現的複雜問題,而沒法將給定的概念或接口集中在一個部分。所以註釋例會告訴你代碼在別的地方做的事:
/* 警告:如果你在這裏添加了某種類型,那麼請確保XX函數也一起修改了*/
6.教程式註釋
作者承認在redis裏是濫用這種註釋的,而且這種註釋大多數人認為是沒用的,因為:
- 沒有説清楚代碼裏晦澀的地方
- 沒有任何設計相關的提示
作者認為,這種註釋就是用來照顧讀者的,來提供清晰的劃分、節奏和介紹,幫助你來閲讀裏面的代碼。簡單來説就是在閲讀代碼時降低你的認知負荷。
/* 如果有,調用節點回調,
*如果回調返回true,則替換節點指針. */
if (it->node_cb && it->node_cb(&it->node))
memcpy(cp,&it->node,sizeof(it->node));
/*對於“下一步”,每次我們找到一個鍵時都要停止,因為該鍵在字典上比子節點中的鍵小*/
if (it->node->iskey) {
it->data = raxGetData(it->node);
return 1;
}
作者也説了,他認為這種註釋的好,是很主觀的。他相信大家覺得redis代碼好讀,一部分原因就是來自於這裏的。
而且教程式註釋可以把代碼劃分成獨立的部分,增加可讀性。從這點上來説,作者也是比較認可這類註釋的。
你問我怎麼看呢?這類註釋對於老手來看是比較囉嗦的,但是的確對新人很友好。好了,你們又學會了一個防失業的技巧,寫代碼不寫註釋。
7.瑣碎的註釋
而瑣碎的註釋正是教程式註釋退化而來的。舉幾個例子感受下:
array_len++; /* Increment the length of our array. */
這讓我想起來我以前寫代碼的時候,有一個老哥讓我補註釋。就像這樣:
// 訂單服務類
public class OrderService{
}
8.債務型註釋
債務型註釋就是在源代碼中的技術債務聲明:
/*在這裏,我們應該執行垃圾收集,
* 以防此時列表包中刪除的條目太多*/
entries -= to_delete;
marked_deleted += to_delete;
if (entries + marked_deleted > 10 && marked_deleted > entries/2) {
/* TODO: perform a garbage collection. */
}
這種註釋作者認為,更加適合寫成設計型註釋比較好。比如在這個例子中,解釋為何沒有GC。
但其實這類註釋比較難避免——因為這樣寫進代碼裏,至少對應的問題不會被忘記。更好的方式則是定期讓人收集起來,放到一個更好的地方,然後排期或立刻去解決它們。
9.備份註釋
這種就是註釋了代碼,保存在源文件裏。而不利用版本管理工具來做這件事。作者對這類註釋感到很疑惑,尤其是git已經流行了這麼長時間的情況下。
小結
在文章的最後,作者闡述了兩個觀點。
第一點:註釋是一種很有效的分析工具。而且你寫下的註釋未來的代碼讀者是會去看的,在開源軟件上搞不好就要被人吊,所以儘量做的體面一點吧,寫點好的註釋,為人為己。
第二個則是寫好註釋比寫代碼還難。因為寫好註釋意味着你能夠在更深層次上理解你寫的代碼,而且這還會鍛鍊的你寫作技巧。作者提到,他寫代碼是因為他很喜歡分享和交流,註釋促進了代碼的分享和交流,所以他寫註釋就和像寫代碼一樣喜歡。