設計模式之美 - 21 | 理論七:重複的代碼就一定違背DRY嗎?如何提高代碼的複用性?

這系列相關博客,參考 設計模式之美

在上一節課中,我們講了 KISS 原則和 YAGNI 原則,KISS 原則可以說是人盡皆知。今天,我們再學習一個你肯定聽過的原則,那就是 DRY 原則。它的英文描述爲:Don’t Repeat Yourself。中文直譯爲:不要重複自己。將它應用在編程中,可以理解爲:不要寫重複的代碼。

你可能會覺得,這條原則非常簡單、非常容易應用。只要兩段代碼長得一樣,那就是違反 DRY 原則了。真的是這樣嗎?答案是否定的。這是很多人對這條原則存在的誤解。實際上,重複的代碼不一定違反 DRY 原則,而且有些看似不重複的代碼也有可能違反DRY 原則。

聽到這裏,你可能會有很多疑問。沒關係,今天我會結合具體的代碼實例,來把這個問題講清楚,糾正你對這個原則的錯誤認知。除此之外,DRY 原則與代碼的複用性也有一些聯繫,所以,今天,我還會講一講,如何寫出可複用性好的代碼。

話不多說,讓我們正式開始今天的學習吧!

DRY 原則(Don’t Repeat Yourself)

DRY 原則的定義非常簡單,我就不再過度解讀。今天,我們主要講三種典型的代碼重複情況,它們分別是:實現邏輯重複、功能語義重複和代碼執行重複。這三種代碼重複,有的看似違反 DRY,實際上並不違反;有的看似不違反,實際上卻違反了。

實現邏輯重複

我們先來看下面這樣一段代碼是否違反了 DRY 原則。如果違反了,你覺得應該如何重構,才能讓它滿足 DRY 原則?如果沒有違反,那又是爲什麼呢?

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;
	}
}

代碼很簡單,我就不做過多解釋了。在代碼中,有兩處非常明顯的重複的代碼片段:isValidUserName() 函數和 isValidPassword() 函數。重複的代碼被敲了兩遍,或者簡單copy-paste 了一下,看起來明顯違反 DRY 原則。爲了移除重複的代碼,我們對上面的代碼做下重構,將 isValidUserName() 函數和 isValidPassword() 函數,合併爲一個更通用的函數 isValidUserNameOrPassword()。重構後的代碼如下所示:

public class UserAuthenticatorV2 {
	public void authenticate(String userName, String password) {
		if (!isValidUsernameOrPassword(userName)) {
			// ...throw InvalidUsernameException...
		}
		if (!isValidUsernameOrPassword(password)) {
			// ...throw InvalidPasswordException...
		}
	}
	private boolean isValidUsernameOrPassword(String usernameOrPassword) {
		// 省略實現邏輯
		// 跟原來的 isValidUsername() 或 isValidPassword() 的實現邏輯一樣...
		return true;
	}
}

經過重構之後,代碼行數減少了,也沒有重複的代碼了,是不是更好了呢?答案是否定的,這可能跟你預期的不一樣,我來解釋一下爲什麼。

單從名字上看,我們就能發現,合併之後的 isValidUserNameOrPassword() 函數,負責兩件事情:驗證用戶名和驗證密碼,違反了“單一職責原則”和“接口隔離原則”。實際上,即便將兩個函數合併成 isValidUserNameOrPassword(),代碼仍然存在問題。

因爲 isValidUserName() 和 isValidPassword() 兩個函數,雖然從代碼實現邏輯上看起來是重複的,但是從語義上並不重複。所謂“語義不重複”指的是:從功能上來看,這兩個函數乾的是完全不重複的兩件事情,一個是校驗用戶名,另一個是校驗密碼。儘管在目前的設計中,兩個校驗邏輯是完全一樣的,但如果按照第二種寫法,將兩個函數的合併,那就會存在潛在的問題。在未來的某一天,如果我們修改了密碼的校驗邏輯,比如,允許密碼包含大寫字符,允許密碼的長度爲 8 到 64 個字符,那這個時候,isValidUserName() 和 isValidPassword() 的實現邏輯就會不相同。我們就要把合併後的函數,重新拆成合並前的那兩個函數。

儘管代碼的實現邏輯是相同的,但語義不同,我們判定它並不違反 DRY 原則。對於包含重複代碼的問題,我們可以通過抽象成更細粒度函數的方式來解決。比如將校驗只包含a ~ z、0 ~ 9、dot 的邏輯封裝成 boolean onlyContains(String str, String charlist); 函數。

功能語義重複

現在我們再來看另外一個例子。在同一個項目代碼中有下面兩個函數:isValidIp() 和 checkIfIpValid()。儘管兩個函數的命名不同,實現邏輯不同,但功能是相同的,都是用來判定 IP 地址是否合法的。

之所以在同一個項目中會有兩個功能相同的函數,那是因爲這兩個函數是由兩個不同的同事開發的,其中一個同事在不知道已經有了 isValidIp() 的情況下,自己又定義並實現了同樣用來校驗 IP 地址是否合法的 checkIfIpValid() 函數。

那在同一項目代碼中,存在如下兩個函數,是否違反 DRY 原則呢?

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 原則。而在這個例子中,儘管兩段代碼的實現邏輯不重複,但語義重複,也就是功能重複,我們認爲它違反了 DRY 原則。我們應該在項目中,統一一種實現思路,所有用到判斷 IP 地址是否合法的地方,都統一調用同一個函數。

假設我們不統一實現思路,那有些地方調用了 isValidIp() 函數,有些地方又調用了checkIfIpValid() 函數,這就會導致代碼看起來很奇怪,相當於給代碼“埋坑”,給不熟悉這部分代碼的同事增加了閱讀的難度。同事有可能研究了半天,覺得功能是一樣的,但又有點疑惑,覺得是不是有更高深的考量,才定義了兩個功能類似的函數,最終發現居然是代碼設計的問題。

除此之外,如果哪天項目中 IP 地址是否合法的判定規則改變了,比如:255.255.255.255 不再被判定爲合法的了,相應地,我們對 isValidIp() 的實現邏輯做了相應的修改,但卻忘記了修改 checkIfIpValid() 函數。又或者,我們壓根就不知道還存在一個功能相同的 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 操作。

按照剛剛的修改思路,我們把代碼重構一下,移除“重複執行”的代碼,只校驗一次email 和 password,並且只查詢一次數據庫。重構之後的代碼如下所示:

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 Resue)和 DRY 原則。

代碼複用表示一種行爲:我們在開發新功能的時候,儘量複用已經存在的代碼。代碼的可複用性表示一段代碼可被複用的特性或能力:我們在編寫代碼的時候,讓代碼儘量可複用。DRY 原則是一條原則:不要寫重複的代碼。從定義描述上,它們好像有點類似,但深究起來,三者的區別還是蠻大的。

首先,“不重複”並不代表“可複用”。在一個項目代碼中,可能不存在任何重複的代碼,但也並不表示裏面有可複用的代碼,不重複和可複用完全是兩個概念。所以,從這個角度來說,DRY 原則跟代碼的可複用性講的是兩回事。

其次,“複用”和“可複用性”關注角度不同。代碼“可複用性”是從代碼開發者的角度來講的,“複用”是從代碼使用者的角度來講的。比如,A 同事編寫了一個 UrlUtils類,代碼的“可複用性”很好。B 同事在開發新功能的時候,直接“複用”A 同事編寫的 UrlUtils 類。

儘管複用、可複用性、DRY 原則這三者從理解上有所區別,但實際上要達到的目的都是類似的,都是爲了減少代碼量,提高代碼的可讀性、可維護性。除此之外,複用已經經過測試的老代碼,bug 會比從零重新開發要少。

“複用”這個概念不僅可以指導細粒度的模塊、類、函數的設計開發,實際上,一些框架、類庫、組件等的產生也都是爲了達到複用的目的。比如,Spring 框架、Google Guava 類庫、UI 組件等等。

怎麼提高代碼複用性?

實際上,我們前面已經講到過很多提高代碼可複用性的手段,今天算是集中總結一下,總結了 7 條,具體如下。

  • 減少代碼耦合

對於高度耦合的代碼,當我們希望複用其中的一個功能,想把這個功能的代碼抽取出來成爲一個獨立的模塊、類或者函數的時候,往往會發現牽一髮而動全身。移動一點代碼,就要牽連到很多其他相關的代碼。所以,高度耦合的代碼會影響到代碼的複用性,我們要儘量減少代碼耦合。

  • 滿足單一職責原則

我們前面講過,如果職責不夠單一,模塊、類設計得大而全,那依賴它的代碼或者它依賴的代碼就會比較多,進而增加了代碼的耦合。根據上一點,也就會影響到代碼的複用性。相反,越細粒度的代碼,代碼的通用性會越好,越容易被複用。

  • 模塊化

這裏的“模塊”,不單單指一組類構成的模塊,還可以理解爲單個類、函數。我們要善於將功能獨立的代碼,封裝成模塊。獨立的模塊就像一塊一塊的積木,更加容易複用,可以直接拿來搭建更加複雜的系統。

  • 業務與非業務邏輯分離

越是跟業務無關的代碼越是容易複用,越是針對特定業務的代碼越難複用。所以,爲了複用跟業務無關的代碼,我們將業務和非業務邏輯代碼分離,抽取成一些通用的框架、類庫、組件等。

  • 通用代碼下沉

從分層的角度來看,越底層的代碼越通用、會被越多的模塊調用,越應該設計得足夠可複用。一般情況下,在代碼分層之後,爲了避免交叉調用導致調用關係混亂,我們只允許上層代碼調用下層代碼及同層代碼之間的調用,杜絕下層代碼調用上層代碼。所以,通用的代碼我們儘量下沉到更下層。

  • 繼承、多態、抽象、封裝

在講面向對象特性的時候,我們講到,利用繼承,可以將公共的代碼抽取到父類,子類複用父類的屬性和方法。利用多態,我們可以動態地替換一段代碼的部分邏輯,讓這段代碼可複用。除此之外,抽象和封裝,從更加廣義的層面、而非狹義的面向對象特性的層面來理解的話,越抽象、越不依賴具體的實現,越容易複用。代碼封裝成模塊,隱藏可變的細節、暴露不變的接口,就越容易複用。

  • 應用模板等設計模式

一些設計模式,也能提高代碼的複用性。比如,模板模式利用了多態來實現,可以靈活地替換其中的部分代碼,整個流程模板代碼可複用。關於應用設計模式提高代碼複用性這一部分,我們留在後面慢慢來講解。

除了剛剛我們講到的幾點,還有一些跟編程語言相關的特性,也能提高代碼的複用性,比如泛型編程等。實際上,除了上面講到的這些方法之外,複用意識也非常重要。在寫代碼的時候,我們要多去思考一下,這個部分代碼是否可以抽取出來,作爲一個獨立的模塊、類或者函數供多處使用。在設計每個模塊、類、函數的時候,要像設計一個外部 API 那樣,去思考它的複用性。

辯證思考和靈活應用

實際上,編寫可複用的代碼並不簡單。如果我們在編寫代碼的時候,已經有複用的需求場景,那根據複用的需求去開發可複用的代碼,可能還不算難。但是,如果當下並沒有複用的需求,我們只是希望現在編寫的代碼具有可複用的特點,能在未來某個同事開發某個新功能的時候複用得上。在這種沒有具體複用需求的情況下,我們就需要去預測將來代碼會如何複用,這就比較有挑戰了。

實際上,除非有非常明確的複用需求,否則,爲了暫時用不到的複用需求,花費太多的時間、精力,投入太多的開發成本,並不是一個值得推薦的做法。這也違反我們之前講到的 YAGNI 原則。

除此之外,有一個著名的原則,叫作“Rule of Three”。這條原則可以用在很多行業和場景中,你可以自己去研究一下。如果把這個原則用在這裏,那就是說,我們在第一次寫代碼的時候,如果當下沒有複用的需求,而未來的複用需求也不是特別明確,並且開發可複用代碼的成本比較高,那我們就不需要考慮代碼的複用性。在之後我們開發新的功能的時候,發現可以複用之前寫的這段代碼,那我們就重構這段代碼,讓其變得更加可複用。

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

重點回顧

今天的內容到此就講完了。我們一塊來回顧一下,你需要重點掌握的內容。

1.DRY 原則

我們今天講了三種代碼重複的情況:實現邏輯重複、功能語義重複、代碼執行重複。實現邏輯重複,但功能語義不重複的代碼,並不違反 DRY 原則。實現邏輯不重複,但功能語義重複的代碼,也算是違反 DRY 原則。除此之外,代碼執行重複也算是違反 DRY 原則。

2. 代碼複用性

今天,我們講到提高代碼可複用性的一些方法,有以下 7 點。

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

實際上,除了上面講到的這些方法之外,複用意識也非常重要。在設計每個模塊、類、函數的時候,要像設計一個外部 API 一樣去思考它的複用性。

我們在第一次寫代碼的時候,如果當下沒有複用的需求,而未來的複用需求也不是特別明確,並且開發可複用代碼的成本比較高,那我們就不需要考慮代碼的複用性。在之後開發新的功能的時候,發現可以複用之前寫的這段代碼,那我們就重構這段代碼,讓其變得更加可複用。

相比於代碼的可複用性,DRY 原則適用性更強一些。我們可以不寫可複用的代碼,但一定不能寫重複的代碼。

課堂討論

除了實現邏輯重複、功能語義重複、代碼執行重複,你還知道有哪些其他類型的代碼重複?這些代碼重複是否違反 DRY 原則?

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