天天看點

程式命名的原則與重構命名來源生活命名的原則結語

命名來源生活

程式命名的原則與重構命名來源生活命名的原則結語

從左到右:正三角形,正方形、正六邊形  正表示邊長相等,進而得到正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()
    • 模組化後可以竟然可以将這條語句語義化,可讀性增強了;
    • STATUS_VALUE、FLAGGED 都被隐藏至Cell,更加了内聚;

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

命名的遊戲

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

程式命名的原則與重構命名來源生活命名的原則結語

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

程式命名的原則與重構命名來源生活命名的原則結語

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

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

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

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

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

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

問題 3/3

程式命名的原則與重構命名來源生活命名的原則結語

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

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

▐  推論3:容器(函數)的明确度與其内部元件的密切程度成正比

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

程式命名的原則與重構命名來源生活命名的原則結語

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

屬性次元可能會關系到他們的功能、目的、戰略,類型等等。關于命名标準,需要關聯到元素自身屬性才有實際意義。

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

命名的原則

▐  名副其實

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

當讀者讀到上文命名的壞味道裡面講的例子中

getThen()

,并不能了解 

getThen()

的意圖是什麼?是擷取什麼呢?

getFlaggedCells()

就能比較準确地表達出來。

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


// 槽糕的命名
private Date userCacheTime;
// 好的命名
private Date customerStayTotalTime;      

▐  避免誤導

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

  • 别用accountList來指稱一組賬号,除非它真的時List類型。

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

  • 避免變量名使用小寫字母l和大寫字母O。

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

▐  有意義的區分

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

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() 這樣的組合則不對稱,容易使人迷惑。下面列出一些常見的對仗詞組:

程式命名的原則與重構命名來源生活命名的原則結語
  • 盡量不要使用info/data為結尾去命名類名或變量名。

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

程式命名的原則與重構命名來源生活命名的原則結語

改名

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

▐  改變函數聲明

程式命名的原則與重構命名來源生活命名的原則結語

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

  • 動機

函數名:

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

函數的參數:

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

  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. 對舊函數使用内聯函數(115)。
  6. 如果新函數使用了臨時的名字,再次使用改變函數聲明(124)将其改回原來的名字。

實戰:還是剛才

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掉或者重構這段程式。當你想到更合适的命名,毫不猶豫地去重構它。

程式命名的原則與重構命名來源生活命名的原則結語

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

程式命名的原則與重構命名來源生活命名的原則結語

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

結語

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