程式碼質量隨想錄(四)排版,不只是為了漂亮

愛飛翔發表於2012-06-07

  寫了前三篇之後,發現比我預想的效果要好。關注程式碼質量的朋友還蠻多的,而且很多意見和建議也很有益,指出了我文章中的一些問題。

  我這種家庭婦男型的自由職業者來說,在平常寫程式碼的時候可以多停下來,思考一些程式碼質量與軟體設計方面的問題。當然啦,由於具體的工作環境、關注領域、自身閱歷等原因,小翔在文中提出的許多觀點難免書生之見,請諸位多包涵。

  針對排版這個問題,不同的公司、團隊都有自己的一套方案,有時網路上也能下載到很多大型的權威程式碼規範,其中亦含有程式排版相關的規則,我也經常與眾友人一起討論某個專案所用的排版約定。在看到《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中已經可以通過類結構導覽檢視來顯示類程式碼中的各種成員,那麼為何還要如此在乎程式碼版式呢?因為不管具體程式碼怎麼排列,檢視中都可以調整顯示順序呀。對於這個問題,我想有時我們不僅僅要通過導覽檢視來看巨集觀結構,還需要進行微觀的具體程式碼審讀與維護,所以微觀程式碼的排列終究還是為了易讀。當然啦,排列方法可以商量,比如你可以說不必按照“靜態、動態”那樣分,而是按照“內部類、變數、方法”這樣來分。

  從下午開始寫,斷斷續續到了深夜,微笑地瀏覽了一遍之後,頓時覺得這一篇文章講的話題有點兒文藝了。嗯,接下來,將和大家聊聊程式碼註釋。

愛飛翔

2012年6月6日至7日

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

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

相關文章