Go 接口:深入內部原理
接口的基本概念不在這裡贅述,詳情請看第十六章:接口
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
時,底層的eface
的data
屬性為空,_type
屬性非空 - 當
obj = nil
時,底層的eface
的data
和_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
)
-
第一步
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 }
-
第二步從
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
會導致判空失敗。
解決的方案是:函數返回參數不要寫出接口類型,在外部先做判空,在轉成接口。