代碼質量隨想錄(二):必也正名乎
不必被我的標題嚇到哈,孔老夫子時代沒有電腦。如果有,估計諸子百家們還得針對軟件工程抒發一系列代碼質量倫理學的教條。
上回文章說到,代碼品質改進應該在三個層面上展開,其中最微觀的就是代碼段的質量考究了。很多時候我在針對一些項目做工程分析和大規模重構之前,首先希望對大概的工作原理有些了解,這個時候就要深入核心模塊的文件之中,挑選代碼來閱讀,以求理順思路了。根據個人的經驗來說,微觀的改進往往能夠激發大規模的結構重組。所以一連幾篇文章,分別會談到“好名稱”、“好格式”、“好注釋”三個微觀的表層質量改進問題。
深入到函數或方法內部的代碼之后,就要面對一行行具體的代碼了。此時最應該關注的首先就是標識符的命名問題。這個問題基本上是講重構或代碼質量的書所必談的話題之一。記得馬叔叔曾經在《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這樣的短名顯然不太合適。現在的編輯環境一般都有自動補完功能,按下某個組合鍵就好了,比如常見的幾種編輯器:
編輯器/開發環境 | 自動補完快捷鍵 |
---|---|
Vi | Ctrl-p |
Emacs | Meta-/ |
Eclipse | Alt-/ |
IntelliJ IDEA | Alt-/ |
TextMate | ESC |
我常用的是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"> ...
嗯,寫了這么多,休息一下吧。輕松地總結一下啦:
”以語句行為單位的微觀代碼管控如何入手呢?”“必也正名乎!”——將信息納入名稱,使讀者通過名字就能領會到其中的含義。
特定技巧:
- 使用更具表達力詞語:例如以在BinaryTree類的設計中以height或nodeCount代替size。
- 避免空泛名稱:tmp、retval、i、j、k等,除非確有必要,否則不用。
- 使用具體而準確的名稱:描述更多細節的CanListenPort()優于ServerCanStart()。
- 附加重要屬性:將Ms綴于以毫秒計時的值名稱之后,將Raw綴于未經處理的數據名稱之前。
- 大作用域用長名:不要把一兩個字符的名稱用在一大段代碼中,短的代碼可以有短名。
- 特殊名稱用特殊格式:類成員可以_結尾,以與局部變量相區隔。$符號、大寫或下劃線等特殊格式可以區隔特殊的名稱。
嗯,這篇文章寫了好幾個小時,休息一下。正名大業分為上下兩部分,這一篇主要是從正面給大家總結一些標識符命名的建議,下一篇則將從反面講解何種名稱會給人帶來誤解。