Go 介面:nil介面為什麼不等於nil?

賈維斯Echo發表於2023-11-08

Go 介面:nil介面為什麼不等於nil?

本文主要內容:深入瞭解介面型別的執行時表示層。

一、Go 介面的地位

Go 語言核心團隊的技術負責人 Russ Cox 也曾說過這樣一句話:“如果要從 Go 語言中挑選出一個特性放入其他語言,我會選擇介面”,這句話足以說明介面這一語法特性在這位 Go 語言大神心目中的地位。

為什麼介面在 Go 中有這麼高的地位呢?這是因為介面是 Go 這門靜態語言中唯一“動靜兼備”的語法特性。而且,介面“動靜兼備”的特性給 Go 帶來了強大的表達能力,但同時也給 Go 語言初學者帶來了不少困惑。要想真正解決這些困惑,我們必須深入到 Go 執行時層面,看看 Go 語言在執行時是如何表示介面型別的。

接下來,我們先來看看介面的靜態與動態特性,看看“動靜皆備”的含義。

二、介面的靜態特性與動態特性

2.1 介面的靜態特性與動態特性介紹

介面的靜態特性體現在介面型別變數具有靜態型別。

比如 var err error 中變數 err 的靜態型別為 error。擁有靜態型別,那就意味著編譯器會在編譯階段對所有介面型別變數的賦值操作進行型別檢查,編譯器會檢查右值的型別是否實現了該介面方法集合中的所有方法。如果不滿足,就會報錯:

var err error = 1 // cannot use 1 (type int) as type error in assignment: int does not implement error (missing Error method)

**而介面的動態特性,就體現在介面型別變數在執行時還儲存了右值的真實型別資訊,這個右值的真實型別被稱為介面型別變數的動態型別。例如,下面示例程式碼:

var err error
err = errors.New("error1")
fmt.Printf("%T\n", err)  // *errors.errorString

我們可以看到,這個示例透過 errros.New 構造了一個錯誤值,賦值給了 error 介面型別變數 err,並透過 fmt.Printf 函式輸出介面型別變數 err 的動態型別為 *errors.errorString

2.2 “動靜皆備”的特性的好處

首先,介面型別變數在程式執行時可以被賦值為不同的動態型別變數,每次賦值後,介面型別變數中儲存的動態型別資訊都會發生變化,這讓 Go 語言可以像動態語言(比如 Python)那樣擁有使用 Duck Typing(鴨子型別)的靈活性。所謂鴨子型別,就是指某型別所表現出的特性(比如是否可以作為某介面型別的右值),不是由其基因(比如 C++ 中的父類)決定的,而是由型別所表現出來的行為(比如型別擁有的方法)決定的。

比如下面的例子:

type QuackableAnimal interface {
    Quack()
}

type Duck struct{}

func (Duck) Quack() {
    println("duck quack!")
}

type Dog struct{}

func (Dog) Quack() {
    println("dog quack!")
}

type Bird struct{}

func (Bird) Quack() {
    println("bird quack!")
}                         
                          
func AnimalQuackInForest(a QuackableAnimal) {
    a.Quack()             
}                         
                          
func main() {             
    animals := []QuackableAnimal{new(Duck), new(Dog), new(Bird)}
    for _, animal := range animals {
        AnimalQuackInForest(animal)
    }  
}

這個例子中,我們用介面型別 QuackableAnimal 來代表具有“會叫”這一特徵的動物,而 DuckBirdDog 型別各自都具有這樣的特徵,於是我們可以將這三個型別的變數賦值給 QuackableAnimal 介面型別變數 a。每次賦值,變數 a 中儲存的動態型別資訊都不同,Quack 方法的執行結果將根據變數 a 中儲存的動態型別資訊而定。

這裡的 DuckBirdDog 都是“鴨子型別”,但它們之間並沒有什麼聯絡,之所以能作為右值賦值給 QuackableAnimal 型別變數,只是因為他們表現出了 QuackableAnimal 所要求的特徵罷了。

不過,與動態語言不同的是,Go 介面還可以保證“動態特性”使用時的安全性。比如,編譯器在編譯期就可以捕捉到將 int 型別變數傳給 QuackableAnimal 介面型別變數這樣的明顯錯誤,決不會讓這樣的錯誤遺漏到執行時才被發現。

介面型別的動靜特性展示了其強大的一面,然而在日常使用中,對Gopher常常困惑與“nil 的 error 值不等於 nil”。下面我們來詳細看一下。

三、nil error 值 != nil

我們先來看一段改編自GO FAQ 中的例子的程式碼:

type MyError struct {
    error
}

var ErrBad = MyError{
    error: errors.New("bad things happened"),
}

func bad() bool {
    return false
}

func returnsError() error {
    var p *MyError = nil
    if bad() {
        p = &ErrBad
    }
    return p
}

func main() {
    err := returnsError()
    if err != nil {
        fmt.Printf("error occur: %+v\n", err)
        return
    }
    fmt.Println("ok")
}

在這個例子中,我們的關注點集中在 returnsError 這個函式上面。這個函式定義了一個 *MyError 型別的變數 p,初值為 nil。如果函式 bad 返回 falsereturnsError 函式就會直接將 p(此時 p = nil)作為返回值返回給呼叫者,之後呼叫者會將 returnsError 函式的返回值(error 介面型別)與 nil 進行比較,並根據比較結果做出最終處理。

我們執行這段程式後,輸出如下:

error occur: <nil>

按照預期:程式執行應該是pnilreturnsError 返回 p,那麼 main 函式中的 err 就等於 nil,於是程式輸出 ok 後退出。但是我們看到,示例程式並未按照預期,程式顯然是進入了錯誤處理分支,輸出了 err 的值。那這裡就有一個問題了:明明 returnsError 函式返回的 p 值為 nil,為什麼卻滿足了 if err != nil 的條件進入錯誤處理分支呢?

為了弄清楚這個問題,我們來了解介面型別變數的內部表示。

四、介面型別變數的內部表示

介面型別“動靜兼備”的特性也決定了它的變數的內部表示絕不像一個靜態型別變數(如 intfloat64)那樣簡單,我們可以在 $GOROOT/src/runtime/runtime2.go 中找到介面型別變數在執行時的表示:

// $GOROOT/src/runtime/runtime2.go
type iface struct {
    tab  *itab
    data unsafe.Pointer
}

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

我們看到,在執行時層面,介面型別變數有兩種內部表示:ifaceeface,這兩種表示分別用於不同的介面型別變數:

  • eface 用於表示沒有方法的空介面(empty interface)型別變數,也就是 interface{} 型別的變數;
  • iface 用於表示其餘擁有方法的介面 interface 型別變數。

這兩個結構的共同點是它們都有兩個指標欄位,並且第二個指標欄位的功能相同,都是指向當前賦值給該介面型別變數的動態型別變數的值。

那它們的不同點在哪呢?就在於 eface 表示的空介面型別並沒有方法列表,因此它的第一個指標欄位指向一個 _type 型別結構,這個結構為該介面型別變數的動態型別的資訊,它的定義是這樣的:

// $GOROOT/src/runtime/type.go

type _type struct {
    size       uintptr
    ptrdata    uintptr // size of memory prefix holding all pointers
    hash       uint32
    tflag      tflag
    align      uint8
    fieldAlign uint8
    kind       uint8
    // function for comparing objects of this type
    // (ptr to object A, ptr to object B) -> ==?
    equal func(unsafe.Pointer, unsafe.Pointer) bool
    // gcdata stores the GC type data for the garbage collector.
    // If the KindGCProg bit is set in kind, gcdata is a GC program.
    // Otherwise it is a ptrmask bitmap. See mbitmap.go for details.
    gcdata    *byte
    str       nameOff
    ptrToThis typeOff
}

iface 除了要儲存動態型別資訊之外,還要儲存介面本身的資訊(介面的型別資訊、方法列表資訊等)以及動態型別所實現的方法的資訊,因此 iface 的第一個欄位指向一個 itab 型別結構。itab 結構的定義如下:

// $GOROOT/src/runtime/runtime2.go
type itab struct {
    inter *interfacetype
    _type *_type
    hash  uint32 // copy of _type.hash. Used for type switches.
    _     [4]byte
    fun   [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}

這裡我們也可以看到,itab 結構中的第一個欄位 inter 指向的 interfacetype 結構,儲存著這個介面型別自身的資訊。你看一下下面這段程式碼表示的 interfacetype 型別定義,這個 interfacetype 結構由型別資訊(typ)、包路徑名(pkgpath)和介面方法集合切片(mhdr)組成。

// $GOROOT/src/runtime/type.go
type interfacetype struct {
    typ     _type
    pkgpath name
    mhdr    []imethod
}

itab 結構中的欄位 _type 則儲存著這個介面型別變數的動態型別的資訊,欄位 fun 則是動態型別已實現的介面方法的呼叫地址陣列。

下面我們再結合例子用圖片來直觀展現 efaceiface 的結構。首先我們看一個用 eface 表示的空介面型別變數的例子:

type T struct {
    n int
    s string
}

func main() {
    var t = T {
        n: 17,
        s: "hello, interface",
    }
    
    var ei interface{} = t // Go執行時使用eface結構表示ei
}

這個例子中的空介面型別變數 ei 在 Go 執行時的表示是這樣的:

WechatIMG274

我們看到空介面型別的表示較為簡單,圖中上半部分 _type 欄位指向它的動態型別 T 的型別資訊,下半部分的 data 則是指向一個 T 型別的例項值。

我們再來看一個更復雜的用 iface 表示非空介面型別變數的例子:

type T struct {
    n int
    s string
}

func (T) M1() {}
func (T) M2() {}

type NonEmptyInterface interface {
    M1()
    M2()
}

func main() {
    var t = T{
        n: 18,
        s: "hello, interface",
    }
    var i NonEmptyInterface = t
}

eface 比起來,iface 的表示稍微複雜些。我也畫了一幅表示上面 NonEmptyInterface 介面型別變數在 Go 執行時表示的示意圖:

WechatIMG275

由上面的這兩幅圖,我們可以看出,每個介面型別變數在執行時的表示都是由兩部分組成的,針對不同介面型別我們可以簡化記作:eface(_type, data)iface(tab, data)

而且,雖然 efaceiface 的第一個欄位有所差別,但 tab_type 可以統一看作是動態型別的型別資訊。Go 語言中每種型別都會有唯一的 _type 資訊,無論是內建原生型別,還是自定義型別都有。Go 執行時會為程式內的全部型別建立只讀的共享 _type 資訊表,因此擁有相同動態型別的同類介面型別變數的 _type/tab 資訊是相同的。

而介面型別變數的 data 部分則是指向一個動態分配的記憶體空間,這個記憶體空間儲存的是賦值給介面型別變數的動態型別變數的值。未顯式初始化的介面型別變數的值為nil,也就是這個變數的 _type/tab 和 data 都為 nil。

也就是說,我們判斷兩個介面型別變數是否相等,只需判斷 _type/tab 以及 data 是否都相等即可。兩個介面變數的 _type/tab 不同時,即兩個介面變數的動態型別不相同時,兩個介面型別變數一定不等。

當兩個介面變數的 _type/tab 相同時,對 data 的相等判斷要有區分。當介面變數的動態型別為指標型別時 (*T),Go 不會再額外分配記憶體儲存指標值,而會將動態型別的指標值直接存入 data 欄位中,這樣 data 值的相等性決定了兩個介面型別變數是否相等;當介面變數的動態型別為非指標型別 (T) 時,我們判斷的將不是 data 指標的值是否相等,而是判斷 data 指標指向的記憶體空間所儲存的資料值是否相等,若相等,則兩個介面型別變數相等。

不過,透過肉眼去辨別介面型別變數是否相等總是困難一些,我們可以引入一些 helper 函式。藉助這些函式,我們可以清晰地輸出介面型別變數的內部表示,這樣就可以一目瞭然地看出兩個變數是否相等了。

由於 efaceifaceruntime 包中的非匯出結構體定義,我們不能直接在包外使用,所以也就無法直接訪問到兩個結構體中的資料。不過,Go 語言提供了 println 預定義函式,可以用來輸出 efaceiface 的兩個指標欄位的值。

在編譯階段,編譯器會根據要輸出的引數的型別將 println 替換為特定的函式,這些函式都定義在 $GOROOT/src/runtime/print.go 檔案中,而針對 efaceiface 型別的列印函式實現如下:

// $GOROOT/src/runtime/print.go
func printeface(e eface) {
    print("(", e._type, ",", e.data, ")")
}

func printiface(i iface) {
    print("(", i.tab, ",", i.data, ")")
}

我們看到,printefaceprintiface 會輸出各自的兩個指標欄位的值。下面我們就來使用 println 函式輸出各類介面型別變數的內部表示資訊,並結合輸出結果,解析介面型別變數的等值比較操作。

第一種:nil 介面變數

我們知道,未賦初值的介面型別變數的值為 nil,這類變數也就是 nil 介面變數,我們來看這類變數的內部表示輸出的例子:

func printNilInterface() {
  // nil介面變數
  var i interface{} // 空介面型別
  var err error     // 非空介面型別
  println(i)
  println(err)
  println("i = nil:", i == nil)
  println("err = nil:", err == nil)
  println("i = err:", i == err)
}

執行這個函式,輸出結果是這樣的:

(0x0,0x0)
(0x0,0x0)
i = nil: true
err = nil: true
i = err: true

我們看到,無論是空介面型別還是非空介面型別變數,一旦變數值為 nil,那麼它們內部表示均為 (0x0, 0x0),也就是型別資訊、資料值資訊均為空。因此上面的變數 ierr 等值判斷為 true

第二種:空介面型別變數

下面是空介面型別變數的內部表示輸出的例子:

  func printEmptyInterface() {
      var eif1 interface{} // 空介面型別
      var eif2 interface{} // 空介面型別
      var n, m int = 17, 18
  
      eif1 = n
      eif2 = m

      println("eif1:", eif1)
      println("eif2:", eif2)
      println("eif1 = eif2:", eif1 == eif2) // false
  
      eif2 = 17
      println("eif1:", eif1)
      println("eif2:", eif2)
      println("eif1 = eif2:", eif1 == eif2) // true
 
      eif2 = int64(17)
      println("eif1:", eif1)
      println("eif2:", eif2)
      println("eif1 = eif2:", eif1 == eif2) // false
 }

這個例子的執行輸出結果是這樣的:

eif1: (0x10ac580,0xc00007ef48)
eif2: (0x10ac580,0xc00007ef40)
eif1 = eif2: false
eif1: (0x10ac580,0xc00007ef48)
eif2: (0x10ac580,0x10eb3d0)
eif1 = eif2: true
eif1: (0x10ac580,0xc00007ef48)
eif2: (0x10ac640,0x10eb3d8)
eif1 = eif2: false

我們按順序分析一下這個輸出結果。

首先,程式碼執行到第 11 行時,eif1eif2 已經分別被賦值整型值 1718,這樣 eif1eif2 的動態型別的型別資訊是相同的(都是 0x10ac580),但 data 指標指向的記憶體塊中儲存的值不同,一個是 17,一個是 18,於是 eif1 不等於 eif2

接著,程式碼執行到第 16 行的時候,eif2 已經被重新賦值為 17,這樣 eif1eif2 不僅儲存的動態型別的型別資訊是相同的(都是 0x10ac580),data 指標指向的記憶體塊中儲存值也相同了,都是 17,於是 eif1 等於 eif2

然後,程式碼執行到第 21 行時,eif2 已經被重新賦值了 int64 型別的數值 17。這樣,eif1eif2 儲存的動態型別的型別資訊就變成不同的了,一個是 int,一個是 int64,即便 data 指標指向的記憶體塊中儲存值是相同的,最終 eif1eif2 也是不相等的。

第三種:非空介面型別變數

這裡,我們也直接來看一個非空介面型別變數的內部表示輸出的例子:

type T int

func (t T) Error() string { 
    return "bad error"
}

func printNonEmptyInterface() { 
    var err1 error // 非空介面型別
    var err2 error // 非空介面型別
    err1 = (*T)(nil)
    println("err1:", err1)
    println("err1 = nil:", err1 == nil)

    err1 = T(5)
    err2 = T(6)
    println("err1:", err1)
    println("err2:", err2)
    println("err1 = err2:", err1 == err2)

    err2 = fmt.Errorf("%d\n", 5)
    println("err1:", err1)
    println("err2:", err2)
    println("err1 = err2:", err1 == err2)
}   

這個例子的執行輸出結果如下:

err1: (0x10ed120,0x0)
err1 = nil: false
err1: (0x10ed1a0,0x10eb310)
err2: (0x10ed1a0,0x10eb318)
err1 = err2: false
err1: (0x10ed1a0,0x10eb310)
err2: (0x10ed0c0,0xc000010050)
err1 = err2: false

我們看到上面示例中每一輪透過 println 輸出的 err1err2tabdata 值,要麼 data 值不同,要麼 tabdata 值都不同。

和空介面型別變數一樣,只有 tabdata 指的資料內容一致的情況下,兩個非空介面型別變數之間才能劃等號。這裡我們要注意 err1 下面的賦值情況:

err1 = (*T)(nil)

針對這種賦值,println 輸出的 err1 是(0x10ed120, 0x0),也就是非空介面型別變數的型別資訊並不為空,資料指標為空,因此它與 nil0x0, 0x0)之間不能劃等號。

現在我們再回到我們開頭的那個問題,你是不是已經豁然開朗了呢?開頭的問題中,從 returnsError 返回的 error 介面型別變數 err 的資料指標雖然為空,但它的型別資訊(iface.tab)並不為空,而是 *MyError 對應的型別資訊,這樣 errnil0x0,0x0)相比自然不相等,這就是我們開頭那個問題的答案解析,現在你明白了嗎?

第四種:空介面型別變數與非空介面型別變數的等值比較

下面是非空介面型別變數和空介面型別變數之間進行比較的例子:

func printEmptyInterfaceAndNonEmptyInterface() {
  var eif interface{} = T(5)
  var err error = T(5)
  println("eif:", eif)
  println("err:", err)
  println("eif = err:", eif == err)

  err = T(6)
  println("eif:", eif)
  println("err:", err)
  println("eif = err:", eif == err)
}

這個示例的輸出結果如下:

eif: (0x10b3b00,0x10eb4d0)
err: (0x10ed380,0x10eb4d8)
eif = err: true
eif: (0x10b3b00,0x10eb4d0)
err: (0x10ed380,0x10eb4e0)
eif = err: false

你可以看到,空介面型別變數和非空介面型別變數內部表示的結構有所不同(第一個欄位:_type vs. tab),兩者似乎一定不能相等。但 Go 在進行等值比較時,型別比較使用的是 eface_typeifacetab._type,因此就像我們在這個例子中看到的那樣,當 eiferr 都被賦值為 T(5) 時,兩者之間是劃等號的。

好了,到這裡,我們已經理解了各類介面型別變數在執行時層的表示。我們可以透過 println 可以檢視這個表示資訊,從中我們也知道了介面變數只有在型別資訊與值資訊都一致的情況下才能劃等號。

五、輸出介面型別變數內部表示的詳細資訊

不過,println 輸出的介面型別變數的內部表示資訊,在一般情況下都是足夠的,但有些時候又顯得過於簡略,比如在上面最後一個例子中,如果僅憑 eif: (0x10b3b00,0x10eb4d0)err: (0x10ed380,0x10eb4d8) 的輸出,我們是無法想到兩個變數是相等的。

那這時如果我們能輸出介面型別變數內部表示的詳細資訊(比如:tab._type),那勢必可以取得事半功倍的效果。接下來我們就看看這要怎麼做。

前面提到過,efaceiface 以及組成它們的 itab_type 都是 runtime 包下的非匯出結構體,我們無法在外部直接引用它們。但我們發現,組成 efaceiface 的型別都是基本資料型別,我們完全可以透過“複製程式碼”的方式將它們拿到 runtime 包外面來。

不過,這裡要注意,由於 runtime 中的 efaceiface,或者它們的組成可能會隨著 Go 版本的變化發生變化,因此這個方法不具備跨版本相容性。也就是說,基於 Go 1.17 版本複製的程式碼,可能僅適用於使用 Go 1.17 版本編譯。這裡我們就以 Go 1.17 版本為例看看:

// dumpinterface.go 
type eface struct {
    _type *_type
    data  unsafe.Pointer
}

type tflag uint8
type nameOff int32
type typeOff int32

type _type struct {
    size       uintptr
    ptrdata    uintptr // size of memory prefix holding all pointers
    hash       uint32
    tflag      tflag
    align      uint8
    fieldAlign uint8
    kind       uint8
    // function for comparing objects of this type
    // (ptr to object A, ptr to object B) -> ==?
    equal func(unsafe.Pointer, unsafe.Pointer) bool
    // gcdata stores the GC type data for the garbage collector.
    // If the KindGCProg bit is set in kind, gcdata is a GC program.
    // Otherwise it is a ptrmask bitmap. See mbitmap.go for details.
    gcdata    *byte
    str       nameOff
    ptrToThis typeOff
}

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

type itab struct {
    inter *interfacetype
    _type *_type
    hash  uint32 // copy of _type.hash. Used for type switches.
    _     [4]byte
    fun   [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}

... ...

const ptrSize = unsafe.Sizeof(uintptr(0))

func dumpEface(i interface{}) {
    ptrToEface := (*eface)(unsafe.Pointer(&i))
    fmt.Printf("eface: %+v\n", *ptrToEface)

    if ptrToEface._type != nil {
        // dump _type info
        fmt.Printf("\t _type: %+v\n", *(ptrToEface._type))
    }

    if ptrToEface.data != nil {
        // dump data
        switch i.(type) {
        case int:
            dumpInt(ptrToEface.data)
        case float64:
            dumpFloat64(ptrToEface.data)
        case T:
            dumpT(ptrToEface.data)

        // other cases ... ...
        default:
            fmt.Printf("\t unsupported data type\n")
        }
    }
    fmt.Printf("\n")
}

func dumpItabOfIface(ptrToIface unsafe.Pointer) {
    p := (*iface)(ptrToIface)
    fmt.Printf("iface: %+v\n", *p)

    if p.tab != nil {
        // dump itab
        fmt.Printf("\t itab: %+v\n", *(p.tab))
        // dump inter in itab
        fmt.Printf("\t\t inter: %+v\n", *(p.tab.inter))

        // dump _type in itab
        fmt.Printf("\t\t _type: %+v\n", *(p.tab._type))

        // dump fun in tab
        funPtr := unsafe.Pointer(&(p.tab.fun))
        fmt.Printf("\t\t fun: [")
        for i := 0; i < len((*(p.tab.inter)).mhdr); i++ {
            tp := (*uintptr)(unsafe.Pointer(uintptr(funPtr) + uintptr(i)*ptrSize))
            fmt.Printf("0x%x(%d),", *tp, *tp)
        }
        fmt.Printf("]\n")
    }
}

func dumpDataOfIface(i interface{}) {
    // this is a trick as the data part of eface and iface are same
    ptrToEface := (*eface)(unsafe.Pointer(&i))

    if ptrToEface.data != nil {
        // dump data
        switch i.(type) {
        case int:
            dumpInt(ptrToEface.data)
        case float64:
            dumpFloat64(ptrToEface.data)
        case T:
            dumpT(ptrToEface.data)

        // other cases ... ...

        default:
            fmt.Printf("\t unsupported data type\n")
        }
    }
    fmt.Printf("\n")
}

func dumpT(dataOfIface unsafe.Pointer) {
    var p *T = (*T)(dataOfIface)
    fmt.Printf("\t data: %+v\n", *p)
}
... ...

這裡只挑選了關鍵部分,省略了部分程式碼。上面這個 dumpinterface.go 中提供了三個主要函式:

  • dumpEface: 用於輸出空介面型別變數的內部表示資訊;
  • dumpItabOfIface: 用於輸出非空介面型別變數的 tab 欄位資訊;
  • dumpDataOfIface: 用於輸出非空介面型別變數的 data 欄位資訊;

我們利用這三個函式來輸出一下前面 printEmptyInterfaceAndNonEmptyInterface 函式中的介面型別變數的資訊:

package main

import "unsafe"

type T int

func (t T) Error() string {
    return "bad error"
}

func main() {
    var eif interface{} = T(5)
    var err error = T(5)
    println("eif:", eif)
    println("err:", err)
    println("eif = err:", eif == err)
    
    dumpEface(eif)
    dumpItabOfIface(unsafe.Pointer(&err))
    dumpDataOfIface(err)
}

執行這個示例程式碼,我們得到了這個輸出結果:

eif: (0x10b38c0,0x10e9b30)
err: (0x10eb690,0x10e9b30)
eif = err: true
eface: {_type:0x10b38c0 data:0x10e9b30}
   _type: {size:8 ptrdata:0 hash:1156555957 tflag:15 align:8 fieldAlign:8 kind:2 equal:0x10032e0 gcdata:0x10e9a60 str:4946 ptrToThis:58496}
   data: bad error

iface: {tab:0x10eb690 data:0x10e9b30}
   itab: {inter:0x10b5e20 _type:0x10b38c0 hash:1156555957 _:[0 0 0 0] fun:[17454976]}
     inter: {typ:{size:16 ptrdata:16 hash:235953867 tflag:7 align:8 fieldAlign:8 kind:20 equal:0x10034c0 gcdata:0x10d2418 str:3666 ptrToThis:26848} pkgpath:{bytes:<nil>} mhdr:[{name:2592 ityp:43520}]}
     _type: {size:8 ptrdata:0 hash:1156555957 tflag:15 align:8 fieldAlign:8 kind:2 equal:0x10032e0 gcdata:0x10e9a60 str:4946 ptrToThis:58496}
     fun: [0x10a5780(17454976),]
   data: bad error

從輸出結果中,我們看到 eif_type0x10b38c0)與 errtab._type0x10b38c0)是一致的,data 指標所指內容(“bad error”)也是一致的,因此 eif == err 表示式的結果為 true

再次強調一遍,上面這個實現可能僅在 Go 1.17 版本上測試透過,並且在輸出 ifaceefacedata 部分內容時只列出了 intfloat64T 型別的資料讀取實現,沒有列出全部型別的實現,你可以根據自己的需要實現其餘資料型別。dumpinterface.go 的完整程式碼你可以在這裡找到。

我們現在已經知道了,介面型別有著複雜的內部結構,所以我們將一個型別變數值賦值給一個介面型別變數值的過程肯定不會像 var i int = 5 那麼簡單,那麼介面型別變數賦值的過程是怎樣的呢?其實介面型別變數賦值是一個“裝箱”的過程。

六、介面型別的裝箱(boxing)原理

裝箱(boxing)是程式語言領域的一個基礎概念,一般是指把一個值型別轉換成引用型別,比如在支援裝箱概念的 Java 語言中,將一個 int 變數轉換成 Integer 物件就是一個裝箱操作。

在 Go 語言中,將任意型別賦值給一個介面型別變數也是裝箱操作。有了前面對介面型別變數內部表示的學習,我們知道介面型別的裝箱實際就是建立一個 efaceiface 的過程。接下來我們就來簡要描述一下這個過程,也就是介面型別的裝箱原理。

我們基於下面這個例子中的介面裝箱操作來說明:

// interface_internal.go

  type T struct {
      n int
      s string
  }
  
  func (T) M1() {}
  func (T) M2() {}
  
  type NonEmptyInterface interface {
      M1()
      M2()
  }
  
  func main() {
      var t = T{
          n: 17,
          s: "hello, interface",
      }
      var ei interface{}
      ei = t
 
      var i NonEmptyInterface
      i = t
      fmt.Println(ei)
      fmt.Println(i)
  }

這個例子中,對 eii 兩個介面型別變數的賦值都會觸發裝箱操作,要想知道 Go 在背後做了些什麼,我們需要“下沉”一層,也就是要輸出上面 Go 程式碼對應的彙編程式碼:

$go tool compile -S interface_internal.go > interface_internal.s

對應 ei = t 一行的彙編如下:

    0x0026 00038 (interface_internal.go:24) MOVQ    $17, ""..autotmp_15+104(SP)
    0x002f 00047 (interface_internal.go:24) LEAQ    go.string."hello, interface"(SB), CX
    0x0036 00054 (interface_internal.go:24) MOVQ    CX, ""..autotmp_15+112(SP)
    0x003b 00059 (interface_internal.go:24) MOVQ    $16, ""..autotmp_15+120(SP)
    0x0044 00068 (interface_internal.go:24) LEAQ    type."".T(SB), AX
    0x004b 00075 (interface_internal.go:24) LEAQ    ""..autotmp_15+104(SP), BX
    0x0050 00080 (interface_internal.go:24) PCDATA  $1, $0
    0x0050 00080 (interface_internal.go:24) CALL    runtime.convT2E(SB)

對應 i = t 一行的彙編如下:

    0x005f 00095 (interface_internal.go:27) MOVQ    $17, ""..autotmp_15+104(SP)
    0x0068 00104 (interface_internal.go:27) LEAQ    go.string."hello, interface"(SB), CX
    0x006f 00111 (interface_internal.go:27) MOVQ    CX, ""..autotmp_15+112(SP)
    0x0074 00116 (interface_internal.go:27) MOVQ    $16, ""..autotmp_15+120(SP)
    0x007d 00125 (interface_internal.go:27) LEAQ    go.itab."".T,"".NonEmptyInterface(SB), AX
    0x0084 00132 (interface_internal.go:27) LEAQ    ""..autotmp_15+104(SP), BX
    0x0089 00137 (interface_internal.go:27) PCDATA  $1, $1
    0x0089 00137 (interface_internal.go:27) CALL    runtime.convT2I(SB)

在將動態型別變數賦值給介面型別變數語句對應的彙編程式碼中,我們看到了 convT2EconvT2I 兩個 runtime 包的函式。這兩個函式的實現位於 $GOROOT/src/runtime/iface.go 中:

// $GOROOT/src/runtime/iface.go

func convT2E(t *_type, elem unsafe.Pointer) (e eface) {
    if raceenabled {
        raceReadObjectPC(t, elem, getcallerpc(), funcPC(convT2E))
    }
    if msanenabled {
        msanread(elem, t.size)
    }
    x := mallocgc(t.size, t, true)
    typedmemmove(t, x, elem)
    e._type = t
    e.data = x
    return
}

func convT2I(tab *itab, elem unsafe.Pointer) (i iface) {
    t := tab._type
    if raceenabled {
        raceReadObjectPC(t, elem, getcallerpc(), funcPC(convT2I))
    }
    if msanenabled {
        msanread(elem, t.size)
    }
    x := mallocgc(t.size, t, true)
    typedmemmove(t, x, elem)
    i.tab = tab
    i.data = x
    return
}

convT2E 用於將任意型別轉換為一個 efaceconvT2I 用於將任意型別轉換為一個 iface。兩個函式的實現邏輯相似,主要思路就是根據傳入的型別資訊(convT2E_typeconvT2Itab._type)分配一塊記憶體空間,並將 elem 指向的資料複製到這塊記憶體空間中,最後傳入的型別資訊作為返回值結構中的型別資訊,返回值結構中的資料指標(data)指向新分配的那塊記憶體空間。

由此我們也可以看出,經過裝箱後,箱內的資料,也就是存放在新分配的記憶體空間中的資料與原變數便無瓜葛了,比如下面這個例子:

func main() {
  var n int = 61
  var ei interface{} = n
  n = 62  // n的值已經改變
  fmt.Println("data in box:", ei) // 輸出仍是61
}

那麼 convT2EconvT2I 函式的型別資訊是從何而來的呢?

其實這些都依賴 Go 編譯器的工作。編譯器知道每個要轉換為介面型別變數(toType)和動態型別變數的型別(fromType),它會根據這一對型別選擇適當的 convT2X 函式,並在生成程式碼時使用選出的 convT2X 函式參與裝箱操作。

不過,裝箱是一個有效能損耗的操作,因此 Go 也在不斷對裝箱操作進行最佳化,包括對常見型別如整型、字串、切片等提供系列快速轉換函式:

// $GOROOT/src/runtime/iface.go
func convT16(val any) unsafe.Pointer     // val must be uint16-like
func convT32(val any) unsafe.Pointer     // val must be uint32-like
func convT64(val any) unsafe.Pointer     // val must be uint64-like
func convTstring(val any) unsafe.Pointer // val must be a string
func convTslice(val any) unsafe.Pointer  // val must be a slice

這些函式去除了 typedmemmove 操作,增加了零值快速返回等特性。

同時 Go 建立了 staticuint64s 區域,對 255 以內的小整數值進行裝箱操作時不再分配新記憶體,而是利用 staticuint64s 區域的記憶體空間,下面是 staticuint64s 的定義:

// $GOROOT/src/runtime/iface.go
// staticuint64s is used to avoid allocating in convTx for small integer values.
var staticuint64s = [...]uint64{
    0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
    0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
  ... ...
}

七、小結

介面型別作為參與構建 Go 應用骨架的重要參與者,在 Go 語言中有著很高的地位。它這個地位的取得離不開它擁有的“動靜兼備”的語法特性。Go 介面的動態特性讓 Go 擁有與動態語言相近的靈活性,而靜態特性又在編譯階段保證了這種靈活性的安全。

要更好地理解 Go 介面的這兩種特性,我們需要深入到 Go 介面在執行時的表示層面上去。介面型別變數在執行時表示為 efaceifaceeface 用於表示空介面型別變數,iface 用於表示非空介面型別變數。只有兩個介面型別變數的型別資訊(eface._type/iface.tab._type)相同,且資料指標(eface.data/iface.data)所指資料相同時,兩個介面型別變數才是相等的。

我們可以透過 println 輸出介面型別變數的兩部分指標變數的值。而且,透過複製 runtimeefaceiface 相關型別原始碼,我們還可以自定義輸出 eface/iface 詳盡資訊的函式,不過要注意的是,由於 runtime 層程式碼的演進,這個函式可能不具備在 Go 版本間的移植性。

最後,介面型別變數的賦值本質上是一種裝箱操作,裝箱操作是由 Go 編譯器和執行時共同完成的,有一定的效能開銷,對於效能敏感的系統來說,我們應該儘量避免或減少這類裝箱操作。

相關文章