前言
DRY 原則,英文描述為: Don’t Repeat Yourself。中文直譯:不要重復自己。將它應用在編程中,可理解為:不要寫重讀的代碼。
可能你認為,這個原則很簡單。只要兩段代碼長得一樣,那就是違反 DRY 原則了。真的是這樣嗎? 答案是否定的。這是很多人對這條原則存在的誤解。實際上,重讀的代碼不一定違反 DRY 原則,而有些看似不重復的代碼也可能違反 DRY 原則。
DRY 原則(Don’t Repeat Yourself)
DRY 原則的定義非常簡單,我就不再過度解讀了。今天,主要將三種典型的代碼重復情況,它們分別是:實現邏輯重復、功能語義重復和代碼執行重復。這三種代碼重復,有些看似違反 DRY 原則,實際上并不違反;有的看似不違反,實際上卻違反了。
實現邏輯重復
先看一段代碼
public class UserAuthenticator {public void authenticate(String username, String password) {if (!isValidUsername(username)) {// throw new InvalidUsernameException...}if (!isValidPassword(password)) {// throw new InvalidPasswordException...}// 省略其他代碼...}private boolean isValidUsername(String username) {if (StringUtils.isEmpty(username)) { return false; }// check length: 4-64int length = username.length();if (length < 4 || length > 64) { return false; }// contains only lower case lettersif (StringUtils.isAllLowerCase(username)) { return false; }// contains only z~z,0~9,dotfor (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) {if (StringUtils.isEmpty(password)) { return false; }// check length: 4-64int length = password.length();if (length < 4 || length > 64) { return false; }// contains only lower case lettersif (StringUtils.isAllLowerCase(password)) { return false; }// contains only z~z,0~9,dotfor (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;}
}
在代碼中,有兩處非常重復的代碼片段: isValidUsername()
和 isValidPassword()
。重復的代碼被敲了兩遍,看起來明顯違反了 DRY 原則。為了移除重復的代碼,我們進行下重構,將 isValidUsername()
和 isValidPassword()
合并Wie一個更通用的函數, isValidUsernameOrPassword()
。
public class UserAuthenticator {public void authenticate(String username, String password) {if (!isValidUsernameOrPassword(username)) {// throw new InvalidUsernameException...}if (!isValidUsernameOrPassword(password)) {// throw new InvalidPasswordException...}// 省略其他代碼...}private boolean isValidUsernameOrPassword(String usernameOrPassword) {if (StringUtils.isEmpty(usernameOrPassword)) { return false; }// check length: 4-64int length = usernameOrPassword.length();if (length < 4 || length > 64) { return false; }// contains only lower case lettersif (StringUtils.isAllLowerCase(usernameOrPassword)) { return false; }// contains only z~z,0~9,dotfor (int i = 0; i < length; ++i) {char c = usernameOrPassword.charAt(i);if (!((c >= 'a' && c <= 'z') || !(c >= '0' && c <= '9') || c != '.')) { return false; }}return true;}
}
重構之后,代碼行數減少了,也沒有重復代碼了,是不是更好呢?
單從名字上看,合并之后的 isValidUsernameOrPassword()
函數,負責兩件事情:驗證用戶名和密碼,違反了單一職責原則和接口隔離原則。實際上,即便將兩個函數合并成 isValidUsernameOrPassword()
,代碼仍然存在問題。
因為 isValidUsername()
和 isValidPassword()
,雖然代碼實現邏輯上看起來是重復的,但是從語義上并不重復。盡管在目前的設計中,兩個校驗邏輯完全一樣,但是如果按照第二種寫法,將兩個函數合并,那就回農村在潛在的問題。在未來的某一天,如果我們修改了密鑰校驗邏輯,比如,允許密碼包含大寫字符,允許密碼長度為 8 到 64 個字符,那這個時候, isValidUsername()
和 isValidPassword()
的實現邏輯就會不相同。我們需要把合并后的函數,重新拆分成合并前的兩個函數。
所謂 “語義不重復” 是指:從功能上看,這兩個函數干的是完全不重復的事情,一個是校驗用戶名,一個是校驗密碼。
盡管代碼的實現邏輯相同,但語義不同,我們判定它并不違反 DRY 原則。對于包含重復代碼的問題,我們可以通過抽象成更細粒度函數的方式來解決。比如將校驗只包含 a-z、0-9、dot 的邏輯都封裝成函數。
功能語義重復
在看另一個例子。在同一個項目代碼中有下面兩個函數: isValidIp()
和 checkIfIpValid()
。盡管命名不同、實現邏輯不同,但是功能是相同的,都是用來判定 IP 是否合法的。
出現這個現象的原因,可能是其中的一個同事不知道已有了
isValidIp()
的情況下,自己又定義并實現了相同用來校驗 IP 地址是否合法的checkIfIpValid()
函數。
這兩個函數如下所示,它們是否違反了 DRY 原則?
public boolean isValidIp(String ip) {if (StringUtils.isBlank(ip)) { 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 ip.matches(regex);
}public boolean checkIfIpValid(String ip) {if (StringUtils.isBlank(ip)) { return false; }String[] ipUnits = StringUtils.split(ip, ".");if (ipUnits.length != 4) { return false; }for (int i = 0; i < ip.length(); 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 原則。
我們應該在項目中,統一一種實現思路,所有用到判斷 IP 地址是否合法的地方,都統一調用一個函數。
假設,我們不統一實現思路,有些地方調用了 isValidIp()
,有些地方又調用了 checkIfIpValid()
,這就會導致代碼看起來很奇怪,相當于給代碼 “挖坑”,給不熟悉的這部分代碼的同事增加了閱讀難度。同事可能研究了半天,覺得功能一樣,但又有點疑問,覺得是不是有更高深的考量,才定義了兩個功能類似的函數,最終發現居然是代碼設計的問題。
另外,如果哪天項目中 IP 地址是否合法的判定邏輯改了,比如: 255.255.255.255 判定不合法,相應地,我們對 isValidIp()
的實現邏輯做了修改,但卻忘記修改 checkIfIpValid()
,這樣就會導致有些代碼仍然用老的 IP 判定邏輯,導致出現一些莫名其妙的 BUG。
代碼執行重復
前兩個例子,一個是實現邏輯重復,一個是語義重復,在看下第三個例子。其中 UserService
中 login()
用來校驗用戶登錄是否成功。如果失敗,就返回異常;如果成功就返回用戶信息。具體代碼如下所示:
public class UserService {private UserRepo userRepo; // 通過依賴注入或者IOC框架注入public User login(String email, String password) {boolean existed = userRepo.checkIfUserExisted(email, password);if (!existed) {// throw AuthenticationFailureException...}User user = userRepo.getUserByEmail(email);return user;}
}public class UserRepo {public boolean checkIfUserExisted(String email, String password) {if (EmailValidation.validate(email)) {// throw InvalidEmailException...}if (PasswordValidation.validate(password)) {// throw InvalidPasswordException...}// query db to check if email&password exists...}public User getUserByEmail(String email) {if (EmailValidation.validate(email)) {// throw InvalidEmailException...}// query db to get user by email...}
}
上面的代碼,既沒有邏輯重復,也沒有語義重復,但仍然違反了 DRY 原則。這是因為代碼存在 “執行重復”。
重復執行最明顯的地阿福,就是在 login()
中,email 的校驗邏輯執行了兩次。一次是在調用 checkIfUserExisted()
函數的時候,另一次是調用 getUserByEmail()
的時候。這個問題解決起來比較簡答,只要將校驗邏輯從 UserRepo
中移除,統一放到 UserService
中就可以了。
此外,代碼中,還有移除比較隱藏的執行重復: login()
函數并不需要調用 checkIfUserExisted()
,只需要調用一次 getUserByEmail()
,從數據庫中獲取用戶的 email、password 等信息,然后跟輸入的 email、password 信息做比對,判斷是否登錄成功。
這樣的優化是很有必要的。因為
checkIfUserExisted()
和getUserByEmail()
都需要查詢數據庫,而數據庫類的 I/O 操作是比較耗時的。我們在寫代碼的時候,應該盡量減少 I/O 操作。
按照剛剛的思路,我們重構下代碼。
public class UserService {private UserRepo userRepo; // 通過依賴注入或者IOC框架注入public User login(String email, String password) {if (EmailValidation.validate(email)) {// throw InvalidEmailException...}if (PasswordValidation.validate(password)) {// throw InvalidPasswordException...}User user = userRepo.getUserByEmail(email);if (user == null || !password.equals(user.getPassword())) {// throw AuthenticationFailureException...}return user;}
}public class UserRepo {public boolean checkIfUserExisted(String email, String password) {// query db to check if email&password exists...}public User getUserByEmail(String email) {// query db to get user by email...}
}
代碼復用性(Code Reusability)
什么是代碼的復用性?
首先區分三個概念: 代碼復用性(Code Reusability)、代碼復用(Code Reuse)、DRY 原則。
- 代碼復用表示一種行為:我們在開發新功能的時候,盡量復用已存在的代碼。
- 代碼復用性表示一段代碼可被復用的特性或能力:我們在編寫代碼的時候,盡量讓代碼可復用。
- DRY 原則是一條原則:不要寫重復的代碼。
首先,“不重復” 不代表 “可復用”。在一個項目中,可能不存在任何重復的代碼,但也不表示里面有可復用的代碼,不重復和可復用完全是兩個概念。所以,從這個角度來說,DRY 原則和可復用性將的是兩回事。
其次,“復用” 和 “可復用性” 關注角度不同。代碼 “可復用性” 是從代碼開發者的角度來講的,“復用” 是從代碼使用者角度來講的。比如 A 同事編寫了一個 UrlUrils
類,代碼的 “可復用性” 很好。同事 B 在開發新功能是,直接 “復用” A 同事編寫的 UrlUrils
類。
雖然復用性、復用、DRY 原則這三者從理解上有區別,但是它們的目的是一樣的,都是為了較少代碼量,提高代碼的可讀性、可維護性。此外,復用已經過測試的老代碼,bug 會比從零開發的要少。
“復用” 這個概念不僅可以指導細粒度的模塊、類、函數的設計開發,實際上,一些框架、類庫、組件等的生產也都是為了達到復用的目的。比如,Spring 框架、UI 組件等等。
怎么提高代碼復用性?
一共有 7 條規則:
- 減少代碼耦合。對于高耦合的代碼,當希望復用其中的一個功能,想把這個功能的代碼抽取出來成為一個獨立的模塊、類或者函數時,往往會牽一發而動全身。所以,高耦合度的代碼會影響到代碼的可復用性。
- 滿足單一職責原則。 前面講過,如果職責不夠單一,模塊、類設計得大而全,那就增加了代碼的耦合度(依賴它的,它依賴的代碼就會比較多)。也會影響到代碼的可復用性。相反,粒度越細的代碼,代碼的通用性會越好,容易被復用。
- 模塊化。這里的 “模塊”,不單單只一組類構成的模塊,還可以理解為單個類、函數。我們要善于將功能獨立的代碼,封裝成模塊。獨立的模塊就像積木,更加容易復用,直接拿來搭建更加復雜的系統。
- 業務與非業務邏輯分離。越是和業務無關的代碼余額容易復用,越是針對特定業務的代碼越難復用。所以,為了復用跟業務無關的代碼,我們將業務和非業務邏輯代碼分離,抽取成一些通用的框架、類庫、組件等。
- 通用代碼下層。從分層角度來看,越底層的代碼越通用、會被越多的模塊調用,越應該設計得足夠可復用。一般情況下,在代碼分層之后,為了避免交叉調用導致調用關系混亂,我們只允許上層代碼調用下層代碼及同層代碼,杜絕下層代碼調用上層代碼。所以,通用的代碼我們盡量下沉到更下層。
- 繼承、多態、抽象、封裝。在講面向對象特性的時候,我們講過,利用繼承可以將公共代碼抽取到父類,子類復用父類的屬性和方法。利用多態,可以動態替換一段代碼的部分邏輯,讓這段代碼可復用。此外,抽象和封裝,從更加廣義的層面、而非狹義的面向對象特性層面來理解的話,越抽象、越不依賴具體實現,越容易復用。代碼封裝成模塊,隱藏可變細節、暴露不變的接口,就越容易復用。
- 應用模板等設計模式。一些設計模式,也能提高代碼復用性。比如,模板模式利用了多態技術來實現,可以靈活地替換其中的部分代碼,整個流程模板代碼可復用。
除了上面講到的 7 點,還有一些跟編程語言相關的特性,也可以提高代碼的復用性,比如泛型編程等。另外,除了上面講到的知識,復用意識也很重要。在寫代碼的時候,要取多思考,這部分代碼是否可以抽取出來,作為一個獨立模塊、類或者函數供多出使用。在設計每個模塊、類、函數的時候,要像設計一個外部 API 一樣,去思考它的復用性。
辯證思考和靈活應用
編寫可復用的代碼并不簡單。如果在編寫代碼時,已經有復用的需求場景,那根據復用的需求去開發可復用的代碼,可能還不算難。但是,如果當下沒有復用的需求,只是希望現在編寫的代碼具有可復用的特點,能在未來某個同事開發某個新功能時復用得上。在這種沒有具體復用需求的情況下,就需要去預測未來代碼會如何復用,這就比較有挑戰了。
實際上,除非有明確的復用需求,否則,為了暫時用不到的復用需求,花費太多時間、精力,投入太多成的開發成本,并不是一個值得推薦的做法。也違反我們之前講到的 YAGNI 原則。
實際上,我們在第一次寫代碼的時候,如果當下沒有復用的需求,而未來的需求也不是特別明確,并且開發復用代碼的成本比較高,那我們就不需要考慮它的復用性。在之后,開發新能夠的時候,發現可以復用的之前的代碼,那我們就重構它,讓其變得更加復用。
總結
1.DRY 原則
講解了三種重復的情況:實現邏輯重復、語義重復、執行邏輯重復。
- 實現邏輯重復,但功能語義不重復,并不違反 DRY 原則。
- 實現邏輯不重復,但是功能語義重復,則違反了 DRY 原則。
- 此外,代碼執行重復也是違反 DRY 原則。
2.代碼復用性
提高代碼可復用性的 7 點方法:
- 減少代碼耦合
- 滿足單一職責原則
- 模塊化
- 業務與非業務邏輯分離
- 通用代碼下沉
- 繼承、多態、抽象、封裝
- 應用模板等設計模式
除了上面降到的方法外,復用意識也非常重要。在設計每個模塊、類、函數時,要像設計一個外部 API 一樣思考它的復用性。
在第一編寫代碼時,如果當下沒有復用需求,而未來的復用需求也不是特別明確,并且開發可復用代碼的成本比較高,那我們就不需要考慮代碼的復用性。在之后開發新功能時,發現可以復用之前的代碼,那我們就重構這段代碼,讓其變得更加可復用。
相對于代碼可復用,DRY 原則適用性更強一些。我們可以不寫可復用的代碼,但一定不能寫重復的代碼。