Golang Go语言中Dig101-Go之读懂interface的底层设计
Golang Go语言中Dig101-Go之读懂interface的底层设计
Dig101: dig more, simplified more and know more
今天我们聊聊万物皆可为的接口( interface )的底层设计。
interface 被定义为一组方法的签名。
有了它,我们可以订立方法契约,去抽象和约束实现。
而 Go 的基础类型,可以认为是没有实现任何方法的空 interface,也就是万物皆为的 interface。
( Go 语言没有泛型,接口可以作为一种替代实现)
接口也被寄予厚望,主力开发 Russ Cox 曾说过:
从语言设计的角度来看,Go 的接口是静态的,在编译时检查过的,在需要时是动态的。如果我可以将 Go 的一个特性导出到其他语言中,那就是接口。 Go Data Structures: Interfaces
那到底 interface 是怎么设计的底层结构呢?
又怎么支持的duck typing?
在类型断言时又发生了什么?
带着这些问题,我们往下看
0x01 底层结构一样么
我们知道定义接口有这两种方式,那他们底层结构是一样的么?
// 方式 1
var a interface{}
// 方式 2
type Stringer interface {
String() string
}
var b Stringer
答案是 [不一样]
我们用 gdb 打印下对应类型( gdb 相关见Tips-如何优雅的使用 GDB 调试 Go)
// 空接口类型
>>> ptype a
type = struct runtime.eface {
runtime._type *_type;
void *data;
}
// 有函数定义的接口类型
>>> ptype b
type = struct runtime.iface {
runtime.itab *tab;
void *data;
}
// itable 相关类型
>>> ptype b.tab
type = struct runtime.itab {
// 接口相关信息
runtime.interfacetype *inter;
// 构造类型
runtime._type *_type;
uint32 hash;
[4]uint8 _;
// 构造类型的函数列表
[1]uintptr fun;
} *
>>> ptype b.tab.inter
type = struct runtime.interfacetype {
// 接口的类型
runtime._type typ;
runtime.name pkgpath;
// 接口定义的函数列表
[]runtime.imethod mhdr;
} *
以此可见 Go 内部定义了两种 interface (但都是两个机器字)
eface
空接口,指没有定义方法的接口
内部存储了构造类型(concrete type
)type
和data
iface
有方法的接口
有了相比eface
的type
更丰富的itab
字段,其中记录了构造类型及所实现的 interface 类型的类型和方法
0x02 类型如何相互转换
如下代码,当我们做接口赋值时,Go 又会怎样填充底层结构呢?
type Binary uint64
func (i Binary) String() string {
return strconv.Itoa(int(i))
}
func conversion() {
var b Stringer
var i Binary = 1
b = i // <= 这里发生了什么
println(b.String())
}
gdb 进到 b = i
这一步,会发现他调用了runtime/iface.go:convT64
方法实现 iface 的赋值
查阅源码,会发现很多convXXX
函数, 他们是干什么的?
convXXX 的命名
convFrom2To
指代 To=From
的转换
From 和 To 的类型有三种:
(参见cmd/compile/internal/types/type.go:Tie
)
- E (eface)
- I (iface)
- T (Type)
这一堆函数看的人眼晕,但参照提交specialize convT2x, don't alloc for zero vals深入分析,就会清晰许多
起初的 convT2{I,E} 和 convI2I
最初只有 convT2{I,E} 和 convI2I
主要实现分配内存(newobject
),然后拷贝赋值(typedmemmove
)
convI2I
还会有getitab
, 具体是什么我们后边类型断言时说
然后也在调用他们前(walkexpr
)做了优化
- 减少值拷贝
ToType 为类指针(pointer-shaped
)或者一个机器字内(int
)的话,可以直接存入 interface 的 data 字段(主要优化在这里)
pointer-shaped 类型: ptr, chan, map, func, unsafe.Pointer
再辅以 type 的存储,就只是两个字(two-word
)的拷贝
- 减少内存分配
零值,bool/byte
可以不用分配内存,而用已存在值(zerobase,staticbytes
)
只读的全局变量(readonly global
)直接可以用
1kb 以内,不escape
到堆上,非interface
的变量可以使用栈上分配的临时变量(stack temporary initialized
)
这类 value 最后以取地址形式转化为 interface: {type/itab, &value}.
- interface 转空接口( eface )
可以丢弃除type
以外的itab
tmp = i.itab
if tmp != nil {
tmp = tmp.type
}
e = iface{tmp, i.data}
针对类型优化后的 convXXX
但这里会有一些可以优化的点,如:
- 分配内存是否可以需要清零?
类指针的类型需要清零,不然内存可能有脏数据
但无指针类型(pointer-free
)如拷贝时直接可以覆盖对应内存则不需要
如int
其拷贝在一个机器字内完成,不需要分配时清零
( 32 位系统上不调用convT64
,就可以保证访问内存是安全的原子操作)
- 是否可以简化值拷贝?
int,string,slice
这些 Type 分配的x
拷贝val
时,可以简化为 *(*Type)(x) = val
- 拷贝内存是否可以不增加 gc 调用(写屏障)?
按 ToType 类型是否含指针区分
类指针类型(pointer-shaped
): convT2{E,I}
需要拷贝时 gc 调用(typedmemmove
)
无指针类型(pointer-free
): convT2{E,I}noptr
不需要拷贝时 gc 调用(memmove
)
这样一看就明白这些函数的用意了,还是为了针对性的提高转化效率
最后结合其调用处convXXX
列表如下:
// cmd/compile/internal/gc/walk.go:walkexpr
case OCONVIFACE:
...
fnname, needsaddr := convFuncName(fromType, toType)
| 函数(fnname) | From 类型 | 值取地址存(needsaddr)| | ---- | ---- | ---- | | convI2I | iface | 否| | convT{16,32,64} | 底层为整型数据(不含指针,对齐不大于机器字) | 否| | convTstring |string| 否| | convTslice|slice| 否| | convT2E |Type| 是| | convT2Enoptr | 无指针 Type| 是| | convT2I |Type| 是| | convT2Inoptr |无指针 Type | 是|
不会存在 convE2E 和 convE2I needsaddr: 类型不含指针,大小大于 64 位字或未知大小时,使用值的地址来存
0x03 类型断言如何实现
interface 支持类型断言,来动态判断其构造类型,
判定成功可返回对应构造类型,便于调用其方法
可构造类型实现 interface 不需要显示声明,
那如下代码是怎么确定 interface b
(构造类型是Binary
)实现Stringer
呢?
type Binary uint64
func (i Binary) String() string {
return fmt.Sprint(i)
}
func typeAssert() {
var b interface{} = Binary(1)
v, ok := b.(Stringer)
println(v, ok)
}
调试后会发现,其调用了assertE2I2
这里函数命名有两类,如下
assertE2I: v := eface1.(iface1)
assertE2I2: v,ok := eface1.(iface1)
这里有一点,类型断言非
v,ok
方式的,断言失败会 panic )
原来其内部进行了itab
表(itabTable
)查询 interface 和构造类型的映射表,如果匹配则说明实现
下边代码分析如下
首先初始 512 个 entry 的表
const itabInitSize = 512
type itabTableType struct {
// 上限
size uintptr
// 当前用量
count uintptr
entries [itabInitSize]*itab
}
查表是否匹配
在类型断言中调用 getitab(inter, typ, canfail)
查表
- 先不加锁 atomic 读取 itabTable,找到返回
- 未找到加锁再查一遍,找到返回
- 还没有就创建一个 itab 添加到表中,添加完后解锁
- 期间如果判定不匹配则按是否可以 panic ( canfail )返回
其中查表用到 itabTable.find(inter, typ)
,
插入用到 itabAdd(m)
尝试插入更新
- 插入前需先用
m.inter/m._type pair
初始化m.fun
数组,不匹配则m.fun[0]==0
(m.fun
类型 [1]uintptr
,实际指向是大小为接口定义方法数的方法数组。详见 func (m *itab) init()
)
-
用量 count 超过上限的 75%触发扩容,大小为 2 倍以上(要向上内存对齐),扩容后更新 itabTable 是原子操作
-
以 itab m 的 interface 类型和构造类型的 hash 计算对应 itabTable 的起始偏移,然后插入到其后第一个不为空的 entry。如果已存在则直接返回
这里用到了开放地址探测法,公式是:
h(i) = h0 + i*(i+1)/2 mod 2^k
具体插入用到 itabTable.add(m)
这里和其实 map 插入的逻辑很相似
动态判定效率优化
不过,这里有一个问题?
假定,interface 定义了ni
个方法,构造类型实现nt
个方法,
常规匹配构造类型是否实现全部ni
个方法需要两层遍历,复杂度为O(ni*nt)
这样在初始化itab.fun
或类型断言匹配是效率会比较低。
Go 设计时也考虑了这个问题,把复杂度降低为O(ni+nt)
这也是使用 hashtable 的原因之一:
-
首先 interface 的函数定义列表
itab.inter.mhdr
和构造类型的函数列表itab.fun
都是按函数名排好序的 -
这样第一次 itab 初始化时,判定构造类型是否实现函数列表可以
O(ni+nt)
内遍历完成 -
然后用开放地址探测法更新到 itabtable 中,查询时也可以用同样的方式定位到此 itab 是否存在。
两个(有序)列表的遍历匹配代码精简如下:
// runtime/iface.go:init()
j:=0
imethods:
// 遍历 interface 定义函数列表
for k := 0; k < ni; k++ {
// 遍历构造类型函数列表
for ; j < nt; j++ {
// 如果两者类型( type ),包路径( pkgpath ),函数名( name )匹配
if xxx {
// 将方法记录到 fun0 (最终全匹配则赋值给 m.fun)
continue imethods
}
}
// 未全匹配
m.fun[0] = 0
}
m.fun[0] = uintptr(fun0)
总结一下 interface 的底层设计:
- interface 分为空接口( eface )和接口( iface )两类,但都是两机器字( two-word )存储结构
- interface 转换中针对不同类型做了优化,主要集中于提升内存分配和值拷贝效率
- interface 类型断言时动态判定,利用有序列表遍历+全局哈希表表缓存优化判定效率
See More: 官方解释 InterfaceSlice 为什么不能直接转化
最后留个问题:
下边这段转换代码内部没有调convT64
,为什么?
var b Stringer = Binary(1)
_ = b.String()
这个问题下一篇文章再来给出解答。
本文代码见 NewbMiao/Dig101-Go
欢迎关注公众号:newbmiao,获取及时更新文章。
推荐阅读:Dig101-Go 系列,挖一挖技术背后的故事。
更多关于Golang Go语言中Dig101-Go之读懂interface的底层设计的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html
更多关于Golang Go语言中Dig101-Go之读懂interface的底层设计的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html
针对“Golang Go语言中Dig101-Go之读懂interface的底层设计”的帖子,以下是我的回复:
在Go语言中,interface的底层设计是其类型系统的重要组成部分。要读懂其底层设计,可以从以下几个方面入手:
- interface的定义:在Go中,interface被定义为一组方法的签名。它允许我们订立方法契约,去抽象和约束实现。空接口interface{}没有定义任何方法,可以接收任意数据类型。
- 底层结构:空接口和非空接口的底层结构有所不同。空接口(eface)包含动态类型信息和动态值。非空接口(iface)则包含指向itab结构的指针和动态值,其中itab结构存储了接口要求的方法列表和动态类型信息。
- 接口赋值:当一个具体类型被赋值给一个接口时,Go语言会在运行时创建一个新的接口值,并填充动态类型和动态值。对于非空接口,还会根据具体类型所实现的方法集合,将接口表中对应的方法与接口中的方法进行绑定。
- 方法调度:在调用接口中的方法时,Go语言会根据动态类型在接口表中查找对应的方法实现,并执行该方法。
综上所述,Go语言中的interface通过其底层设计实现了类型的抽象和动态方法的调度,为开发者提供了灵活、可扩展且易于维护的多态性。