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

愛飛翔發表於2012-06-18

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

  從程式碼質量的角度看,註釋寫得應不應該,寫得好不好,應該從它是否有助於加深程式碼讀者及程式碼使用者對程式的理解這一標準來判斷。按照《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<float> data;
  ...
  void Clear() {
    vector<float>().swap(data);
  }
};


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

// 在vector物件上進行強制記憶體回收,參見“STL容器的swap技巧”(STL swap trick)
vector<float>().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_cn/

相關文章