設計模式之美 - 36 | 實戰二(上):程序出錯該返回啥?NULL、異常、錯誤碼、空對象?

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

設計模式之美 - 36 | 實戰二(上):程序出錯該返回啥?NULL、異常、錯誤碼、空對象?

我們可以把函數的運行結果分爲兩類。一類是預期的結果,也就是函數在正常情況下輸出的結果。一類是非預期的結果,也就是函數在異常(或叫出錯)情況下輸出的結果。比如,在上一節課中,獲取本機名的函數,在正常情況下,函數返回字符串格式的本機名;在異常情況下,獲取本機名失敗,函數返回 UnknownHostException 異常對象。

在正常情況下,函數返回數據的類型非常明確,但是,在異常情況下,函數返回的數據類型卻非常靈活,有多種選擇。除了剛剛提到的類似 UnknownHostException 這樣的異常對象之外,函數在異常情況下還可以返回錯誤碼、NULL 值、特殊值(比如 -1)、空對象(比如空字符串、空集合)等。

每一種異常返回數據類型,都有各自的特點和適用場景。但有的時候,在異常情況下,函數到底該返回什麼樣的數據類型,並不那麼容易判斷。比如,上節課中,在本機名獲取失敗的時候,ID 生成器的 generate() 函數應該返回什麼呢?是異常?空字符?還是 NULL 值?又或者是其他特殊值(比如 null-15293834874-fd3A9KBn,null 表示本機名未獲取到)呢?

函數是代碼的一個非常重要的編寫單元,而函數的異常處理,又是我們在編寫函數的時候,時刻都要考慮的。所以,今天我們就聊一聊,如何設計函數在異常情況下的返回數據類型。

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

從上節課的 ID 生成器代碼講起

上兩節課中,我們把一份非常簡單的 ID 生成器的代碼,從“能用”重構成了“好用”。最終給出的代碼看似已經很完美了,但是如果我們再用心推敲一下,代碼中關於出錯處理的方式,還有進一步優化的空間,值得我們拿出來再討論一下。

爲了方便你查看,我將上節課的代碼拷貝到了這裏。

public class RandomIdGenerator implements IdGenerator {
	private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerato
	@Override
	public String generate() {
		String substrOfHostName = getLastFiledOfHostName();
		long currentTimeMillis = System.currentTimeMillis();
		String randomString = generateRandomAlphameric(8);
		String id = String.format("%s-%d-%s",
		substrOfHostName, currentTimeMillis, randomString);
		return id;
	}
	private String getLastFiledOfHostName() {
		String substrOfHostName = null;
		try {
			String hostName = InetAddress.getLocalHost().getHostName();
			substrOfHostName = getLastSubstrSplittedByDot(hostName);
		} catch (UnknownHostException e) {
			logger.warn("Failed to get the host name.", e);
		}
		return substrOfHostName;
	}
	@VisibleForTesting
	protected String getLastSubstrSplittedByDot(String hostName) {
		String[] tokens = hostName.split("\\.");
		String substrOfHostName = tokens[tokens.length - 1];
		return substrOfHostName;
	}
	@VisibleForTesting
	protected String generateRandomAlphameric(int length) {
		char[] randomChars = new char[length];
		int count = 0;
		Random random = new Random();
		while (count < length) {
			int maxAscii = 'z';
			int randomAscii = random.nextInt(maxAscii);
			boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
			boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
			boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
			if (isDigit|| isUppercase || isLowercase) {
				randomChars[count] = (char) (randomAscii);
				++count;
			}
		}
		return new String(randomChars);
	}
}

這段代碼中有四個函數。針對這四個函數的出錯處理方式,我總結出下面這樣幾個問題。

  • 對於 generate() 函數,如果本機名獲取失敗,函數返回什麼?這樣的返回值是否合理?
  • 對於 getLastFiledOfHostName() 函數,是否應該將 UnknownHostException 異常在函數內部吞掉(try-catch 並打印日誌)?還是應該將異常繼續往上拋出?如果往上拋出的話,是直接把 UnknownHostException 異常原封不動地拋出,還是封裝成新的異常拋出?
  • 對於 getLastSubstrSplittedByDot(String hostName) 函數,如果 hostName 爲NULL 或者是空字符串,這個函數應該返回什麼?
  • 對於 generateRandomAlphameric(int length) 函數,如果 length 小於 0 或者等於0,這個函數應該返回什麼?

對於上面這幾個問題,你可以試着思考下,我先不做解答。等我們學完本節課的理論內容之後,我們下一節課再一塊來分析。這一節我們重點講解一些理論方面的知識。

函數出錯應該返回啥?

關於函數出錯返回數據類型,我總結了 4 種情況,它們分別是:錯誤碼、NULL 值、空對象、異常對象。接下來,我們就一一來看它們的用法以及適用場景。

1. 返回錯誤碼

C 語言中沒有異常這樣的語法機制,因此,返回錯誤碼便是最常用的出錯處理方式。而在Java、Python 等比較新的編程語言中,大部分情況下,我們都用異常來處理函數出錯的情況,極少會用到錯誤碼。

在 C 語言中,錯誤碼的返回方式有兩種:一種是直接佔用函數的返回值,函數正常執行的返回值放到出參中;另一種是將錯誤碼定義爲全局變量,在函數執行出錯時,函數調用者通過這個全局變量來獲取錯誤碼。針對這兩種方式,我舉個例子來進一步解釋。具體代碼如下所示:

// 錯誤碼的返回方式一:pathname/flags/mode爲入參;fd爲出參,存儲打開的文件句柄。
int open(const char *pathname, int flags, mode_t mode, int* fd) {
	if (/*文件不存在*/) {
		return EEXIST;
	}
	if (/*沒有訪問權限*/) {
		return EACCESS;
	}
	if (/*打開文件成功*/) {
		return SUCCESS; // C語言中的宏定義:#define SUCCESS 0
	}
	// ...
}

//使用舉例
int fd;
int result = open(“c:\test.txt”, O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO, &fd);
if (result == SUCCESS) {
	// 取出fd使用
} else if (result == EEXIST) {
	//...
} else if (result == EACESS) {
	//...
}

// 錯誤碼的返回方式二:函數返回打開的文件句柄,錯誤碼放到errno中。
int errno; // 線程安全的全局變量
int open(const char *pathname, int flags, mode_t mode){
	if (/*文件不存在*/) {
		errno = EEXIST;
		return -1;
	}
	if (/*沒有訪問權限*/) {
		errno = EACCESS;
		return -1;
	}
	// ...
}
// 使用舉例
int hFile = open(“c:\test.txt”, O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO);
if (-1 == hFile) {
	printf("Failed to open file, error no: %d.\n", errno);
	if (errno == EEXIST ) {
		// ...
	} else if(errno == EACCESS) {
		// ...
	}
	// ...
}

實際上,如果你熟悉的編程語言中有異常這種語法機制,那就儘量不要使用錯誤碼。異常相對於錯誤碼,有諸多方面的優勢,比如可以攜帶更多的錯誤信息(exception 中可以有message、stack trace 等信息)等。關於異常,我們待會還會非常詳細地講解。

2. 返回 NULL 值

在多數編程語言中,我們用 NULL 來表示“不存在”這種語義。不過,網上很多人不建議函數返回 NULL 值,認爲這是一種不好的設計思路,主要的理由有以下兩個。

  • 如果某個函數有可能返回 NULL 值,我們在使用它的時候,忘記了做 NULL 值判斷,就有可能會拋出空指針異常(Null Pointer Exception,縮寫爲 NPE)。

  • 如果我們定義了很多返回值可能爲 NULL 的函數,那代碼中就會充斥着大量的 NULL 值判斷邏輯,一方面寫起來比較繁瑣,另一方面它們跟正常的業務邏輯耦合在一起,會影響代碼的可讀性。

我舉個例子解釋一下,具體代碼如下所示:

public class UserService {
	private UserRepo userRepo; // 依賴注入
	
	public User getUser(String telephone) {
		// 如果用戶不存在,則返回null
		return null;
	}
}

// 使用函數getUser()
User user = userService.getUser("18917718965");
if (user != null) { // 做NULL值判斷,否則有可能會報NPE
	String email = user.getEmail();
	if (email != null) { // 做NULL值判斷,否則有可能會報NPE
		String escapedEmail = email.replaceAll("@", "#");
	}
}

那我們是否可以用異常來替代 NULL 值,在查找用戶不存在的時候,讓函數拋出UserNotFoundException 異常呢?

我個人覺得,儘管返回 NULL 值有諸多弊端,但對於以 get、find、select、search、query 等單詞開頭的查找函數來說,數據不存在,並非一種異常情況,這是一種正常行爲。所以,返回代表不存在語義的 NULL 值比返回異常更加合理。

不過,話說回來,剛剛講的這個理由,也並不是特別有說服力。對於查找數據不存在的情況,函數到底是該用 NULL 值還是異常,有一個比較重要的參考標準是,看項目中的其他類似查找函數都是如何定義的,只要整個項目遵從統一的約定即可。如果項目從零開始開發,並沒有統一約定和可以參考的代碼,那你選擇兩者中的任何一種都可以。你只需要在函數定義的地方註釋清楚,讓調用者清晰地知道數據不存在的時候會返回什麼就可以了。

再補充說明一點,對於查找函數來說,除了返回數據對象之外,有的還會返回下標位置,比如 Java 中的 indexOf() 函數,用來實現在某個字符串中查找另一個子串第一次出現的位置。函數的返回值類型爲基本類型 int。這個時候,我們就無法用 NULL 值來表示不存在的情況了。對於這種情況,我們有兩種處理思路,一種是返回 NotFoundException,一種是返回一個特殊值,比如 -1。不過,顯然 -1 更加合理,理由也是同樣的,也就是說“沒有查找到”是一種正常而非異常的行爲。

3. 返回空對象

剛剛我們講到,返回 NULL 值有各種弊端。應對這個問題有一個比較經典的策略,那就是應用空對象設計模式(Null Object Design Pattern)。關於這個設計模式,我們在後面章節會詳細講,現在就不展開來講解了。不過,我們今天來講兩種比較簡單、比較特殊的空對象,那就是空字符串和空集合

當函數返回的數據是字符串類型或者集合類型的時候,我們可以用空字符串或空集合替代NULL 值,來表示不存在的情況。這樣,我們在使用函數的時候,就可以不用做 NULL 值判斷。我舉個例子來解釋下。具體代碼如下所示:

// 使用空集合替代NULL
public class UserService {
	private UserRepo userRepo; // 依賴注入
	public List<User> getUsers(String telephonePrefix) {
		// 沒有查找到數據
		return Collectiosn.emptyList();
	}
}

// getUsers使用示例
List<User> users = userService.getUsers("189");
for (User user : users) { //這裏不需要做NULL值判斷
	// ...
}

// 使用空字符串替代NULL
public String retrieveUppercaseLetters(String text) {
	// 如果text中沒有大寫字母,返回空字符串,而非NULL值
	return "";
}

// retrieveUppercaseLetters()使用舉例
String uppercaseLetters = retrieveUppercaseLetters("wangzheng");
int length = uppercaseLetters.length();// 不需要做NULL值判斷
System.out.println("Contains " + length + " upper case letters.");

4. 拋出異常對象

儘管前面講了很多函數出錯的返回數據類型,但是,最常用的函數出錯處理方式就是拋出異常。異常可以攜帶更多的錯誤信息,比如函數調用棧信息。除此之外,異常可以將正常邏輯和異常邏輯的處理分離開來,這樣代碼的可讀性就會更好。

不同的編程語言的異常語法稍有不同。像 C++ 和大部分的動態語言(Python、Ruby、JavaScript 等)都只定義了一種異常類型:運行時異常(Runtime Exception)。而像Java,除了運行時異常外,還定義了另外一種異常類型:編譯時異常(Compile Exception)。

對於運行時異常,我們在編寫代碼的時候,可以不用主動去 try-catch,編譯器在編譯代碼的時候,並不會檢查代碼是否有對運行時異常做了處理。相反,對於編譯時異常,我們在編寫代碼的時候,需要主動去 try-catch 或者在函數定義中聲明,否則編譯就會報錯。所以,運行時異常也叫作非受檢異常(Unchecked Exception),編譯時異常也叫作受檢異常(Checked Exception)。

如果你熟悉的編程語言中,只定義了一種異常類型,那用起來反倒比較簡單。如果你熟悉的編程語言中(比如 Java),定義了兩種異常類型,那在異常出現的時候,我們應該選擇拋出哪種異常類型呢?是受檢異常還是非受檢異常?

對於代碼 bug(比如數組越界)以及不可恢復異常(比如數據庫連接失敗),即便我們捕獲了,也做不了太多事情,所以,我們傾向於使用非受檢異常。對於可恢復異常、業務異常,比如提現金額大於餘額的異常,我們更傾向於使用受檢異常,明確告知調用者需要捕獲處理。

我舉一個例子解釋一下,代碼如下所示。當 Redis 的地址(參數 address)沒有設置的時候,我們直接使用默認的地址(比如本地地址和默認端口);當 Redis 的地址格式不正確的時候,我們希望程序能 fail-fast,也就是說,把這種情況當成不可恢復的異常,直接拋出運行時異常,將程序終止掉。

// address格式:"192.131.2.33:7896"
public void parseRedisAddress(String address) {
	this.host = RedisConfig.DEFAULT_HOST;
	this.port = RedisConfig.DEFAULT_PORT;
	if (StringUtils.isBlank(address)) {
		return;
	}
	String[] ipAndPort = address.split(":");
	if (ipAndPort.length != 2) {
		throw new RuntimeException("...");
	}
	this.host = ipAndPort[0];
	// parseInt()解析失敗會拋出NumberFormatException運行時異常
	this.port = Integer.parseInt(ipAndPort[1]);
}

實際上,Java 支持的受檢異常一直被人詬病,很多人主張所有的異常情況都應該使用非受檢異常。支持這種觀點的理由主要有以下三個。

  • 受檢異常需要顯式地在函數定義中聲明。如果函數會拋出很多受檢異常,那函數的定義就會非常冗長,這就會影響代碼的可讀性,使用起來也不方便。

  • 編譯器強制我們必須顯示地捕獲所有的受檢異常,代碼實現會比較繁瑣。而非受檢異常正好相反,我們不需要在定義中顯示聲明,並且是否需要捕獲處理,也可以自由決定。

  • 受檢異常的使用違反開閉原則。如果我們給某個函數新增一個受檢異常,這個函數所在的函數調用鏈上的所有位於其之上的函數都需要做相應的代碼修改,直到調用鏈中的某個函數將這個新增的異常 try-catch 處理掉爲止。而新增非受檢異常可以不改動調用鏈上的代碼。我們可以靈活地選擇在某個函數中集中處理,比如在 Spring 中的 AOP 切面中集中處理異常。

不過,非受檢異常也有弊端,它的優點其實也正是它的缺點。從剛剛的表述中,我們可以看出,非受檢異常使用起來更加靈活,怎麼處理的主動權這裏就交給了程序員。我們前面也講到,過於靈活會帶來不可控,非受檢異常不需要顯式地在函數定義中聲明,那我們在使用函數的時候,就需要查看代碼才能知道具體會拋出哪些異常。非受檢異常不需要強制捕獲處理,那程序員就有可能漏掉一些本應該捕獲處理的異常。

對於應該用受檢異常還是非受檢異常,網上的爭論有很多,但並沒有一個非常強有力的理由能夠說明一個就一定比另一個更好。所以,我們只需要根據團隊的開發習慣,在同一個項目中,制定統一的異常處理規範即可。

剛剛我們講了兩種異常類型,現在我們再來講下,**如何處理函數拋出的異常?**總結一下,一般有下面三種處理方法。

  • 直接吞掉。具體的代碼示例如下所示:
public void func1() throws Exception1 {
	// ...
}
public void func2() {
	//...
	try {
		func1();
	} catch(Exception1 e) {
		log.warn("...", e); //吐掉:try-catch打印日誌
	}
	//...
}
  • 原封不動地 re-throw。具體的代碼示例如下所示:
public void func1() throws Exception1 {
	// ...
}
public void func2() throws Exception1 {//原封不動的re-throw Exception1
	//...
	func1();
	//...
}
  • 包裝成新的異常 re-throw。具體的代碼示例如下所示:
public void func1() throws Exception1 {
	// ...
}
public void func2() throws Exception2 {
	//...
	try {
		func1();
	} catch(Exception1 e) {
		throw new Exception2("...", e); // wrap成新的Exception2然後re-throw
	}
	//...
}

當我們面對函數拋出異常的時候,應該選擇上面的哪種處理方式呢?我總結了下面三個參考原則:

  • 如果 func1() 拋出的異常是可以恢復,且 func2() 的調用方並不關心此異常,我們完全可以在 func2() 內將 func1() 拋出的異常吞掉;

  • 如果 func1() 拋出的異常對 func2() 的調用方來說,也是可以理解的、關心的 ,並且在業務概念上有一定的相關性,我們可以選擇直接將 func1 拋出的異常 re-throw;

  • 如果 func1() 拋出的異常太底層,對 func2() 的調用方來說,缺乏背景去理解、且業務概念上無關,我們可以將它重新包裝成調用方可以理解的新異常,然後 re-throw。

總之,是否往上繼續拋出,要看上層代碼是否關心這個異常。關心就將它拋出,否則就直接吞掉。是否需要包裝成新的異常拋出,看上層代碼是否能理解這個異常、是否業務相關。如果能理解、業務相關就可以直接拋出,否則就封裝成新的異常拋出。關於這部分理論知識,我們在下一節課中,會結合 ID 生成器的代碼來進一步講解。

重點回顧

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

對於函數出錯返回數據類型,我總結了 4 種情況,它們分別是:錯誤碼、NULL 值、空對象、異常對象。

1. 返回錯誤碼

C 語言沒有異常這樣的語法機制,返回錯誤碼便是最常用的出錯處理方式。而 Java、Python 等比較新的編程語言中,大部分情況下,我們都用異常來處理函數出錯的情況,極少會用到錯誤碼。

2. 返回 NULL 值

在多數編程語言中,我們用 NULL 來表示“不存在”這種語義。對於查找函數來說,數據不存在並非一種異常情況,是一種正常行爲,所以返回表示不存在語義的 NULL 值比返回異常更加合理。

3. 返回空對象

返回 NULL 值有各種弊端,對此有一個比較經典的應對策略,那就是應用空對象設計模式。當函數返回的數據是字符串類型或者集合類型的時候,我們可以用空字符串或空集合替代 NULL 值,來表示不存在的情況。這樣,我們在使用函數的時候,就可以不用做 NULL值判斷。

4. 拋出異常對象

儘管前面講了很多函數出錯的返回數據類型,但是,最常用的函數出錯處理方式是拋出異常。異常有兩種類型:受檢異常和非受檢異常。

對於應該用受檢異常還是非受檢異常,網上的爭論有很多,但也並沒有一個非常強有力的理由,說明一個就一定比另一個更好。所以,我們只需要根據團隊的開發習慣,在同一個項目中,制定統一的異常處理規範即可。

對於函數拋出的異常,我們有三種處理方法:直接吞掉、直接往上拋出、包裹成新的異常拋出。這一部分我們留在下一節課中結合實戰進一步講解。

課堂討論

結合我們今天學的理論知識,試着回答一下在文章開頭針對 RandomIdGenerator 提到的四個問題。

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