Go 接口:深入內部原理

image

接口的基本概念不在這裡贅述,詳情請看第十六章:接口

nil 非空?

package main

func main() {
   var obj interface{}
   obj = 1
   println(obj == 1)  // true
   obj = "hello"
   println(obj == "hello")  // true

   type User struct {

   }
   var u *User
   obj = u
   println(u == nil)  // true
   println(obj == nil)  // true
}

前面的只是對比,說明interface can hold everything。我們需要注意的最後兩個判斷:

  • u是一個User類型的空指針,println(u == nil)輸出true是意料之內;
  • u賦值給obj後,println(obj == nil)輸出的是false意料之外

為什麼把空指針u賦值給interface後,obj就不是nil了嗎?那它會是什麼呢?

通過gdb工具調試,我們看到interface原來是長這樣的:

(gdb) ptype obj 
type = struct runtime.eface {
    runtime._type *_type;
    void *data;
}

通過goland斷點看一下obj裏面到底了什麼

image.png

可以看出來data是用來存儲數據,_type用來存儲類型:

  • obj = 1時,底層的eface的兩個屬性都是有值的;
  • obj = u時,底層的efacedata屬性為空,_type屬性非空
  • obj = nil時,底層的efacedata_type屬於都為空

對應結構體類型的比較,要求結構體中的所有字段都相等時兩個變量才是相等的,因為eface_type屬於非空,所以當將u賦值給obj後,println(obj == nil輸出的是false

這就引出了另一個問題,當執行obj = u這行代碼時,golang runtime是如何把靜態類型的值u轉換成eface結構的呢?

當給接口賦值時

接着上面的問題,我們通過下面這段簡單代碼,看看是如何把一個靜態類型值轉換成eface

package main

import "fmt"

func main() {
   var a int64 = 123
   var i interface{} = a  // 這一行進行轉換
   fmt.Println(i)
}

通過命令go tool compile -N -l -S main.go將其轉成彙編代碼
image.png
紅框內的正是第 7 行對應的彙編指CALL runtime.convT64(SB)(彙編代碼可以直接調用 Go func),我們可以在runtime包中找到對應的函數函數

// runtime/iface.go
func convT64(val uint64) (x unsafe.Pointer) {
   if val < uint64(len(staticuint64s)) {
      x = unsafe.Pointer(&staticuint64s[val])
   } else {
      x = mallocgc(8, uint64Type, false) // 分配內存,(size, _type, needzero) 
      *(*uint64)(x) = val // 複製
   }
   return
}

eface, iface

通過上面的實驗,我們了解了接口的底層結構是eface。實際上,Golang 根據接口是否包含方法,將接口分為兩類:

  • eface:不包含任何綁定方法的接口
    • 比如:空接口 interface{}
  • iface:包含綁定方法的接口
    • 比如:os.Writer
        type Writer interface {
           Write(p []byte) (n int, err error)
        }
    

eface

eface的數據結構:

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

這個我們應該比較熟悉了,在上面的實驗中我們已經見過了:_type 和 data 屬性,分別代表底層的指向的類型信息和指向的值信息指針。

我們在看一下_type屬性,它的類型是又是一個結構體:

type _type struct {
   size       uintptr // 類型的大小
   ptrdata    uintptr // 包含所有指針的內存前綴的大小
   hash       uint32  // 類型的 hash 值,此處提前計算好,可以避免在哈希表中計算
   tflag      tflag   // 額外的類型信息標誌,此處為類型的 flag 標誌,主要用於反射
   align      uint8   // 對應變量與該類型的內存對齊大小
   fieldAlign uint8   // 對應類型的結構體的內存對齊大小
   kind       uint8   // 類型的枚舉值, 包含 Go 語言中的所有類型,例如:`kindBool`、`kindInt`、`kindInt8`、`kindInt16` 等
   equal func(unsafe.Pointer, unsafe.Pointer) bool  // 用於比較此對象的回調函數
   gcdata    *byte    // 存儲垃圾收集器的 GC 類型數據
   str       nameOff
   ptrToThis typeOff
}

總結來說:runtime 只需在這裡查詢,就能得到與類型相關的所有信息(位元組大小、類型標誌、內存對齊等)。

iface

iface的數據結構:

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

iface相比,它們的data屬性是一樣的,用於存儲數據;不同的是,因為iface不僅要存儲類型信息,還要存儲接口綁定的方法,所有需要使用itab結構來存儲兩者信息。我們看一下itab

type itab struct {
   inter *interfacetype  // 接口的類型信息
   _type *_type          // 具體類型信息
   hash  uint32          // _type.hash 的副本,用於目標類型和接口變量的類型對比判斷
   _     [4]byte
   fun   [1]uintptr      // 存儲接口的方法集的具體實現的地址,其包含一組函數指針,實現了接口方法的動態分派,且每次在接口發生變更時都會更
}

總結來講,接口的數據結構基本表示形式比較簡單,就是類型和值描述。再根據其具體的區別,例如是否包含方法集,具體的接口類型等進行組合使用。
image.png

iface,接口綁定的 method 你存到了哪裡?

通過上節,我們知道iface可以存儲接口綁定的方法。從其結構體也能看出來iface.tab.fun字段就是用來干這個事。但是,我有一個疑問:fun類型是長度為 1 的指針數組,難道它就只能存一個 method?

type Animal interface {
   Speak () string
   Move()
   Attack()
}

type Lion struct {

}

func (l Lion) Speak() string {
   return "Uh....."
}

func (l Lion) Move() {
}

func (l Lion) Attack() {
}

func main() {
    lion := Lion{}
    var obj interface{} = lion
    cc, _ := obj.(Animal)
    fmt.Println(cc.Speak()) // Un....
}

Lion是一個實現了接口Animal所有方法的結構體,所以一個接口obj嘗試通過類型斷言轉換成Animal接口是,是可以成功的。通過 Debug 調試,當我執行cc, _ := obj.(Animal)這行代碼時,內部回去調 assertE2I2方法然後返回

func assertE2I2(inter *interfacetype, e eface) (r iface, b bool) {
   t := e._type
   if t == nil {
      return
   }
   tab := getitab(inter, t, true)
   if tab == nil {
      return
   }
   r.tab = tab
   r.data = e.data
   b = true
   return
}

所以返回的cc變量實際上是一個iface結構體,因為iface無法導出我們看不到內部數據,但我們可以通過在 main 程序中把iface結構體定義一封,通過指針操作進行轉換:

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.
}
...

func main() {
   lion := Lion{}
   var obj interface{} = lion
   cc, _ := obj.(Animal)
   fmt.Println(cc.Speak())  // Uh.....

   dd := *(*iface)(unsafe.Pointer(&cc))  // 當cc轉成 iface 接口體
   fmt.Printf("%v\n", dd)
   fmt.Printf("%+V", cc)
}

通過 debug 可以看到,接口Animal對應的eface的一個完整的數據
image.png

tab裏面保存了類型和綁定方法的數據:inter.mhdr的長度為 3,看起來是存儲了 3 個方法的名字和類型,fun里存儲了一個指針,應該就是第一個方法的地址了。下面這段代碼可以證實:

// itab 的初始化
func (m *itab) init() string {
   inter := m.inter
   typ := m._type
   x := typ.uncommon()

   // ni的值為接口綁定的方法數量
   ni := len(inter.mhdr)
   nt := int(x.mcount)
   // 我猜 xmhdr 是真實存儲接口的方法的地方
   xmhdr := (*[1 << 16]method)(add(unsafe.Pointer(x), uintptr(x.moff)))[:nt:nt]
   j := 0
   methods := (*[1 << 16]unsafe.Pointer)(unsafe.Pointer(&m.fun[0]))[:ni:ni]
   var fun0 unsafe.Pointer
imethods:
   // 遍歷3個方案
   for k := 0; k < ni; k++ {
      i := &inter.mhdr[k]
      itype := inter.typ.typeOff(i.ityp)
      name := inter.typ.nameOff(i.name)
      iname := name.name()
      ipkg := name.pkgPath()
      if ipkg == "" {
         ipkg = inter.pkgpath.name()
      }
      for ; j < nt; j++ {
         t := &xmhdr[j]
         tname := typ.nameOff(t.name)
         // 通過遍歷 xmhdr,如果和mhrd[k]的名字、類型並且pkgpath都相等,就找到了
         if typ.typeOff(t.mtyp) == itype && tname.name() == iname {
            pkgPath := tname.pkgPath()
            if pkgPath == "" {
               pkgPath = typ.nameOff(x.pkgpath).name()
            }
            if tname.isExported() || pkgPath == ipkg {
               if m != nil {
                  // 獲取方法的地址
                  ifn := typ.textOff(t.ifn)
                  if k == 0 {
                     // 記錄第一個方法的地址
                     fun0 = ifn // we'll set m.fun[0] at the end
                  } else {
                     methods[k] = ifn
                  }
               }
               continue imethods
            }
         }
      }
      // didn't find method
      m.fun[0] = 0
      return iname
   }
   // func[0] = 第一個方法的地址
   m.fun[0] = uintptr(fun0)
   return ""
}

總結一下,在將一個不確定的interface{}類型斷言成某個特定接口時,runtime 會將原來的數據、方法以iface的數據結構進行返回。iface實際上只保存第一個方法的地址,其他的方法通過偏移量就能找到,偏移的信息保存在 mhdr 中(待驗證)

類型斷言是怎麼做到的

Go 是強類型的語言,變量類型、函數傳參的類型一定定義就不能變換。這為程序的類型提供了安全穩定的保證,但也為程序的編碼帶來更多的工作量。比如我們去是實現一個加法函數,需要對不同的類型都寫一遍,並且使用起來也不方便:

func addInt(a, b int) int { return a + b }
func addInt32(a, b int32) int32 { return a + b }
func addInt64(a, b int64) int64 { return a + b }
func addFloat32(a, b float32) float32 { return a + b }
func addFloat64(a, b float64) float64 { return a + b }

基於interface can hold everything,我們通過使用interface{}當入參類型,用一個函數來實現:

func add(a, b interface{}) interface{} {
   switch av := a.(type) {
   case int:
      if bv, ok := b.(int); ok {
         return av + bv
      }
      panic("bv is not int")
   case int32:
      if bv, ok := b.(int32); ok {
         return av + bv
      }
      panic("bv is not int32")
   ...
   case float64:
      if bv, ok := b.(float64); ok {
         return av + bv
      }
      panic("bv is not float64")

   }

   panic("illegal a and b")
}

func main() {
    var a int64 = 1
    var b int64 = 4
    c := add(a, b)
    fmt.Println(c)  // 5
}

可能會有人問:add函數的參數變量類型是interface{}了, 它在函數裏面是後如何把從interface{}中的帶變量?(答案就是eface

  1. 第一步int64 -> eface

    注意這行代碼 c := add(a, b),翻譯成彙編的話:

    0x002f 00047 (main.go:132)      FUNCDATA      $2, "".main.stkobj(SB)
    0x002f 00047 (main.go:142)      MOVQ    $1, "".a+56(SP)
    0x0038 00056 (main.go:143)      MOVQ    $4, "".b+48(SP)
    0x0041 00065 (main.go:144)      MOVQ    "".a+56(SP), AX
    0x0046 00070 (main.go:144)      MOVQ    AX, (SP)
    0x004a 00074 (main.go:144)      PCDATA  $1, $0
    0x004a 00074 (main.go:144)      CALL    runtime.convT64(SB)
    

    注意最後一行runtime.convT64,上面提到過,這裡的操作就拷貝一份值給到函數add

    func convT64(val uint64) (x unsafe.Pointer) {
        if val < uint64(len(staticuint64s)) {
          x = unsafe.Pointer(&staticuint64s[val])
        } else {
          x = mallocgc(8, uint64Type, false)
          *(*uint64)(x) = val
        }
        return
    }
    
  2. 第二步從eface中得到類型信息

    為了驗證我們的猜想,我們在add函數入口處通過類型轉換把interface{} a轉成eface dd來看一它的具體數據長什麼樣

    func add(a, b interface{}) interface{} {
        dd := *(*eface)(unsafe.Pointer(&a))
        fmt.Println(dd)
        switch av := a.(type) {
        case int:
          if bv, ok := b.(int); ok {
             return av + bv
          }
          panic("bv is not int")
       }
       ...
    

    通過 debug 看到的 dd 數據如下:
    image.png
    注意dd._type.kind字段的只為 6,在src/runtime/typekind.go文件中,維護了每個類型對應一個常量

    const (
       kindBool = 1 + iota
       kindInt
       kindInt8
       kindInt16
       kindInt32
       kindInt64 // 6
       kindUint
       kindUint8
       kindUint16
       kindUint32
       kindUint64
       kindUintptr
       kindFloat32
       ...
    )
    

    可以看到,int64對應的常量值正好是 6。這也就解釋通過類型斷言獲取將interface{}轉成具體類型的原理。

總結

接口的作用

  • 在 Go 運行時,為方便內部傳遞數據、操作數據,使用interface{}作為存儲數據的媒介,大大降低了開發成本。這個媒介存儲了數據的位置數據的類型,有這兩個信息,就能代表一切變量,即interface can hold everything
  • 接口也作為一種抽象的能力,通過定義一個接口所需實現的方法,等同於對如何判定這個 struct 是不是這類接口完成了明確的定義,即必須是接口綁定的所有方法。通過這種能力,可以在編碼上做到很大程度的解耦,接口就好比上下游開發者之間協議。

接口的內部存儲有兩類

Golang 根據接口是否包含方法,將接口分為兩類:

  • eface:不包含任何綁定方法的接口
    • 比如:空接口 interface{}
  • iface:包含綁定方法的接口
    • 比如:os.Writer

二者之間的差別在與eface多存了接口綁定的方法信息。

當心,變成接口後,判空不準

判空的條件是結構體的所有字段都為nil才行,當nil的固定類型值轉成接口後,接口的數據值為nil,但是類型值不為nil會導致判空失敗。

image.png

解決的方案是:函數返回參數不要寫出接口類型,在外部先做判空,在轉成接口。

Tags: