【Go】內存中的接口類型
前言
在Go
語言中,接口是一種語法類型,用來定義一種編程規範。
在Go
語言中,接口主要有兩類:
-
沒有方法定義的空接口
-
有方法定義的非空接口
之前,有兩篇圖文詳細介紹了空接口對象及其類型:
本文將深入探究包含方法的非空接口,以下簡稱接口。
環境
OS : Ubuntu 20.04.2 LTS; x86_64
Go : go version go1.16.2 linux/amd64
聲明
操作系統、處理器架構、Go
版本不同,均有可能造成相同的源碼編譯後運行時的寄存器值、內存地址、數據結構等存在差異。
本文僅包含 64
位系統架構下的 64
位可執行程序的研究分析。
本文僅保證學習過程中的分析數據在當前環境下的準確有效性。
代碼清單
// interface_in_memory.go
package main
import "fmt"
import "reflect"
import "strconv"
type foo interface {
fmt.Stringer
Foo()
ree()
}
type fooImpl int
//go:noinline
func (i fooImpl) Foo() {
println("hello foo")
}
//go:noinline
func (i fooImpl) ree() {
println("hello ree")
}
//go:noinline
func (i fooImpl) String() string {
return strconv.Itoa(int(i))
}
func main() {
impl := fooImpl(123)
impl.Foo()
impl.ree()
fmt.Println(impl.String())
typeOf(impl)
exec(impl)
}
//go:noinline
func exec(foo foo) {
foo.Foo()
foo.ree()
fmt.Println(foo.String())
typeOf(foo)
fmt.Printf("exec 參數類型地址:%p\n", reflect.TypeOf(exec).In(0))
}
//go:noinline
func typeOf(i interface{}) {
v := reflect.ValueOf(i)
t := v.Type()
fmt.Printf("類型:%s\n", t.String())
fmt.Printf("地址:%p\n", t)
fmt.Printf("值 :%d\n", v.Int())
fmt.Println()
}
以上代碼,定義了一個包含3
個方法的接口類型foo
,還定義了一個fooImpl
類型。在語法上,我們稱fooImpl
類型實現了foo
接口。
運行結果
程序結構
數據結構介紹
接口數據類型的結構定義在reflect/type.go
源文件中,如下所示:
// 表示一個接口方法
type imethod struct {
name nameOff // 方法名稱相對程序 .rodata 節的偏移量
typ typeOff // 方法類型相對程序 .rodata 節的偏移量
}
// 表示一個接口數據類型
type interfaceType struct {
rtype // 基礎信息
pkgPath name // 包路徑信息
methods []imethod // 接口方法
}
其實這只是一個表象,完整的接口數據類型結構如下僞代碼所示:
// 表示一個接口類型
type interfaceType struct {
rtype // 基礎信息
pkgPath name // 包路徑信息
methods []imethod // 接口方法的 slice,實際指向 array 字段
u uncommonType // 佔位
array [len(methods)]imethod // 實際的接口方法數據
}
完整的結構分佈圖如下:
另外兩個需要了解的結構體,之前文章已經多次介紹過,也在reflect/type.go
源文件中,定義如下:
type uncommonType struct {
pkgPath nameOff // 包路徑名稱偏移量
mcount uint16 // 方法的數量
xcount uint16 // 公共導出方法的數量
moff uint32 // [mcount]method 相對本對象起始地址的偏移量
_ uint32 // unused
}
reflect.uncommonType
結構體用於描述一個數據類型的包名和方法信息。對於接口類型,意義不是很大。
// 非接口類型的方法
type method struct {
name nameOff // 方法名稱偏移量
mtyp typeOff // 方法類型偏移量
ifn textOff // 通過接口調用時的地址偏移量;接口類型本文不介紹
tfn textOff // 直接類型調用時的地址偏移量
}
reflect.method
結構體用於描述一個非接口類型的方法,它是一個壓縮格式的結構,每個字段的值都是一個相對偏移量。
type nameOff int32 // offset to a name
type typeOff int32 // offset to an *rtype
type textOff int32 // offset from top of text section
-
nameOff
是相對程序.rodata
節起始地址的偏移量。 -
typeOff
是相對程序.rodata
節起始地址的偏移量。 -
textOff
是相對程序.text
節起始地址的偏移量。
接口實現類型
從以上 “運行結果” 可以看到,fooImpl
的類型信息位於0x4a9be0
內存地址處。
關於fooImpl
類型,【Go】再談整數類型一文曾進行過非常詳細的介紹,此處僅分析其方法相關內容。
查看fooImpl
類型的內存數據如下:
繪製成圖表如下:
fooImpl
類型有**3
**個方法,我們以**Foo
**方法來說明接口相關的底層原理。
Foo
方法的相關數據如下:
var Foo = reflect.method {
name: 0x00000172, // 方法名稱相對程序 `.rodata` 節起始地址的偏移量
mtyp: 0x00009960, // 方法類型相對程序 `.rodata` 節起始地址的偏移量
ifn: 0x000989a0, // 接口調用的指令相對程序 `.text` 節起始地址的偏移量
tfn: 0x00098160, // 正常調用的指令相對程序 `.text` 節起始地址的偏移量
}
方法名稱
method.name
用於定位方法的名稱,即一個reflect.name
對象。
Foo
方法的reflect.name
對象位於 0x49a172
(0x00000172 + 0x49a000)地址處,毫無疑問,解析結果是Foo
。
(gdb) p /x 0x00000172 + 0x49a000
$3 = 0x49a172
(gdb) x /3bd 0x49a172
0x49a172: 1 0 3
(gdb) x /3c 0x49a172 + 3
0x49a175: 70 'F' 111 'o' 111 'o'
(gdb)
方法類型
method.mtyp
用於定位方法的數據類型,即一個reflect.funcType
對象。
Foo
方法的reflect.funcType
對象,其位於0x4a3960
(0x00009960 + 0x49a000)地址處。
Foo
方法的數據類型的字符串表示形式是func()
。
(gdb) x /56bx 0x4a3960
0x4a3960: 0x08 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x4a3968: 0x08 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x4a3970: 0xf6 0xbc 0x82 0xf6 0x02 0x08 0x08 0x33
0x4a3978: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x4a3980: 0xa0 0x4a 0x4c 0x00 0x00 0x00 0x00 0x00
0x4a3988: 0x34 0x11 0x00 0x00 0x00 0x00 0x00 0x00
0x4a3990: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
(gdb) x /wx 0x4a3988
0x4a3988: 0x00001134
(gdb) x /s 0x00001134 + 0x49a000 + 3
0x49b137: "*func()"
(gdb)
想要深入瞭解函數類型,請閱讀【Go】內存中的函數。
接口方法
method.ifn
字段的英文註釋爲function used in interface call
,即調用接口方法時使用的函數。
在本例中,就是通過foo
接口調用fooImpl
類型的Foo
函數時需要執行的指令集合。
具體來講就是,代碼清單中的exec
函數內調用Foo
方法需要執行的指令集合。
Foo
函數的method.ifn = 0x000989a0
,計算出其指令集合位於地址0x4999a0
(0x000989a0 + 0x401000)處。
通過內存數據可以清楚地看到,接口方法的符號是main.(*fooImpl).Foo
。該函數主要做了兩件事:
-
檢查
panic
-
在
0x4999d7
地址處調用另一個函數main.fooImpl.Foo
。
類型方法
method.tfn
字段的英文註釋爲function used for normal method call
,即正常方法調用時使用的函數。
在本例中,就是通過fooImpl
類型的對象調用Foo
函數時需要執行的指令集合。
具體來講就是,代碼清單中的main
函數內調用Foo
方法需要執行的指令集合。
Foo
函數的method.tfn = 0x00098160
,計算出其指令集合位於地址0x499160
(0x00098160 + 0x401000)處。
通過內存數據可以清楚地看到,類型方法的符號是main.fooImpl.Foo
。
調用堆棧
通過上述分析,已經能夠對method.ifn
和method.tfn
兩個字段的含義建立起基本的認知。
實踐是檢驗真理的唯一標準。能動手儘量別吵吵。
在main.(*fooImpl).Foo
和main.fooImpl.Foo
兩個函數的入口處設置斷點,通過行動鞏固我們對接口類型的認識。
通過動態調試,我們清晰地看到:
-
main
函數調用了main.fooImpl.Foo
函數 -
exec
函數調用了main.(*fooImpl).Foo
函數 -
main.(*fooImpl).Foo
函數調用了main.fooImpl.Foo
函數 -
main.(*fooImpl).Foo
函數的調試信息顯示autogenerated
,表示其是由編譯器生成的
對比本文 “代碼清單”,你是否對Go
語言的方法調用有了全新的認識。
幾乎每種編程語言都會存在編譯器自動生成代碼的情況,用來實現某些通用邏輯的處理。本例中自動生成的main.(*fooImpl).Foo
函數中增加了panic
檢查邏輯,不過, 乍看起來這像是某種設計缺陷導致不能直接調用main.fooImpl.Foo
函數,而是必須經過一個 "中間人" 纔行。
接口類型
從以上 “運行結果” 可以看到,exec
函數的參數類型的地址是0x4aa5c0
,也就是foo
接口的類型信息存儲位置。查看類型數據如下:
將以上內存數據繪製成圖表如下:
-
rtype.size = 16
-
rtype.ptrdata = 16
-
rtype.hash = 0x187f135e
-
rtype.tflag = 0xf =
reflect.tflagUncommon | reflect.tflagExtraStar | reflect.tflagNamed
-
rtype.align = 8
-
rtype.fieldAlign = 8
-
rtype.kind = 0x14 = 20 =
reflect.Interface
-
rtype.equal = 0x4c4d38 ->
runtime.interequal
-
rtype.str = 0x000003e3 ->
*main.foo
-
rtype.ptrToThis = 0x00006a20 ->
*foo
-
interfaceType.pkgPath = 0x49a34c ->
main
-
interfaceType.methods.Data = 0x4aa620
-
interfaceType.methods.Len = 3
-
interfaceType.methods.Cap = 3
-
uncommonType.pkgPath = 0x0000034c
-
uncommonType.mcount = 0
-
uncommonType.xcount = 0
-
uncommonType.moff = 0x28
-
interfaceType.methods[0].name = 0x00000172 ->
Foo
-
interfaceType.methods[0].typ = 0x00009960 ->
func()
-
interfaceType.methods[1].name = 0x00000d7a ->
String
-
interfaceType.methods[1].typ = 0x0000a140 ->
func() string
-
interfaceType.methods[2].name = 0x000002ce ->
ree
-
interfaceType.methods[2].typ = 0x00009960 ->
func()
對象大小
接口類型的對象大小(rtype.size
)是16
字節,指針數據(rtype.ptrdata
)佔16
字節;也就是說,接口類型的對象由2
個指針組成,與空接口(interface{}
)對象大小一樣。
比較函數
內存數據顯示,接口類型的對象使用runtime.interequal
進行相等性比較,該函數定義在runtime/alg.go
源文件中:
func interequal(p, q unsafe.Pointer) bool {
x := *(*iface)(p)
y := *(*iface)(q)
return x.tab == y.tab && ifaceeq(x.tab, x.data, y.data)
}
func ifaceeq(tab *itab, x, y unsafe.Pointer) bool {
if tab == nil {
return true
}
t := tab._type
eq := t.equal
if eq == nil {
panic(errorString("comparing uncomparable type " + t.string()))
}
if isDirectIface(t) {
// See comment in efaceeq.
return x == y
}
return eq(x, y)
}
該函數的執行邏輯是:
-
接口類型不同返回 false
-
接口類型爲空返回 true
-
實現類型不可比較立即 panic
-
比較兩個實現類型的對象並返回結果
uncommonType
在接口類型數據中,包路徑信息可以通過interfaceType.pkgPath
字段獲取,方法信息通過interfaceType.methods
字段獲取, 因此uncommonType
數據幾乎沒什麼意義,只不過保持一致性罷了。
在本例中,可執行程序.rodata
節的起始地址是0x49a000
, interfaceType.pkgPath
=uncommonType.pkgPath
+0x49a000
。
接口方法
接口方法(reflect.imethod
)只有名稱
和類型
信息,沒有可執行指令,所以相對普通方法(reflect.method
)缺少兩個字段。
foo
接口的方法的名稱
和類型
,與fooImpl
類型的方法的名稱
和類型
完全一致,此處不再贅述。如有需要請閱讀上文中方法相關的內容。
接口對象
runtime.interequal
函數源碼清晰地顯示,其比較的是兩個runtime.iface
對象。
runtime.iface
結構體定義在runtime/runtime2.go
源碼文件中,包含兩個指針字段,大小是16
個字節(rtype.size
)。
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.
}
該結構體與reflect/value.go
源文件中定義的nonEmptyInterface
結構體是等價的:
type nonEmptyInterface struct {
itab *struct {
ityp *rtype // 接口類型
typ *rtype // 具體實現類型
hash uint32 // 實現類型哈希種子
_ [4]byte // 內存對齊
fun [100000]unsafe.Pointer // 方法數組,編譯器控制數組長度
}
word unsafe.Pointer // 具體實現類型對象
}
沒錯,接口對象就是iface
對象,接口對象就是nonEmptyInterface
對象。
源碼清單中的exec
函數接受一個foo
接口類型的參數,在該函數入口處設置斷點,即可查看其參數:
內存數據顯示,exec
函數的參數foo
的值如下僞代碼所示:
foo := runtime.iface {
tab: 0x4dcbb8,
data: 0x543ad8, // 指向整數 123
}
iface.data
指針指向的內存數據是整數123
,關於整數
和runtime.staticuint64s
,請閱讀【Go】內存中的整數。
iface.tab
指針指向一個全局符號go.itab.main.fooImpl,main.foo
。該符號可以被視爲一個全局常量,它是由Go
編譯器生成的,保存在可執行程序的.rodata
節,其值如下僞代碼所示:
go.itab.main.fooImpl,main.foo = & runtime.itab {
inter: 0x4aa5c0, // foo 接口類型的地址,上文已經詳細分析
_type: 0x4a9be0, // fooImpl 實現類型的地址,上文已經詳細分析
hash: 0xb597252a, // fooImpl 類型的哈希種子拷貝
fun: [0x4999a0, 0x499a20, 0x499aa0] // 方法數組
}
在本例中,runtime.iface.tab.fun
字段值包含三個指針,分別指向以下三個函數:
-
main.(*fooImpl).Foo (0x4999a0)
-
main.(*fooImpl).String (0x499a20)
-
main.(*fooImpl).ree (0x499aa0)
當exec
函數調用foo
接口的方法時,實際是從runtime.iface.tab.fun
字段的數組中獲得方法地址;
所以,在本例中,exec` 函數只能尋址以上三個方法,而無法尋址以下三個方法:
-
main.fooImpl.Foo
-
main.fooImpl.String
-
main.fooImpl.ree
如果定義新的類型實現了foo
接口,作爲參數傳遞給exec
函數,Go
編譯器就會生成新的runtime.itab
對象,並命名爲go.itab.${pkg}.${type},main.foo
格式,也是以相同的方式進行調用和執行。
在Go
語言中,接口方法的調用邏輯是一致的。
接口擴展(繼承)
在源碼清單中,foo
接口繼承了fmt.Stringer
接口,並擴展了兩個方法。
type foo interface {
fmt.Stringer
Foo()
ree()
}
而在程序運行時的內存數據中,在動態調試過程中,根本就沒有fmt.Stringer
接口什麼事,連根毛都沒看見。
實際上,Go
編譯器把foo
接口的定義調整爲以下代碼,這就是接口繼承和擴展的本質。
type foo interface {
String() string
Foo()
ree()
}
總結
本文完整地、詳細地、深入地剖析了Go
語言接口的類型結構、對象結構、實現類型、方法調用、繼承擴展等等的各個方面的底層原理。
相信這是對 Go 接口類型的一次重新認識。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/c31jSNN8Kji8pa9YwfcigA