Go 編程模式:錯誤處理

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

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

本文是全系列中第2 / 8篇:Go編程模式

« 上一篇文章 下一篇文章 »

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語言 – 使用 derfer 關鍵詞進行清理。

下面是一個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微信公衆賬號和微信小程序

(轉載本站文章請註明作者和出處 酷 殼 – CoolShell ,請勿用於任何商業用途)

——=== 訪問 酷殼404頁面 尋找遺失兒童。 ===——
好爛啊 有點差 湊合看看 還不錯 很精彩 (沒人打分)

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