程式碼質量隨想錄(六)用心寫好註釋

愛飛翔發表於2012-08-04

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

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

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

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

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

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

  比如:

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


  上一段程式碼的註釋太長了,不如直接以對映關係來解釋它:

// CategoryType -> (score, weight)
typedef hash_map<int, pair<float, float> > 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) { ... }


  那麼,"hello\n\r cruel\n world\r"到底是幾行?如果只考慮'\n',就是3行;如果只考慮"\n\r",就是兩行。還有,如果檔案內容是"hello\n",那麼考慮'\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<int> l, int pivot){...}


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

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


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

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

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

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

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

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

/* 依照pivot將l劃為兩個區段。 */
public static int partition(List<int> 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<int> data=createRandomList(listSize, duplicatedElementsRate);
  int pivot=getRandomElement(l);

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

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

  // teardown
  ...
}


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

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

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


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

  例如:

public void display(List<Product> 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_cn/

相關文章