程式碼質量隨想錄(二)必也正名乎

愛飛翔發表於2012-06-05

  不必被我的標題嚇到哈,孔老夫子時代沒有電腦。如果有,估計諸子百家們還得針對軟體工程抒發一系列程式碼質量倫理學的教條。

  上回文章說到,程式碼品質改進應該在三個層面上展開,其中最微觀的就是程式碼段的質量考究了。很多時候我在針對一些專案做工程分析和大規模重構之前,首先希望對大概的工作原理有些瞭解,這個時候就要深入核心模組的檔案之中,挑選程式碼來閱讀,以求理順思路了。根據個人的經驗來說,微觀的改進往往能夠激發大規模的結構重組。所以一連幾篇文章,分別會談到“好名稱”、“好格式”、“好註釋”三個微觀的表層質量改進問題。

  深入到函式或方法內部的程式碼之後,就要面對一行行具體的程式碼了。此時最應該關注的首先就是識別符號的命名問題。這個問題基本上是講重構或程式碼質量的書所必談的話題之一。記得馬叔叔曾經在《Clean Code》中說,給識別符號起名時,應該像給你們家小朋友起名字一樣認真(大意,並非原文)。當時我看到此話不禁微笑了一下。是哇,很多時候我在程式碼評審中遇到的思維不順都是源於名字問題。

  一直以來,朋友和同事都偶爾會拿整個專案或是程式碼片段來和我討論,對於企業級開發領域,我看的程式碼不多,對程式碼質量不便妄言,不過具體到和我關係比較密切的移動開發領域,可就真的是令我非常頭疼了。由於移動軟體或遊戲的開發經常週期很短,而且重結果,輕過程,更不講求後續的版本更新、維護與複用。所以經常在開發過程中程式設計師容易在工期的壓力下過於隨心所欲,導致專案的程式碼理解起來大費周折。有時候我越是急於理解,就越是摸不著頭緒。後來想想,很多困難都源於具體的識別符號名稱。必須理解了它們,才有可能理解更高層級的內容。

  通過閱讀《The Art of Readable Code》以及其他相關的書,我漸漸把原來學到的一些程式碼質量知識總結起來了。ARC這本書的好處之一就是,它講的東西不見得多新,很多都是Clean Code或者類似的書中講了又講的話題,不過,它善於把這些零散的知識點按照一定的框架整合起來,讓我能夠更系統地歸納並鞏固這些知識。

  簡單的說,好的識別符號名稱,必須封裝恰當的資訊,同時不致誤解。

  至於如何封裝恰當的資訊,這個問題要看個人的把握,有幾條能夠作為指導的建議,不妨梳理給大家來看。

1.選擇更具表達力詞語

  我自己在程式碼中就經常忽視這一點,用慣了get和size之後,遇到什麼情況,不管具體細節,一律使用getXXX或size作為方法名稱。今天就看到了幾個反例。例如


class BinaryTree{

  public int size(){...}

}

  這個size到底獲取的是高度,節點數還是佔據的記憶體位元組數?這三種情況應該分別用更為特定的height、nodeCount或occupiedMemoryBytes來表示,而不是空泛的size。

  說到這個問題,我覺得增加個人的詞彙量是非常有好處的。可以經常翻看英英詞典來瞭解各個詞語之間的細微差別。例如用“deliver, dispatch, announce, distribute, route”(投遞、派發、播報、分配、按指定線路傳送,就是路由)之中的某個詞代替send(送),用“search, extract, locate, recover”(搜尋、提取、定位、重新找回)代替find(找)等等。

  有一個問題,就是命名含義豐富了會不會影響以後的修改。有同學可能會說,我故意放一個朦朧且曖昧的size來代替height、nodeCount或occupiedMemoryBytes,這樣將來萬一內部的邏輯有變化,我直接修改具體程式碼就行了,連size這個方法名都不用修改,豈不是更符合“針對介面而非實現來程式設計”的物件導向設計理論麼?一開始我也有這個想法,後來想想後果十分可怕,這樣做根本就沒有明確表述出該介面的具體意圖:一旦將表示height的size方法之中的演算法改為返回nodeCount,而保留size方法名不做修改,那麼這會害苦了該API的客戶程式碼編寫者們。你的同事仍然以為size返回的是二叉樹的高度,殊不知現在它返回的是節點數目了。一旦出現這樣的bug,除非兩人緊密配合,否則除錯很費時,而且隨著時間的推移更為難辦。反之如果方法名從height改為nodeCount,那麼下游開發者在原始碼管理系統中更新程式碼時立刻就看出其中的差別,從而能夠很從容地修改已有的邏輯,避免了頻繁除錯。總之,我同意ARC作者的看法:應該選擇更具表現力、含義更為豐富的詞語。

  當然,特定不等於標新立異或者聳人聽聞。友人goldlion曾經在學習NDK開發時被Android的詩意文件所苦。當時我看到“punch a hole”這個表述(參見這裡,類的概覽部分,第二段首句),就笑得三分鐘沒停下來,是有點可愛。文件可愛一點還好,如果具體的函式就麻煩了,比如ARC作者所提到的PHP的explode()函式。初看莫名其妙,定神想了想才明白可能是用於打散字串用的。如果溫柔一點兒,應該叫做split或者delimit。而且更有趣的則是新支援的第三個引數。


array explode ( string $delimiter , string $string [, int $limit ] )

  這個引數如果取負值,則最後的-limit組小字串會被丟棄,例如


explode('|', 'one|two|three|four', -1)

  只會返回“one、two、three”三個子串所合成的陣列。這種一魚兩吃的豪爽頗有古典程式設計師的遺風。不過我還是建議在工作程式碼中將這種特定的處理命名為splitButLast(char delimiter, String str, int thrownCount)更清爽,這樣一來寫的人和看的人都不累。

2.避免空泛的名稱

  tmp(temp)和retVal(returnValue、result)是十大空泛名稱排行榜上的前兩名(其餘請讀者補充)


public double euclideanNorm(int values){
  double result=0.0;
  for(int i=0,count<values.length;i<count;i++)
    result+=values[i]*values[i];
  }
  return Math.sqrt(result);
}

  這種命名不當我也常犯,第一句不假思索就用result了。上述程式碼的result應該被squareSum代替,這樣一旦將for迴圈中的程式碼誤寫為squareSum+=values[i](忘記求平方了,直接加),立刻就能看出錯誤來。因為sum前面的square已經明示了+=運算子後面必須是平方形式。

  temp這種名字也不是不能用。如果某個變數唯一存在目的就是交換資料的暫存空間,那麼也很貼切。


if (right < left) {
  temp = right;
  right = left;
  left = temp;
}

  反之如果是


String temp = user.name();
temp += " " + user.phoneNumber();
temp += " " + user.email();
...
template.set("user_info", temp);

  那麼以上程式碼的temp就明顯是userInfo的偷懶寫法了,必須糾正。

  有時可以使用temp修飾另一箇中心詞,將此偏正短語作為識別符號,倒也恰當,比如:


tempFile = namedTemporaryFile();
...
saveData(tempFile, ...);

  temp修飾了File,如果僅用saveData(temp, ...),人們要去猜temp到底是臨時檔案本身,還是臨時檔名,又或是被寫入的臨時資料?

  在迴圈語句所使用的迭代變數中,尤其要注意命名問題。空泛的i、j、k有時合適,有時則不行。尤其是會導致下標錯亂的情況下,更要注意迴圈變數的起名。例如:


for (int i = 0; i < clubs.size(); i++)
  for (int j = 0; j < clubs[i].members.size(); j++)
    for (int k = 0; k < users.size(); k++)
      if (clubs[i].members[k] == users[j])
        System.out.println("user[" + j + "] is in club[" + i + "]");

  很難注意到其中的bug,如果寫成


if (clubs[ci].members[ui] == users[mi])

  一下子就看到問題所在了。members陣列的下標居然是ui(user index),users的下標居然是mi(member index),很明顯,這兩個寫反了。

3.名稱對內容的描述要具體而準確

  比如經常會定義如下的巨集來防止生成預設的拷貝構造器與複製操作符。


#define DISALLOW_EVIL_CONSTRUCTORS(ClassName) \
ClassName(const ClassName&); \
void operator=(const ClassName&);

  這個evil constructors就太過感情化,不具體(怎麼evil了?),而且不甚準確(operator=並不是一個構建子)。所以莫如更為精確的好:


#define DISALLOW_COPY_AND_ASSIGN(ClassName) ...

  上文一望即知:禁止提供拷貝構造器和賦值操作符。

  正交性也是考量準確度的一個標準。比如在設計引數選項時,經常會犯這樣的錯誤:有時候我們開發的某個手機程式需要列印除錯資訊到手機螢幕,同時需要遮蔽內嵌的程式廣告,有些小朋友以為,開發的時候總是用模擬器來執行程式,所以就把這兩個功能強行塞入一個對應的選項中,並命名為on-emulator。這樣的話有時候需要在真機上執行程式,而且要看除錯資訊,那麼不得不把on-emulator選項設定為true。這看起來很容易造成誤解,而且一旦這樣設計,如果在真機上即要列印除錯資訊,同時還要顯示內嵌廣告,那麼on-emulator便怎麼設定都不對了。所以常犯的錯誤就是:根據表面現象,將兩個毫不相關或可以各自獨立存在的功能強行塞入一個選項中,既造成了誤解,又喪失了使用的靈活度。上述這種情況莫如分別設計成print-debug-on-screen和show-ads比較好。

4.將重要資訊納入名稱中

  如果某個附加資訊,程式碼使用者非得知道它,才能正確地使用程式碼的話,那它就得被納入識別符號的名稱當中了。比如:


String id; // 使用範例: "af84ef845cd8"

  如果id一定要用十六進位制字串,否則後續程式無法正常執行的話,那麼這個資訊必須讓大家知道。所以最好將程式碼改成:


String hexID;

  這樣的話,大家看到了hex字首,都會明白程式碼作者的本意:非使用十六進位制字串不可。

  除了進位制資訊,計量的單位也應該被納入命名之中。   例如:


long start = (new Date()).getTime(); 
...
long elapsed = (new Date()).getTime() - start;
System.out.println("Load time was: " + elapsed + " seconds");

  上面這段程式碼很容易出錯,因為elapsed並沒有指明計時單位,是微秒?毫秒?秒?還是分鐘?小時?如果加上了計量單位:


long startMs = (new Date()).getTime(); 
...
long elapsedMs = (new Date()).getTime() - start;
System.out.println("Load time was: " + elapsedMs/1000 + " seconds");

  這樣的程式碼一目瞭然。而且有了錯誤也非常好查詢。萬一把“elapsedMs/1000”錯寫成“elapsedMs”,那麼一眼就能看到:明明後面是“seconds”,前面卻是“Ms”,單位明顯不統一,當即知道漏掉了“/1000”。

  根據以上這個例子,我們建議將左邊的引數改為右邊的式樣:


public void start(int delay ){...}; //delay改為delaySecs
public void createCache(int size){...}; //size改為sizeMB
public void throttleDownload(float limit){...}; //limit 改為maxKBPS
public void rotate(float angle){...}; //angle改為degreesClockwise

  上面之中的第4條最為嚴重。angle既沒說是角度還是弧度,又沒說是順時針還是逆時針,如果不配合詳細的Javadoc說明文件,很難一眼讀透該方法所要表達的意思。

  除了計量單位之外,其餘程式碼讀者或程式碼使用者必須注意的資訊也要納入命名之中。這樣以後該部分若有變動,可以在重構時及時更動變數名及使用它的其他語句,以維護程式碼語義的一致性。例如:明文密碼應該叫plaintextPassword,以提醒使用者加密後方可使用,不宜直接叫做password。以後如果決定將初始的程式碼由明文變為已經加密好的,那麼只需要使用開發環境的重構功能將plaintextPassword變為encryptedPassword即可,然後藉助開發工具找出所有使用encryptedPassword的地方,一一對照,如有邏輯不符,即行修改——這樣就維護了程式碼邏輯的一致性,不會因為是否加密而導致bug或程式行為改變。同理,使用者提供的註釋裡面可能包含需要進行轉義處理的字元,此時應叫unescapedComment而非comment;已經轉換為UTF-8格式的html位元組序應叫htmlUTF8而非html;經由URL編碼形式傳入的資料應叫dataURLEnc而非data。

  很久以前,我也是一名Win32的API研究愛好者,當然忘不了匈牙利命名法了,那麼“將重要資訊納入名稱中“與”匈牙利命名法“有何區別呢?(這裡主要講的是系統匈牙利命名法,另外一種叫匈牙利應用命名法——感謝網友李先生在原文樓下評論中指出此問題)它們的區別是,後者是一套正規的強制規範,納入名稱中的一般是指標(p)、對映表(m)、零終結字串(sz)、計數(c)等特定屬性,而前者則無此強制屬性規定,凡對使用者重要的屬性均可納入。可以仿稱其為“要素命名法”("Essential Factor Notation")。(ARC的作者用“English Notation”來命名它,小翔覺之不確)

5.識別符號的長短應符合其作用域的大小。


if (debug) {
  Map<String, int> m=...;
  ...
  print(m);
}

  變數m的作用域很小,所以短命稱不會帶來問題。但是如果是在一個很大的作用域中,比如有上千行程式碼的類中:


public class PhoneBook{
  private Map<String, int> m=...;
  ... //幾千行程式碼之後
  public void someFun(){
    ...
    print(m); // m是啥咪東東呀?
    ...
  }
  ... //還有數千行程式碼
}

  那麼m這樣的短名顯然不太合適。現在的編輯環境一般都有自動補完功能,按下某個組合鍵就好了,比如常見的幾種編輯器:


編輯器/開發環境自動補完快捷鍵
ViCtrl-p
EmacsMeta-/
EclipseAlt-/
IntelliJ IDEAAlt-/
TextMateESC


  我常用的是eclipse,其餘的歡迎大家補充。

  當然啦,將不必要的詞彙省略是好的。例如convertToString()簡稱toString(),doServerLoop()簡稱serverLoop()。翔以為主要是將不言自明的動詞(比如convert,do等)省去。

6.使用格式來傳達資訊

  使用特殊的符號來表示特殊的物件,同其他普通物件區隔開來。例如在JavaScript中,用$為字首來表示經由jQuery的$("...")選擇子而選中的一系列具有某名稱的DOM節點。(小翔對JS不是很熟悉,因為日常工作是單機的手機應用/遊戲開發。目前正在學習中,這部分程式碼有錯誤還望朋友們賜教)


var $all_images = $("img"); // $all_images是jQuery物件
var height = 250;//而height則是普通變數

  每種特殊識別符號都用一套特殊命名法來區隔。例如HTML/CSS中,id與class都是特殊屬性,所以分別採用下劃線與連字元來命名這兩種識別符號。(再次捂臉:HTML/CSS苦手飄過,仍然是在努力學習這項技術之中)例如:


<div id="middle_column" class="main-content"> ...

  嗯,寫了這麼多,休息一下吧。輕鬆地總結一下啦:

  ”以語句行為單位的微觀程式碼管控如何入手呢?”“必也正名乎!”——將資訊納入名稱,使讀者通過名字就能領會到其中的含義。

  特定技巧:

  1. 使用更具表達力詞語:例如以在BinaryTree類的設計中以height或nodeCount代替size。
  2. 避免空泛名稱:tmp、retval、i、j、k等,除非確有必要,否則不用。
  3. 使用具體而準確的名稱:描述更多細節的CanListenPort()優於ServerCanStart()。
  4. 附加重要屬性:將Ms綴於以毫秒計時的值名稱之後,將Raw綴於未經處理的資料名稱之前。
  5. 大作用域用長名:不要把一兩個字元的名稱用在一大段程式碼中,短的程式碼可以有短名。
  6. 特殊名稱用特殊格式:類成員可以_結尾,以與區域性變數相區隔。$符號、大寫或下劃線等特殊格式可以區隔特殊的名稱。

  嗯,這篇文章寫了好幾個小時,休息一下。正名大業分為上下兩部分,這一篇主要是從正面給大家總結一些識別符號命名的建議,下一篇則將從反面講解何種名稱會給人帶來誤解。

愛飛翔 2012年6月1日至2日

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

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

相關文章