Go:源碼剖析類型斷言是如何實現的!附性能損耗測試

前言

哈嘍,everyBody,我是asong,今天我們一起來探索一下interface的類型斷言是如何實現的。我們通常使用interface有兩種方式,一種是帶方法的interface,一種是空的interface。因爲Go中是沒有泛型,所以我們可以用空的interface{}來作爲一種僞泛型使用,當我們使用到空的interface{}作爲入參或返回值時,就會使用到類型斷言,來獲取我們所需要的類型,所以平常我們會在代碼中看到大量的類型斷言使用,你就不好奇它是怎麼實現的嘛?你就不好奇它的性能損耗是多少嘛?反正我很好奇,略~。

類型斷言的基本使用

Type Assertion(斷言)是用於interface value的一種操作,語法是x.(T)xinterface type的表達式,而Tasserted 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,表示斷言失敗,此時tT的零值。所以推薦使用這種方式,可以保證代碼的健壯性。

如果我們想要區分多種類型,可以使用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

如果斷言失敗,就會清空AXCX寄存器,因爲AXCX中存的是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 不相同,則重新根據 *interfacetypeiface.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

從結果我們可以分析一下:

好啦,現在我們也知道怎樣使用類型斷言能提高性能啦,又可以和同事吹水一手啦。

總結

好啦,本文到這裏就已經接近尾聲了,在最後做一個小小的總結:

文中代碼已上傳github:https://github.com/asong2020/Golang_Dream/tree/master/code_demo/assert_test,歡迎star

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/dq5PVHdMBMkbyGSxN28DHA