Go 介面:深入內部原理

2022-07-23 12:00:18

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

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裡面到底了什麼

可以看出來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將其轉成組合程式碼

紅框內的正是第 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      // 儲存介面的方法集的具體實現的地址,其包含一組函數指標,實現了介面方法的動態分派,且每次在介面發生變更時都會更
}

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

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的一個完整的資料

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 資料如下:

    注意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會導致判空失敗。

解決的方案是:函數返回引數不要寫出介面型別,在外部先做判空,在轉成介面。