代碼質量隨想錄(六)用心寫好注釋

  上個月工作一直很忙,于是就很久沒有更新博客了。今天早晨51CTO的博客管理員同學問了我一下,我也覺得是該繼續寫文章了。

  我要先說說對待註釋的態度問題。有一種不寫註釋的理由,叫做“代碼是最好的註釋”或是“好的代碼應該是自解釋型的”。這兩個觀點其實我都非常贊同,只不過,它們容易被人誤用爲不寫註釋的藉口。我們有理由質疑,那種胡亂拼湊瞎寫出來的代碼能當作“最好的註釋”來用嗎?即便是非常注重質量的代碼,也可能會有程序本身所傳達不盡的意思,這個時候,需要些微的註釋來提點一下。其實這種心態和隨意塗鴉式的註釋風格所存在的毛病是一樣的,就是靠“不寫註釋”或者“狂寫註釋”來迴避、掩蓋領域模型的缺陷,以及測試用例的不完備。

  所以我標題中的“用心”就是這個意思,用正確的心態去寫註釋,須知註釋能夠發揮作用的前提是對領域模型有着正確的理解,以及對產品代碼有着合適的測試覆蓋度。在滿足這兩個前提的情況下,我們才會用註釋來提升代碼的可讀性。否則,就是“寬嚴皆誤”:如果沒滿足剛說的那兩個前提,你寫註釋,就是掩蓋錯誤;不寫,就是逃避錯誤,實質是一樣的。如果你發現註釋不好寫,寫着不順,你別怪註釋,多半是業務模型或測試本身先出了問題。

  上一篇文章主要講的是注釋的重要性,這一篇則來談談注釋的具體寫作技巧。標題中所謂“寫好”註釋,除了能夠通過註釋來闡明代碼的未盡之意,還有就是要讓註釋充當我們打磨領域模型與提升測試覆蓋度的催化劑。有的時候我們先寫了一定量的註釋,然後在代碼審查中發現可以由此來找出業務邏輯中存在的問題,從而完善標識符的命名,同時刪減原有註釋(ARC作者沒有太強調這一點,我下面的例子補充了一些自己的看法)。這樣一來,它在很多場合,其實是提高代碼質量的一種中間過程和手段。

  ARC一書的作者總結說,好的注釋就是要“精准”(precise)和“簡潔“(compact),也就是有高的“信息/空間比”(information-to-space ratio)。通俗一點說,就是注釋要寫得言簡義賅、微言大義,儘量用最少的文句精准地表達代碼意圖

1.儘量減少注釋所用的詞語,同時考慮以更加精准的名稱來取代注釋。

  比如:

// int表示CategoryType。
// 內部數值對的第一個浮點數是“分數”,
// 第二個是“重量”。
typedef hash_map<int, pair > ScoreMap;

  上一段代碼的注釋太長了,不如直接以映射關系來解釋它:

// CategoryType -> (score, weight)
typedef hash_map<int, pair > ScoreMap;

  ARC的作者覺得修改之後的注釋將3行減少至1行,很合適。不過我卻覺得,做到這一步并沒有結束,既然寫出了“CategoryType -> (score, weight)”這個映射關系,那麽是不是應該考慮將“(score, weight)”這個數值對封裝起來呢?如果不是在程序級別進行封裝,那麽至少應該在語義上進行封裝,比如,如果它表示一個重量查詢表,那麽,ScoreMap這個變量就應該命名為CategoryToWeightQueryTable或TypeToScoreWeightPair。

  如果從業務領域的角度看,前者好,畢竟根據Score來查Weight,應該是業務模型的一部分,該模型應該在某個類或包的總結說明處深入闡釋過了,不需要再重復解釋了,提一下WeightQueryTable這個領域模型的名字,就足夠了。但如果從直白度看,則後者好。如果這段代碼沒有對領域進行深入建模,那麽類似後者這種“傻瓜式”的表達則是減少閱讀難度所必須的命名技巧。

  小翔以上提出的兩種命名方法,雖然長了一些,但是該說的卻都說了,而且省去了維護注釋的麻煩。所以我說,“精簡注釋”與“琢磨命名”之間,并不矛盾,而且針對前者所做的努力,往往會激發後者。有些時候,我們精簡了注釋之後,發現可以用精准的命名來取代精簡之後的注釋;還有些時候,我們則發現,導致注釋囉嗦,無法精簡的根源,其實在於命名不當。或者更深入地看,有些情況下是由於對領域模型的不了解或者分析錯誤所致。通過“精簡注釋“這個工作流程,我們可以求得更好的命名,也可以釐清對領域模型的誤解,所以我說,“精簡注釋“這一步,其實可以看作編寫高質量代碼的催化劑。

2.減少有歧義的表述方式。

  例如:

// 將數據插入緩存,但要先檢查它是否過大。

  到底是檢查誰的大小?數據還是緩存?如果是數據,應該寫成:

// 將數據插入緩存,但要先檢查數據是否過大。

  或者更簡潔些:

// 若數據足夠小,則將其插入緩存。

3.澄清模糊的用詞。

  其實注釋的書寫與標識符的命名一樣,都要竭力避免歧義與模糊表述。比如在某個網絡爬蟲程序中:

# 根據原來是否收錄過該URL,對其賦予不同的優先級。

  優先度的高低到底怎麼個“不同”法?沒抓取過的網址優先度高,還是已經收錄過的網址優先度高?應該用更為清晰的表述來闡明這個問題:

# 給未曾收錄的URL賦予更高的優先級。

4.可能引發不同理解的情況,可以注釋,但仍應盡力將其納入標識符中。

  例如:

// 返回文件的行數。
public int countLines(String filename) { ... }

  那麽,"hellonr crueln worldr"到底是幾行?如果只考慮’n’,就是3行;如果只考慮"nr",就是兩行。還有,如果文件內容是"hellon",那麽考慮’n’之後的空字符串""嗎?算的話就是兩行,不算就是1行。如果類似Unix的wc程序那樣,無視一切特例,只認’n’為標准,那麼就應在註釋中寫明:

// 根據新行符'n'計算文件行數。
public int countLines(String filename) { ... }

  ARC作者舉的這個例子並不錯,不過小翔以為這種情況最好還想辦法把可能消除理解歧義的因子納入到標識符之中。比如我可能會直接將方法命名為:

/**
  * 以“新行符數加1”為標准統計文件內容所佔行數。
  * (其餘的參數、返回值及異常說明)
  */
public int countLinesByNewline(String filename) { ... }

  這樣一來就可以省掉剛才那行注釋了,而把寶貴的信息空間留給更有用的內容。而且,我心裡還有個小算盤:萬一將來更改了統計標準,那麼標識符中赫然在目的“Newline”不得不引起修改者的注意,逼着其將它重構爲符合新算法的名稱,比如countLinesByCarriageReturn;反之,如果單單將它放到了註釋中,那麼很容易就會被忽視了。如果誰在修改統計標準時居然無視“精心”(也可以說是“矯情”)嵌入的那個“Newline”,我想你可以罰這個人請你吃頓漢堡王了。

  這裡要多說兩句。至少小愛我認爲:

  第一,註釋,尤其是Javadoc這樣的API註釋,是特別需要字斟句酌的。在表達清晰不致引發歧義的前提下,應該儘量簡省。這樣代碼擁有者維護起來也方便,類庫使用者運用起來也方便,對雙方都有好處。

  第二,簡省下來的空間應當更加着墨於那種不能或者不便納入標識符的問題,這包括:可接受的參數範圍、返回值以及各種可能發生的異常情況。切忌將本來可以納入API標識符中的意涵放到註釋裡面大說特說,這樣註釋本應強調的其他信息就會被淡化,而且代碼也沒有充分利用標識符來容納重要信息。

5.應該用注釋來精確地描述那些微妙的函數行為,如邊界狀況、特殊輸入值等。

  有些函數行為僅僅通過其標識符是無法判斷的,如果硬要將這些可能引發理解問題的要素全部都納入標識符中,恐怕很難有人能記住那麽長的符號。這個時候最好是請注釋幫忙,來釐清那些微妙之處。

  在程序開發中,最精准和簡潔的文句其實還是用例,有的時候只要舉出例子來,代碼閱讀者就可以依此來理解程序的意圖了。

  例如:

// 從'src'輸入源中移除包含'chars'的前後綴。
public String strip(String src, String chars) { ... }

  到底是將chars中指明的所有字符無差別地移除,還是精確地按照chars中字符的排列順序來移除。如果前後綴中出現了多份匹配數據,那麼是移除一份還是全部移除?

  要想澄清上述疑問,還是舉特例吧:

// 例如,strip("abba/a/ba", "ab")將返回"/a/"。
public String strip(String src, String chars) { ... }

  上述這個特例舉得就很好,首先它說清了第一個問題,應該是無差別地移除,而不是精確匹配,不然的話,返回的就是"ba/a/ba"了。而且還說清楚了第二個問題,應該是儘可能地移除多份前後綴,而不是僅移除最外部的一份,不然的話,返回值就是了"ba/a/"了。

  所舉的例子必須具備特殊性。太過簡單的說明不了問題:

// 例如,strip("ab", "a")將返回"b"。

  上面這個例子既沒說清第一個疑問,也沒說請第二個問題。其實,如果你和小翔一樣,在代碼質量這個問題上,是個“普瑞坦派”(Puritan,清教徒。至於爲什麼雞毛蒜皮的小事都要分成這派那派的,請參考歷史魔幻題材動漫巨著《銀魂》),那麼我低調地建議可以將上面這兩個招人疑惑的問題通過標識符來澄清:

/**
  * 移除所有與無序字符組相匹配之前後綴。
  * ……(其餘的參數、返回值及異常說明)
  */
public String stripAllPrefixesAndSuffixes(String src, 
        String unorderedCharsToRemove) { ... }

  這個頗有些自鳴得意的修改,和前面那個例子一樣,可以將更多的文字空間留給那些更加“說不清”的事情,像是參數範圍、返回值、異常等。

  所以我還是那句老話:不管怎麼說,對註釋的提煉畢竟還是很有可能促成對標識符甚至領域模型的完善。所以,註釋這東西,確實是“寫寫更健康”。(本來我這廣告專業的老毛病又犯了,想用這五個字作爲本文的標題,後來想想那太過做作了,於是就小清新了一把)

  對於這個問題,ARC一書舉的第二個例子倒真的是很恰當:

// 重排l中元素的位置,使小於pivot的元素出現在大於等於它的元素之前。
// 然後返回小於pivot的元素中下標最大者之下標,若無此種元素,則返回-1。
public int partition(List l, int pivot){...}

  如果舉出特例,則可以釐清幾個疑惑:

// ……
// partition([8 5 9 8 2], 8)將會把l重排爲[5 2 | 8 9 8],並返回1。
public int partition(List l, int pivot){...}

  這個特例說明了好幾個問題:

  1. 重排所參考的標杆元素"8″恰好與列表中的元素有重複,直接寫出運行結果可以澄清該方法在邊界狀況的行爲。
  2. 由舉例可知該方法可以接納含有重復元素的列表。
  3. 排列後的兩段是各自無序的。
  4. 返回值1不在列表元素之中,釐清了誤解:該方法返回的是“小於指標值的元素所具有之最大下標”,而不是“小於指標值的最大元素”(那樣的話返回的是5),也不是“左方區域下標最大者所對應的集合元素”(那樣的話是2)。

  如果沒有這個“多功能”的例子,那麼上面這四個問題很容易惹人疑惑。畢竟寫得再好的文字註釋還是會有人視而不見,此時只能通過這種華麗的例子來吸引這些程序員的眼球了。

6.遇到文檔不完備等帶有缺陷的第三方庫時,應該編寫學習型測試用例來掌握其用法,並進行適當封裝。

  本來這一條是我閱讀剛纔那個例子想到的,但是鑑於它一來非常重要,二來有些程序員又有那麼一種僥倖心理,對於含有明顯缺陷的第三方庫,希望通過馬馬虎虎的幾行程序來隨意應付過去,所以我必須將這個問題單獨列出來說明。

  如果第三方代碼的註釋本身不是很清晰,需要如何來釐清誤解呢?例如:

/* 依照pivot將l劃爲兩個區段。 */
public static int partition(List l, int pivot) {...}

  如果你遇到了這樣的遺留項目或者第三方庫,那麼不要猶豫,趕緊編寫學習型測試用例來釐清它的用法。比如針對上一條中提出的幾個疑問,假設我們的業務要求大者在前,同時與指標值相等的值應該歸爲大者區,那麼我們可以這麼編寫學習型測試(僞代碼,架空語言):

@Test(repeat=LEARNING_TEST_RUN_COUNT, exception=none)
public void testPartitionEnsureBiggerPartComeFirst(){
  // setup
  int listSize=createRandomInt(MAX_LIST_SIZE);
  int duplicatedElementsRate=createRandomDouble(MIN_REPEAT_RATE, 
                MAX_REPEAT_RATE);
  List data=createRandomList(listSize, duplicatedElementsRate);
  int pivot=getRandomElement(l);

  // call
  int result=TesteeClass.partition(data,pivot);

  // assert
  List BigNumbers=data.range(0,result);
  List smallNumbers=data.slice(result+1);
  assertMoreThanOrEqualTo(BigNumbers,pivot);
  assertLessThan(smallNumbers,pivot);

  // teardown
  ...
}

  這裡我偷了個小懶。按照標準的測試用例書寫規範,每一個受測的問題都應該單獨採用一個測試方法來寫。爲了節省篇幅,我就將這幾個受測內容合併到一個測試裡面了。大家寫工作代碼時一定要分開。而且,還是要嚴格按照我剛給出的“設置”、“調用”、“斷言”、“拆卸“這個步驟來,不能順序錯亂。

  以上這個測試用例可以讓我們瞭解到這個第三方庫的三個特性:是否接納有重複元素的列表;“大於等於指標值的部分”是否排列在“小於其值的部分”之前;在指標值恰好等於列表中某個元素的特殊情況下,還能否得到正確的結果。如果用例能夠通過,那麼滿足這三個特性的可能性就大大提高了。至於每個小區段內部的數據是否排列有序,這個只能通過查看源碼或者根據輸出值來統計分析了,很難通過單元測試反映。爲了下游使用者的方便,我們還需要將它封裝得更爲精緻一些,如果第三方庫有缺陷,比如不能正確處理含有重復元素的列表,不能正確處理邊界值,返回值不合要求等等,我們可以在封裝內部對其進行調整:

public int rearrange(List l, int pivot){
  int result=-1;
  try{
    result=partition(l,pivot);
  }catch(...){
    ... // 如果異常合乎業務邏輯,則將其翻譯爲本領域中的異常。
    ... // 如果異常是由於第三方庫的缺陷導致的,修正之。
  }
  ... // 如果第三方庫的返回結果有缺陷,修正之。
  return result;
}

7.註釋有時可充當提示語,在代碼審閱時能幫助糾正邏輯錯誤。

  例如:

public void display(List products) {
  products.sort(priceComparator);

  // 按照從低至高的順序顯示產品價格。
  for (Product item: products)
    print(item->price);
  ...
}

  如果有“好心人”將priceComparator實現爲逆序排列,那麼代碼出了Bug後,“按照從高至低的順序顯示產品價格。”這句註釋可以幫助代碼審閱者發現問題。

  當然啦,光靠註釋還不行,綜合我前面幾條說過的,可以在標識符命名上做做文章。我們應該在代碼中應該直接把priceComparator叫成AscPriceComparator,加了一個Asc前綴,這就明確了低價應該在前,從而能讓很多“聰明人”和“粗心人”少犯點錯誤(至於犯錯後的懲戒方法,請參考第4條)。

  要想儘可能地杜絕邏輯錯誤,最爲根本的解決方案,還是完備的測試用例。有了testDisplayEnsureCheapestProductShownFirst這樣的測試方法,能給咱們開發者省卻好多心力。

8.適當地對函數參數註釋,可以糾正第三方庫的不足。

  Python等語言支持按照參數名來調用函數,例如:

def Connect(timeout, use_encryption): ...

# 使用命名參數來呼叫函數。
Connect(timeout = 10, use_encryption = False)

  C++和Java等語言不行,不過可以使用行內參數註釋。例如:

public void connect(int timeout, bool useEncryption) { ... }

connect(/* timeout = */ 10, /* useEncryption= */ false);

  ARC作者強調的這一條,我有保留地同意。其實,有了完善的API文檔,再搭配IDE的鼠標懸停,完全可以彌補Java這樣不支持“按參數名調用”的語言所帶有的缺點。調用時之所以需要註釋,根本原因還是API的設計問題。好的函數或方法,在恰當名稱的引領下,其參數的個數與意義應當不言自明,配合重載與可變參數列表機制,應該能讓用戶不假思索地使用它。例如,繪制矩形的函數drawRect(),自然讓人想到需要4個參數,起點橫縱坐標與寬高。如果要按照對角線兩個端點進行繪製呢?提供一個drawRectByPoints()即可。用戶在開發環境中敲入drawRect這幾個字符之後,各種前導名稱相同的方法以及它們的重載版本就都會自動列出來了,旁邊還有說明文檔,我們可以在這些候選方法中選擇自己需要的來調用。

  所以,提供豐富的方法族(drawRect()、drawRectByPoints())與重載方法(drawRect(int, int, int, int)、drawRect(Point, int, int)、drawRect(Point, Size)),可以在很大程度上取代按參數調用。像剛纔的connect方法那樣,確實很難通過其他手段來揭示參數意義時,可以使用行內參數註釋。另外,這個小技巧,還能夠糾正第三方庫在參數命名方面的不足,因爲有時我們需要直接調用第三方庫,同時又不便修改其參數,只能通過行內註釋對它稍作說明

9.不要長篇累牘地註釋業已約定成俗的範式。直書範式名稱即可,必要時可輔以英文或參考網址。

  例如:

// 本類含有大量成員,其存儲的信息與數據庫中的相同。
// 存於此處是爲了快速訪問。此對象在接受查詢時,
// 先判斷所查數據是否存在,若是則返回;
// 否則將從數據庫中讀取其值並保存以備下次使用。

  不如直書:

// 該類充當數據庫的緩存層(caching layer)。

  代碼的讀者如果知道緩存層是什麼,那麼立刻就明白該類的用途了,要是不知道的話,也可以詢問他人或從網上得知caching layer的具體原理。

  同理,我們在編寫遊戲時,也要多用專有名詞來代替解釋,例如,不要詳細解釋卡馬克卷軸算法是怎麼起源的,有幾個變種,用多少個緩衝區,每個緩衝區多大,按照什麼順序繪製緩衝區,怎麼更新緩衝區來應對地圖移動等等等等……而是直接寫明:

/**
  * 使用卡馬克卷軸(Carmack Scroll)算法,
  * 參考網址:http://en.wikipedia.org/wiki/Adaptive_tile_refresh。
  */
public void draw(Graphics g) {...}

  專有名詞甚至可以直接納入標識符中,比如,可以直接刪去上例中的註釋,而把方法名改爲drawWithCarmackScroll。

  除了專有名詞外,也有很多詞彙用於總結這種約定成俗的範式,比如:試探法(heuristic)、蠻力法或暴力法(brute force)、笨辦法(naive solution)等。

  這篇文章看起來有點兒長,是要好好總結一下了。

  • 首先,在註釋中要使用精準簡潔的詞語,避免模糊或有歧義的表達。(第1、2、3條)
  • 然後,根據提煉之後的註釋,儘量將可能引發誤會的要素直接納入標識符中。確有必要時,可舉幾個能夠說明邊界狀況與特殊值的例子做註釋,以促進理解。(第4、5條)
  • 還要注意,好的註釋可以充當提示語,幫助我們在代碼評審中發現邏輯錯誤、澄清某些不易理解的參數、快速掌握代碼中所用的專有技術。(第7、8、9條)

  但是,最後小翔必須將自己的一點原創心得分享給大家:註釋所要闡明的問題,其根本解決方案還在於對“領域模型的準確把握”以及“對業務流程的完備測試”。有了對領域模型的準確把握,我們就可以將很大一部分問題融入標識符之中,使代碼閱讀者立刻就能抓住程序所要解決的核心問題,能夠流暢地讀完並理解全部代碼。我一直對朋友們說我想要像讀一本引人入勝的小說那樣閱讀一段“引人入勝”的代碼,說的就是上面這個意思。在另一方面,如果有了完備的測試用例,我們就能夠獲得它所帶來的原動力,讓它督促我們寫出可讀性好的高質量代碼來。畢竟,要靠註釋纔能夠闡明的那些個隱晦問題,其實在負責任的程序員看來,早就應該通過測試用例將其覆蓋了。完備的測試用例,能夠推動你去闡明那些你不願去面對,想要僥倖逃避的棘手問題。

  寫了這麼多年的程序,我後來纔明白一個道理,那就是領域模型和測試用例其實都是註釋,一個是思維型註釋,一個是代碼型註釋。它們兩個都有文字註釋所無法取代的重要職能。這三者並不矛盾,有了前兩者,再加上畫龍點睛式的註釋,這纔真正算得上高質量的代碼!看完了這兩篇講註釋的文章之後,我想請大家思考一下,那些想通過大段註釋來極力掩蓋的問題,是不是由於領域模型的抽象不準確或是測試用例的編寫不完備所導致的,同時,號稱從來不寫註釋的朋友們,你們是不是真的將那部分精力投入到業務模型的提煉以及測試用例的編寫上去了呢?寫出來的代碼有沒有經過反覆評審、多次重構,有沒有達到“代碼就是最好的註釋”這種境地呢?

  所謂“用心寫好註釋”,就是我們應該用正確的態度去編寫註釋,既不能拿它來掩蓋問題,又不能在需要寫它的時候找藉口逃避。用好的註釋來促進讀者對代碼的理解,用好的註釋來激發對代碼可讀性的提升,這,纔是它真正發光的地方。

  下面幾篇文章將會關注稍微高一級的程序組織單元,那就是循環與邏輯控制流。

愛飛翔
2012年8月3日至4日

歡迎轉載,請標明作者與原文網址。如需商用,請與本人聯繫。

原文網址:http://agilemobidev.com/eastarlee/code-quality/thinking_in_code_quality_6_write_elegant_comments_zh_tw/

廣告

發表迴響

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

WordPress.com 標誌

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

Google+ photo

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

Twitter picture

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

Facebook照片

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

連結到 %s