一些可以提升代碼質量的設計原則

算是讀書筆記吧

極客時間--設計模式之美


單一職責原則 -- SRP(Single Responsibility Principle)

A class or module should have a single reponsibility
一個類或者模塊只負責完成一個職責(或者功能)

  • 類和模塊

其實類和模塊都可以看做抽象集合
本質上都是一個領域的抽象,類作爲方法的聚合抽象、模塊作爲類的聚合抽象。

  • 完成單一職責

不要設計大而全的類,要設計粒度小、功能單一的類。
簡單來說,一個類包含了兩個或者兩個以上業務不相干的功能,那我們就說它職責不夠單一,應該將它拆分成粒度更小的類。

  • 不要過度拆分

業務是否相干,職責是否單一。很多時候具有主觀性,也就是程序員對模塊使命的理解。
對於沒辦法完全說服自己進行拆分的兩個功能:

我們可以先寫一個粗粒度的類,滿足業務需求。隨着業務的發展,如果粗粒度的類越來越龐大,代碼越來越多,這個時候,我們就可以將這個粗粒度的類,拆分成幾個更細粒度的類。這就是所謂的持續重構。

  • 一些可以借鑑的量化指標

  1. 類中的代碼行數、函數或屬性過多
    會影響代碼的可讀性和可維護性,我們就需要考慮對類進行拆分;
  2. 類依賴的其他類過多,或者依賴類的其他類過多
    不符合高內聚、低耦合的設計思想,我們就需要考慮對類進行拆分;
  3. 私有方法過多
    我們就要考慮能否將私有方法獨立到新的類中,設置爲 public 方法,供更多的類使用,從而提高代碼的複用性
  4. 比較難給類起一個合適名字
    很難用一個業務名詞概括,或者只能用一些籠統的 Manager、Context 之類的詞語來命名,這就說明類的職責定義得可能不夠清晰
  5. 類中大量的方法都是集中操作類中的某幾個屬性
    比如,在 UserInfo 例子中,如果一半的方法都是在操作 address 信息,那就可以考慮將這幾個屬性和對應的方法拆分出來。也能提高代碼複用性
  • 一個類多少行代碼合適?

這個也很難明確的量化,就像問大廚“放鹽少許”中的“少許”是多少一樣。

不過當一個類的代碼,讀起來讓你頭大了,實現某個功能時不知道該用哪個函數了,想用哪個函數翻半天都找不到了,只用到一個小功能要引入整個類(類中包含很多無關此功能實現的函數)的時候,這就說明類的行數、函數、屬性過多了。


接口隔離 -- ISP(Interface Segregation Principle)

Clients should not be forced to depend upon interfaces that they do not use
客戶端(使用者)不應該強迫依賴它不需要的接口

  • 一組 API 接口集合

如果部分接口只被部分調用者使用,那我們就需要將這部分接口隔離出來,單獨給對應的調用者使用,而不是強迫其他調用者也依賴這部分不會被用到的接口


public interface UserService {
  boolean register(String cellphone, String password);
  boolean login(String cellphone, String password);
  UserInfo getUserInfoById(long id);
  UserInfo getUserInfoByCellphone(String cellphone);
}

// 刪除用戶接口不應該暴露給客戶端
public interface RestrictedUserService {
  boolean deleteUserByCellphone(String cellphone);
  boolean deleteUserById(long id);
}

public class UserServiceImpl implements UserService, RestrictedUserService {
  // ...省略實現代碼...
}
  • 把“接口”理解爲單個 API 接口或函數

函數的設計要功能單一,不要將多個不同的功能邏輯在一個函數中實現

以一個統計用類來舉例:

public class Statistics {
  private Long max;
  private Long min;
  private Long average;
  private Long sum;
  private Long percentile99;
  private Long percentile999;
  //...省略constructor/getter/setter等方法...
}

public Statistics count(Collection<Long> dataSet) {
  Statistics statistics = new Statistics();
  //...省略計算邏輯...
  return statistics;
}

count方法內部會計算各種各樣的結果,對單個結果的修改,都需要修改count方法。
如果大部分結果的使用頻率不高,那麼每次調用count,也會進行許多無用計算。
我們可以把接口更加細化,支持單個條件的獲取

public Long max(Collection<Long> dataSet) { //... }
public Long min(Collection<Long> dataSet) { //... } 
public Long average(Colletion<Long> dataSet) { //... }
// ...省略其他統計函數...

  • 把“接口”理解爲 OOP 中的接口概念

主要體現在面向接口/協議編程中

不要設計大而全的接口,通過按類型、功能劃分的方式細化接口粒度。
在複用性以及擴展性上都有好處。
大而全的接口,也會強迫接入者實現無用方法,不利於後期修改維護。


依賴反轉原則

高層模塊(high-level modules)不要依賴低層模塊(low-level)。高層模塊和低層模塊應該通過抽象(abstractions)來互相依賴。除此之外,抽象(abstractions)不要依賴具體實現細節(details),具體實現細節(details)依賴抽象(abstractions)。

所謂高層模塊和低層模塊的劃分,簡單來說就是,在調用鏈上,調用者屬於高層,被調用者屬於低層。

簡而言之,依賴反轉希望調用者的執行,不依賴被具體的調用者。
這個具體的被調用者,指的是具體的類。
如何能夠不依賴具體的類?答案是面向接口編程。二者共同依賴同一個抽象(接口)。

以發電廠爲電器供應電力爲例
發電廠並不依賴具體的電器,而是通過共同的抽象(電源插口),與具體的電器相連。
在新增電器時,發電廠並不需要對其進行單獨的設置,只要把這個電器也接入電源插口即可即可。

這樣設計好處有兩點:

  1. 低層次模塊更加通用,適用性更廣
  2. 高層次模塊沒有依賴低層次模塊的具體實現,方便低層次模塊的替換

保持簡單 -- KISS原則

KISS原則主要想說“如何做”的問題:儘量保持簡單

他的描述有好幾個
Keep It Simple and Stupid.
Keep It Short and Simple.
Keep It Simple and Straightforward.

  • 並不是代碼行數越少肯定好
    比如正則表達式和一些奇技淫巧,他們行數很少,但是維護起來可能要付出大量的精力

  • 不要使用同事可能不懂的技術來實現代碼
    如果同時維護你的代碼,要花很多時間去學習一門技術,那會大大的降低開發效率

  • 不要重複造輪子,要善於使用已經有的工具類庫
    在寫一個新輪子之前,看一看項目文檔,或者問問同事。
    使用已有的工具類庫,或者對其進行擴展。
    不要讓項目中同樣的功能,出現兩個工具類。
    自己去實現這些類庫,出 bug 的概率會更高,維護的成本也比較高。

  • 不要過度優化
    不要過度使用一些奇技淫巧來優化代碼,犧牲代碼的可讀性。
    比如,位運算代替算術運算、複雜的條件語句代替 if-else、使用一些過於底層的函數等


過度設計 -- YAGNI原則

YAGNI 原則說的是“要不要做”的問題:當前不需要的就不要做

比如模塊的可擴展性、各種設計模式的使用。
如果在可預見的範圍內,並不需要就不要那樣設計。
當項目的發展超出了預期,再去重構


重複代碼 -- DRY原則

Don’t Repeat Yourself
不要寫重複的代碼

當代碼的某些地方必須更改時,你是否發現自己在多個位置以多種不同格式進行了更改?
你是否需要更改代碼和文檔,或更改包含其的數據庫架構和結構,或者…?
如果是這樣,則您的代碼不是DRY。

  • 1.實現邏輯重複

比如登錄時對於用戶名密碼的格式校驗。

二者分別叫isValidUserName() 函數和 isValidPassword()。初期二者可能校驗邏輯相同,所以被copy了兩份。


public class UserAuthenticator {
  public void authenticate(String username, String password) {
    if (!isValidUsername(username)) {
      // ...throw InvalidUsernameException...
    }
    if (!isValidPassword(password)) {
      // ...throw InvalidPasswordException...
    }
    //...省略其他代碼...
  }

  private boolean isValidUsername(String username) {
    // check not null, not empty
    if (StringUtils.isBlank(username)) {
      return false;
    }
    // check length: 4~64
    int length = username.length();
    if (length < 4 || length > 64) {
      return false;
    }
    // contains only lowcase characters
    if (!StringUtils.isAllLowerCase(username)) {
      return false;
    }
    // contains only a~z,0~9,dot
    for (int i = 0; i < length; ++i) {
      char c = username.charAt(i);
      if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') {
        return false;
      }
    }
    return true;
  }

  private boolean isValidPassword(String password) {
    // check not null, not empty
    if (StringUtils.isBlank(password)) {
      return false;
    }
    // check length: 4~64
    int length = password.length();
    if (length < 4 || length > 64) {
      return false;
    }
    // contains only lowcase characters
    if (!StringUtils.isAllLowerCase(password)) {
      return false;
    }
    // contains only a~z,0~9,dot
    for (int i = 0; i < length; ++i) {
      char c = password.charAt(i);
      if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') {
        return false;
      }
    }
    return true;
  }
}

在合併時,我們要注意不能將其簡單的合併成isValidUserNameOrPassword(),這樣會導致將來難以維護。

我們可以將其根據具體功能抽象成一個或幾個單獨的校驗函數,分別組裝進isValidUserName() 函數和 isValidPassword()中。
比如將校驗只包含 a-z、0~9、dot 的邏輯封裝成 boolean onlyContains(String str, String charlist); 函數。

  • 功能語義重複

比如兩個判斷IP地址的函數isValidIp() 和 checkIfIpValid()
儘管兩個函數的命名不同,實現邏輯不同,但功能是相同的。


public boolean isValidIp(String ipAddress) {
  if (StringUtils.isBlank(ipAddress)) return false;
  String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\."
          + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
          + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
          + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$";
  return ipAddress.matches(regex);
}

public boolean checkIfIpValid(String ipAddress) {
  if (StringUtils.isBlank(ipAddress)) return false;
  String[] ipUnits = StringUtils.split(ipAddress, '.');
  if (ipUnits.length != 4) {
    return false;
  }
  for (int i = 0; i < 4; ++i) {
    int ipUnitIntValue;
    try {
      ipUnitIntValue = Integer.parseInt(ipUnits[i]);
    } catch (NumberFormatException e) {
      return false;
    }
    if (ipUnitIntValue < 0 || ipUnitIntValue > 255) {
      return false;
    }
    if (i == 0 && ipUnitIntValue == 0) {
      return false;
    }
  }
  return true;
}

儘管兩段代碼的實現邏輯不重複,但語義重複,也就是功能重複,我們認爲它違反了 DRY 原則。
我們應該在項目中,統一一種實現思路,同樣語義的代碼,都統一調用同一個函數。

  • 3.代碼執行重複

很簡單,如果一個代碼的調用鏈中。有些無用邏輯的調用或者重複調用。
就需要重構一下,將重複的邏輯抽離出來。

  • 4.過多的過程性註釋

寫了好多的註釋解釋代碼的執行邏輯,後續修改的這個方法的時候可能,忘記修改註釋,造成對代碼理解的困難。
實際應用應該使用KISS原則,將方法寫的見名知意,儘量容易閱讀。註釋不必過多。

  • 如何提升代碼複用性

  1. 減少代碼耦合
  2. 滿足單一職責原則
  3. 模塊化
  4. 業務與非業務邏輯分離
  5. 通用代碼下沉
  6. 繼承、多態、抽象、封裝
  7. 應用模板等設計模式

複用意識也非常重要。
在設計每個模塊、類、函數的時候,要像設計一個外部 API 一樣去思考它的複用性。

  • Rule of Three

第一次編寫代碼的時候,我們不考慮複用性;
第二次遇到複用場景的時候,再進行重構使其複用。
需要注意的是,“Rule of Three”中的“Three”並不是真的就指確切的“三”,這裏就是指“二”。


高內聚、松耦合 -- 迪米特法則(LOD)

The Least Knowledge Principle:
Each unit should have only limited knowledge about other units: only units “closely” related to the current unit. Or: Each unit should only talk to its friends; Don’t talk to strangers.

最小知識原則
每個模塊(unit)只應該瞭解那些與它關係密切的模塊(units: only units “closely” related to the current unit)的有限知識(knowledge)。或者說,每個模塊只和自己的朋友“說話”(talk),不和陌生人“說話”(talk)。

  • 不該有直接依賴關係的類之間,不要有依賴
    假如你現在要去商店買東西,你肯定不會直接把錢包給收銀員,讓收銀員自己從裏面拿錢,而是你從錢包裏把錢拿出來交給收銀員。
    具體一些,非必要情況下,不要爲了一兩個屬性傳入整個類。
    把類和類偶合起來。

  • 有依賴關係的類之間,儘量只依賴必要的接口(也就是定義中的“有限知識”)
    類似序列化與反序列化的類,二者必須在一個類中實現,當方法較少時沒什麼問題。
    一旦實現了許多序列化與反序列化的方式,大部分代碼只需要用到序列化的功能。
    對於這部分使用者,沒必要了解反序列化的“知識”。
    那麼,我們可以通過接口隔離原則,用序列化和反序列化兩個接口來對兩個接口進行隔離。

  • 高內聚
    相近的功能應該放到同一個類中,不相近的功能不要放到同一個類中

  • 松耦合
    類與類之間的依賴關係簡單清晰
    即使兩個類有依賴關係,一個類的代碼改動不會或者很少導致依賴類的代碼改動

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章