二、GO 程式設計模式:錯誤處理

zhaocrazy發表於2022-02-06

錯誤處理一直以一是程式設計必需要面對的問題,錯誤處理如果做的好的話,程式碼的穩定性會很好。不同的語言有不同的出現處理的方式。Go語言也一樣,在本篇文章中,我們來討論一下Go語言的出錯出處,尤其是那令人抓狂的 if err != nil

在正式討論Go程式碼裡滿屏的 if err != nil 怎麼辦這個事之前,我想先說一說程式設計中的錯誤處理。這樣可以讓大家在更高的層面理解程式設計中的錯誤處理。

C語言的錯誤檢查

首先,我們知道,處理錯誤最直接的方式是通過錯誤碼,這也是傳統的方式,在過程式語言中通常都是用這樣的方式處理錯誤的。比如 C 語言,基本上來說,其通過函式的返回值標識是否有錯,然後通過全域性的 errno 變數並配合一個 errstr 的陣列來告訴你為什麼出錯。

為什麼是這樣的設計?道理很簡單,除了可以共用一些錯誤,更重要的是這其實是一種妥協。比如:read(), write(), open() 這些函式的返回值其實是返回有業務邏輯的值。也就是說,這些函式的返回值有兩種語義,一種是成功的值,比如 open() 返回的檔案控制程式碼指標 FILE* ,或是錯誤 NULL。這樣會導致呼叫者並不知道是什麼原因出錯了,需要去檢查 errno 來獲得出錯的原因,從而可以正確地處理錯誤。

一般而言,這樣的錯誤處理方式在大多數情況下是沒什麼問題的。但是也有例外的情況,我們來看一下下面這個 C 語言的函式:

int  atoi(const  char *str)

這個函式是把一個字串轉成整型。但是問題來了,如果一個要傳的字串是非法的(不是數字的格式),如 “ABC” 或者整型溢位了,那麼這個函式應該返回什麼呢?出錯返回,返回什麼數都不合理,因為這會和正常的結果混淆在一起。比如,返回 0,那麼會和正常的對 “0” 字元的返回值完全混淆在一起。這樣就無法判斷出錯的情況。你可能會說,是不是要檢查一下 errno,按道理說應該是要去檢查的,但是,我們在 C99 的規格說明書中可以看到這樣的描述——

7.20.1The functions atof, atoi, atol, and atoll need not affect the value of the integer expression errno on an error. If the value of the result cannot be represented, the behavior is undefined.

atoi(), atof(), atol() 或是 atoll() 這樣的函式是不會設定 errno的,而且,還說了,如果結果無法計算的話,行為是undefined。所以,後來,libc 又給出了一個新的函式strtol(),這個函式在出錯的時會設定全域性變數 errno

long val = strtol(in_str, &endptr, 10);  //10的意思是10進位制

//如果無法轉換
if (endptr == str) {
    fprintf(stderr, "No digits were found\n");
    exit(EXIT_FAILURE);
}

//如果整型溢位了
if ((errno == ERANGE && (val == LONG_MAX || val == LONG_MIN)) {
    fprintf(stderr, "ERROR: number out of range for LONG\n");
    exit(EXIT_FAILURE);
 }

//如果是其它錯誤
if (errno != 0 && val == 0) {
    perror("strtol");
    exit(EXIT_FAILURE);
}

雖然,strtol() 函式解決了 atoi() 函式的問題,但是我們還是能感覺到不是很舒服和自然。

因為,這種用 返回值 + errno 的錯誤檢查方式會有一些問題:

  • 程式設計師一不小心就會忘記返回值的檢查,從而造成程式碼的 Bug;
  • 函式介面非常不純潔,正常值和錯誤值混淆在一起,導致語義有問題。

所以,後來,有一些類庫就開始區分這樣的事情。比如,Windows 的系統呼叫開始使用 HRESULT 的返回來統一錯誤的返回值,這樣可以明確函式呼叫時的返回值是成功還是錯誤。但這樣一來,函式的 input 和 output 只能通過函式的引數來完成,於是出現了所謂的 入參 和 出參 這樣的區別。

然而,這又使得函式接入中引數的語義變得複雜,一些引數是入參,一些引數是出參,函式介面變得複雜了一些。而且,依然沒有解決函式的成功或失敗可以被人為忽略的問題。

Java的錯誤處理

Java語言使用 try-catch-finally 通過使用異常的方式來處理錯誤,其實,這比起C語言的錯處理進了一大步,使用拋異常和抓異常的方式可以讓我們的程式碼有這樣的一些好處:

  • 函式介面在 input(引數)和 output(返回值)以及錯誤處理的語義是比較清楚的。
  • 正常邏輯的程式碼可以與錯誤處理和資源清理的程式碼分開,提高了程式碼的可讀性。
  • 異常不能被忽略(如果要忽略也需要 catch 住,這是顯式忽略)。
  • 在物件導向的語言中(如 Java),異常是個物件,所以,可以實現多型式的 catch。
  • 與狀態返回碼相比,異常捕捉有一個顯著的好處是,函式可以巢狀呼叫,或是鏈式呼叫。比如:
    • int x = add(a, div(b,c));
    • Pizza p = PizzaBuilder().SetSize(sz).SetPrice(p)...;

Go語言的錯誤處理

Go 語言的函式支援多返回值,所以,可以在返回介面把業務語義(業務返回值)和控制語義(出錯返回值)區分開來。Go 語言的很多函式都會返回 result, err 兩個值,於是:

  • 引數上基本上就是入參,而返回介面把結果和錯誤分離,這樣使得函式的介面語義清晰;
  • 而且,Go 語言中的錯誤引數如果要忽略,需要顯式地忽略,用 _ 這樣的變數來忽略;
  • 另外,因為返回的 error 是個介面(其中只有一個方法 Error(),返回一個 string ),所以你可以擴充套件自定義的錯誤處理。

另外,如果一個函式返回了多個不同型別的 error,你也可以使用下面這樣的方式:

if err != nil {
  switch err.(type) {
    case *json.SyntaxError:
      ...
    case *ZeroDivisionError:
      ...
    case *NullPointerError:
      ...
    default:
      ...
  }
}

我們可以看到,Go語言的錯誤處理的的方式,本質上是返回值檢查,但是他也兼顧了異常的一些好處 – 對錯誤的擴充套件。

資源清理

出錯後是需要做資源清理的,不同的程式語言有不同的資源清理的程式設計模式:

  • C語言 – 使用的是 goto fail; 的方式到一個集中的地方進行清理(有篇有意思的文章可以看一下《由蘋果的低階BUG想到的》)
  • C++語言- 一般來說使用 RAII模式,通過物件導向的代理模式,把需要清理的資源交給一個代理類,然後在解構函式來解決。
  • Java語言 – 可以在finally 語句塊裡進行清理。
  • Go語言 – 使用 defer 關鍵詞進行清理。

下面是一個Go語言的資源清理的示例:

func Close(c io.Closer) {
  err := c.Close()
  if err != nil {
    log.Fatal(err)
  }
}

func main() {
  r, err := Open("a")
  if err != nil {
    log.Fatalf("error opening 'a'\n")
  }
  defer Close(r) // 使用defer關鍵字在函式退出時關閉檔案。

  r, err = Open("b")
  if err != nil {
    log.Fatalf("error opening 'b'\n")
  }
  defer Close(r) // 使用defer關鍵字在函式退出時關閉檔案。
}

Error Check Hell

好了,說到 Go 語言的 if err !=nil 的程式碼了,這樣的程式碼的確是能讓人寫到吐。那麼有沒有什麼好的方式呢,有的。我們先看如下的一個令人崩潰的程式碼。

func parse(r io.Reader) (*Point, error) {

    var p Point

    if err := binary.Read(r, binary.BigEndian, &p.Longitude); err != nil {
        return nil, err
    }
    if err := binary.Read(r, binary.BigEndian, &p.Latitude); err != nil {
        return nil, err
    }
    if err := binary.Read(r, binary.BigEndian, &p.Distance); err != nil {
        return nil, err
    }
    if err := binary.Read(r, binary.BigEndian, &p.ElevationGain); err != nil {
        return nil, err
    }
    if err := binary.Read(r, binary.BigEndian, &p.ElevationLoss); err != nil {
        return nil, err
    }
}

要解決這個事,我們可以用函數語言程式設計的方式,如下程式碼示例:

func parse(r io.Reader) (*Point, error) {
    var p Point
    var err error
    read := func(data interface{}) {
        if err != nil {
            return
        }
        err = binary.Read(r, binary.BigEndian, data)
    }

    read(&p.Longitude)
    read(&p.Latitude)
    read(&p.Distance)
    read(&p.ElevationGain)
    read(&p.ElevationLoss)

    if err != nil {
        return &p, err
    }
    return &p, nil
}

上面的程式碼我們可以看到,我們通過使用Closure 的方式把相同的程式碼給抽出來重新定義一個函式,這樣大量的 if err!=nil 處理的很乾淨了。但是會帶來一個問題,那就是有一個 err 變數和一個內部的函式,感覺不是很乾淨。

那麼,我們還能不能搞得更乾淨一點呢,我們從Go 語言的 bufio.Scanner()中似乎可以學習到一些東西:

scanner := bufio.NewScanner(input)

for scanner.Scan() {
    token := scanner.Text()
    // process token
}

if err := scanner.Err(); err != nil {
    // process the error
}

上面的程式碼我們可以看到,scanner在操作底層的I/O的時候,那個for-loop中沒有任何的 if err !=nil 的情況,退出迴圈後有一個 scanner.Err() 的檢查。看來使用了結構體的方式。模仿它,我們可以把我們的程式碼重構成下面這樣:

首先,定義一個結構體和一個成員函式

type Reader struct {
    r   io.Reader
    err error
}

func (r *Reader) read(data interface{}) {
    if r.err == nil {
        r.err = binary.Read(r.r, binary.BigEndian, data)
    }
}

然後,我們的程式碼就可以變成下面這樣:

func parse(input io.Reader) (*Point, error) {
    var p Point
    r := Reader{r: input}

    r.read(&p.Longitude)
    r.read(&p.Latitude)
    r.read(&p.Distance)
    r.read(&p.ElevationGain)
    r.read(&p.ElevationLoss)

    if r.err != nil {
        return nil, r.err
    }

    return &p, nil
}

有了上面這個技術,我們的“流式介面 Fluent Interface”,也就很容易處理了。如下所示:

package main

import (
  "bytes"
  "encoding/binary"
  "fmt"
)

// 長度不夠,少一個Weight
var b = []byte {0x48, 0x61, 0x6f, 0x20, 0x43, 0x68, 0x65, 0x6e, 0x00, 0x00, 0x2c} 
var r = bytes.NewReader(b)

type Person struct {
  Name [10]byte
  Age uint8
  Weight uint8
  err error
}
func (p *Person) read(data interface{}) {
  if p.err == nil {
    p.err = binary.Read(r, binary.BigEndian, data)
  }
}

func (p *Person) ReadName() *Person {
  p.read(&p.Name) 
  return p
}
func (p *Person) ReadAge() *Person {
  p.read(&p.Age) 
  return p
}
func (p *Person) ReadWeight() *Person {
  p.read(&p.Weight) 
  return p
}
func (p *Person) Print() *Person {
  if p.err == nil {
    fmt.Printf("Name=%s, Age=%d, Weight=%d\n",p.Name, p.Age, p.Weight)
  }
  return p
}

func main() {   
  p := Person{}
  p.ReadName().ReadAge().ReadWeight().Print()
  fmt.Println(p.err)  // EOF 錯誤
}

相信你應該看懂這個技巧了,但是,其使用場景也就只能在對於同一個業務物件的不斷操作下可以簡化錯誤處理,對於多個業務物件的話,還是得需要各種 if err != nil的方式。

包裝錯誤

最後,多說一句,我們需要包裝一下錯誤,而不是乾巴巴地把err給返回到上層,我們需要把一些執行的上下文加入。

通常來說,我們會使用 fmt.Errorf()來完成這個事,比如:

if err != nil {
   return fmt.Errorf("something failed: %v", err)
}

另外,在Go語言的開發者中,更為普遍的做法是將錯誤包裝在另一個錯誤中,同時保留原始內容:

type authorizationError struct {
    operation string
    err error   // original error
}

func (e *authorizationError) Error() string {
    return fmt.Sprintf("authorization failed during %s: %v", e.operation, e.err)
}

當然,更好的方式是通過一種標準的訪問方法,這樣,我們最好使用一個介面,比如 causer介面中實現 Cause() 方法來暴露原始錯誤,以供進一步檢查:

type causer interface {
    Cause() error
}

func (e *authorizationError) Cause() error {
    return e.err
}

這裡有個好訊息是,這樣的程式碼不必再寫了,有一個第三方的錯誤庫(github.com/pkg/errors),對於這個庫,我無論到哪都能看到他的存在,所以,這個基本上來說就是事實上的標準了。程式碼示例如下:

import "github.com/pkg/errors"

//錯誤包裝
if err != nil {
    return errors.Wrap(err, "read failed")
}

// Cause介面
switch err := errors.Cause(err).(type) {
case *MyError:
    // handle specifically
default:
    // unknown error
}

本文非本人所作,轉載左耳朵耗子部落格和出處 酷 殼 – CoolShell

本作品採用《CC 協議》,轉載必須註明作者和本文連結
滴水穿石,石破天驚----馬乂

相關文章