深度剖析 Go 的 nil

堅持思考,就會很酷

前幾天有小夥伴問我說,golang 裏面很多類型使用 nil 來賦值和做條件判斷,總是混淆記不住。你可能見過:

  1. 很多文章和書會教你:Go 語言默認定義的類型賦值會被 nil

  2. error 返回值經常用 return nil 的寫法;

  3. 多種類型都可以使用 if 是否 != nil

上面的事情在 Go 編程裏隨處可見,下面思考幾個問題,看自己對 nil 這個知識點是否做到了知其所以然

  1. nil 是一個關鍵字?還是類型?還是變量?

  2. 並非所有類型都跟 nil 有關係,有哪些類型可以使用 != nil 的語法?

  3. 這些不同的類型和 nil 打交道又有什麼異同?

  4. 爲什麼有些複合結構定義了變量還不夠,還必須要 make(Type) 才能使用 ?否則會出 panic

  5. 很多書裏講 slice 也要 make 之後才能用,但其實不必要,其實 slice 只要定義了就能用。map 結構卻光定義還不行,一定要 make(Type) 才能使用

下面我們就這幾個思考題展開,剖析 nil 的祕密。

Go 裏面 nil 到底是什麼?

我們思考的第一個問題是:nil 是一個關鍵字?還是類型?還是變量?

答案自然是:變量。具體是什麼樣的變量,我們可以點進去 Go 的源碼看下:

一窺 Go 官方定義和解釋


`// nil is a predeclared identifier representing the zero value for a

// pointer, channel, func, interface, map, or slice type.
var nil Type // Type must be a pointer, channel, func, interface, map, or slice type

// Type is here for the purposes of documentation only. It is a stand-in
// for any Go type, but represents the same type for any given function
// invocation.
type Type int
`

從類型定義得到兩個關鍵點

  1. nil 本質上是一個 Type 類型的變量而已;

  2. Type 類型僅僅是基於 int 定義出來的一個新類型;

nil 官方的註釋中,我們可以得到一個重要信息:

劃重點nil 適用於 指針函數interfacemapslicechannel 這 6 種類型。

Go 和 C 的變量定義異同


相同點

Go 和 C 的變量定義迴歸最本質原理:分配變量指定大小的內存,確定一個變量名稱。

不同點

Go 置 0 分配的原理

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    // ...
}

思考一個小問題:Go 既然所用的類型定義都是置 0 分配的,那爲什麼 mallocgc 需要 needzero 這麼一個參數來控制呢?

首先,Go 的類型定義一定確保是置 0 分配的,這個是 Go 語言給到 Go 程序員的語義。Go runtime 衆多的內部的流程(對 Go 程序員不感知的層面)是沒有這個規定的。其次,置 0 分配是有性能代價的,如果在確保語義的情況下,能不做自然是最好的。

劃重點:Go 的變量定義由語言層面確保置 0 分配,確保內存塊全 0 數據。請記住這個最本質的約定。

怎麼理解 nil

通過上面,我們理解了幾個東西:

  1. Go 的類型定義僅比 C 多做了一件事,把分配的內存塊置 0,而已;

  2. 能夠和 nil 值做判斷的,僅僅有 6 個類型。如果你用來其他類型來和 nil 比較,那麼在編譯期間 typecheck 會報錯檢查到會報錯;

就筆者理解,nil 這個概念是更高一層的概念,在語言級別,而這個概念是由編譯器帶給你的。不是所有的類型都可以和 nil 進行比較或者賦值,只有這 6 種類型的變量才能和 nil 值比較,因爲這是編譯器決定的。

同樣的,你不能賦值一個 nil 變量給一個整型,原理也很簡單,僅僅是編譯器不讓,就這麼簡單。

所以,nil 其實更準確的理解是一個觸發條件,編譯器看到和 nil 值比較的寫法,那麼就要確認類型在這 6 種類型以內,如果是賦值 nil,那麼也要確認在這 6 種類型以內,並且對應的結構內存爲全 0 數據。

所以,記住這句話,****nil 是編譯器識別行爲的一個觸發點而已,看到這個 nil 會觸發編譯器的一些特殊判斷和操作。

和 nil 打交道的 6 大類型

slice 類型


變量定義

創建 slice 的本質上是 2 種:

  1. var 關鍵字定義;

  2. make 關鍵字創建;

// 方式一
var slice1 []byte
var slice2 []byte = []byte{0x1, 0x2, 0x3}

// 方式二
var slice3 = make([]byte, 0)
var slice4 = make([]byte, 3)

首先,slice 變量本身佔多少個字節?

答案是:24 個字節。1 個指針字段,2 個 8 字節的整形字段。

思考:varmake 這兩種方式有什麼區別?

變量本身

定義的變量本身分配了多少內存?

上面已經說過了,無論多大的 slice ,變量本身佔用 24 字節。這 24 個字節其實是動態數組的管理結構,如下:

type slice struct {
   array unsafe.Pointer         // 管理的內存塊首地址
   len   int                    // 動態數組實際使用大小
   cap   int                    // 動態數組內存大小
}

該結構體定義在 src/runtime/slice.go 裏。

劃重點:我們看到無論是 var 聲明定義的 slice 變量,還是 make(xxx,num) 創建的 slice 變量,slice 管理結構是已經分配出來了的(也就是 struct slice 結構 )。

所以, 對於 slice 來說,其實並不需要 make 創建的才能使用,直接用 var 定義出來的 slice 也能直接使用。如下:

// 定義一個 slice
var slice1 []byte
// 使用這個 slice
slice1 = append(slice1, 0x1)

定義的時候,slice 結構本身就已經置 0 分配了,這個 24 字節的 slice 結構就是管理動態數組的核心。有這個在 append 函數就能正常處理 slice 變量。

思考:append 又是怎麼處理的呢?

本質是調用 runtime.growslice 函數來處理。

nil 賦值

如果把一個已經存在的 slice 結構賦值 nil ,會發生什麼事情?

var slice2 []byte = []byte{0x1, 0x2, 0x3}

// slice 賦值 nil
slice2 = nil

發生什麼事?

事情在編譯期間就確定了,就是把 slice2 變量本身內存塊置 0 ,也就是說 slice2 本身的 24 字節的內存塊被置 0。

nil 值判斷

編譯器認爲 slice 做可以做 nil 判斷,那麼什麼樣的 slice 認爲是 nil 的?

指針值爲 0 的,也就是說這個動態數組沒有實際數據的時候。

思考:僅判斷指針?對 len 和 cap 兩個字段不做判斷嗎?

只對首字段 array 做非 0 判斷,len,cap 字段不做判斷。

如下:

var a []byte = []byte{0x1, 0x2, 0x3}
if a != nil {
}

對應的部分彙編代碼如下:

// 賦值 array 的值
0x00000000004587cd <+93>: mov    %rax,0x20(%rsp)
// 賦值 len 的值
0x00000000004587d2 <+98>: movq   $0x3,0x28(%rsp)
// 賦值 cap 的值
0x00000000004587db <+107>: movq   $0x3,0x30(%rsp)
// 判斷 slice 是否是 nil
=> 0x00000000004587e4 <+116>: test   %rax,%rax

不信 Go 只判斷首字段?爲了驗證,自己思考下一下的程序的輸出:

package main

import (
   "unsafe"
)

type sliceType struct {
   pdata unsafe.Pointer
   len   int
   cap   int
}

func main() {
   var a []byte

   ((*sliceType)(unsafe.Pointer(&a))).len = 0x3
   ((*sliceType)(unsafe.Pointer(&a))).cap = 0x4

   if a != nil {
      println("not nil")
   } else {
      println("nil")
   }
}

答案是:輸出 nil

map 類型


變量定義

// 變量定義
var m1 map[string]int
// 定義 & 初始化
var m2 = make(map[string]int)

和 slice 類似,上面也是兩種差別的方式:

變量本身

map 的變量本身究竟是什麼?比如上面的 m1m2 ?

m1, m2 變量本身是一個指針,內存佔用 8 字節。這個指針指向的結構才大有來頭,指向一個 struct hmap 結構。

type hmap struct {
   count     int // # live cells == size of map.  Must be first (used by len() builtin)
   flags     uint8
   B         uint8  // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
   noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
   hash0     uint32 // hash seed

   buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
   oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
   nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)

   extra *mapextra // optional fields
}

所以,回到思考問題:爲什麼 map 結構卻光定義還不行,一定要 make(XXMap) 才能使用?

因爲,map 結構的核心在於 struct hmap 結構體,這個結構體是很大的一個結構體。map 的操作核心都是基於這個結構體之上的。而 var 定義一個 map 結構的時候,只是分配了一個 8 字節的指針,只有調用 make 的時候,才觸發調用 makemap ,在這個函數里面分配出一個龐大的 struct hmap 結構體。

nil 賦值

如果把一個 map 變量賦值 nil 那就很容易理解了,僅僅是把這個變量本身置 0 而已,也就是這個指針變量置 0 ,hmap 結構體本身是不會動的。

當然考慮垃圾回收的話,如果這個 m1 是唯一的指向這個 hmap 結構,那麼 m1 賦值 nil 之後,那麼這個 hmap 結構體之後就可能被回收。

nil 值判斷

搞懂了變量本身和管理結構的區別就很簡單了,這裏的 nil 值判斷也僅僅是針對變量本身的判斷,只要是非 0 指針,那麼就是非 nil 。也就是說 m1 只要是一個非 0 的指針,就不會是非nil 的。

package main

func main() {
   var m1 map[string]int
   var m2 = make(map[string]int)
   if m1 != nil {
      println("m1 not nil")
   } else {
      println("m1 nil")
   }
   if m2 != nil {
      println("m2 not nil")
   } else {
      println("m2 nil")
   }
}

如上示例程序,m1 是一個 0 指針,m2 被賦值了的。

interface 類型


變量定義

// 定義一個接口
type Reader interface {
   Read([]byte) (n int, err error)
}

// 定義一個接口變量
var reader Reader
// 或者一個空接口
var empty interface{}

變量本身

interface 稍微有點特殊,有兩種對應的結構體,如下:

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

type eface struct {
    _type *_type
    data  unsafe.Pointer
}

其中,iface 就是通常定義的 interface 類型,eface 則是通常人們常說的空接口 對應的數據結構。

不管內部怎麼樣,這兩個結構體佔用內存是一樣的,都是一個正常的指針類型和一個無類型的指針類型( Pointer ),總共佔用 16 個字節。

也就是說,如果你聲明定義一個 interface 類型,無論是空接口,還是具體的接口類型,都只是分配了一個 16 字節的內存塊給你,注意是置 0 分配哦。

nil 賦值

和上面類似,如果對一個 interface 變量賦值 nil 的話,發生的事情也僅僅是把變量本身這 16 個字節的內存塊置 0 而已。

nil 值判斷

判斷 interface 是否是 nil ?這個跟 slice 類似,也僅僅是判斷首字段(指針類型)是否爲 0 即可。因爲如果是初始化過的,首字段一定是非 0 的。

channel 類型


變量定義

// 變量本身定義
var c1 chan struct{}
// 變量定義和初始化
var c2 = make(chan struct{})

區別:

變量本身

定義的 channel 變量本身是什麼一個表現?

答案是:一個 8 字節的指針而已,意圖指向一個 channel 管理結構,也就是 struct hchan 的指針。

程序員定義的 channel 變量本身內存僅僅是一個指針,channel  所有的邏輯都在 hchan 這個管理結構體上,所以,channel  也是必須 make(chan Xtype) 之後才能使用,就是這個道理。

nil 賦值

賦值 nil 之後,僅僅是把這 8 字節的指針置 0 。

nil 值判斷

簡單,僅僅是判斷這 channel 指針是否非 0 而已。

指針 類型


指針和函數類型比較好理解,因爲之前的 4 種類型 slicemapchannelinterface 是複合結構。

指針本身來說也只是一個 8 字節的整型,函數變量類型則本身就是個指針。

變量定義

var ptr *int

變量本身

變量本身就是一個 8 字節的內存塊,這個沒啥好講的,因爲指針都不是複合類型。

nil 賦值

ptr = nil

這 8 字節的指針置 0。

nil 值判斷

判斷這 8 字節的指針是否爲 0 。

函數 類型


變量定義

var f func(int) error

變量本身

變量本身是一個 8 字節的指針。

nil 賦值

本身就是指針,只不過指向的是函數而已。所以賦值也僅僅是這 8 字節置 0 。

nil 值判斷

判斷這 8 字節是否爲 0 。

總結

下面總結一些上述分享:

  1. 請撇開死記硬背的語法和玄學,變量僅僅是綁定到一個指定內存塊的名字;

  2. Go 從語言層面對程序員做了承諾,變量定義分配的內存一定是置 0 分配的;

  3. 並不是所有的類型能夠賦值 nil,並且和 nil 進行對比判斷。只有 slicemapchannelinterface、指針、函數 這 6 種類型;

  4. 不要把 nil 理解成一個特殊的值,而要理解成一個觸發條件,編譯器識別到代碼裏有 nil 之後,會對應做出處理和判斷;

  5. channelmap 類型的變量必須要 make 才能使用的原因(否則會出現空指針的 panic )在於 var 定義的變量僅僅是分配了一個指向 hchanhmap 的指針變量而已,並且還是置 0 分配的。真正的管理結構只有 make 調用才能分配出來,對應的函數分別是 makechanmakemap 等;

  6. slice 變量爲什麼 var 就能用是因爲 struct slice 核心結構是定義的時候就分配出來了

  7. 以上 6 種變量賦值 nil 的行爲都是把變量本身置 0 ,僅此而已。slice 的 24 字節管理結構,map 的  8 字節指針,channel 的 8 字節指針,interface 的 16 字節,8 字節指針和函數指針也是如此;

  8. 以上 6 種類型和 nil 進行比較判斷本質上都是和變量本身做判斷,slice 是判斷管理結構的第一個指針字段mapchannel 本身就是指針,interface 也是判斷管理結構的第一個指針字段,指針和函數變量本身就是指針;

後記

推薦使用 gdb 進行對上面的 demo 程序進行調試,加深自己理解。重點關注內存分配和內部代碼的生成(反彙編),比如類似 makechan 這樣的函數,如果你不調試,你根本不會知道竟然還有這個,我明明沒有寫過這函數呀?這個是編譯器幫你生成的

堅持思考,方向比努力更重要。關注我:奇伢雲存儲

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/sHLYy_4XA6254-vLmlu0IA