nil 指針錯誤
引子
在某一次編寫代碼的過程中,我需要調用Close()方法來關閉多個對象,就像下面的代碼一樣
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判斷感覺不太優雅,B,C和D都實現了Close方法,應該可以更簡潔一點,於是我把它們放進了一個切片中,然後循環判斷
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
}這樣看起來似乎要更好一點,那麼運行一下看看
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,而這種情況往往出現在接口身上。看下面一個簡單的例子
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,我們可以通過反射來看看它到底是什麼
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,因為接口中的類型實際上也是一個指針
type iface struct {
tab *itab
data unsafe.Pointer
}如果想要繞開類型,直接判斷其值是否為nil,可以使用反射,下面是一個例子
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是可用的,看下面一個例子
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
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 服務器可以這樣寫
http.ListenAndServe(":8080", nil)我們可以直接傳入一個nil Handler,然後http庫就會使用默認的Handler來處理 HTTP 請求。
TIP
感興趣的可以看看這個視頻Understanding nil - Gopher Conference 2016,講的非常清晰易懂。
