原文链接:
Go 当中 interface 设计(下)mp.weixin.qq.com编译器自动检测类型是否实现接口
奇怪的代码:
var _ io.Writer = (*myWriter)(nil)
为啥有上面的代码, 上面的代码究竟是要干什么? 编译器会通过上述的代码检查 *myWriter
类型是否实现了 io.Writer
接口.
例子:
type myWriter struct{
}
/*
func (w myWriter) Write(p []byte) (n int, err error) {
return
}
*/
func main() {
// 检查 *myWriter 是否实现了 io.Writer 接口
var _ io.Writer = (*myWriter)(nil)
// 检查 myWriter 是否实现了 io.Writer 接口
var _ io.Writer = myWriter{}
}
注释掉为 myWriter 定义的 Writer 函数后, 运行程序, 报错信息:
*myWriter/myWriter 未实现 io.Writer 接口
, 也就是未实现 Write 方法. 解除注释后, 运行程序不报错.
实际上, 上述赋值语句会发生隐式转换, 在转换的过程中, 编译器会检测等号右边的类型是否实现了等号左边接口所规定的函数.
接口的构造过程
先从一个例子开始:
type Person interface {
grow()
}
type Student struct {
age int
name string
}
func (p Student) grow() {
p.age += 1
return
}
func main() {
var qcrao = Person(Student{age: 18, name:"san"})
fmt.Println(qcrao)
}
使用 go tool compile -S example.go
打印汇编代码. go 版本是 1.13
"".main STEXT size=331 args=0x0 locals=0xa8
00000 (main.go:19) TEXT "".main(SB), ABIInternal, $168-0
00000 (main.go:19) MOVQ (TLS), CX
00009 (main.go:19) LEAQ -40(SP), AX
00014 (main.go:19) CMPQ AX, 16(CX)
00018 (main.go:19) JLS 321
00024 (main.go:19) SUBQ $168, SP
00031 (main.go:19) MOVQ BP, 160(SP)
00039 (main.go:19) LEAQ 160(SP), BP
00047 (main.go:20) XORPS X0, X0
00050 (main.go:20) MOVUPS X0, ""..autotmp_1+136(SP)
00058 (main.go:20) MOVQ $0, ""..autotmp_1+152(SP)
00070 (main.go:20) MOVQ $18, ""..autotmp_1+136(SP)
00082 (main.go:20) LEAQ go.string."san"(SB), AX
00089 (main.go:20) MOVQ AX, ""..autotmp_1+144(SP)
00097 (main.go:20) MOVQ $3, ""..autotmp_1+152(SP)
00109 (main.go:20) LEAQ go.itab."".Student,"".Person(SB), AX
00116 (main.go:20) MOVQ AX, (SP)
00120 (main.go:20) LEAQ ""..autotmp_1+136(SP), AX
00128 (main.go:20) MOVQ AX, 8(SP)
00133 (main.go:20) CALL runtime.convT2I(SB)
00138 (main.go:20) MOVQ 24(SP), AX
00143 (main.go:20) MOVQ 16(SP), CX
00148 (main.go:20) MOVQ CX, "".qcrao+64(SP)
00153 (main.go:20) MOVQ AX, "".qcrao+72(SP)
00158 (main.go:22) MOVQ "".qcrao+72(SP), AX
00163 (main.go:22) MOVQ "".qcrao+64(SP), CX
00168 (main.go:22) MOVQ CX, ""..autotmp_3+80(SP)
00173 (main.go:22) MOVQ AX, ""..autotmp_3+88(SP)
00178 (main.go:22) MOVQ CX, ""..autotmp_4+56(SP)
00183 (main.go:22) CMPQ ""..autotmp_4+56(SP), $0
00189 (main.go:22) JNE 193
00191 (main.go:22) JMP 319
00193 (main.go:22) TESTB AL, (CX)
00195 (main.go:22) MOVQ 8(CX), AX
00199 (main.go:22) MOVQ AX, ""..autotmp_4+56(SP)
00204 (main.go:22) JMP 206
00206 (main.go:22) PCDATA $1, $5
00206 (main.go:22) XORPS X0, X0
00209 (main.go:22) MOVUPS X0, ""..autotmp_2+96(SP)
00214 (main.go:22) LEAQ ""..autotmp_2+96(SP), AX
00219 (main.go:22) MOVQ AX, ""..autotmp_6+48(SP)
00224 (main.go:22) TESTB AL, (AX)
00226 (main.go:22) MOVQ ""..autotmp_4+56(SP), CX
00231 (main.go:22) MOVQ ""..autotmp_3+88(SP), DX
00236 (main.go:22) MOVQ CX, ""..autotmp_2+96(SP)
00241 (main.go:22) MOVQ DX, ""..autotmp_2+104(SP)
00246 (main.go:22) TESTB AL, (AX)
00248 (main.go:22) JMP 250
00250 (main.go:22) MOVQ AX, ""..autotmp_5+112(SP)
00255 (main.go:22) MOVQ $1, ""..autotmp_5+120(SP)
00264 (main.go:22) MOVQ $1, ""..autotmp_5+128(SP)
00276 (main.go:22) MOVQ AX, (SP)
00280 (main.go:22) MOVQ $1, 8(SP)
00289 (main.go:22) MOVQ $1, 16(SP)
00298 (main.go:22) CALL fmt.Println(SB)
00303 (main.go:23) MOVQ 160(SP), BP
00311 (main.go:23) ADDQ $168, SP
00318 (main.go:23) RET
00319 (main.go:22) JMP 206
00321 (main.go:22) NOP
00321 (main.go:19) CALL runtime.morestack_noctxt(SB)
00326 (main.go:19) JMP 0
最重要核心的代码是 runtime.convT2I(SB)
, 该函数位于 src/runtime/iface.go
当中. convT2I()
函数是将一个 struct
对象转换为 iface
. 类似的方法还有, convT64()
, convTstring()
等.这些方法都涉及到了接口的动态值.
下面看一下其中几个代表性的函数源码:
// 源码位置 src/runtime/iface.go
var (
uint64Eface interface{} = uint64InterfacePtr(0)
stringEface interface{} = stringInterfacePtr("")
sliceEface interface{} = sliceInterfacePtr(nil)
uint64Type *_type = (*eface)(unsafe.Pointer(&uint64Eface))._type
stringType *_type = (*eface)(unsafe.Pointer(&stringEface))._type
sliceType *_type = (*eface)(unsafe.Pointer(&sliceEface))._type
)
// type(uint64) -> interface
func convT64(val uint64) (x unsafe.Pointer) {
if val == 0 {
x = unsafe.Pointer(&zeroVal[0])
} else {
x = mallocgc(8, uint64Type, false)
*(*uint64)(x) = val
}
return
}
// type(uint64) -> interface
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
}
// type -> iface
func convT2I(tab *itab, elem unsafe.Pointer) (i iface) {
t := tab._type
// 启用了 -race 选项
if raceenabled {
raceReadObjectPC(t, elem, getcallerpc(), funcPC(convT2I))
}
// 启用了 -msan 选项
if msanenabled {
msanread(elem, t.size)
}
// 生成 itab 当中动态类型的内存空间(指针), 并将 elem 的值拷贝相应位置
x := mallocgc(t.size, t, true) // convT2I 和 convT2Enoptr 的区别
typedmemmove(t, x, elem)
i.tab = tab
i.data = x
return
}
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer
分配一个 size 大小字节的 object. 从per-P缓存的空闲列表中分配小对象; 从堆直接分配大对象( >32 kB)
参数 needzero 描述分配的对象是否是指针.
参数 typ 描述当前分配对象的数据类型.
上述的代码逻辑都比较简单, 这里就不再解释了.
类型转换与断言的区别
Go 当中不允许饮食类型转换, 也就是说在 =
两边, 变量的类型必须相同.
类型转换
, 类型断言
本质都是把一个类型转换为另外一个类型. 不同之处在于, 类型断言是接口变量
进行的操作.
类型转换(任何类型变量):
<结果类型值> := <目标类型>(<表达式>)
var x = 15.21
y = int(x)
类型断言(必须是接口变量):
<目标类型值>, <布尔参数> := <表达式>.(目标类型)
<目标类型值> := <表达式>.(目标类型)
var x interface{} = &bytes.Buffer{}
y, ok := x.(Writer)
var x Reader = &bytes.Buffer{}
y, ok := x.(Writer)
类型转换
对于类型转换而言, 需要转换前后的两个类型是兼容的才可以.
断言
引申1: fmt.Println
函数的参数是 interface
. 对于内置类型, 函数内部会用穷举法, 得出它的真实类型, 然后转换为字符串打印. 而对于自定义类型, 首先确定该类型是否实现了 String()
方法, 如果实现了, 则直接打印输出 String()
方法的结果; 否则, 会通过反射来遍历对象的成员进行打印.
type Student struct{
Name string
Age int
}
func main() {
var s = Student{Name:"www", Age:18}
fmt.Println(s)
}
输出结果为 {www 18}
.
由于 Student
没有实现 String()
方法, 所以 fmt.Println
会利用反射获取成员变量.
如果增加一个 String()
方法:
func (s String) String() string {
return fmt.Sprintf("[Name:%s], [Age:%d]", s.Name, s.Age)
}
输出结果为 [Name:www], [Age:18]
如果 String()
方法是下面这样的:
func (s *String) String() string {
return fmt.Sprintf("[Name:%s], [Age:%d]", s.Name, s.Age)
}
输出结果为 {www 18}
. 是不是有点意外? 其实仔细思考一下, 因为 String()
方法是指针接收者, 不会隐式生成值类型接收者的 String()
方法, 那么在断言的时候, 会将 Student{}
判断为没有实现 String()
方法的接口. 因此按照一般的反射方法进行打印喽了.
一般情况下, 实现
String()
方法最好是值接收者, 这样无论是值还是指针在打印的时候都会用到此方法.
接口间转换原理
原理:
<interface类型, 实体类型> -> itab
当判断一种类型是否满足某个接口时, Go 使用类型的方法集和接口所需要的方法集进行匹配. 如果类型的方法集完全包含接口的方法集, 则认为该类型实现可该接口.
例如某类型有 m
个方法, 某接口有 n
个方法, 则很容易知道这种判定的时间复杂度为 O(mn)
, Go会对方法集的函数按照函数名的字典序进行排序, 所以实际的时间复杂度为 O(m+n)
.
接口转换另外一个背后的原理: 类型兼容.
一个例子:
type coder interface {
code()
run()
}
type runner interface {
run()
}
type Language string
func (l Language) code() {
}
func (l Language) run() {
}
func main() {
var c coder = Language{}
var r runner
r = c
fmt.Println(c, r)
}
代码定义了两个接口, coder
和 runner
. 定义了一个实体类 Language
. 类型 Language
实现了两个方法 code()
和 run()
. main 函数里定义了一个接口变量 c
, 绑定了一个 Language
对象, 之后将 c
赋值给另一个接口变量 r
. 赋值成功的原因在于 c
中包含了 run()
方法. 这样这两个接口变量完成了转换.
通过汇编可以得到, 上述的 r=c
背后实际上是调用了 runtime.convI2I(SB)
, 也就是 convI2I
函数.
// iface -> iface
// inter 是目标的接口类型, i 是源 iface, r 是最终转换的 iface
func convI2I(inter *interfacetype, i iface) (r iface) {
tab := i.tab
if tab == nil {
return
}
if tab.inter == inter {
r.tab = tab
r.data = i.data
return
}
r.tab = getitab(inter, tab._type, false)
r.data = i.data
return
}
代码非常简单. 最重要的代码是 getitab
函数, 根据 interfacetype
(接口类型) 和 _type
(动态值的类型) 获取到 r 的itab
值.
// inter是接口的类型, typ是值的类型, canfail表示转换是否可接受失败.
// 如果不接受失败, 也就是canfail为false, 在转换失败的状况下会 panic
func getitab(inter *interfacetype, typ *_type, canfail bool) *itab {
if len(inter.mhdr) == 0 {
throw("internal error - misuse of itab")
}
// 也就是 typ 的最低位是 0 状况下, 直接快速失败. 至于为什么, 不太清楚.
// tflagUncommon=1
if typ.tflag&tflagUncommon == 0 {
if canfail {
return nil
}
name := inter.typ.nameOff(inter.mhdr[0].name)
panic(&TypeAssertionError{nil, typ, &inter.typ, name.name()})
}
var m *itab
// 首先, 查看现有表(itabTable, 一个保存了 itab 的全局未导出的变量) 以查看是否可以找到所需的itab.
// 在这种状况下, 不要使用锁.(常识)
//
// 使用 atomic 确保我们看到该线程完成的所有先前写操作. (下面使用 atomic 的原因解释)
// 如果未找到所要的 itab, 则只能创建一个, 然后更新itabTable字段(在itabAdd中使用atomic.Storep)
t := (*itabTableType)(atomic.Loadp(unsafe.Pointer(&itabTable)))
if m = t.find(inter, typ); m != nil {
goto finish
}
// 没有找到所需的 itab, 这种状况下, 在加锁的状况下再次查找. 这就是所谓的 dobule-checking
lock(&itabLock)
if m = itabTable.find(inter, typ); m != nil {
unlock(&itabLock)
goto finish
}
// 没有查找到. 只能先 create 一个 itab, 然后 update itabTable
// 这个是在当前的线程栈上去操作分配内存.
m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*sys.PtrSize, 0, &memstats.other_sys))
m.inter = inter
m._type = typ
m.init() // itab 初始化
// 将 itab 添加到 itabTable 当中.
// 这当中可能发生 itabTable 的扩容(默认存储512个, 长度超过 75% 就会发生扩容.
itabAdd(m)
unlock(&itabLock)
finish:
if m.fun[0] != 0 {
return m
}
if canfail {
return nil
}
// 如果是断言, 当没有使用 _, ok := x.(X) 的状况下, 转换失败就 panic
// 如果是转换, 在 T -> I, 则允许失败; 如果是 I -> I, 则不允许失败
panic(&TypeAssertionError{concrete: typ, asserted: &inter.typ, missingMethod: m.init()})
}
接下来看一下, itabTable 是怎么缓存 itab 的.
首先, 全局变量 itabTable
的结构是长啥样子的?
type itabTableType struct {
size uintptr // entries 的长度. 大小必须是 2 的指数
count uintptr // 当前已经填充了的 entries 的长度
entries [itabInitSize]*itab // 真实的长度是 size, itabInitSize=512
}
数据结构比较简单, 但需要注意的点是:
itabTableType
的 entries 真实长度是 size, 不是 entries 数组的大小. 这个数组会在适当的状况下进行扩容的.itabTableType
的 entries 使用的内存是连续的, 它类似一个 "切片" ("切片"只是整体结构上类似, 但并不是真的切片), 可以使用内存偏移的方法来获取entries
当中存储的值. 也正式因为这一点, 它又特别像一个 table, 我猜测这也是称为itabTable
的原因吧.
在 itabTableType 当中是怎样去查找 itab 的呢? 思路的使用了类似 hashtable 的技术. 接下来看看它是怎样去实现的.
这里哈希函数
使用的是二次探测
实现的. h(i) = (h0 + i*(i+1)/2) mod 2^k. 哈希函数冲突的解决方法:
1. 线性探测法: h(i) = (h0 + i) mod k
2. 二次探测法: h(i) = (h0 + i*(i+1)/2) mod k
3. 链地址法: 数组 + 链表
func (t *itabTableType) find(inter *interfacetype, typ *_type) *itab {
// 使用二次探测实现.
// 探测顺序为 h(i) = (h0 + i*(i+1)/2) mod 2^k. i 表示第i次探测
// h(i) = ( h(i-1) + 1 ) mod 2^k. i 表示第i次探测
// 我们保证使用此探测序列击中所有表条目.
mask := t.size - 1
// itabHashFunc => inter.typ.hash ^ typ.hash
// h 是初始化的 h0 的值, 也可以认为它是 itabTableType 当中 entries 的 index
h := itabHashFunc(inter, typ) & mask
for i := uintptr(1); ; i++ {
p := (**itab)(add(unsafe.Pointer(&t.entries), h*sys.PtrSize))
// 在这里使用atomic read, 因此如果我们看到m != nil, 我们还将看到m字段的初始化.
// m := *p
m := (*itab)(atomic.Loadp(unsafe.Pointer(p)))
if m == nil {
return nil
}
if m.inter == inter && m._type == typ {
return m
}
h += i
h &= mask
}
}
主要的逻辑就是进行二次探测, 看是否能找到合适的 itab. 你可能存在这样疑问, 当 entries 全部填满的话, 如果没有合适的itab, 上述的代码就会陷入死循环? 从逻辑角度考虑, 确实是这样的, 但是, entries 是永远不会被填满的, 最多在填充 75% 之后, 就会发生扩容. 那么扩容发生在哪里呢, 前面的 getitab
函数当中的 itabAdd
就会导致扩容.
func itabAdd(m *itab) {
t := itabTable
// 75% load factor, 发生扩容
if t.count >= 3*(t.size/4) {
// itabTable进行扩容.
// t2的内存大小 = (2+2*t.size)*sys.PtrSize, sys.PtrSize是指针大小, 多出来的2表示的是size, count字段
// 我们撒谎并告诉 malloc 我们想要无指针的内存, 因为所有指向的值都不在堆中.
t2 := (*itabTableType)(mallocgc((2+2*t.size)*sys.PtrSize, nil, true))
t2.size = t.size * 2
// copy
// 注意: 在复制时, 其他线程可能会寻找itab并找不到它. 没关系, 然后他们将尝试获取 itab 锁, 结果请等到复制完成.
iterate_itabs(t2.add)
if t2.count != t.count {
throw("mismatched count during itab table copy")
}
// 发布新的哈希表. 使用atomic write
atomicstorep(unsafe.Pointer(&itabTable), unsafe.Pointer(t2))
// Adopt the new table as our own.
t = itabTable
// Note: the old table can be GC'ed here.
}
t.add(m)
}
iterate_itabs
就是一个迭代拷贝. add
的逻辑和find
的逻辑是十分类似的, 有兴趣的可以区看下代码, 这里不再过多的介绍了.
还有一个函数, 就是 itab
的初始化函数 init
, 稍微有点复杂, 下面看看吧.
// init用 m.inter/m._type 对的所有代码指针填充 m.fun数组.
// 如果该类型未实现该接口, 将 m.fun[0]设置为0, 并返回缺少的接口函数的名称.
// 可以在同一 m 上多次调用此函数, 甚至可以同时调用.
func (m *itab) init() string {
inter := m.inter
typ := m._type
x := typ.uncommon()
// both inter and typ have method sorted by name,
// and interface names are unique,
// so can iterate over both in lock step;
// the loop is O(ni+nt) not O(ni*nt).
ni := len(inter.mhdr)
nt := int(x.mcount)
methods := (*[1 << 16]unsafe.Pointer)(unsafe.Pointer(&m.fun[0]))[:ni:ni] // 接口方法(用于绑定对应于值当中方法)
xmhdr := (*[1 << 16]method)(add(unsafe.Pointer(x), uintptr(x.moff)))[:nt:nt] // 值当中的方法
var fun0 unsafe.Pointer
j := 0
imethods:
for k := 0; k < ni; k++ {
// 获取接口当中方法 i, itype, iname, ipkg
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, ttype, tname, tpkg
t := &xmhdr[j]
tname := typ.nameOff(t.name)
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
}
}
}
// 只要有一个方法不匹配, 则都会走到这里. 最终会失败的. 正常的跳出循环才是匹配成功的.
m.fun[0] = 0
return iname
}
m.fun[0] = uintptr(fun0)
m.hash = typ.hash
return ""
}
总结:
getitab 函数的目的在缓存的 itabTable
当中查找一个合适的 itab
, 如果没有查找到, 则向 itabTable
当中添加一个新的 itab. itabTable
就是一个哈希表, 采用的是 二次探测法
来解决哈希冲突的, 并且哈希表的装载因子是 75%, 超过这个值就会发生扩容.
还有就是 getitab 的参数 canfail
, 在不同状况(断言, 接口转换)下 canfail 的参数是固定:
- 带参数的断言(
runtime.assertI2I2
和runtime.assertE2I2
) 值是 true - 不带参数的断言 (
runtime.assertI2I
和runtime.assertE2I
), 值是 false - 接口转换(
runtime.convI2I
) 值是 false
也就意味着, 当不带参数断言和接口转换失败之后, 程序会 panic 的. 这个在开发过程中一定要慎重使用.
参考:
- 深度解密Go语言之关于 interface 的 10 个问题
相关文章阅读:
Go 当中 interface 设计(上)zhuanlan.zhihu.com![d588f3effd662bd8671d99208eb05008.png](https://i-blog.csdnimg.cn/blog_migrate/1f0a26208ae75aa98a181fbda4e9f6ba.jpeg)
![1fed6ae6dd00d12ed1a99b87ffb04c08.png](https://i-blog.csdnimg.cn/blog_migrate/e5cefdfa1e1367405dca9c8839b651d6.jpeg)