一文喫透 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
結構體:表示不包含任何方法的空接口,也稱爲 empty interface。 -
runtime.iface
結構體:表示包含方法的接口。
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
}
-
size:類型的大小。
-
ptrdata:包含所有指針的內存前綴的大小。
-
hash:類型的 hash 值。此處提前計算好,可以避免在哈希表中計算。
-
tflag:額外的類型信息標誌。此處爲類型的 flag 標誌,主要用於反射。
-
align:對應變量與該類型的內存對齊大小。
-
fieldAlign:對應類型的結構體的內存對齊大小。
-
kind:類型的枚舉值。包含 Go 語言中的所有類型,例如:
kindBool
、kindInt
、kindInt8
、kindInt16
等。 -
equal:用於比較此對象的回調函數。
-
gcdata:存儲垃圾收集器的 GC 類型數據。
總結一句,就是類型信息所需的信息都會存儲在這裏面,其中包含字節大小、類型標誌、內存對齊、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
}
-
inter
:接口的類型信息。 -
_type
:具體類型信息 -
hash
:_type.hash
的副本,用於目標類型和接口變量的類型對比判斷。 -
fun
:底層數組,存儲接口的方法集的具體實現的地址,其包含一組函數指針,實現了接口方法的動態分派,且每次在接口發生變更時都會更新。
對應 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
}
-
_type
:接口的具體類型信息。 -
pkgpath
:接口的包(package)名信息。 -
mhdr
:接口所定義的函數列表。
而相對應 interfacetype
,還有各種類型的 type
。例如:maptype
、arraytype
、chantype
、slicetype
等,都是針對具體的類型做的具體類型定義:
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
結構體中我們進行了針對性的實現。
具體的區別就是:
-
在
Say
方法中是值接收對象,如:(t TestA)
。 -
在
Eat
方法中是指針接收對象,如:(t *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()})
}
-
調用
atomic.Loadp
方法加載並查找現有的 itab hash table,看看是否是否可以找到所需的 itab 元素。 -
若沒有找到,則調用
lock
方法對itabLock
上鎖,並進行重試(再一次查找)。 -
若找到,則跳到
finish
標識的收尾步驟。 -
若沒有找到,則新生成一個 itab 元素,並調用
itabAdd
方法新增到全局的 hash table 中。 -
返回
fun
屬性的首位地址,繼續後續業務邏輯。
新增 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)
}
-
檢查 itab hash table 的容量情況,查看容量情況是否已經滿足大於或等於 75%。
-
若滿足擴容策略,則調用
mallocgc
方法申請內存,按既有size
大小擴容雙倍容量。 -
若不滿足擴容策略,則直接新增
itab
元素到 hash table 中。
總結
在本文中,我們先介紹了 Go 語言接口的 runtime.eface
和 runtime.iface
兩個基本數據結構,其代表了一切的開端。
隨後針對值接受者和指針接收者進行了詳細的說明,同時日常用的較多的類型斷言和轉換也一一進行了描述。
最後對接口的多方法這個神祕的地方進行了基本分析和了解,相信這一番輪流吸收下來,能夠打開大家對接口的一個新的理解。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/vSgV_9bfoifnh2LEX0Y7cQ