代碼質量隨想錄(五):注得多不如注得巧
寫代碼也流行注水了么?不是不是,我說的是注釋。其實注釋這個東西,歷史久遠。我們可以寬泛一點兒說,《春秋》就是要配上左傳的注解,才能興發其“微言大義”嘛!注釋有很多種,如果按照注釋者與原文作者是不是同一個人來分,可以劃分成自注和他注。在程序員這個行當內,一般來說,還是自注多一些,自己寫代碼,自己加注。有的時候進行代碼審查或者復用遺留代碼時,才可能會有必要對他人寫的代碼加注。
從代碼質量的角度看,注釋寫得應不應該,寫得好不好,應該從它是否有助于加深代碼讀者及代碼使用者對程序的理解這一標準來判斷。按照《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)
不知道上面這個頑皮搞笑的過程能不能克服注釋恐懼癥,如果不能的話,大家也可以跟帖想想辦法。
這段時間一直沒有寫文章,一來由于工作繁忙,二來是晚上想貪玩看看比賽,三嘛,你別說,還真有可能是寫作恐懼癥呢!其實這更像是寫作倦怠癥。好了,不管怎么說,這次寫開了,就不倦怠了。這一篇講的是注釋的時機問題,也就是什么時候應該注釋,什么時候不該注釋,下一篇來講講內容問題,也就是說,如果要寫注釋的話,怎么寫才算好。