源码剖析Go类型断言是如何实现的!附性能损耗测试
共 18410字,需浏览 37分钟
·
2021-04-28 17:25
前言
★今天我们一起来探索一下
”interface
的类型断言是如何实现的。我们通常使用interface
有两种方式,一种是带方法的interface
,一种是空的interface
。因为Go
中是没有泛型,所以我们可以用空的interface{}
来作为一种伪泛型使用,当我们使用到空的interface{}
作为入参或返回值时,就会使用到类型断言,来获取我们所需要的类型,所以平常我们会在代码中看到大量的类型断言使用,你就不好奇它是怎么实现的嘛?你就不好奇它的性能损耗是多少嘛?反正我很好奇,略~。
类型断言的基本使用
Type Assertion
(断言)是用于interface value
的一种操作,语法是x.(T)
,x
是interface type
的表达式,而T
是asserted type
,被断言的类型。举个例子看一下基本使用:
func main() {
var demo interface{} = "Golang梦工厂"
str := demo.(string)
fmt.Printf("value: %v", str)
}
上面我们声明了一个接口对象demo
,通过类型断言的方式断言一个接口对象demo
是不是nil
,并判断接口对象demo
存储的值的类型是T
,如果断言成功,就会返回值给str
,如果断言失败,就会触发panic
。这段代码加上如果这样写,就会触发panic
:
number := demo.(int64)
fmt.Printf("value: %v\n", number)
所以为了安全起见,我们还可以这样使用:
func main() {
var demo interface{} = "Golang梦工厂"
number, ok := demo.(int64)
if !ok {
fmt.Printf("assert failed")
return
}
fmt.Printf("value: %v\n", number)
}
运行结果:assert failed
这里使用的表达式是t,ok:=i.(T)
,这个表达式也是可以断言一个接口对象(i)
里不是nil
,并且接口对象(i)
存储的值的类型是 T
,如果断言成功,就会返回其类型给t
,并且此时 ok
的值 为true
,表示断言成功。如果接口值的类型,并不是我们所断言的 T
,就会断言失败,但和第一种表达式不同的是这个不会触发 panic
,而是将 ok
的值设为false
,表示断言失败,此时t
为T
的零值。所以推荐使用这种方式,可以保证代码的健壮性。
如果我们想要区分多种类型,可以使用type switch
断言,使用这种方法就不需要我们按上面的方式去一个一个的进行类型断言了,更简单,更高效。上面的代码我们可以改成这样:
func main() {
var demo interface{} = "Golang梦工厂"
switch demo.(type) {
case nil:
fmt.Printf("demo type is nil\n")
case int64:
fmt.Printf("demo type is int64\n")
case bool:
fmt.Printf("demo type is bool\n")
case string:
fmt.Printf("demo type is string\n")
default:
fmt.Printf("demo type unkonwn\n")
}
}
type switch
的一个典型应用是在go.uber.org/zap
库中的zap.Any()
方法,里面就用到了类型断言,把所有的类型的case
都列举出来了,default
分支使用的是Reflect
,也就是当所有类型都不匹配时使用反射获取相应的值,具体大家可以去看一下源码。
类型断言实现源码剖析
非空接口和空接口都可以使用类型断言,我们分两种进行剖析。
空接口
我们先来写一段测试代码:
type User struct {
Name string
}
func main() {
var u interface{} = &User{Name: "asong"}
val, ok := u.(int)
if !ok {
fmt.Printf("%v\n", val)
}
}
老样子,我们将上述代码转换成汇编代码看一下:
go tool compile -S -N -l main.go > main.s4 2>&1
截取部分重要汇编代码如下:
0x002f 00047 (main.go:12) XORPS X0, X0
0x0032 00050 (main.go:12) MOVUPS X0, ""..autotmp_8+136(SP)
0x003a 00058 (main.go:12) PCDATA $2, $1
0x003a 00058 (main.go:12) PCDATA $0, $0
0x003a 00058 (main.go:12) LEAQ ""..autotmp_8+136(SP), AX
0x0042 00066 (main.go:12) MOVQ AX, ""..autotmp_7+96(SP)
0x0047 00071 (main.go:12) TESTB AL, (AX)
0x0049 00073 (main.go:12) MOVQ $5, ""..autotmp_8+144(SP)
0x0055 00085 (main.go:12) PCDATA $2, $2
0x0055 00085 (main.go:12) LEAQ go.string."asong"(SB), CX
0x005c 00092 (main.go:12) PCDATA $2, $1
0x005c 00092 (main.go:12) MOVQ CX, ""..autotmp_8+136(SP)
0x0064 00100 (main.go:12) MOVQ AX, ""..autotmp_3+104(SP)
0x0069 00105 (main.go:12) PCDATA $2, $2
0x0069 00105 (main.go:12) PCDATA $0, $2
0x0069 00105 (main.go:12) LEAQ type.*"".User(SB), CX
0x0070 00112 (main.go:12) PCDATA $2, $1
0x0070 00112 (main.go:12) MOVQ CX, "".u+120(SP)
0x0075 00117 (main.go:12) PCDATA $2, $0
0x0075 00117 (main.go:12) MOVQ AX, "".u+128(SP)
上面这段汇编代码的作用就是赋值给空接口,数据都存在栈上,因为空interface{}
的结构是eface
,所以就是组装了一个eface
在内存中,内存布局如下:
我们知道空接口的数据结构中只有两个字段,一个_type
字段,一个data
字段,从上图中,我们可以看出来,eface
的_type
存储在内存的+120(SP)
处,unsafe.Pointer
存在了+128(SP)
处,现在我们知道了他是怎么存的了,接下来我们看一下空接口的类型断言汇编是怎么实现的:
0x007d 00125 (main.go:13) PCDATA $2, $1
0x007d 00125 (main.go:13) MOVQ "".u+128(SP), AX
0x0085 00133 (main.go:13) PCDATA $0, $0
0x0085 00133 (main.go:13) MOVQ "".u+120(SP), CX
0x008a 00138 (main.go:13) PCDATA $2, $3
0x008a 00138 (main.go:13) LEAQ type.int(SB), DX
0x0091 00145 (main.go:13) PCDATA $2, $1
0x0091 00145 (main.go:13) CMPQ CX, DX
0x0094 00148 (main.go:13) JEQ 155
0x0096 00150 (main.go:13) JMP 395
0x009b 00155 (main.go:13) PCDATA $2, $0
0x009b 00155 (main.go:13) MOVQ (AX), AX
0x009e 00158 (main.go:13) MOVL $1, CX
0x00a3 00163 (main.go:13) JMP 165
0x00a5 00165 (main.go:13) MOVQ AX, ""..autotmp_4+80(SP)
0x00aa 00170 (main.go:13) MOVB CL, ""..autotmp_5+71(SP)
0x00ae 00174 (main.go:13) MOVQ ""..autotmp_4+80(SP), AX
0x00b3 00179 (main.go:13) MOVQ AX, "".val+72(SP)
0x00b8 00184 (main.go:13) MOVBLZX ""..autotmp_5+71(SP), AX
0x00bd 00189 (main.go:13) MOVB AL, "".ok+70(SP)
0x00c1 00193 (main.go:14) CMPB "".ok+70(SP), $0
从上面这段汇编我们可以看出来,空接口的类型断言是通过判断eface
中的_type
字段和比较的类型进行对比,相同就会去准备接下来的返回值,如果类型断言正确,经过中间临时变量的传递,最终val
保存在内存中+72(SP)
处。ok
保存在内存+70(SP)
处。
0x018b 00395 (main.go:15) XORL AX, AX
0x018d 00397 (main.go:15) XORL CX, CX
0x018f 00399 (main.go:13) JMP 165
0x0194 00404 (main.go:13) NOP
如果断言失败,就会清空AX
和CX
寄存器,因为AX
和CX
中存的是eface
结构体里面的字段。
最后总结一下空接口类型断言实现流程:空接口类型断言实质是将eface
中_type
与要匹配的类型进行对比,匹配成功在内存中组装返回值,匹配失败直接清空寄存器,返回默认值。
非空接口
老样子,还是先写一个例子,然后我们在看他的汇编实现:
type Basic interface {
GetName() string
SetName(name string) error
}
type User struct {
Name string
}
func (u *User) GetName() string {
return u.Name
}
func (u *User) SetName(name string) error {
u.Name = name
return nil
}
func main() {
var u Basic = &User{Name: "asong"}
switch u.(type) {
case *User:
u1 := u.(*User)
fmt.Println(u1.Name)
default:
fmt.Println("failed to match")
}
}
使用汇编指令看一下他的汇编代码如下:
0x002f 00047 (main.go:26) PCDATA $2, $0
0x002f 00047 (main.go:26) PCDATA $0, $1
0x002f 00047 (main.go:26) XORPS X0, X0
0x0032 00050 (main.go:26) MOVUPS X0, ""..autotmp_5+152(SP)
0x003a 00058 (main.go:26) PCDATA $2, $1
0x003a 00058 (main.go:26) PCDATA $0, $0
0x003a 00058 (main.go:26) LEAQ ""..autotmp_5+152(SP), AX
0x0042 00066 (main.go:26) MOVQ AX, ""..autotmp_4+64(SP)
0x0047 00071 (main.go:26) TESTB AL, (AX)
0x0049 00073 (main.go:26) MOVQ $5, ""..autotmp_5+160(SP)
0x0055 00085 (main.go:26) PCDATA $2, $2
0x0055 00085 (main.go:26) LEAQ go.string."asong"(SB), CX
0x005c 00092 (main.go:26) PCDATA $2, $1
0x005c 00092 (main.go:26) MOVQ CX, ""..autotmp_5+152(SP)
0x0064 00100 (main.go:26) MOVQ AX, ""..autotmp_2+72(SP)
0x0069 00105 (main.go:26) PCDATA $2, $2
0x0069 00105 (main.go:26) PCDATA $0, $2
0x0069 00105 (main.go:26) LEAQ go.itab.*"".User,"".Basic(SB), CX
0x0070 00112 (main.go:26) PCDATA $2, $1
0x0070 00112 (main.go:26) MOVQ CX, "".u+104(SP)
0x0075 00117 (main.go:26) PCDATA $2, $0
0x0075 00117 (main.go:26) MOVQ AX, "".u+112(SP)
上面这段汇编代码作用就是赋值给非空接口的iface
结构,组装了iface
的内存布局,因为上面分析了非空接口的,这里就不细讲了,理解他的意思就好。接下来我们看一下他是如何进行类型断言的。
0x00df 00223 (main.go:29) PCDATA $2, $1
0x00df 00223 (main.go:29) PCDATA $0, $2
0x00df 00223 (main.go:29) MOVQ "".u+112(SP), AX
0x00e4 00228 (main.go:29) PCDATA $0, $0
0x00e4 00228 (main.go:29) MOVQ "".u+104(SP), CX
0x00e9 00233 (main.go:29) PCDATA $2, $3
0x00e9 00233 (main.go:29) LEAQ go.itab.*"".User,"".Basic(SB), DX
0x00f0 00240 (main.go:29) PCDATA $2, $1
0x00f0 00240 (main.go:29) CMPQ CX, DX
0x00f3 00243 (main.go:29) JEQ 250
0x00f5 00245 (main.go:29) JMP 583
0x00fa 00250 (main.go:29) MOVQ AX, "".u1+56(SP)
上面代码我们可以看到调用iface
结构中的itab
字段,这里为什么这么调用呢?因为我们类型推断的是一个具体的类型,编译器会直接构造出iface
,不会去调用已经在runtime/iface.go
实现好的断言方法。上述代码中,先构造出iface
,其中*itab
存在内存 +104(SP)
中,unsafe.Pointer
存在 +112(SP)
中。然后在类型推断的时候又重新构造了一遍 *itab
,最后将新的 *itab
和前一次 +104(SP)
里的*itab
进行对比。
后面的赋值操作也就不再细说了,没有什么特别的。
这里还有一个要注意的问题,如果我们类型断言的是接口类型,那么我们在就会看到这样的汇编代码:
// 代码修改
func main() {
var u Basic = &User{Name: "asong"}
v, ok := u.(Basic)
if !ok {
fmt.Printf("%v\n", v)
}
}
// 部分汇编代码
0x008c 00140 (main.go:27) MOVUPS X0, ""..autotmp_4+168(SP)
0x0094 00148 (main.go:27) PCDATA $2, $1
0x0094 00148 (main.go:27) MOVQ "".u+128(SP), AX
0x009c 00156 (main.go:27) PCDATA $0, $0
0x009c 00156 (main.go:27) MOVQ "".u+120(SP), CX
0x00a1 00161 (main.go:27) PCDATA $2, $4
0x00a1 00161 (main.go:27) LEAQ type."".Basic(SB), DX
0x00a8 00168 (main.go:27) PCDATA $2, $1
0x00a8 00168 (main.go:27) MOVQ DX, (SP)
0x00ac 00172 (main.go:27) MOVQ CX, 8(SP)
0x00b1 00177 (main.go:27) PCDATA $2, $0
0x00b1 00177 (main.go:27) MOVQ AX, 16(SP)
0x00b6 00182 (main.go:27) CALL runtime.assertI2I2(SB)
我们可以看到,直接调用的是runtime.assertI2I2()
方法进行类型断言,这个方法的实现代码如下:
func assertI2I(inter *interfacetype, i iface) (r iface) {
tab := i.tab
if tab == nil {
// explicit conversions require non-nil interface value.
panic(&TypeAssertionError{nil, nil, &inter.typ, ""})
}
if tab.inter == inter {
r.tab = tab
r.data = i.data
return
}
r.tab = getitab(inter, tab._type, false)
r.data = i.data
return
}
上述代码逻辑很简单,如果 iface
中的itab.inter
和第一个入参 *interfacetype
相同,说明类型相同,直接返回入参 iface
的相同类型,布尔值为 true
;如果iface
中的itab.inter
和第一个入参 *interfacetype
不相同,则重新根据 *interfacetype
和 iface.tab
去构造tab
。构造的过程会查找itabTable
。如果类型不匹配,或者不是属于同一个 interface
类型,都会失败。getitab()
方法第三个参数是 canfail
,这里传入了true
,表示构建 *itab
允许失败,失败以后返回 nil
。
差异:如果我们断言的类型是具体类型,编译器会直接构造出iface
,不会去调用已经在runtime/iface.go
实现好的断言方法。如果我们断言的类型是接口类型,将会去调用相应的断言方法进行判断。
小结:非空接口类型断言的实质是 iface 中 *itab
的对比。*itab
匹配成功会在内存中组装返回值。匹配失败直接清空寄存器,返回默认值。
类型断言的性能损耗
前面我们已经分析了断言的底层原理,下面我们来看一下不同场景下进行断言的代价。
针对不同的场景可以写出测试文件如下(截取了部分代码,全部代码获取戳这里):
var dst int64
// 空接口类型直接类型断言具体的类型
func Benchmark_efaceToType(b *testing.B) {
b.Run("efaceToType", func(b *testing.B) {
var ebread interface{} = int64(666)
for i := 0; i < b.N; i++ {
dst = ebread.(int64)
}
})
}
// 空接口类型使用TypeSwitch 只有部分类型
func Benchmark_efaceWithSwitchOnlyIntType(b *testing.B) {
b.Run("efaceWithSwitchOnlyIntType", func(b *testing.B) {
var ebread interface{} = 666
for i := 0; i < b.N; i++ {
OnlyInt(ebread)
}
})
}
// 空接口类型使用TypeSwitch 所有类型
func Benchmark_efaceWithSwitchAllType(b *testing.B) {
b.Run("efaceWithSwitchAllType", func(b *testing.B) {
var ebread interface{} = 666
for i := 0; i < b.N; i++ {
Any(ebread)
}
})
}
//直接使用类型转换
func Benchmark_TypeConversion(b *testing.B) {
b.Run("typeConversion", func(b *testing.B) {
var ebread int32 = 666
for i := 0; i < b.N; i++ {
dst = int64(ebread)
}
})
}
// 非空接口类型判断一个类型是否实现了该接口 两个方法
func Benchmark_ifaceToType(b *testing.B) {
b.Run("ifaceToType", func(b *testing.B) {
var iface Basic = &User{}
for i := 0; i < b.N; i++ {
iface.GetName()
iface.SetName("1")
}
})
}
// 非空接口类型判断一个类型是否实现了该接口 12个方法
func Benchmark_ifaceToTypeWithMoreMethod(b *testing.B) {
b.Run("ifaceToTypeWithMoreMethod", func(b *testing.B) {
var iface MoreMethod = &More{}
for i := 0; i < b.N; i++ {
iface.Get()
iface.Set()
iface.One()
iface.Two()
iface.Three()
iface.Four()
iface.Five()
iface.Six()
iface.Seven()
iface.Eight()
iface.Nine()
iface.Ten()
}
})
}
// 直接调用方法
func Benchmark_DirectlyUseMethod(b *testing.B) {
b.Run("directlyUseMethod", func(b *testing.B) {
m := &More{
Name: "asong",
}
m.Get()
})
}
运行结果:
goos: darwin
goarch: amd64
pkg: asong.cloud/Golang_Dream/code_demo/assert_test
Benchmark_efaceToType/efaceToType-16 1000000000 0.507 ns/op
Benchmark_efaceWithSwitchOnlyIntType/efaceWithSwitchOnlyIntType-16 384958000 3.00 ns/op
Benchmark_efaceWithSwitchAllType/efaceWithSwitchAllType-16 351172759 3.33 ns/op
Benchmark_TypeConversion/typeConversion-16 1000000000 0.473 ns/op
Benchmark_ifaceToType/ifaceToType-16 355683139 3.38 ns/op
Benchmark_ifaceToTypeWithMoreMethod/ifaceToTypeWithMoreMethod-16 85421563 12.8 ns/op
Benchmark_DirectlyUseMethod/directlyUseMethod-16 1000000000 0.000000 ns/op
PASS
ok asong.cloud/Golang_Dream/code_demo/assert_test 7.797s
从结果我们可以分析一下:
空接口类型的类型断言代价并不高,与直接类型转换几乎没有性能差异 空接口类型使用 type switch
进行类型断言时,随着case
的增多性能会直线下降非空接口类型进行类型断言时,随着接口中方法的增多,性能会直线下降 直接进行方法调用要比非接口类型进行类型断言要高效很多
好啦,现在我们也知道怎样使用类型断言能提高性能啦,又可以和同事吹水一手啦。
总结
好啦,本文到这里就已经接近尾声了,在最后做一个小小的总结:
空接口类型断言实现流程:空接口类型断言实质是将
eface
中_type
与要匹配的类型进行对比,匹配成功在内存中组装返回值,匹配失败直接清空寄存器,返回默认值。非空接口类型断言的实质是 iface 中
*itab
的对比。*itab
匹配成功会在内存中组装返回值。匹配失败直接清空寄存器,返回默认值泛型是在编译期做的事情,使用类型断言会消耗一点性能,类型断言使用方式不同,带来的性能损耗也不同,具体请看上面的章节。
文中代码已上传github
:https://github.com/asong2020/Golang_Dream/tree/master/code_demo/assert_test,欢迎star
推荐阅读