這系列相關博客,參考 設計模式之美
設計模式之美 - 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 條編碼規範就講完了。不知道你掌握了多少呢?除了今天我提到的這些,還有哪些其他的編程技巧,可以明顯改善代碼的可讀性?