實戰二(上):程序出錯該返回啥?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(RandomIdGenerator.class);

  @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 等信息)等。關於異常,我們待會還會非常詳細地講解。

  1. 返回 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 更加合理,理由也是同樣的,也就是說“沒有查找到”是一種正常而非異常的行為。

  1. 返回空對象

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

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

// 使用空集合替代NULL
public class UserService {
  private UserRepo userRepo; // 依賴注入
  
  public List<User> getUsers(String telephonePrefix) {
   // 沒有查找到數據
    return Collections.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.");
  1. 拋出異常對象

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

不同的編程語言的異常語法稍有不同。像 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 等比較新的編程語言中,大部分情況下,我們都用異常來處理函數出錯的情況,極少會用到錯誤碼。

  1. 返回 NULL 值

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

  1. 返回空對象

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

  1. 拋出異常對象

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

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

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

回答問題
1.拋出異常,因為服務器獲取不到host是一種異常情況,並且打印的異常日志不能是warm,而是err,因為該異常不會自動回復。

2.往上拋,原封不動。應該在api統一出口處處理異常,這樣異常代碼會比較聚合(個人習慣)。該異常描述已經很准確,且處理異常依舊在genId接口中,所以上層函數可以認識該異常,所以原封不動。(而統一出口函數,則可以拋自定義異常,以收斂api使用方的考慮范圍)。

3.拋出異常,null值裁剪名稱是一種異常情況。或則說,對於裁剪名稱這個函數,入參不能為null。

4.返回空字符串。小於等於0說明不需要帶隨機后綴,這也是一個正常的業務場景。返回空字符串是為了方便調用方不用做null判斷。

分歧:
1.get,find,select等dao層操作,返回null是正常業務情況,表示數據不存在。但在其應用層,數據不存在可能意味着有臟數據,數據缺失等情況,屬於異常情況,需要拋出異常。所以同樣是get方法,持久層返回null,業務層返回可能是異常。

2.異常流開銷大,在對響應時間要求很嚴格的場景。放棄合理的異常處理,采用不合理的特殊返回值的方式也是合理的。所以合理的運用異常流在java也是一個選擇項。在可讀和性能我們需要權衡,而這兩玩意經常是相駁的。

最后:
祝欄主和同學們新年快樂!

1、不拋。返回null-123123784378-aldjf780。從功能上講,函數是生成logtraceid,用於給記錄加id,便於查找日志。返回null不影響定位問題,同時程序不會蹦。
2、上拋,到generate中處理。
3、返回空串
4、返回空串
我個人是比較支持這一點,因為即使是返回了帶null的id,但只要它能夠達到我們的業務目的,我覺得就是ok,技術本身還是服務於業務,所以我也認為不拋,因為當前我們用此id的目的僅僅是用於定位同一個請求的log.
當然,如果后續我們需要在日志里加入某些需要追蹤的信息,而這些信息跟節點是強相關的,那么這時就需要拋出。
總的來說,就是看業務需求。

第二種返回Null的情況,可以使用Optional嗎
作者回復: 可以,Java8的語法,因為有些朋友不熟悉java語言,所以高級語法我就沒講了

異常,這對我來說是一個多么陌生的概念。
OC中的try catch不能捕獲UncaughtException,而內存溢出、野指針等大部分異常都是UncaughtException,而可以捕獲的異常基本都是我們可以預防的,所以OC中的異常處理很雞肋,也因此異常處理對我來說是真空的。異常信息依賴於**Error指針參數。
轉寫dart代碼后,發現異常處理居然是一種流程控制語句,拋出異常會影響后續代碼的執行。異常流程是一個很優雅的錯誤處理方案,用上了就停不下來。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM