一文喫透 Go 語言解密之接口 interface

大家好,我是煎魚。

自古流傳着一個傳言... 在 Go 語言面試的時候必有人會問接口(interface)的實現原理。這又是爲什麼?爲何對接口如此執着?

實際上,Go 語言的接口設計在整體扮演着非常重要的角色,沒有他,很多程序估計都跑的不愉快了。

在 Go 語言的語義上,只要某個類型實現了所定義的一組方法集,則就認爲其就是同一種類型,是一個東西。大家常常稱其爲鴨子類型(Duck typing),因爲其與鴨子類型類型的定義相對吻合。

圖來自網絡後重整

在維基百科中,鴨子類型的諺語定義爲 ”If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.“,翻譯過來就是” 如果它看起來像鴨子,像鴨子一樣游泳,像鴨子一樣嘎嘎叫,那他就可以認爲是鴨子 “。

迴歸到 Go 語言,在接口之下,接口又蘊含了怎麼樣的底層結構,其設計原理和思考又是什麼呢?我們不能只看表面,接下來在這一章節中都會進行一一分析和道來。看看其深層到底是何 “物”。

本文目錄:

什麼是 interface

Go 語言中的接口聲明:

type Human interface {
 Say(s string) error
}

關鍵字主體爲 type xxx interface,緊接着可以在方括號中編寫方法集,用於聲明和定義該接口所包含的方法集。

更進一步的代碼演示:

type Human interface {
 Say(s string) error
}

type TestA string

func (t TestA) Say(s string) error {
 fmt.Printf("煎魚:%s\n", s)
 return nil
}

func main() {
 var h Human
 var t TestA
 _ = t.Say("炸雞翅")
 h = t
 _ = h.Say("烤羊排")
}

輸出結果:

煎魚炸雞翅
煎魚烤羊排

我們在上述代碼中,聲明瞭一個名爲 Human 的 interface,其包含一個 Say 方法。同時我們聲明瞭一個 TestA 類型,也有自己的一個 Say 方法。他們兩者的方法入參和出參類型均爲一樣。

而與此同時,我們在主函數 main 中通過聲明和賦值,成功將類型爲 TestA 的變量 t 賦給了類型爲 Human 的變量 h,也就是說兩者只因有了個 Say 方法,在 Go 語言的編譯器中就認爲他們是 “一樣” 的了,這也就是業界中常說的鴨子類型。

數據結構

通過上面的功能代碼一看,似乎 Go 語言非常優秀。一個接口,不同的類型,2 個包含相同的方法,也能夠對標到一起。

接口到底是怎麼實現的呢?底層數據結構又是什麼?帶着問題,我們開始深挖細節之路。

在 Go 語言中,接口的底層數據結構在運行時一共分爲兩類結構體(struct),分別是:

runtime.eface

首先我們來介紹 eface,看看 “他” 到底是何許人也。源碼如下:

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

其表示不包含任何方法的空接口。在結構上來講 eface 非常簡單,就兩個屬性,分別是 _type 和 data 屬性,分別代表底層的指向的類型信息和指向的值信息指針。

再進一步到 type 屬性裏看看,其包含的類型信息更多:

type _type struct {
 size       uintptr
 ptrdata    uintptr 
 hash       uint32
 tflag      tflag
 align      uint8
 fieldAlign uint8
 kind       uint8
 equal func(unsafe.Pointer, unsafe.Pointer) bool
 gcdata    *byte
 str       nameOff
 ptrToThis typeOff
}

總結一句,就是類型信息所需的信息都會存儲在這裏面,其中包含字節大小、類型標誌、內存對齊、GC 等相關屬性。而在 eface 來講,其由於沒有方法集的包袱,因此只需要存儲類型和值信息的指針即可,非常簡單。

runtime.iface

其次就是我們日常在應用程序中應用的較多的 iface,源碼如下:

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

與 eface 結構體類型一樣,主要也是分爲類型和值信息,分別對應 tab 和 data 屬性。但是我們再加思考一下,爲什麼 iface 能藏住那麼多的方法集呢,難道施了黑魔法?

爲了解密,我們進一步深入看看 itab 結構體。源碼如下:

type itab struct {
 inter *interfacetype
 _type *_type
 hash  uint32 
 _     [4]byte
 fun   [1]uintptr 
}

對應 func 屬性會在後面的章節進一步展開講解,便於大家對於接口中的函數指針管理的使用和理解,在此可以先行思考長度爲 1 的 uintptr 數組是如何做到存儲多方法的?

接下來我們進一步展開 interfacetype 結構體。源碼如下:

type nameOff int32
type typeOff int32

type imethod struct {
 name nameOff
 ityp typeOff
}

type interfacetype struct {
 typ     _type
 pkgpath name
 mhdr    []imethod
}

而相對應 interfacetype,還有各種類型的 type。例如:maptypearraytypechantypeslicetype 等,都是針對具體的類型做的具體類型定義:

type arraytype struct {
 typ   _type
 elem  *_type
 slice *_type
 len   uintptr
}

type chantype struct {
 typ  _type
 elem *_type
 dir  uintptr
}
...

若有興趣自行翻看 runtime 裏相應源碼即可,都是一些基本數據結構信息的存儲和配套方法,就不在此一一展開講解了。

小結

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

值接收者和指針接收者

在接口的具體應用使用場景中,有一個是大家常常會碰到,甚至會對其產生較大糾結心裏的東西。那就是到底用值接收者,又或是用指針接收者來聲明。

演示說明

演示代碼如下:

type Human interface {
 Say(s string) error
 Eat(s string) error
}

type TestA struct{}

func (t TestA) Say(s string) error {
 fmt.Printf("說煎魚:%s\n", s)
 return nil
}

func (t *TestA) Eat(s string) error {
 fmt.Printf("喫煎魚:%s\n", s)
 return nil
}

func main() {
 var h Human = &TestA{}
 _ = h.Say("催更")
 _ = h.Eat("真香")
}

在 Human 接口中,其包含 Say 和 Eat 方法,並且在 TestA 結構體中我們進行了針對性的實現。

具體的區別就是:

最終的輸出結果:

說煎魚催更
喫煎魚真香

值和指針

如果我們將演示代碼的主函數 main 改成下述這樣:

func main() {
 var h Human = TestA{}
 _ = h.Say("催更")
 _ = h.Eat("真香")
}

你覺得這段代碼還能正常運行嗎?在編譯時會出現如下報錯信息:

# command-line-arguments
./main.go:23:6: cannot use TestA literal (type TestA) as type Human in assignment:
 TestA does not implement Human (Eat method has pointer receiver)

顯然是不能的。因爲接口校驗不對,編譯器過不了。其根本原因在於 Eat 是指針接收者。而當聲明改爲 TestA{} 後,其就會變成值對象,所以不匹配。

這時候又會出現新的問題,爲什麼在上面代碼聲明爲 &TestA{} 時,那肯定是指針引用了,那爲什麼 Say 方法又能正常運行,不會報錯呢?

其實 TestA{} 實現了 Say 方法,那麼 &TestA{} 也能自動擁有該方法。顯然,這是 Go 語言自身在背後做了一些事情。

因此如果我們實現了一個值對象的接收者時,也會相應擁有了一個指針接收者。兩者並不會互相影響,因爲值對象會產生值拷貝,對象會獨立開來。

而指針對象的接收者不行,因爲指針引用的對象,在應用上是期望能夠直接對源接收者的值進行修改,若又支持值接收者,顯然是不符合其語義的。

兩者怎麼用

既然支持值接收,又支持指針接收。那平時在工程應用開發中,到底用誰?還是說隨便用?

其實問題的答案,在前面就有提到。本質上還是要看你業務邏輯所期望修改的是什麼?還是說程序很嚴謹,每次都重新 new 一個,是值又或是指針引用對於程序邏輯的結果都沒有任何的影響。

總結一下,如果你想使用指針接收者,可以想想是否有以下訴求:

但若應用場景沒什麼區別,只是個人習慣問題就不用過於糾結了,適度統一也是很重要的一環。

類型斷言

在 Go 語言中使用接口,必搭配一個 “技能”。那就是進行類型斷言(type assertion):

var i interface{} = "喫煎魚"

// 進行變量斷言,若不判斷容易出現 panic
s := i.(string)

// 進行安全斷言
s, ok := i.(string)

在 switch case 中,還有另外一種寫法:

var i interface{} = "炸煎魚"

// 進行 switch 斷言
switch i.(type) {
case string:
    // do something...
case int:
    // do something...
case float64:
    // do something...
}

採取的是 (變量).(type) 的調用方式,再給予 case 不同的類型進行判斷識別。在 Go 語言的背後,類型斷言其實是在編譯器翻譯後,根據 iface 和 eface 分別對應了下述方法:

func assertI2I2(inter *interfacetype, i iface) (r iface, b bool) {
 tab := i.tab
 if tab == nil {
  return
 }
 if tab.inter != inter {
  tab = getitab(inter, tab._type, true)
  if tab == nil {
   return
  }
 }
 r.tab = tab
 r.data = i.data
 b = true
 return
}
func assertI2I(inter *interfacetype, i iface) (r iface)

func assertE2I2(inter *interfacetype, e eface) (r iface, b bool)
func assertE2I(inter *interfacetype, e eface) (r iface)

主要是根據接口的類型信息進行一輪判斷和識別,基本就完成了。主要核心在於 getitab 方法,會在後面進行統一介紹和說明。

類型轉換

演示代碼如下:

func main() {
 x := "煎魚"
 var v interface{} = x
 fmt.Println(v)
}

查看彙編代碼:

 0x0021 00033 (main.go:9) LEAQ go.string."煎魚"(SB), AX
 0x0028 00040 (main.go:9) MOVQ AX, (SP)
 0x002c 00044 (main.go:9) MOVQ $6, 8(SP)
 0x0035 00053 (main.go:9) PCDATA $1, $0
 0x0035 00053 (main.go:9) CALL runtime.convTstring(SB)
 0x003a 00058 (main.go:9) MOVQ 16(SP), AX
 0x003f 00063 (main.go:10) XORPS X0, X0

主要對應了 runtime.convTstring 方法。同時很顯然其是根據類型來區分來方法:

func convTstring(val string) (x unsafe.Pointer) {
 if val == "" {
  x = unsafe.Pointer(&zeroVal[0])
 } else {
  x = mallocgc(unsafe.Sizeof(val), stringType, true)
  *(*string)(x) = val
 }
 return
}

func convT16(val uint16) (x unsafe.Pointer)
func convT32(val uint32) (x unsafe.Pointer)
func convT64(val uint64) (x unsafe.Pointer)
func convTstring(val string) (x unsafe.Pointer) 
func convTslice(val []byte) (x unsafe.Pointer)
func convT2Enoptr(t *_type, elem unsafe.Pointer) (e eface)
func convT2I(tab *itab, elem unsafe.Pointer) (i iface)
...

動態分派

前面有提到接口中的 fun [1]uintptr 屬性會可以存儲接口的方法集,但不知道爲什麼。

接下來我們將進行具體的分析,演示代碼:

type Human interface {
 Say(s string) error
 Eat(s string) error
 Walk(s string) error
}

type TestA string

func (t TestA) Say(s string) error {
 fmt.Printf("煎魚:%s\n", s)
 return nil
}
func (t TestA) Eat(s string) error {
 fmt.Printf("煎魚:%s\n", s)
 return nil
}

func (t TestA) Walk(s string) error {
 fmt.Printf("煎魚:%s\n", s)
 return nil
}

func main() {
 var h Human
 var t TestA
 h = t
 _ = h.Eat("烤羊排")
 _ = h.Say("炸雞翅")
 _ = h.Walk("去炸雞翅")
}

存儲方式

執行 go build -gcflags '-l' -o awesomeProject . 編譯後,再次執行 go tool objdump -s "main" awesomeProject

查看具體的彙編代碼:

  LEAQ go.itab.main.TestA,main.Human(SB), AX 
  TESTB AL, 0(AX)     
  MOVQ 0x10(SP), AX    
  MOVQ AX, 0x28(SP)    
  MOVQ go.itab.main.TestA,main.Human+32(SB), CX 
  MOVQ AX, 0(SP)     
  LEAQ go.string.*+3048(SB), DX   
  MOVQ DX, 0x8(SP)    
  MOVQ $0x9, 0x10(SP)    
  CALL CX      
  MOVQ go.itab.main.TestA,main.Human+24(SB), AX 
  MOVQ 0x28(SP), CX    
  MOVQ CX, 0(SP)     
  LEAQ go.string.*+3057(SB), DX   
  MOVQ DX, 0x8(SP)    
  MOVQ $0x9, 0x10(SP)    
  CALL AX      
  MOVQ go.itab.main.TestA,main.Human+40(SB), AX 
  MOVQ 0x28(SP), CX    
  MOVQ CX, 0(SP)     
  LEAQ go.string.*+4973(SB), CX   
  MOVQ CX, 0x8(SP)    
  MOVQ $0xc, 0x10(SP)    
  CALL AX

結合來看,雖然 fun 屬性的類型是 [1]uintptr,只有一個元素,但其實就是存放了接口方法集的首個方法的地址信息, 接着根據順序往後計算並獲取就好了。也就是說其是存在一定規律的。在存入方法時就決定了,所以獲取也能明確。

我們進一步展開,看看 itab hash table 是如何獲取和新增的。

獲取 itab 元素

getitab 方法的主要作用是獲取 itab 元素,若不存在則新增。源碼如下:

func getitab(inter *interfacetype, typ *_type, canfail bool) *itab {
 // 省略一些邊界、異常處理
 var m *itab

 t := (*itabTableType)(atomic.Loadp(unsafe.Pointer(&itabTable)))
 if m = t.find(inter, typ); m != nil {
  goto finish
 }

 lock(&itabLock)
 if m = itabTable.find(inter, typ); m != nil {
  unlock(&itabLock)
  goto finish
 }

 m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*sys.PtrSize, 0, &memstats.other_sys))
 m.inter = inter
 m._type = typ
 m.hash = 0
 m.init()
 itabAdd(m)
 unlock(&itabLock)
finish:
 if m.fun[0] != 0 {
  return m
 }

 panic(&TypeAssertionError{concrete: typ, asserted: &inter.typ, missingMethod: m.init()})
}

新增 itab 元素

itabAdd 方法的主要作用是將所生成好的 itab 元素新增到 itab hash table 中。源碼如下:

func itabAdd(m *itab) {
 // 省略一些邊界、異常處理
 t := itabTable
 if t.count >= 3*(t.size/4) { // 75% load factor
  t2 := (*itabTableType)(mallocgc((2+2*t.size)*sys.PtrSize, nil, true))
  t2.size = t.size * 2
  iterate_itabs(t2.add)
  if t2.count != t.count {
   throw("mismatched count during itab table copy")
  }

  atomicstorep(unsafe.Pointer(&itabTable), unsafe.Pointer(t2))
  t = itabTable
 }
 t.add(m)
}

總結

在本文中,我們先介紹了 Go 語言接口的 runtime.eface 和 runtime.iface 兩個基本數據結構,其代表了一切的開端。

隨後針對值接受者和指針接收者進行了詳細的說明,同時日常用的較多的類型斷言和轉換也一一進行了描述。

最後對接口的多方法這個神祕的地方進行了基本分析和了解,相信這一番輪流吸收下來,能夠打開大家對接口的一個新的理解。

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