深度剖析 Go 的 nil
堅持思考,就會很酷
前幾天有小夥伴問我說,golang 裏面很多類型使用 nil
來賦值和做條件判斷,總是混淆記不住。你可能見過::
-
很多文章和書會教你:Go 語言默認定義的類型賦值會被
nil
; -
error
返回值經常用return nil
的寫法; -
多種類型都可以使用
if
是否!= nil
;
上面的事情在 Go 編程裏隨處可見,下面思考幾個問題,看自己對 nil
這個知識點是否做到了知其所以然 :
-
nil
是一個關鍵字?還是類型?還是變量? -
並非所有類型都跟
nil
有關係,有哪些類型可以使用!= nil
的語法? -
這些不同的類型和
nil
打交道又有什麼異同? -
爲什麼有些複合結構定義了變量還不夠,還必須要
make(Type)
才能使用 ?否則會出panic
; -
很多書裏講
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
`
從類型定義得到兩個關鍵點:
-
nil
本質上是一個Type
類型的變量而已; -
Type
類型僅僅是基於int
定義出來的一個新類型;
從 nil
官方的註釋中,我們可以得到一個重要信息:
劃重點:nil
適用於 指針,函數,interface
,map
,slice
,channel
這 6 種類型。
Go 和 C 的變量定義異同
相同點:
Go 和 C 的變量定義迴歸最本質原理:分配變量指定大小的內存,確定一個變量名稱。
不同點:
-
Go 分配內存是置 0 分配的。置 0 分配的意思是:Go 確保分配出來的內存塊裏面是全 0 數據;
-
C 默認分配的內存則僅僅是分配內存,裏面的數據不能做任何假設,裏面是未定義的數據,可能是全 0 ,可能是全 1,可能是
0101
等;
Go 置 0 分配的原理:
-
棧上變量的內存編譯階段由編譯器就保證了置 0 分配,這種反彙編看下就知道了;
-
堆上變量的內存由
runtime
保證,可以仔細觀察下mallocgc
這個函數參數有一個needzero
的參數,用戶變量定義觸發的入口(比如newobject
等等 )這個參數爲true
,而該參數就是顯式指定置 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
通過上面,我們理解了幾個東西:
-
Go 的類型定義僅比 C 多做了一件事,把分配的內存塊置 0,而已;
-
能夠和 nil 值做判斷的,僅僅有 6 個類型。如果你用來其他類型來和 nil 比較,那麼在編譯期間
typecheck
會報錯檢查到會報錯;
就筆者理解,nil
這個概念是更高一層的概念,在語言級別,而這個概念是由編譯器帶給你的。不是所有的類型都可以和 nil
進行比較或者賦值,只有這 6 種類型的變量才能和 nil 值比較,因爲這是編譯器決定的。
同樣的,你不能賦值一個 nil
變量給一個整型,原理也很簡單,僅僅是編譯器不讓,就這麼簡單。
所以,nil
其實更準確的理解是一個觸發條件,編譯器看到和 nil
值比較的寫法,那麼就要確認類型在這 6 種類型以內,如果是賦值 nil
,那麼也要確認在這 6 種類型以內,並且對應的結構內存爲全 0 數據。
所以,記住這句話,****nil
是編譯器識別行爲的一個觸發點而已,看到這個 nil
會觸發編譯器的一些特殊判斷和操作。
和 nil 打交道的 6 大類型
slice 類型
變量定義
創建 slice
的本質上是 2 種:
-
var
關鍵字定義; -
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 字節的整形字段。
思考:var
和 make
這兩種方式有什麼區別?
-
第一種
var
的方式定義變量純粹真的是變量定義,如果逃逸分析之後,確認可以分配在棧上,那就在棧上分配這 24 個字節,如果逃逸到堆上去,那麼調用newobject
函數進行類型分配。 -
第二種
make
方式則略有不同,如果逃逸分析之後,確認分配在棧上,那麼也是直接在棧上分配 24 字節,如果逃逸到堆上則會導致調用makeslice
函數來分配變量。
變量本身
定義的變量本身分配了多少內存?
上面已經說過了,無論多大的 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 類似,上面也是兩種差別的方式:
-
第一種方式僅僅定義了 m1 變量本身;
-
第二種方式則是分配 m2 的內存,還會調用
makehmap
函數(不一定是這個函數,要看逃逸分析的結果,如果是可以棧上分配的,會有一些優化)來創建某個結構,並且把這個函數的返回值賦給 m2;
變量本身
map
的變量本身究竟是什麼?比如上面的 m1
,m2
?
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(p []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{})
區別:
-
第一種方式僅僅定義了 c1 變量本身;
-
第二種方式則是分配 c2 的內存,還會調用
makechan
函數來創建某個結構,並且把這個函數的返回值賦給 c2;
變量本身
定義的 channel
變量本身是什麼一個表現?
答案是:一個 8 字節的指針而已,意圖指向一個 channel
管理結構,也就是 struct hchan
的指針。
程序員定義的 channel
變量本身內存僅僅是一個指針,channel
所有的邏輯都在 hchan
這個管理結構體上,所以,channel
也是必須 make(chan Xtype)
之後才能使用,就是這個道理。
nil
賦值
賦值 nil 之後,僅僅是把這 8 字節的指針置 0 。
nil
值判斷
簡單,僅僅是判斷這 channel 指針是否非 0 而已。
指針 類型
指針和函數類型比較好理解,因爲之前的 4 種類型 slice
,map
,channel
,interface
是複合結構。
指針本身來說也只是一個 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 。
總結
下面總結一些上述分享:
-
請撇開死記硬背的語法和玄學,變量僅僅是綁定到一個指定內存塊的名字;
-
Go 從語言層面對程序員做了承諾,變量定義分配的內存一定是置 0 分配的;
-
並不是所有的類型能夠賦值
nil
,並且和nil
進行對比判斷。只有slice
、map
、channel
、interface
、指針、函數 這 6 種類型; -
不要把
nil
理解成一個特殊的值,而要理解成一個觸發條件,編譯器識別到代碼裏有nil
之後,會對應做出處理和判斷; -
channel
,map
類型的變量必須要make
才能使用的原因(否則會出現空指針的 panic )在於 var 定義的變量僅僅是分配了一個指向hchan
和hmap
的指針變量而已,並且還是置 0 分配的。真正的管理結構只有 make 調用才能分配出來,對應的函數分別是makechan
和makemap
等; -
slice
變量爲什麼var
就能用是因爲struct slice
核心結構是定義的時候就分配出來了; -
以上 6 種變量賦值
nil
的行爲都是把變量本身置 0 ,僅此而已。slice
的 24 字節管理結構,map
的 8 字節指針,channel
的 8 字節指針,interface
的 16 字節,8 字節指針和函數指針也是如此; -
以上 6 種類型和
nil
進行比較判斷本質上都是和變量本身做判斷,slice
是判斷管理結構的第一個指針字段,map
,channel
本身就是指針,interface
也是判斷管理結構的第一個指針字段,指針和函數變量本身就是指針;
後記
推薦使用 gdb 進行對上面的 demo 程序進行調試,加深自己理解。重點關注內存分配和內部代碼的生成(反彙編),比如類似 makechan 這樣的函數,如果你不調試,你根本不會知道竟然還有這個,我明明沒有寫過這函數呀?這個是編譯器幫你生成的。
堅持思考,方向比努力更重要。關注我:奇伢雲存儲
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/sHLYy_4XA6254-vLmlu0IA