Skip to content

nil 指針錯誤

引子

在某一次編寫代碼的過程中,我需要調用Close()方法來關閉多個對象,就像下面的代碼一樣

go
type A struct {
  b B
  c C
  d D
}

func (a A) Close() error {
  if a.b != nil {
    if err := a.b.Close(); err != nil {
      return err
    }
  }

  if a.c != nil {
    if err := a.c.Close(); err != nil {
      return err
    }
  }

    if a.d != nil {
        if err := a.d.Close(); err != nil {
            return err
        }
    }

  return nil
}

但寫這麼多if判斷感覺不太優雅,BCD都實現了Close方法,應該可以更簡潔一點,於是我把它們放進了一個切片中,然後循環判斷

go
func (a A) Close() error {
  closers := []io.Closer{
    a.b,
    a.c,
    a.d,
  }

  for _, closer := range closers {
    if closer != nil {
      if err := closer.Close(); err != nil {
        return err
      }
    }
  }
  return nil
}

這樣看起來似乎要更好一點,那麼運行一下看看

go
func main() {
  var a A
  if err := a.Close(); err != nil {
    panic(err)
  }
  fmt.Println("success")
}

結果出乎意料,居然崩了,錯誤信息如下,意思就是不能對nil接收者調用方法,循環中的if closer != nil似乎沒有起到過濾作用,

panic: value method main.B.Close called using nil *B pointer

上面這個例子是筆者曾經遇到過的一個 bug 的簡化版,很多初學者剛開始可能都會和我一樣犯這種錯誤,下面就來講講到底是怎麼個回事。

接口

在之前的章節提到過,nil是引用類型的零值,比如切片,map,通道,函數,指針,接口的零值。對於切片,map,通道,函數,可以將它們都看作是指針,都是由指針指向具體的實現。

但唯獨接口不一樣,接口由兩個東西組成:類型和值

當試圖對一個變量賦值nil時,會無法通過編譯,並且提示如下信息

use of untyped nil in assignment

內容大致為不能聲明一個值為untyped nil的變量。既然有untyped nil,相對的就肯定會有typed nil,而這種情況往往出現在接口身上。看下面一個簡單的例子

go
func main() {
  var p *int
  fmt.Println(p)
  fmt.Println(p == nil)
  var pa any
  pa = p
  fmt.Println(pa)
  fmt.Println(pa == nil)
}

輸出

<nil>
true
<nil>
false

結果非常奇怪,明明pa的輸出就是nil,但它就是不等於nil,我們可以通過反射來看看它到底是什麼

go
func main() {
  var p *int
  fmt.Println(p)
  fmt.Println(p == nil)
  var pa any
  pa = p
  fmt.Println(reflect.TypeOf(pa))
  fmt.Println(reflect.ValueOf(pa))
}

輸出

<nil>
true
*int
<nil>

從結果可以看到,它實際上是(*int)(nil),也就是說pa存儲的類型是*int,而它實際的值是nil,當對一個接口類型的值進行相等運算的時候,首先會判斷它們的類型是否相等,如果類型不相等,則直接判定為不相等,其次再去判斷值是否相等,這一段的接口判斷的邏輯可以參考自cmd/compile/internal/walk.walkCompare函數。

所以,如果想要一個接口等於nil,必須要它的值為nil,並且類型也為nil,因為接口中的類型實際上也是一個指針

go
type iface struct {
  tab  *itab
  data unsafe.Pointer
}

如果想要繞開類型,直接判斷其值是否為nil,可以使用反射,下面是一個例子

go
func main() {
  var p *int
  fmt.Println(p)
  fmt.Println(p == nil)
  var pa any
  pa = p
  fmt.Println(reflect.ValueOf(pa).IsNil())
}

通過IsNil()可以直接判斷其值是否為nil,這樣一來就不會出現上述的問題了。所以在平時使用的過程中,假設函數的返回值是一個接口類型,如果你想返回一個零值,最好直接返回nil,不要返回任何具體實現的零值,就算它實現了該接口,但它永遠也不會等於nil,這就可能導致例子裡面的錯誤。

小結

解決了上面的問題,接下來看看下面這幾個例子

當結構體的接收者為指針接收者時,nil是可用的,看下面一個例子

go
type A struct {

}

func (a *A) Do()  {

}

func main() {
  var a *A
  a.Do()
}

這段代碼可以正常運行,並且不會報空指針錯誤。

當切片為nil的時候,可以訪問它的長度和容量,也可以對其添加元素

func main() {
  var s []int
  fmt.Println(len(s))
  fmt.Println(cap(s))
  s = append(s, 1)
}

當 map 為nil的時候,還可以對其進行訪問,但nil的 map 是只讀的,一旦嘗試寫入就會引發panic

go
func main() {
  var s map[string]int
  i, ok := s[""]
  fmt.Println(i, ok)
  fmt.Println(len(s))

  // 嘗試寫入時,會引發panic
  s["a"] = 1 // panic: assignment to entry in nil map

}

上面例子中的這些有關於nil的特性可能會讓人比較困惑,尤其是對於 go 的初學者而言,nil代表著上面幾種類型的零值,也就是默認值,默認值應當表現出默認的行為,這也正是 go 的設計者所希望看到的:讓nil變得更有用,而不是直接拋出空指針錯誤。這一理念同樣也體現在標准庫中,比如開啟一個 HTTP 服務器可以這樣寫

go
http.ListenAndServe(":8080", nil)

我們可以直接傳入一個nil Handler,然後http庫就會使用默認的Handler來處理 HTTP 請求。

TIP

感興趣的可以看看這個視頻Understanding nil - Gopher Conference 2016,講的非常清晰易懂。

Golang學習網由www.golangdev.cn整理維護