代碼質量隨想錄(四):排版,不只是為了漂亮
寫了前三篇(一、二、三)之后,發現比我預想的效果要好。關注代碼質量的朋友還蠻多的,而且很多意見和建議也很有益,指出了我文章中的一些問題。
我這種家庭婦男型的自由職業者來說,在平常寫代碼的時候可以多停下來,思考一些代碼質量與軟件設計方面的問題。當然啦,由于具體的工作環境、關注領域、自身閱歷等原因,小翔在文中提出的許多觀點難免書生之見,請諸位多包涵。
針對排版這個問題,不同的公司、團隊都有自己的一套方案,有時網絡上也能下載到很多大型的權威代碼規范,其中亦含有程序排版相關的規則,我也經常與眾友人一起討論某個項目所用的排版約定。在看到《The Art of Readable Code》一書中有關此話題的章節時,我的感覺是,很難總結出一套萬用的“宇宙排版律”來,多半要根據自身環境、團隊和項目的特點來擬定,所給出的建議僅僅是參考,并不能強行照搬。
1. 功能相似的代碼,版式也應相似
public class PerformanceTester { public static final TcpConnectionSimulator wifi = new TcpConnectionSimulator( 500 /* 以Kbps計量的吞吐量 */, 80 /* 以毫秒計的網絡延遲 */, 200 /* 包抖動 */, 1 /* 丟包百分比 */); public static final TcpConnectionSimulator t3Fiber = new TcpConnectionSimulator( 45000 /* 以Kbps計量的吞吐量 */, 10 /* 以毫秒計的網絡延遲 */, 0 /* 包抖動 */, 0 /* 丟包百分比 */); public static final TcpConnectionSimulator cell = new TcpConnectionSimulator( 100 /* 以Kbps計量的吞吐量 */, 400 /* 以毫秒計的網絡延遲 */, 250 /* 包抖動 */, 5 /* 丟包百分比 */); }
上面這個例子是ARC書中所舉的,我認為很恰當。該類的三個靜態字段功能類似,都指代某種環境下的網絡模擬器,所以排版也應該相似。每行都只寫一個實參,而且后面用行內注釋的形式解釋該實參的意思。在垂直方向上的對齊做得也很好:字段申明前面空2格,實例化語句前面空4格,各實參前面空6格(以上數字非實指,僅是舉例而已)。這樣要修改某個參數,很快就能定位到它,而且以后如果增加類似的字段,如badWIFI,也可以比照這個格式來,便于維護。
由以上范例還可引出一個問題,那就是在實例化或方法調用中,經常會遇到一些孤立的魔法數字(magic number),如果確有必要為它起名,那么不妨執行一個小的重構,以常量來代替它。反之,如果是大段的硬數值,則不一定非要為每個值都起一個名字,例如:
TcpConnectionSimulator wifi = new TcpConnectionSimulator( WIFI_KBPS_THROUGHPUT, WIFI_LATENCY, WIFI_JITTER, WIFI_PACKET_LOSS_PERCENT);
這樣反而顯得累贅。不妨像上例那樣采用行內注釋的辦法來解釋這些硬值的意思。
承上,ARC的作者又推導出一條建議,就是將相似的方法調用參數注釋提取到一處,例如:
public class PerformanceTester { // TcpConnectionSimulator(throughput, latency, jitter, packet_loss) // [Kbps] [ms] [ms] [percent] public static final TcpConnectionSimulator wifi = new TcpConnectionSimulator(500, 80, 200, 1); public static final TcpConnectionSimulator t3Fiber = new TcpConnectionSimulator(45000, 10, 0, 0); public static final TcpConnectionSimulator cell = new TcpConnectionSimulator(100, 400, 250, 5); }
說實在的,以前在工作中還沒太重視這個問題,一來是覺得我在寫Javadoc時一貫非常完備,出現這種情況時只需靠鼠標懸停就可知道某個方法或構造器的具體信息了;二來嘛,也是想著如果使用大量數值的調用代碼多到無法管控,我可能會祭出配置文件這個大旗來,將它們全部納入配置中了事。所以關于以上例子中談到的這些問題,我覺得還是根據大家的具體實踐來理解為好,不要機械地尋求一致。
2. 將大量相似的嵌套式、接續式調用邏輯整合到共用方法之中,即利于排版,又可凸顯重要數據
在測試用例等代碼中,經常會出現類似下面這種狀況:
// 某受測類中: // 將類似"Doug Adams"這樣的不完整稱呼進行補全,擴展為"Mr. Douglas Adams"的形式。 // 如若不能(查不到數據或無法補完),則于error參數中填充錯誤信息并返回空串。 // 此方法會置空錯誤信息接收參數。 public String expandToFullName(DatabaseConnection conn, String partialName, ErrorMessageReceiver error){...} // 某測試方法中: DatabaseConnection connection=...; ErrorMessageReceiver error=...; assertEquals(expandToFullName(connection,"Doug Adams" ,error) , "Mr. Douglas Adams"); assertEquals(error.getMessage() , ""); assertEquals(expandToFullName(connection,"Jake Brown" ,error) , "Mr. Jacob Brown III"); assertEquals(error.getMessage() , ""); assertEquals(expandToFullName(connection,"No Such Guy“,error) , ""); assertEquals(error.getMessage() , "no match found"); assertEquals(expandToFullName(connection,"John“, error) , ""); assertEquals(error.getMessage() , "more than one result");
這符合上面所說的“量大”、“形似”、“嵌套”等特征,而且諸如輸入字串、預期結果、預期錯誤消息等重要的數據,被埋沒于connection、error、getMessage()等技術細節之中。所以可以借由美化版式之機進行重構:
checkPartialToFull("Doug Adams" , "Mr. Douglas Adams" , ""); checkPartialToFull("Jake Brown" , "Mr. Jake Brown III", ""); checkPartialToFull("No Such Guy", "" , "no match found"); checkPartialToFull("John" , "" , "more than one result"); private void checkPartialToFull(String partialName, String expectedFullName, String expectedErrorMessage) { // connection已被提取為測試固件類的成員變量 ErrorMessageReceiver error=...; String actualFullName = expandToFullName(connection, partialName, error); assertEquals(expectedErrorMessage, error.getMessage()); assertEquals(expectedFullName , actualFullName); }
如此一來一舉三得:既消除了重復代碼,同時美化了版式,凸顯了輸入字串、預期結果、預期錯誤消息等重要數據,順帶著還方便了后續測試數據的維護。這種藉由版式整理帶來的重構,我看可以有!
3. 明智地使用縱向對齊來減少拼寫錯誤、厘清大量同組數據。
我覺得這一條和第1條有重復,其實也屬于類似功能的代碼應具類似版式之意,不過既然ARC作者將它單列,我想可能是為了強調縱向對齊的好處吧。
// 將POST參數中的屬性分別提取至各個局部變量中 ServletRequest request=...; String details = request.getParameter("details"); String location = request.getParameter("location"); String hone = request.getParameter("phon"); String email = request.getParameter("email"); String url = request.getParameter("url");
經由縱向對齊,很容易看出第三個局部變量這行的錯誤:將變量名“phone”誤寫為“hone”,參數名的“phone”則錯成了”phon“。
另外,在進行結構體數據、數組成員等這種同組數據排列時,也可以充分利用版式來厘清每個元素的意義。ARC的作者就大贊wget這個命令行工具在指定參數結構體時,代碼排列地很工整。
// 非原文,小翔以Java形式改寫 Object[][] commands = { //參數名 , 默認值 , 類型 { "timeout", null, TIMEOUT }, { "timestamping", defOpt.timestamp, BOOLEAN }, { "tries", defOpt.tryCount, NUMBER }, { "useproxy", defOpt.useProxy, BOOLEAN }, { "useragent", null, USER_AGENT } };
這一條建議如果與第1條合并起來說,那就是:任務相似的代碼塊應該具有相似的輪廓(ARC的作者叫它silhouette),如行數、縮進、縱向對齊等。
4. 使用適當空行與注釋,將代碼按功能分段
有時候經常在考慮代碼與散文或詩的聯系,如果從隱喻(metaphor)的觀點來看,的確有相似性:都是信息的載體,都可以用一定的段落來整合文意。要說區別嘛,前者服務于軟件需求,后者服務于社會關系。前者為了向更低階的執行機制去接合,所以更加注重語法格式。我可不是第一個進行這種思維比擬的人,記得臺灣的技術暢銷書作者侯捷先生(侯俊杰)就曾寫過一本《左手程序右手詩》的書。
class FrontendServer { public: FrontendServer(); void ViewProfile(HttpRequest* request); void OpenDatabase(string location, string user); void SaveProfile(HttpRequest* request); string ExtractQueryParam(HttpRequest* request, string param); void ReplyOK(HttpRequest* request, string html); void FindFriends(HttpRequest* request); void ReplyNotFound(HttpRequest* request, string error); void CloseDatabase(string location); ~FrontendServer(); };
上面的代碼挺蝸居的,如果加上適當的空行與說明,就顯得清晰多了。
class FrontendServer { public: FrontendServer(); ~FrontendServer(); // 與用戶配置相關的處理函數 void ViewProfile(HttpRequest* request); void SaveProfile(HttpRequest* request); void FindFriends(HttpRequest* request); // 回覆及應答工具函數 string ExtractQueryParam(HttpRequest* request, string param); void ReplyOK(HttpRequest* request, string html); void ReplyNotFound(HttpRequest* request, string error); // 數據庫操作工具函數 void OpenDatabase(string location, string user); void CloseDatabase(string location); };
上述類將聲明區按照構建子/析構子、社交功能函數、工具函數這個標準劃分為的三大思維區段,工具函數區又按題材劃分為消息操作與數據庫操作兩小段。這樣一來,以后再要維護這份聲明代碼就會很清爽了。同理,如果聲明一個集合類的接口,也應該按照“增、刪、改、查”等概念來將API劃分為若干小組,以便幫助代碼閱讀者理順思路。
就算是在流水式的業務代碼中,也可以用段落來襯托出邏輯的“起、承、轉、合”。
// 導入用戶電子郵件賬戶中聯系人,同本產品中已有的聯系人相比對。 // 然后展示正在使用本產品但未與用戶建立朋友關系的聯絡人列表。 public ListDataModel suggestNewFriends(User user,Password emailPassword){ SocialCircle friends = user.friends(); Emails friendEmails = friend.dumpAllEmails(); Contacts contacts = importContacts(user.email, emailPassword); Emails contactEmails = contacts.extractAllEmails(); Emails productUserEmails = UserDataCenter.selectEmails(contactEmails); Emails suggestedFriends = productUserEmails.subtract(friendEmails); ListDataModel displayModel = new ListDataModel(user,friends,suggestedFriends); return displayModel; }
上面的代碼給人的壓迫感很強列,沒有思維喘息的機會。不如把注釋拆解,按其邏輯將代碼分成小段,為每一段冠以簡短標題。
public ListDataModel suggestNewFriends(User user,Password emailPassword){ // 取得當前用戶全部朋友的郵件地址 SocialCircle friends = user.friends(); Emails friendEmails = friend.dumpAllEmails(); // 引入當前用戶電子郵件賬戶中的聯系人 Contacts contacts = importContacts(user.email, emailPassword); Emails contactEmails = contacts.extractAllEmails(); // 找出正在使用本產品但尚未與本用戶建立朋友關系的聯系人 Emails productUserEmails = UserDataCenter.selectEmails(contactEmails); Emails suggestedFriends = productUserEmails.subtract(friendEmails); // 返回待顯示列表的數據模型 ListDataModel displayModel = new ListDataModel(user,friends,suggestedFriends); return displayModel; }
欣賞一下上面這段代碼吧,每小段以一句概括性的注釋引領,然后是兩句實現代碼,排列得非常整齊,代碼的閱讀者根據此的版式,很容易就能抓住代碼的思維走向:“2(獲取朋友列表)-2(獲取聯系人郵箱)-2(找出潛在友人)-2(返回數據模型)”。怎么樣,是不是有點兒“起、承、轉、合”的意思了?照這樣寫下去,可以山寨一個小的Google+了吧?我這個SocialCircle類比谷加的還厲害,它那個只能是同級別的平行關系,我這個還能像組合體模式那樣,互相嵌套呢!(大誤)
由上例可見,適當地進行代碼分段并通過注釋來充當代碼段的概括語,有助于梳理代碼閱讀者的思路,也有助于代碼修改、維護和后續查錯。比如想做一個“向未使用本社交網站的電郵聯絡人發送邀請”的功能,掃一眼上述這段清晰排版的代碼,大家立刻就能看出,只需要寫好測試用例,復制一份suggestNewFriends的代碼,把selectEmails改成excludeEmails,就能找到這些潛在的被邀請人了。給新的方法起個名字,叫inviteContacts,刪去多余的程序,然后通過重構提取一下共用代碼,再確保測試無誤,就可以收工了。思路順了,編碼的過程自然也就更加流暢了。
好了,小小總結一下吧。其實代碼排版這種略帶個人化的東西,不僅僅是讓代碼看起來更漂亮,其根本目的還是著眼于代碼的可讀性,要有助于代碼的理解、維護、糾錯。具體到執行層面,除了可以參考上述4條建議外,還要注意兩方面的問題。
第一個問題,ARC的作者也提到了,那就是很多朋友對代碼排版有排斥心理,不愿意認真排版。有一部分原因是怕浪費時間,還有就是擔心代碼管理系統會將排版后的代碼與排版之前的代碼判定為兩份截然不同的程序,在版本比對時導致滿屏的diff,非常難看。其實,在現有的成熟IDE之中(抑或各位Geek們慣用的文本編輯器之中)已經有非常完備的功能來支援代碼版式的調整了。比如Eclipse、Netbeans等開發環境,都可以把版式定義文件導出為xml等數據格式,到了陌生的環境時,只需導入即可。而且代碼排版一旦確定,就可以一次性地更改所有項目源碼的版式然后提交,這樣就可以避免在版本比對時顯示過多的修改提示了。
第二個問題就是應該在必要的范圍內保持代碼排版的一致性。雖然我剛也說了,代碼排版沒有絕對的真理,不過,它卻應該有一個相對的底線。在公司與公司之間、團隊與團隊之間,的確沒有必要強行要求一致的版式。例如我們不宜妄自菲薄,說I記或G社的代碼排得如何如何漂亮,同時也不能過分地自高自大,說自己團隊的版式是天下最美觀、最養眼的。但是,如果具體到某個項目,尤其是中小型項目里面,那么就要想方設法達成一致的版式規范了,否則將會給代碼的閱讀、理解與維護造成不必要的障礙。為此,項目組的成員應該富有的妥協精神,在堅持個人風格這個問題上稍作讓步,以求達成大家對代碼版式的共識。比如,小翔在個人項目或由我帶隊的項目中,通常使用以下版式:
public class MyArrayList extends MyAbstractList implements MyCollection{ // 靜態部分在前: // 靜態內部類型區。同區成員按存取級別排序,高者在前。 /** * 列表容量參數。 */ public static class CapacityOptions{ /** 初始容量。 */ private final int initialElementCount; /** 擴容時新增的容量與擴容前容量之比。 */ private final int expandRatio; } ... // 靜態初始化塊與靜態字段區。 private static final Map<String, CapacityOptions> commonCapacityOptions=...; ... static{ commonCapacityOptions.put("normal" , new CapacityOptions(12,1)); ...; } ... // 靜態方法區。 /** * 從既有數組中構建列表。 * @param elements 用以構建的數組,不能為null。 * @return 構建好的列表 */ public static List create(Object[] elements){ ...; } ... // 動態部分在后: // 動態內部類型區。 public class MyIterator{ public Object next(){ ...; } } // 動態初始化塊與實例成員變量區。 { ...; } private int count; ... // 構造器區。 public MyList(){ ...; } ... // 實例方法區。 // 先寫本類方法。 void expand(){ ...; } // 其次,從直接超類開始,層層追溯至Object,將各層級上的覆寫方法列出。 @Override public boolean add(Object e){ ...; } // 然后按由進至遠的順序,實現接口中的方法。 // 同等層級的接口,按其出現在implements、extends子句中的先后順序,依次實現其方法。 @Override public void clear(){ ...; } // 最后覆寫Object類中的方法。 @Override public String toString(){ ...; } //準析構方法區。 protected void finalize() throws Throwable{ ...; } }
上述這個“3+5式分段法”(靜態部分:靜態內部類型、靜態初始化塊與字段、靜態方法;動態部分:動態內部類性、動態初始化塊與實例成員變量、構造器、實例方法、準析構方法),小翔在六年多的工作中一直用著,我覺得它對于代碼閱讀來說,還算滿流暢的,在此也分享給大家。不過,如果現有項目大多數成員要求將左花括號放于新行之首,并要求動態部分出現在靜態部分的前邊,那么我就會在這個項目中按照大家喜歡的格式來辦(并不是隨意放棄自己認為合理的版式,而是在某個項目的具體語境下為了求得共識而妥協),同理,類似新行符是\n、\r還是\n\r,空白符是空格還是制表符之類問題,我覺得只要大家認為合適,就沒有必要過分爭執,定出一個項目內部易于統一管理的規范就好。
再多說一句吧,有同學可能會問,既然Eclipse等IDE中已經可以通過類結構導覽視圖來顯示類代碼中的各種成員,那么為何還要如此在乎代碼版式呢?因為不管具體代碼怎么排列,視圖中都可以調整顯示順序呀。對于這個問題,我想有時我們不僅僅要通過導覽視圖來看宏觀結構,還需要進行微觀的具體代碼審讀與維護,所以微觀代碼的排列終究還是為了易讀。當然啦,排列方法可以商量,比如你可以說不必按照“靜態、動態”那樣分,而是按照“內部類、變量、方法”這樣來分。
從下午開始寫,斷斷續續到了深夜,微笑地瀏覽了一遍之后,頓時覺得這一篇文章講的話題有點兒文藝了。嗯,接下來,將和大家聊聊代碼注釋。