程序命名的原則與重構

命名是對事物本質的一種認知探索,是給讀者一份寶貴的承諾。糟糕的命名會像迷霧,引領讀者走進深淵;而好的命名會像燈塔,照亮讀者前進的路。命名如此美妙,本文將一步步揭開它的神祕面紗!

命名來源生活

從左到右:正三角形,正方形、正六邊形  正表示邊長相等,從而得到正 XXX 的邊長一定是相等的。

這些事物的特徵比較明顯,容易被人們所記憶。但是也有一些比較難以命名,比如化學有機物:

相比於化學有機物,軟件世界的事物更加繁多,命名將更加的困難。

Phil Karlton (菲兒 · 卡爾頓) 曾說過:在計算機科學中只有兩件困難的事情:緩存失效和命名規範。

命名一直是軟件領域的難題,好的命名能夠信達雅:

譯事三難:信、達、雅。——嚴復

信:含義準確
達:通順流暢
雅:簡明優雅

命名的壞味道

查看下面代碼,說出其含義:

public List<int[]> getThen(){
    List<int[]> list1 = new ArrayList<int[]>();
    for (int[] x: theList)
      if (x[0] == 4)
        list1.add(x);
    return list1;
}

問題不在於代碼的簡潔度,而是在於代碼的模糊度:即上下文在代碼中未被明確體現的程度。

  1. theList  中是什麼類型的東西?

  2. theList 零下表條目的意義是什麼?

  3. 值 4 的意義是什麼?

  4. 我如何使用返回的列表

問題的答案沒體現在代碼段中,可那就是它們該在的地方。

再看看重命名後的代碼:

public List<int[]> getFlaggedCells(){
    List<int[]> flaggedCells = new ArrayList<int[]>();
    for (int[] cell: gameBoard)
      if (cell[STATUS_VALUE] == FLAGGED)
         flaggedCells.add(cell);
    return flaggedCells;
}

注意,代碼的簡潔性並未觸及。運算符和常量的數量全然保持不變,嵌套數量也全然保持不變。但代碼變得明確多了。

站在使用者的角度,還可以更近一步嗎?

雖然 getFlaggedCells 一名錶明方法會返回 FlaggedCells,但是返回的數據結構並未表達出來:

public List<Cell> getFlaggedCells(){
    List<Cell> flaggedCells = new ArrayList<Cell>();
    for (Cell cell: gameBoard)
      if (cell.isFlagged())
         flaggedCells.add(cell);
    return flaggedCells;
}

能否看出代碼的兩處差異點?

稍微仔細點觀察,就會發現有兩個點被改進:

  1. int[] -> Cell : 將數據進行建模,賦予其含義

  2. cell[STATUS_VALUE] == FLAGGED  ==> cell.isFlagged()

只要簡單改了以下名稱,就能輕易知道發生了什麼,這就是命名的力量。

命名的遊戲

首先來做一個遊戲,遊戲名爲 “我們住在哪個房間?”,如下會爲你提供一張圖片,請你說說看這是什麼房間

從上面的圖片不難看出,這肯定是客廳。基於一件物品,我們可以聯想到一個房間的名稱,這很簡單,那麼請看下圖。

基於這張圖片,我們可以肯定的說,這是廁所。通過上面兩張圖片,不難發現,房間的名稱只是一個標籤屬性,有了這個標籤,甚至我們不需要看它裏面有什麼東西。這樣我們便可以建立第一個推論:

▐****推論 1:容器(函數)的名稱應包含其內部所有元素

如果有一張牀?那麼它就是臥室。我們也可以反過來進行分析。

問題:基於一個容器名稱,我們可以推斷出它的組成部分。如果我們以臥室爲例,那麼很有可能這個房間有一張牀。這樣我們便可以建立第二個推論:

▐****推論 2:根據容器(函數)的名稱推斷其內部組成元素

現在我們有了兩條推論,據此我們試着看下面這張圖片。

問題 3/3

好吧,牀和馬桶在同一個房間?根據我們的推論,如上圖片使我們很難立即做出判斷,如果依然使用上述兩條推論來給它下定義的話,那麼我會稱它爲:怪物的房間。

這個問題並不在於同一個房間的物品數量上,而是完全不相關的物品被認作爲具備同樣的標籤屬性。在家中,我們通常會把有關聯的,意圖以及功能相近的東西放在一起,以免混淆視聽,所以現在我們有了第三條推論:

▐****推論 3:容器(函數)的明確度與其內部組件的密切程度成正比

這可能比較難理解,所以我們用下面這一張圖來做說明:

如果容器內部元素屬性關聯性很強,那麼更容易找到一個用來說明它的名字;反之,元素之間的無關性越強,越難以描述說明。

屬性維度可能會關係到他們的功能、目的、戰略,類型等等。關於命名標準,需要關聯到元素自身屬性纔有實際意義。

在軟件工程方面,這個觀點也同樣適用。例如我們熟知的組件、類、函數方法、服務、應用。羅伯特 · 德拉奈曾說過:“我們的理解能力很大程度與我們的認知相關聯”,那麼在這種技術背景下,我們的代碼是否可以使閱讀者以最簡單的方式感知到業務需求以及相關訴求?

命名的原則

**  名副其實**

命名應該描述其所做的所有事情 (或者它的意圖)。

當讀者讀到上文命名的壞味道裏面講的例子中getThen(),並不能理解 getThen()的意圖是什麼?是獲取什麼呢?getFlaggedCells()就能比較準確地表達出來。

// 槽糕的命名
public List<int[]> getThen();
// 好的命名
public List<Cell> getFlaggedCells();
// 槽糕的命名
private Date userCacheTime;
// 好的命名
private Date customerStayTotalTime;

**  避免誤導**

避免留下掩藏代碼本意的錯誤線索。

List 一詞對於程序員來說有特殊含義。如果包納賬號的容器並非真實一個 List, 就會引起錯誤的判斷。所有建議用 accountGroup 或 bunchOfAccounts, 甚至是 accounts 都會好一些。

這樣的拼寫方式容易誤導讀者或者讓讀者花較大的力氣去辨別。

▐****有意義的區分

如果同一作用範圍內有多個命名,最好讓它們之間有區分度。

public static void copyChars(char a1[], char a2[]){
  for (int i = 0; i < a1.length; i++){
    a2[i] = a1[i];
  }
}

這裏參數 a1,a2 是依義進行命名的,完全沒有提供正確的信息,沒有提供導向作者意圖的線索。

如何進行有意義的區分呢?

如果參數改爲 source 和 destination,這個函數的命名就更符合其用途。

public static void copyChars(char source[], char destination[]){
  for (int i = 0; i < source.length; i++){
    destination[i] = source[i];
  }
}

命名時遵守對仗詞的命名規則有助於保持一致性,從而也可以提高可讀性。像 first/last 這樣的對仗詞就很容易理解;而像 FileOpen() 和 _lclose() 這樣的組合則不對稱,容易使人迷惑。下面列出一些常見的對仗詞組:

J9Zqio

info 和 data 的含義過於寬泛,導致沒有額外的信息量,反而增加了讀者的閱讀成本,得不償失。

▐****風格一致

讓同一個項目中的代碼命名規則保持統一。

比如:

  1. 每個 class 的 Logger 取名爲 logger、log 還是 LOGGER,取哪個名字均可,但是需要保持項目統一

  2. 類屬性的 getter/setter 方法的命名統一。getName() 還是 name() 均可,但是需要保持項目統一

  3. 註釋的風格統一。

/**
   *  用戶的姓名
   */
   public Sring userName;
   /** 用戶的姓名 **/

▐****抽象一致

 讓同一作用域內的變量或方法具有相同的抽象。

public class Employee {
  ...
  public String getName(){...}
  public String getAddress(){...}
  public String getWorkPhone(){...}
  public boolean isJobClassificaitionValid(JobClassification jobClass){...}
  public boolean isZipCodeValid(Address address){...}
  public boolean isPhoneNumberValid(PhoneNumber phoneNumber){...}
  public SqlQuery getQueryToCreateNewEmployee(){...}
  public SqlQuery getQueryToModifyEmployee(){...}
  public SqlQuery getQueryToRetrieveEmployee(){...}
  ...
}

查看這個類,看看它有幾個抽象層次?

通過函數名可以查看:

  1. getName、getAddress、getWorkPhone 都是獲取 Employee 的主要屬性,符合 Employee 的抽象

  2. isJobClassificaitionValid 是 校驗 JobClassification 對象是否有效,屬於 JobClassification 的抽象層次,與 Employee 無關

  3. isZipCodeValid 是校驗 Address 的合理性,屬於 Address 的抽象層次,而 Address 是 Employee 的屬性,不是一個抽象層次。isPhoneNumberValid 同理。

  4. getQueryToCreateNewEmployee/getQueryToModifyEmployee/getQueryToRetrieveEmployee 看似與 Employee 有關,但是這裏暴露 SQL 語句查詢細節,是實現細節,層次比 Employee 要低。

多個不同層次的方法會讓這個類看起來非常怪,就像將晶體管、芯片零件、手機放在一個檯面上一樣。

public class Employee {
  ...
  public String getName(){...}
  public String getAddress(){...}
  public String getWorkPhone(){...}
  public String createEmployee(...){...}
  public String updateEmployee(...){...}
  public String deleteEmployee(...){...}
  ...
}

▐****命名建模

如果在一個項目中,發現有一段組裝搜索條件的代碼,在幾十個地方都有重複。這個搜索條件還比較複雜,是以元數據的形式存在數據庫中,因此組裝的過程是這樣的:

  1. 首先,我們要從緩存中把搜索條件列表取出來;

  2. 然後,遍歷這些條件,將搜索的值填充進去;

//取默認搜索條件 
List<String> defaultConditions = searchConditionCacheTunnel.getJsonQueryByLabelKey(labelKey);
for (String jsonQuery : defaultConditions) {
    jsonQuery = jsonQuery.replaceAll(SearchConstants.SEARCH_DEFAULT_PUBLICSEA_ENABLE_TIME,
                                     String.valueOf(System.currentTimeMillis() / 1000));
    jsonQueryList.add(jsonQuery);
}
//取主搜索框的搜索條件 
if (StringUtils.isNotEmpty(cmd.getContent())) {
    List<String> jsonValues = searchConditionCacheTunnel.getJsonQueryByLabelKey(
        SearchConstants.ICBU_SALES_MAIN_SEARCH);
    for (String value : jsonValues) {
        String content = StringUtil.transferQuotation(cmd.getContent());
        value = StringUtil.replaceAll(value, SearchConstants.SEARCH_DEFAULT_MAIN, content);
        jsonQueryList.add(value);
    }
}

簡單的重構無外乎就是把這段代碼提取出來,放到一個 Util 類裏面給大家複用。然而我認爲這樣的重構只是完成了工作的一半,我們只是做了簡單的歸類,並沒有做抽象提煉。

簡單分析,不難發現,此處我們是缺失了兩個概念:一個是用來表達搜索條件的類——SearchCondition;另一個是用來組裝搜索條件的類——SearchConditionAssembler。只有配合命名,顯性化的將這兩個概念表達出來,纔是一個完整的重構。

重構後,搜索條件的組裝會變成一種非常簡潔的形式,幾十處的複用只需要引用SearchConditionAssembler就好了。

public class SearchConditionAssembler {
    public static SearchCondition assemble(String labelKey) {
        String jsonSearchCondition = getJsonSearchConditionFromCache(labelKey);
        SearchCondition sc = assembleSearchCondition(jsonSearchCondition);
        return sc;
    }
}

由此可見,提取重複代碼只是我們重構工作的第一步。對重複代碼進行概念抽象,尋找有意義的命名纔是我們工作的重點

因此,每一次遇到重複代碼的時候,你都應該感到興奮,想着,這是一次鍛鍊抽象能力的絕佳機會,當然,測試代碼除外。

▐****語境通用化

如果你使用的命名來自一個比較冷門的語境,比如俗語或者俚語,不知道這個語境的人將很難理解它的含義。

如:用 whack 來標識 kill,wsBank 來標識網商銀行

如果不能用程序員熟悉的術語命名,就採用從所涉及問題領域而來的名稱,至少維護代碼的程序員就能去請教領域專家了。這樣至少問題域的專家能清晰理解開發者命名的語境,讀者可以詢問領域專家或者查詢領域詞彙含義。

以消息中間件領域爲例:topic、、message、tag、offset、commitLog

ZJ5EPL

改名

如果子程序名稱、類名、變量 含糊不清或者名不副實時,就需要對這個變量進行改名或者重構。

▐****改變函數聲明

好的命名讓讀者一看看出函數的用途,而不必查詢實現代碼。

函數名:

改進函數聲明的小技巧:先寫一句註釋描述這個函數的用途,再把這句註釋變成函數的名字。

函數的參數:

函數的參數列表闡述了函數如何與外部世界共處,是函數和函數使用者共同的依賴,這其實也是一種耦合

  1. 最小使用原則:函數的參數列表正是函數所依賴的,不會依賴沒有用到的信息
  1. 根據函數的意圖引入函數參數。

關於如何選擇正確的參數,沒有簡單的規則可循,需要視具體情況而定。

常用的重構做法有兩種:簡單式做法 和遷移式做法

簡單式做法:適用於一步到位地修改函數聲明及其所有調用者。

  1. 如果想要移除一個參數,需要先確定函數體內沒有使用該參數。

  2. 修改函數聲明,使其成爲你期望的狀態。

  3. 找出所有使用舊函數聲明的地方,將它們改爲使用新的函數聲明。

  4. 測試。

最好能把大的修改拆成小的步驟,所以如果你既想要修改函數名,又想添加參數,最好分成兩部來做。比較幸運的是,簡單式做法一般可以用 IDE 工具直接重構完成。

實戰:下列函數的名字太過簡略

public long circum(long radius){
  return 2 * Math.PI * radius;
}

將這個命名改得更加有意義一些:

public long circumference(long radius){
  return 2 * Math.PI * radius;
}

遷移式做法:函數被很多地方調用、修改不容易或者要修改的是一個多態函數或者對函數聲明的修改比較複雜

  1. 如果有必要的話,先對函數體內部加以重構,使後面的提煉步驟易於開展。

  2. 使用提煉函數(106)將函數體提煉成一個新函數。

  3. Tip 如果你打算沿用舊函數的名字,可以先給新函數起一個易於搜索的臨時名字。

  4. 如果提煉出的函數需要新增參數,用前面的簡單做法添加即可。

  5. 測試。

  6. 對舊函數使用內聯函數(115)。

  7. 如果新函數使用了臨時的名字,再次使用改變函數聲明(124)將其改回原來的名字。

  8. 測試。

實戰:還是剛纔circum方法改名的例子

這個簡略的函數名先不做修改。

public long circum(long radius){
  return 2 * Math.PI * radius;
}

再新增circumference函數:

public long circumference(long radius){
  return 2 * Math.PI * radius;
}

逐漸小步地將circum 方法的調用處改成circumference方法,每次修改都運行一下測試;如果測試成功,則提交此次修改進行一下修改,否則返回至上一步重新進行修改。這樣及時中間出錯,也能準確定位至某此修改,穩定推進重構,間接提高了重構的效率。

▐****變量改名

好的變量命名可以額解釋一段程序來幹什麼——如果變量名起得好的話。

  1. 如果變量被廣泛使用,考慮運用封裝變量將其封裝起來。

  2. 找出所有使用該變量的代碼,逐一修改。

    如果在另一個代碼庫中使用了該變量,這就是一個 “已發佈變量”(published variable),此時不能進行這個重構。

    如果變量值從不修改,可以將其複製到一個新名字之下,然後逐一修改使用代碼,每次修改後執行測試。

  3. 測試

如果要改名的變量只作用於一個函數,對其改名是最簡單的,直接使用 IDE 進行重命名即可。

如果變量的作用於不至於單個函數,重命名的風險就不太好把控,這時需要對變量進行封裝。

變量初始化

int treeName = "untitled";

變量被修改

treeName = "bigtree";

變更被讀取

leftTree = treeName;

此時可以考慮採用封裝變量進行完成

private int treeName;
public void init(){
  treeName = "untitled";
}
public String getTreeName(){
  return treeName;
}
public void setTreeName(String treeName){
    this.treeName = treeName;
}

命名的過程

命名是一個迭代的過程。當你持續很長時間想不到比較好的命名時,不要掉入取名的陷阱,可以先用折中的命名 commit 掉或者重構這段程序。當你想到更合適的命名,毫不猶豫地去重構它。

命名是一個接近描述事物本質的過程。命名得越好,越容易接近描述事物的本質。

取好名字最難的地方在於需要良好的描述技巧和共有文化背景。

結語

好的命名是自解釋的,讀者不用瞭解程序實現的細節,就能知道程序實現的意圖 (契約式編程)。在項目實戰中,有時候很難給一段子程序取到一個比較好的名字,這其實是程序在說話 -- 讓我乾的事情太雜了,導致不知道我是用來幹啥的。一般這種情況下,需要重構這段子程序,對齊進行職責拆分,分而治之。命名是門藝術,美在它的簡單,美在它的明確,美在它的名副其實。

加入我們

歡迎加入淘寶終端體驗平臺基礎服務團隊,團隊成員大牛雲集,有阿里移動中間件的創始人員、鷹眼全鏈路追蹤平臺核心成員、更有一羣熱愛技術,期望用技術推動業務的小夥伴。
淘寶終端體驗平臺基礎服務團隊,推進淘系(淘寶、天貓等)架構升級,致力於爲淘系、整個集團提供基礎核心能力、產品與解決方案:

  1. 業務高可用的解決方案與核心能力(應用高可用:爲業務提供自適應的限流、隔離與熔斷的柔性高可用解決方案,站點高可用:故障自愈、多機房與異地容災與快速切流恢復)

  2. 新一代的業務研發模式 FaaS(一站式函數研發 Gaia 平臺)

  3. 下一代網絡協議 QUIC 實現與落地

  4. 移動中間件(API 網關 MTop、域名調度 AMDC、消息 / 推送、文件上傳 AUS、移動配置推送 Orange 等等)

參考文檔

  1. TwoHardThings:

    https://martinfowler.com/bliki/TwoHardThings.html

  2. Software Complexity: The Art of Naming:

    https://medium.com/hackernoon/software-complexity-naming-6e02e7e6c8cb

  3. 《代碼大全》

  4. 《代碼整潔之道》

作者 | 玄蘇

編輯 | 橙子君

出品 | 阿里巴巴新零售淘系技術

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/_Dp1Y8owHJOjanPGkNBUKw