代碼質量隨想錄(五)注得多不如注得巧

  寫代碼也流行注水了麼?不是不是,我說的是註釋。其實註釋這個東西,歷史久遠。我們可以寬泛一點兒說,《春秋》就是要配上左傳的註解,才能興發其“微言大義”嘛!註釋有很多種,如果按照註釋者與原文作者是不是同一個人來分,可以劃分成自注和他注。在程序員這個行當內,一般來說,還是自注多一些,自己寫代碼,自己加注。有的時候進行代碼審查或者復用遺留代碼時,纔可能會有必要對他人寫的代碼加註。

  從代碼質量的角度看,註釋寫得應不應該,寫得好不好,應該從它是否有助於加深代碼讀者及代碼使用者對程序的理解這一標準來判斷。按照《The Art of Readable Code》作者的說法,註釋的目標,就是讓讀者儘量明白代碼作者的編程意圖

  那麼,具體到代碼書寫層面,究竟怎麼註釋纔算好呢?這個問題得展開來談。這一篇文章先談談註釋的時機問題,下一篇再來研究註釋的內容。

1.顯而易見的代碼別註釋

  寫註釋經常會遭遇兩種極端態度,一種是絕對不寫註釋,一種是寫廢話連篇的註釋。對於持第一種態度的人,小翔希望看完講註釋的這兩篇文章之後,能夠適當轉變一下態度,稍稍緩釋惜墨如金的執念,多爲大家帶來一些精彩的註釋。有很多理由都會被拿來爲不寫註釋做辯護,這在後文會一一講到,我在這裏主要是想先說說口水型註釋的害處。從我個人的工作經歷來看,不寫註釋的人一旦能夠理性地認識到註釋的好處,那麼他們很有可能養成在編碼的同時自發地爲代碼精準加註的好習慣,然而沒話找話型的程序員,則很難寫出優雅簡潔的註釋來,對這些人來說,先要消解註釋泡沫才行。

  比如,代碼本身就含有的題中之義就不宜再以註釋的形式重複了。

// Account類的定義。
class Account {
  // 構造器
  public Account(){...}

  // 將profit字段設定爲新指定的值
  public void setProfit(double profit){...}

  // 獲取本Account對象的profit字段值
  public double getProfit(){...}
}

  以上幾行註釋的內容完全是在重述代碼,意義不大

2.註釋要儘量闡發被注標識符無法容納的意思,比如操作的同步性、工作流程、參數的範圍、返回值、異常等有价值的信息

  形成上例這種情況,也許還有一個原因,那就是有些公司或者團隊會對註釋形成一種強制要求,比如在Java語言中要求公有和保護級別的API必須寫Javadoc。這種規範是好的,不過要定出具體細則來,比如類的總結部分怎麼寫,構建子怎麼寫註釋,簡單的setter/getter方法怎麼寫註釋。

  針對上述這些問題,我覺得在制定開發團隊的註釋規範時,要明確指出:註釋應該儘量闡明被注標識符無法容納的義涵。例如,針對本類字段的簡單存取方法,如果其中有特殊之處,比如setter方法參數的取值範圍、參數非法時是否會造成異常、設置的新值是否立刻生效等等問題,那麼這些情況就應當明確標註。例如:

/** 
 * 將profit字段設定爲新指定的值。設置動作有可能不會立即生效,要根據該賬戶對象的修改策略
 * 所允許的單位時段內最大修改次數來定。如果修改策略是“延時生效”,則超過修改次數限制的   
 * 修改動作會在下個時間段生效.
 * @param profit 新的收益率,必須在[0.0d, 1.0d]之間
 * @throws IllegalArgumentException  如果收益率不在合法區間內
 * @throws IllegalOperationException 如果本次設置已在修改策略容許次數之外,
 *                                   且修改策略是“立即生效”
 */
public void setProfit(double profit){...}

  雖然有點兒囉嗦(我寫註釋的毛病,哈哈),不過比起上例來說,畢竟還是帶來了一些新內容。而且一旦通過註釋把這些隱晦的東西挑明了,那麼還可以由此引發新的討論,以促進團隊成員對代碼的理解,進而觸發重構。比如大家可以盡情吐槽:這個方法名怎麼能簡簡單單地叫成setProfit呢?這樣怎麼能體現出它還受制於“賬戶修改策略”這個事實?參數怎麼能叫成profit?爲什麼不寫成profitBetweenZeroAndOne?如果設置無法立刻生效的話,那爲什麼不提供通知機制?不然客戶代碼怎麼知道什麼時候才能設置生效?等等等等……這些質疑未必各個都有道理,不過可以由此讓我們重新審視該方法,甚至是整個類,看看它設計得是不是有問題,對下游開發者是否友好。

  再看getProfit方法,可就有點兒尷尬了,因爲不管怎麼寫註釋,貌似都很無力。這時咱們就可以很有自信地無視它了。不過使用Eclipse的開發者可能會遇到一些小障礙,比如在設定裏面設置好了強制要求所有protected、public的API都要寫Javadoc註釋,那麼略去這種getProfit方法不注,可能會有警告或者錯誤。這種小麻煩,恐怕就需要一些變通辦法了,大家如果有好辦法,也請告訴我。

  如果代碼讀者和下游開發者有必要適當地瞭解工作流程和返回值詳情,那麼這些信息就要註釋,比如:

// 在子樹中尋找某個深度範圍內,具有給定名稱的節點。
public Node findNodeInSubtree(Node subtree, string name, int depth){...}

  就應該改爲:

// 找尋具有指定名稱的節點,找不到則返回null。
// 如果深度值小於等於0,則整個子樹都將被查找。
// 如果大於0,則只在N級深度範圍內查找。
public Node findNodeInSubtree(Node subtree, string name, int depth){...}

3.如果編程意圖不夠明顯,則可以適當地加些註釋。此種情況的根本解決辦法還是通過重構來理順複雜的代碼,使之清晰、直觀

# 移除第二個'*'字符及其後內容
name = '*'.join(line.split('*')[:2])

  ARC作者可能認爲以上這句大家看到之後第一眼有點搞不清楚狀況,所以建議加上那行註釋。小翔倒是覺得,不妨對上面的代碼進行重構,將“切割、數組切片、拼合”這個大操作拆解成三個小操作,並且封裝起來,這樣更符合迪米特原則(又叫得墨忒耳定律、最少知識原則),而且看上去代碼會更加清晰,不需加註即可明白。

String name=truncateFromDelimiter(line,'*',2);
...
private String truncateFromDelimiter(String input, char delimiter, 
                                     int groupIndexToDropFrom){...}

4.再好的註釋也無法徹底掩飾壞名稱

// 確保回覆對象的內容符合請求對象中關於條目數量、總字節數等規格的限定。
public void cleanReply(Request request, Reply reply){...}

  以上註釋中的“確保”(Enforce)、“限定”(Limit)等詞應該直接納入方法名稱中。不妨改成:

// 經請求對象所限定的規格包括“條目數量”、“總字節數”等指標。
public void enforceLimitsFromRequest(Request request, Reply reply){...}

  這樣不僅註釋內容變簡單了,而且方法名稱所表達的意思也比原來精確許多,讓人更易理解。關於這一點,我在做項目時體會特別深刻,千萬不要試圖用註釋去粉飾糟糕的名字,而應該直接修正不當的命名

// 釋放主鍵所指向的註冊表操作句柄。該方法並不修改實際的註冊表內容。
public void deleteRegistry(RegistryKey key);

  既然“並不修改實際的註冊表內容”,那麼名稱中delete何謂?用註釋無法掩飾這個矛盾。莫如去掉註釋,直書其意,這樣不需要註釋大家也能從方法名稱中準確判斷出該操作的效果僅僅是釋放句柄:

public void releaseRegistryHandle(RegistryKey key);

5.能夠對代碼讀者起到警示、啓發或備忘作用的註釋值得去寫

  有時需要警告同組開發者,不要進行倉促的優化:

// 在處理該數據時,使用二叉樹比哈希表要快40%,計算哈希碼的開銷比進行左右比較的開銷要大。

  有時則要避免開發者在無關緊要的問題上浪費時間:

// 這種試探法可能會漏掉一些詞語,不過不影響使用,100%解決這個問題很難。

  有時陳述將來可改觀之處:

// 這個類很亂,也許應該創建一個ResourceNode子類來下移一部分代碼。
// TODO:應該使用更快的算法

  有時要陳述不完備的功能:

// TODO: 除了JPEG之外,還得處理其他格式。

  上述最後兩種情況要特別注意,也就是在註釋待改進或者功能不完備的代碼時,強烈建議使用特殊的前導標識符來標明註釋行。這樣可以藉助文本統計或者IDE提供的待辦任務視圖來立刻檢索到項目中存在的隱患,促進開發者之間對代碼現狀的理解,以便發現問題及時溝通。這種註釋其實扮演了“待辦任務”或“待辦事項”的角色。咱們業內通用的標註法按照緊急程度從低到高排列如下,新入行的小朋友們可以學習一下:

// TODO: 可改觀或不完備的功能。
// HACK:  用來應急的雜技代碼,稍後必須糾正。
// FIXME: 代碼有錯,需要修正。
// XXX:     代碼大誤,即行修正!

6.關乎代碼邏輯的常量,如其名稱不足以描述其包含的重要信息,則必須加註

  必須具備某種特性,方能使程序正常運轉的常量應該加註,例如:

/** 只要不小於處理器數量的2倍就好. */
public static final int NUM_THREADS = 8;

  翔按:ARC作者在說明此種情況應當加註時,舉了上面這個例子。其實,這裏不妨補以// TODO: 提示信息,因爲這種“不小於處理器數量的2倍”的特性可能會隨着運行環境的改變而無法滿足。僅憑這個註釋,程序員未必能在出問題時第一時間就定位到該常量。大家可以在遇到這種情況時,補以提示性註釋,例如“// TODO: 在後續版本改進過程中,應使用系統硬件信息來初始化此常量值,不宜手工指定”。

  隨意選取數值的限定常量亦應加註,以便後續版本要對其進行可定製的功能擴展時參考(注意TODO後面的話):

// TODO: 如果將來要由客戶自行指定訂閱點上限,則可把此值改爲變量。
/** 最大的RSS訂閱點數量。這麼多訂閱點足以應對客戶當前的需求了. */
public static final int MAX_RSS_SUBSCRIPTIONS = 1000;

  精心調優後的常量應加註,避免誤調

// 使用0.72作爲質量參數,可以在畫質與佔用空間之間取得良好平衡。
public static final double IMAGE_QUALITY = 0.72d;

  其實這一條原則的三個小分支,都與上一條所述的“能夠對代碼讀者起到警示、啓發或備忘作用的註釋值得去寫”這一原則有重複。之所以要單列出來,是因爲常量的設置尤爲微妙,經常會暗含無法用標識符全面涵蓋的細微特徵,應當適時地輔以註釋。

7.提高註釋質量所奉行的原則之一與提高代碼質量的大原則一致:用局外人的視點來審讀代碼

  這一點,我在日常編碼中曾一再對身邊同事強調,此時不妨再囉嗦幾句。那就是要從當前代碼中跳出來,“冷眼看程序,熱心挑毛病”

  大部分人不甚明瞭的微妙語言細節應該加註,例如:

struct Recorder {
  vector data;
  ...
  void Clear() {
    vector().swap(data);
  }
};

  如果誰突然闖進來看到上面的代碼,肯定第一個就要問:爲什麼不直接調用data.clean()函數呢?與其讓讀者陷入猜測與不解之中,咱們不如直接用註釋把隱晦的細節說明白了:

// 在vector對象上進行強制內存回收,參見“STL容器的swap技巧”(STL swap trick)
vector().swap(data);

  好久沒做C++的項目了,剛Google了一下,這個技巧問的人還蠻多,我想起當時Scott Meyers在《Effective STL》一書裏面講過,Stack Overflow上面有人說是條目17,大家可以去複習一下。我覺得,如果真是像本例這種情況,某段代碼使用了一個不成文的高端技巧或者某權威著作中深入講述的代碼慣用法,那麼不如在註釋中直接給出明確的參考源,例如“參閱網址:……;參考書目或文章:……”

  可能會導致客戶代碼出狀況的API要加註。例如:

// 調用外部程序投遞郵件(有可能耗時長達1分鐘,若屆時還未完成,則算超時)
public void sendEmail(String to, String subject, String body){...}

// 算法的時間複雜度是O(標籤數量*平均標籤深度),若輸入數據含有大量嵌套錯誤,可能會相當耗時。
public void fixBrokenHtml(String html){...}

  類之間的互動、整個系統數據流、程序的入口點等宏觀信息應該加註。講到這個問題時,ARC的作者讓我們假想一下,如果某個程序狼(或者程序娘,原文按照英語慣例,寫的是her)突然闖入團隊裏面,你怎麼以代碼的方式向他解釋整個項目的架構,使他儘速融入開發過程中呢?這個時候就必須有一些全局性的註釋了,通過閱讀這些註釋,新人就可以迅速把握住整個項目的大方向、大節奏。例如:

// 在業務邏輯與數據庫層之間的粘合代碼,應用程序不直接使用它。
// 該類內部邏輯稍顯複雜,不過僅僅扮演智能緩存池的角色。它並不依賴於系統的其他部分。

  在Java項目中,我們通常以包註釋或類概覽的Javadoc形式來提供宏觀註釋。

/**
 * 爲便於訪問與文件操作有關的功能而提供的工具類。其內部會處理與操作權限等事項相關的細節問題。
 */
public class FileMiscellaneousUtility{...}

8.以註釋將長段代碼分爲小段,使讀者快速掌握程序流程

  在上一篇文章中舉過一個類似的例子,那次是編寫一個社交軟件中的潛在友人推薦功能。那個例子其實只有8行有效代碼。所以只需分段,不用註釋,讀者就可以清晰地理解它。然而有的時候,如果某方法內部包含數十甚至上百行代碼,而因爲效率或複雜度等原因無法立刻進行代碼整理的話,那麼可以先寫一些註釋來釐清程序流程,這樣也便於後續的維護。例如:

public void  generateUserReport{
  // 獲取配給該用戶的鎖
  ...
  // 從數據源讀入用戶信息
  ...
  // 將信息寫入文件
  ...
  // 釋放用戶鎖
  ...
}

  本來上述方法的四段應該分別被重構提取到四個不同的小方法之內,不過如果由於內部邏輯過於複雜,提取小方法的時候需要提取過多的參數以配合程序流程,那麼在短期內無法進行有效重構的情況下,方法內部的適當註釋可以起到“起、承、轉、合”之目的,也可以爲稍後進行重構的人釐清思路。

  嗯,這一篇講的心得有點多,可以小小總結一下。有一種傳統的說法,那就是“只註釋寫代碼的原因(why),不要註釋代碼具體內容(what)以及代碼的算法(how)”。不過看了上述這些例子之後,我想大家應該明白,有些時候,代碼的具體細節以及算法等內容,如果與代碼的理解緊密相關,那麼就應該毫不吝惜地註釋。

  巧妙的註釋,好就好在它能促進代碼理解這一點上。不僅能讓讀者快速抓住代碼的意圖,而且還能爲將來潛在的重構打開思路,同時還利於項目的維護,再有就是方便下游開發者進行二次開發。相反,對代碼理解毫無益處的註釋,就顯得笨拙、累贅,應該刪去。所以嘛,我想大家可以稍微修正一下上述說法了:只要有助於代碼的理解,“做什麼、爲什麼做、怎麼做“這幾方面都應加註。

  最後說一個小問題,那就是“註釋恐懼症”。本文開頭說道,有些人不願意寫註釋,原因有很多種。其中有一種就是註釋恐懼症,一旦形成這個習慣,同時又沒有督促因素的話,則很難改正。此時如果通過團隊註釋規範強迫開發者去寫註釋的話,那麼在沒有養成良好註釋習慣的情況下,就很可能會立刻走入另一個極端,爲了應付差事而寫出毫無意義甚至刻意掩蓋代碼隱患的註釋來。對於如何克服註釋恐懼症的問題,ARC的作者說了一個方法,我轉述給大家聽聽。他們二位建議,將自己的第一感覺以“原生態”的方式寫出來,例如:

// 額滴神啊,如果列表中有重復元素的話,這傢伙就玩兒不轉了。
// (其實,ARC這本書的原文是這樣的:)
// Oh crap, this stuff will get tricky if there are ever duplicates in this list.

  上面這種話我估計人人都會寫吧。好,寫完了之後,用具體的、精確的詞語代替模糊的、情緒化的描述。

  • “額滴神啊”這幾個字,其實是想說“這裏有必須要注意的狀況發生”。
  • “這傢伙”其實指的是“處理輸入數據的代碼”。
  • “玩兒不轉了”意思是“這種情況下的算法很難實現”。

  所以,上述註釋經過美化之後,就變成了:

// 注意:這段代碼並不能處理含有重復元素的列表,因爲那種情況下的算法太難實現了。
// (ARC的原文是:)
// Careful: this code doesn't handle duplicates in the list
// (because that's hard to do)

  不知道上面這個頑皮搞笑的過程能不能克服註釋恐懼症,如果不能的話,大家也可以跟帖想想辦法。

  這段時間一直沒有寫文章,一來由於工作繁忙,二來是晚上想貪玩看看比賽,三嘛,你別說,還真有可能是寫作恐懼症呢!其實這更像是寫作倦怠症。好了,不管怎麼說,這次寫開了,就不倦怠了。這一篇講的是註釋的時機問題,也就是什麼時候應該註釋,什麼時候不該註釋,下一篇來講講內容問題,也就是說,如果要寫註釋的話,怎麼寫纔算好。

愛飛翔
2012年6月16日至17日

本文使用Creative Commons BY-NC-ND 3.0協議(創作共用 自由轉載-保持署名-非商業使用-禁止衍生)發佈。

原文網址:http://agilemobidev.net/eastarlee/code-quality/think_in_code_quality_5_judicious_comments_zh_tw/

廣告

發表迴響

在下方填入你的資料或按右方圖示以社群網站登入:

WordPress.com 標誌

您的留言將使用 WordPress.com 帳號。 登出 /  變更 )

Google+ photo

您的留言將使用 Google+ 帳號。 登出 /  變更 )

Twitter picture

您的留言將使用 Twitter 帳號。 登出 /  變更 )

Facebook照片

您的留言將使用 Facebook 帳號。 登出 /  變更 )

連結到 %s