設計模式之美 - 33 | 理論五:讓你最快速地改善代碼質量的20條編程規範(下)

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

上兩節課,我們講了命名和註釋、代碼風格,今天我們來講一些比較實用的編程技巧,幫你切實地提高代碼可讀性。這部分技巧比較瑣碎,也很難羅列全面,我僅僅總結了一些我認爲比較關鍵的,更多的技巧需要你在實踐中自己慢慢總結、積累。

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

1. 把代碼分割成更小的單元塊

大部分人閱讀代碼的習慣都是,先看整體再看細節。所以,我們要有模塊化和抽象思維,善於將大塊的複雜邏輯提煉成類或者函數,屏蔽掉細節,讓閱讀代碼的人不至於迷失在細節中,這樣能極大地提高代碼的可讀性。不過,只有代碼邏輯比較複雜的時候,我們其實才建議提煉類或者函數。畢竟如果提煉出的函數只包含兩三行代碼,在閱讀代碼的時候,還得跳過去看一下,這樣反倒增加了閱讀成本。

這裏我舉一個例子來進一步解釋一下。代碼具體如下所示。重構前,在 invest() 函數中,最開始的那段關於時間處理的代碼,是不是很難看懂?重構之後,我們將這部分邏輯抽象成一個函數,並且命名爲 isLastDayOfMonth,從名字就能清晰地瞭解它的功能,判斷今天是不是當月的最後一天。這裏,我們就是通過將複雜的邏輯代碼提煉成函數,大大提高了代碼的可讀性。

// 重構前的代碼
public void invest(long userId, long financialProductId) {
	Calendar calendar = Calendar.getInstance();
	calendar.setTime(date);
	calendar.set(Calendar.DATE, (calendar.get(Calendar.DATE) + 1));
	if (calendar.get(Calendar.DAY_OF_MONTH) == 1) {
		return;
	}
	//...
}
// 重構後的代碼:提煉函數之後邏輯更加清晰
public void invest(long userId, long financialProductId) {
	if (isLastDayOfMonth(new Date())) {
		return;
	}
	//...
}
public boolean isLastDayOfMonth(Date date) {
	Calendar calendar = Calendar.getInstance();
	calendar.setTime(date);
	calendar.set(Calendar.DATE, (calendar.get(Calendar.DATE) + 1));
	if (calendar.get(Calendar.DAY_OF_MONTH) == 1) {
		return true;
	}
	return false;
}

2. 避免函數參數過多

我個人覺得,函數包含 3、4 個參數的時候還是能接受的,大於等於 5 個的時候,我們就覺得參數有點過多了,會影響到代碼的可讀性,使用起來也不方便。針對參數過多的情況,一般有 2 種處理方法。

  • 考慮函數是否職責單一,是否能通過拆分成多個函數的方式來減少參數。示例代碼如下所示:
public void getUser(String username, String telephone, String email);

// 拆分成多個函數
public void getUserByUsername(String username);
public void getUserByTelephone(String telephone);
public void getUserByEmail(String email);
  • 將函數的參數封裝成對象。示例代碼如下所示:
public void postBlog(String title, String summary, String keywords, String cont
// 將參數封裝成對象
public class Blog {
	private String title;
	private String summary;
	private String keywords;
	private Strint content;
	private String category;
	private long authorId;
}
public void postBlog(Blog blog);

除此之外,如果函數是對外暴露的遠程接口,將參數封裝成對象,還可以提高接口的兼容性。在往接口中添加新的參數的時候,老的遠程接口調用者有可能就不需要修改代碼來兼容新的接口了。

3. 勿用函數參數來控制邏輯

不要在函數中使用布爾類型的標識參數來控制內部邏輯,true 的時候走這塊邏輯,false 的時候走另一塊邏輯。這明顯違背了單一職責原則和接口隔離原則。我建議將其拆成兩個函數,可讀性上也要更好。我舉個例子來說明一下。

public void buyCourse(long userId, long courseId, boolean isVip);
// 將其拆分成兩個函數
public void buyCourse(long userId, long courseId);
public void buyCourseForVip(long userId, long courseId);

不過,如果函數是 private 私有函數,影響範圍有限,或者拆分之後的兩個函數經常同時被調用,我們可以酌情考慮保留標識參數。示例代碼如下所示:

// 拆分成兩個函數的調用方式
boolean isVip = false;
//...省略其他邏輯...
if (isVip) {
	buyCourseForVip(userId, courseId);
} else {
	buyCourse(userId, courseId);
}

// 保留標識參數的調用方式更加簡潔
boolean isVip = false;
//...省略其他邏輯...
buyCourse(userId, courseId, isVip);

除了布爾類型作爲標識參數來控制邏輯的情況外,還有一種“根據參數是否爲 null”來控制邏輯的情況。針對這種情況,我們也應該將其拆分成多個函數。拆分之後的函數職責更明確,不容易用錯。具體代碼示例如下所示:

public List<Transaction> selectTransactions(Long userId, Date startDate, Date e
	if (startDate != null && endDate != null) {
		// 查詢兩個時間區間的transactions
	}
	if (startDate != null && endDate == null) {
		// 查詢startDate之後的所有transactions
	}
	if (startDate == null && endDate != null) {
		// 查詢endDate之前的所有transactions
	}
	if (startDate == null && endDate == null) {
		// 查詢所有的transactions
	}
}

// 拆分成多個public函數,更加清晰、易用
public List<Transaction> selectTransactionsBetween(Long userId, Date startDate
	return selectTransactions(userId, startDate, endDate);
}
public List<Transaction> selectTransactionsStartWith(Long userId, Date startDat
	return selectTransactions(userId, startDate, null);
}
public List<Transaction> selectTransactionsEndWith(Long userId, Date endDate) {
	return selectTransactions(userId, null, endDate);
}
public List<Transaction> selectAllTransactions(Long userId) {
	return selectTransactions(userId, null, null);
}
private List<Transaction> selectTransactions(Long userId, Date startDate, Date
	// ...
}

4. 函數設計要職責單一

我們在前面講到單一職責原則的時候,針對的是類、模塊這樣的應用對象。實際上,對於函數的設計來說,更要滿足單一職責原則。相對於類和模塊,函數的粒度比較小,代碼行數少,所以在應用單一職責原則的時候,沒有像應用到類或者模塊那樣模棱兩可,能多單一就多單一。

具體的代碼示例如下所示:

public boolean checkUserIfExisting(String telephone, String username, String em
	if (!StringUtils.isBlank(telephone)) {
		User user = userRepo.selectUserByTelephone(telephone);
		return user != null;
	}
	if (!StringUtils.isBlank(username)) {
		User user = userRepo.selectUserByUsername(username);
		return user != null;
	}
	if (!StringUtils.isBlank(email)) {
		User user = userRepo.selectUserByEmail(email);
		return user != null;
	}
	return false;
}

// 拆分成三個函數
public boolean checkUserIfExistingByTelephone(String telephone);
public boolean checkUserIfExistingByUsername(String username);
public boolean checkUserIfExistingByEmail(String email);

5. 移除過深的嵌套層次

代碼嵌套層次過深往往是因爲 if-else、switch-case、for 循環過度嵌套導致的。我個人建議,嵌套最好不超過兩層,超過兩層之後就要思考一下是否可以減少嵌套。過深的嵌套本身理解起來就比較費勁,除此之外,嵌套過深很容易因爲代碼多次縮進,導致嵌套內部的語句超過一行的長度而折成兩行,影響代碼的整潔。

解決嵌套過深的方法也比較成熟,有下面 4 種常見的思路。

去掉多餘的 if 或 else 語句。代碼示例如下所示:

// 示例一
public double caculateTotalAmount(List<Order> orders) {
	if (orders == null || orders.isEmpty()) {
		return 0.0;
	} else { // 此處的else可以去掉
		double amount = 0.0;
		for (Order order : orders) {
			if (order != null) {
				amount += (order.getCount() * order.getPrice());
			}
		}
		return amount;
	}
}
// 示例二
public List<String> matchStrings(List<String> strList,String substr) {
	List<String> matchedStrings = new ArrayList<>();
	if (strList != null && substr != null) {
		for (String str : strList) {
			if (str != null) { // 跟下面的if語句可以合併在一起
				if (str.contains(substr)) {
					matchedStrings.add(str);
				}
			}
		}
	}
	return matchedStrings;
}

使用編程語言提供的 continue、break、return 關鍵字,提前退出嵌套。代碼示例如下所示:

// 重構前的代碼
public List<String> matchStrings(List<String> strList,String substr) {
	List<String> matchedStrings = new ArrayList<>();
	if (strList != null && substr != null){
		for (String str : strList) {
			if (str != null && str.contains(substr)) {
				matchedStrings.add(str);
				// 此處還有10行代碼...
			}
		}
	}
	return matchedStrings;
}
// 重構後的代碼:使用continue提前退出
public List<String> matchStrings(List<String> strList,String substr) {
	List<String> matchedStrings = new ArrayList<>();
	if (strList != null && substr != null){
		for (String str : strList) {
			if (str == null || !str.contains(substr)) {
				continue;
			}
			matchedStrings.add(str);
			// 此處還有10行代碼...
		}
	}
	return matchedStrings;
}

調整執行順序來減少嵌套。具體的代碼示例如下所示:

// 重構前的代碼
public List<String> matchStrings(List<String> strList,String substr) {
	List<String> matchedStrings = new ArrayList<>();
	if (strList != null && substr != null) {
		for (String str : strList) {
			if (str != null) {
				if (str.contains(substr)) {
					matchedStrings.add(str);
				}
			}
		}
	}
	return matchedStrings;
}
// 重構後的代碼:先執行判空邏輯,再執行正常邏輯
public List<String> matchStrings(List<String> strList,String substr) {
	if (strList == null || substr == null) { //先判空
		return Collections.emptyList();
	}
	List<String> matchedStrings = new ArrayList<>();
	for (String str : strList) {
		if (str != null) {
			if (str.contains(substr)) {
				matchedStrings.add(str);
			}
		}
	}
	return matchedStrings;
}

將部分嵌套邏輯封裝成函數調用,以此來減少嵌套。具體的代碼示例如下所示:

// 重構前的代碼
public List<String> appendSalts(List<String> passwords) {
	if (passwords == null || passwords.isEmpty()) {
		return Collections.emptyList();
	}
	List<String> passwordsWithSalt = new ArrayList<>();
	for (String password : passwords) {
		if (password == null) {
			continue;
		}
		if (password.length() < 8) {
			// ...
		} else {
			// ...
		}
	}
	return passwordsWithSalt;
}
// 重構後的代碼:將部分邏輯抽成函數
public List<String> appendSalts(List<String> passwords) {
	if (passwords == null || passwords.isEmpty()) {
		return Collections.emptyList();
	}
	List<String> passwordsWithSalt = new ArrayList<>();
	for (String password : passwords) {
		if (password == null) {
			continue;
		}
		passwordsWithSalt.add(appendSalt(password));
	}
	return passwordsWithSalt;
}

private String appendSalt(String password) {
	String passwordWithSalt = password;
	if (password.length() < 8) {
		// ...
	} else {
		// ...
	}
	return passwordWithSalt;
}

除此之外,常用的還有通過使用多態來替代 if-else、switch-case 條件判斷的方法。這個思路涉及代碼結構的改動,我們會在後面的章節中講到,這裏就暫時不展開說明了。

6. 學會使用解釋性變量

常用的用解釋性變量來提高代碼的可讀性的情況有下面 2 種。

常量取代魔法數字。示例代碼如下所示:

public double CalculateCircularArea(double radius) {
	return (3.1415) * radius * radius;
}

// 常量替代魔法數字
public static final Double PI = 3.1415;
public double CalculateCircularArea(double radius) {
	return PI * radius * radius;
}

使用解釋性變量來解釋複雜表達式。示例代碼如下所示:

if (date.after(SUMMER_START) && date.before(SUMMER_END)) {
	// ...
} else {
	// ...
}

// 引入解釋性變量後邏輯更加清晰
boolean isSummer = date.after(SUMMER_START)&&date.before(SUMMER_END);
if (isSummer) {
	// ...
} else {
	// ...
}

重點回顧

好了,今天的內容到此就講完了。除了今天講的編程技巧,前兩節課我們還分別講解了命名與註釋、代碼風格。現在,我們一塊來回顧複習一下這三節課的重點內容。

1. 關於命名

  • 命名的關鍵是能準確達意。對於不同作用域的命名,我們可以適當地選擇不同的長度。

  • 我們可以藉助類的信息來簡化屬性、函數的命名,利用函數的信息來簡化函數參數的命名。

  • 命名要可讀、可搜索。不要使用生僻的、不好讀的英文單詞來命名。命名要符合項目的統一規範,也不要用些反直覺的命名。

  • 接口有兩種命名方式:一種是在接口中帶前綴“I”;另一種是在接口的實現類中帶後綴“Impl”。對於抽象類的命名,也有兩種方式,一種是帶上前綴“Abstract”,一種是不帶前綴。這兩種命名方式都可以,關鍵是要在項目中統一。

2. 關於註釋

  • 註釋的內容主要包含這樣三個方面:做什麼、爲什麼、怎麼做。對於一些複雜的類和接口,我們可能還需要寫明“如何用”。

  • 類和函數一定要寫註釋,而且要寫得儘可能全面詳細。函數內部的註釋要相對少一些,一般都是靠好的命名、提煉函數、解釋性變量、總結性註釋來提高代碼可讀性。

3. 關於代碼風格

  • 函數、類多大才合適?函數的代碼行數不要超過一屏幕的大小,比如 50 行。類的大小限制比較難確定。

  • 一行代碼多長最合適?最好不要超過 IDE 的顯示寬度。當然,也不能太小,否則會導致很多稍微長點的語句被折成兩行,也會影響到代碼的整潔,不利於閱讀。

  • 善用空行分割單元塊。對於比較長的函數,爲了讓邏輯更加清晰,可以使用空行來分割各個代碼塊。

  • 四格縮進還是兩格縮進?我個人比較推薦使用兩格縮進,這樣可以節省空間,尤其是在代碼嵌套層次比較深的情況下。不管是用兩格縮進還是四格縮進,一定不要用 tab 鍵縮進。

  • 大括號是否要另起一行?將大括號放到跟上一條語句同一行,可以節省代碼行數。但是將大括號另起新的一行的方式,左右括號可以垂直對齊,哪些代碼屬於哪一個代碼塊,更加一目瞭然。

  • 類中成員怎麼排列?在 Google Java 編程規範中,依賴類按照字母序從小到大排列。類中先寫成員變量後寫函數。成員變量之間或函數之間,先寫靜態成員變量或函數,後寫普通變量或函數,並且按照作用域大小依次排列。

4. 關於編碼技巧

  • 將複雜的邏輯提煉拆分成函數和類。

  • 通過拆分成多個函數或將參數封裝爲對象的方式,來處理參數過多的情況。

  • 函數中不要使用參數來做代碼執行邏輯的控制。

  • 函數設計要職責單一。

  • 移除過深的嵌套層次,方法包括:去掉多餘的 if 或 else 語句,使用 continue、break、return 關鍵字提前退出嵌套,調整執行順序來減少嵌套,將部分嵌套邏輯抽象成函數。

  • 用字面常量取代魔法數。

  • 用解釋性變量來解釋複雜表達式,以此提高代碼可讀性。

5. 統一編碼規範

除了這三節講到的比較細節的知識點之外,最後,還有一條非常重要的,那就是,項目、團隊,甚至公司,一定要制定統一的編碼規範,並且通過 Code Review 督促執行,這對提高代碼質量有立竿見影的效果。

課堂討論

到此爲止,我們整個 20 條編碼規範就講完了。不知道你掌握了多少呢?除了今天我提到的這些,還有哪些其他的編程技巧,可以明顯改善代碼的可讀性?

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