淺入淺出 Go 語言接口的原理

淺入淺出 Go 語言接口的原理

接口是 Go 語言的重要組成部分,它在 Go 語言中經過一組方法指定了一個對象的行爲,接口 interface 的引入可以讓咱們在 Go 語言更好地組織並寫出易於測試的代碼。然而不少使用 Go 語言的工程師其實對接口的瞭解都很是有限,對於它的底層實現也一無所知,這其實成爲了咱們使用和理解 interface 的最大阻礙。java

在這一節中,咱們就會介紹 Go 語言中這個重要類型 interface 的一些常見問題以及它底層的實現,包括接口的基本原理、類型斷言和轉換的過程以及動態派發機制,幫助各位 Go 語言開發者更好地理解 interface 類型。git

概述

接口是計算機系統中多個組件共享的邊界,經過定義接口,具體的實現能夠和調用方徹底分離,其本質就是引入一箇中間層對不一樣的模塊進行解耦,上層的模塊就不須要依賴某一個具體的實現,而是隻須要依賴一個定義好的接口,這種面向接口的編程方式有着很是強大的生命力,不管是從框架仍是操做系統中咱們都可以看到使用接口帶來的便利。github

golang-interface

POSIX(可移植操做系統接口)就是一個典型的例子,它定義了應用程序接口和命令行等標準,爲計算機軟件帶來了可移植性 — 只要操做系統實現了 POSIX,沒有使用操做系統或者 CPU 架構特定功能的計算機軟件就能夠無需修改在不一樣操做系統上運行。golang

Go 語言中的接口 interface 不只是一組方法,仍是一種內置的類型,咱們在這一節中將介紹接口相關的幾個基本概念以及常見的問題,爲咱們以後介紹它的實現原理進行一些簡單的鋪墊,幫助各位讀者更好地理解 Go 語言中的接口類型。編程

方法

不少面嚮對象語言其實也有接口這一律念,例如 Java 中也有 interface 接口,這裏的接口其實不止包含一組方法的簽名,還能夠定義一些變量,這些變量能夠直接在實現接口的類中使用:數組

public interface MyInterface { public String hello = "Hello"; public void sayHello(); } 

上述 Java 代碼就定義了一個必需要實現的方法 sayHello 和一個會被注入到實現類中的變量 hello,下面的 MyInterfaceImpl 類型就是一個 MyInterface 的實現:數據結構

public class MyInterfaceImpl implements MyInterface { public void sayHello() { System.out.println(MyInterface.hello); } } 

Java 中的類都必需要經過上述方式顯式地聲明實現的接口並實現其中的方法,然而 Go 語言中的接口相比之下就簡單了不少。架構

若是想在 Go 語言中定義一個接口,咱們也須要使用 interface 關鍵字,可是在接口中咱們只能定義須要實現的方法,而不能包含任何的變量或者字段,因此一個常見的 Go 語言接口是這樣的:框架

type error interface { Error() string } 

任意類型只要實現了 Error 方法其實就實現了 error 接口,然而在 Go 語言中全部接口的實現都是隱式的,咱們只須要實現 Error 就至關於隱式的實現了 error 接口:編程語言

type RPCError struct { Code int64 Message string } func (e *RPCError) Error() string { return fmt.Sprintf("%s, code=%d", e.Message, e.Code) } 

當咱們使用上述 RPCError 結構體時,其實並不關心它實現了哪些接口,Go 語言只會在傳遞或者返回參數以及變量賦值時纔會對某個結構是否實現接口進行檢查,咱們能夠簡單舉幾個例子來演示發生接口類型檢查的時機:

func main() { var rpcErr error = NewRPCError(400, "unknown err") // typecheck1 err := AsErr(rpcErr) // typecheck2 println(err) } func NewRPCError(code int64, msg string) error { return &RPCError{ // typecheck3 Code: code, Message: msg, } } func AsErr(err error) error { return err } 

Go 語言會 編譯期間 對上述代碼進行類型檢查,這裏總共觸發了三次類型檢查:

  1. 將 *RPCError 類型的變量賦值給 error 類型的變量 rpcErr
  2. 將 *RPCError 類型的變量 rpcErr 傳遞給簽名中參數類型爲 error 的 AsErr 函數;
  3. 將 *RPCError 類型的變量從函數簽名的返回值類型爲 error 的 NewRPCError 函數中返回;

從編譯器類型檢查的過程來看,編譯器僅在須要時纔會對類型進行檢查,類型實現接口時其實也只須要隱式的實現接口中的所有方法,不須要像 Java 等編程語言中同樣顯式聲明。

類型

接口也是 Go 語言中的一種類型,它可以出如今變量的定義、函數的入參和返回值中並對它們進行約束,不過 Go 語言中其實有兩種略微不一樣的接口,其中一種是帶有一組方法的接口,另外一種是不帶有任何方法的 interface{} 類型:

golang-different-interface

在 Go 語言的源代碼中,咱們將第一種接口表示成 iface 結構體,將第二種不須要任何方法的接口表示成 eface 結構體,兩種不一樣的接口雖然都使用 interface 進行聲明,可是後者因爲在 Go 語言中很是常見,因此在實現時也將它實現成了一種特殊的類型。

須要注意的是,與 C 語言中的 void * 不一樣,interface{} 類型並不表示任意類型,interface{} 類型的變量在運行期間的類型只是 interface{}

package main func main() { type Test struct{} v := Test{} Print(v) } func Print(v interface{}) { println(v) } 

上述函數也不接受任意類型的參數,而是隻接受 interface{} 類型的值,在調用 Print 函數時其實會對參數 v 進行類型轉換,將原來的 Test 類型轉換成 interface{} 類型,咱們會在這一節的後面介紹類型轉換髮生的過程和原理。

指針和接口

Go 語言是一個有指針類型的編程語言,當指針和接口同時出現時就會遇到一些讓人困惑或者感到詭異的問題,接口在定義一組方法時其實沒有對實現的接受者作限制,因此咱們其實會在一個類型上看到如下兩種不一樣的實現方式:

golang-interface-and-pointer

這兩種不一樣的實現不能夠同時存在,Go 語言的編譯器會在遇到這種狀況時報錯 method redeclared

對於 Cat 結構體來講,它不只在實現時能夠選擇將接受者的類型 — 結構體和結構體指針,在初始化時也能夠初始化成結構體或者指針:

golang-interface-initialize

咱們會在這時獲得兩個不一樣維度的『編碼方式』,實現接口的接受者類型和初始化時返回的類型,這兩個維度總共會產生以下的四種不一樣狀況:

golang-interface-receiver

在這四種不一樣狀況中,只有一種會發生編譯不經過的問題,也就是方法接受者是指針類型,變量初始化成結構體類型,其餘的三種狀況均可以正常經過編譯,下面兩種狀況可以經過編譯其實很是好理解:

  • 方法接受者和初始化類型都是結構體;
  • 方法接受者和初始化類型都是結構體指針;

而剩下的兩種方式爲何一種可以經過編譯,另外一種沒法經過編譯呢?咱們先來看一下可以經過編譯的狀況,也就是方法的接受者是結構體,而初始化的變量是指針類型:

type Cat struct{} func (c Cat) Walk() { fmt.Println("catwalk") } func (c Cat) Quack() { fmt.Println("meow") } func main() { var c Duck = &Cat{} c.Walk() c.Quack() } 

上述代碼中的 Cat 結構體指針實際上是可以直接調用 Walk 和 Quack 方法的,由於做爲指針它可以隱式獲取到對應的底層結構體,咱們能夠將這裏的調用理解成 C 語言中的 d->Walk() 和 d->Speak(),先獲取底層結構體再執行對應的方法。

若是咱們將上述代碼中的接受者和初始化時的類型進行交換,就會發生編譯不經過的問題:

type Duck interface { Walk() Quack() } type Cat struct{} func (c *Cat) Walk() { fmt.Println("catwalk") } func (c *Cat) Quack() { fmt.Println("meow") } func main() { var c Duck = Cat{} c.Walk() c.Quack() } $ go build interface.go ./interface.go:20:6: cannot use Cat literal (type Cat) as type Duck in assignment: Cat does not implement Duck (Quack method has pointer receiver) 

編譯器會提醒咱們『Cat 類型並無實現 Duck 接口,Quack 方法的接受者是指針』,這兩種狀況其實很是讓人困惑,尤爲是對於剛剛接觸 Go 語言接口的開發者,想要理解這個問題,首先要知道 Go 語言在進行 參數傳遞 時都是值傳遞的。

golang-interface-method-receive

當代碼中的變量是 Cat{} 時,調用函數其實會對參數進行復制,也就是當前函數會接受一個新的 Cat{} 變量,因爲方法的參數是 *Cat,而編譯器沒有辦法根據結構體找到一個惟一的指針,因此編譯器會報錯;當代碼中的變量是 &Cat{} 時,在方法調用的過程當中也會發生值的拷貝,建立一個新的 Cat 指針,這個指針可以指向一個肯定的結構體,因此編譯器會隱式的對變量解引用(dereference)獲取指針指向的結構體完成方法的正常調用。

nil 和 non-nil

咱們能夠再經過一個例子理解『Go 語言的接口類型不是任意類型』這一句話,下面的代碼在 main 函數中初始化了一個 *TestStruct 結構體指針,因爲指針的零值是 nil,因此變量 s 在初始化以後也是 nil

package main type TestStruct struct{} func NilOrNot(v interface{}) { if v == nil { println("nil") } else { println("non-nil") } } func main() { var s *TestStruct NilOrNot(s) } $ go run main.go non-nil 

可是當咱們將 s 變量傳入 NilOrNot 時,該方法卻打印出了 non-nil 字符串,這主要是由於調用 NilOrNot函數時其實會發生隱式的類型轉換,變量 nil 會被轉換成 interface{} 類型,interface{} 類型是一個結構體,它除了包含 nil 變量以外還包含變量的類型信息,也就是 TestStruct,因此在這裏會打印出 non-nil,咱們會在接下來詳細介紹結構的實現原理。

實現原理

相信經過上一節的內容,咱們已經對 Go 語言中的接口有了必定的瞭解,接下來就會從 Golang 的源代碼和彙編指令層面介紹接口的底層數據結構、類型轉換、動態派發等過程的實現原理。

數據結構

在上一節中其實介紹過 Go 語言中的接口類型會根據『是否包含一組方法』被分紅兩種不一樣的類型,包含方法的接口被實現成 iface 結構體,不包含任何方法的 interface{} 類型在底層其實就是 eface 結構體,咱們先來看 eface 結構體的組成:

type eface struct { // 16 bytes _type *_type data unsafe.Pointer } 

因爲 interface{} 類型不包含任何方法,因此它的結構也相對來講比較簡單,只包含指向底層數據和類型的兩個指針,從這裏的結構咱們也就可以推斷出 — 任意的類型均可以轉換成 interface{} 類型。

type iface struct { // 16 bytes tab *itab data unsafe.Pointer } 

另外一個用於表示接口 interface 類型的結構體就是 iface 了,在這個結構體中也有指向原始數據的指針 data,在這個結構體中更重要的實際上是 itab 類型的 tab 字段。

itab 結構體

itab 結構體是接口類型的核心組成部分,每個 itab 都佔 32 字節的空間,其中包含的 _type 字段是 Go 語言類型在運行時的內部結構,每個 _type 結構體中都包含了類型的大小、對齊以及哈希等信息:

type itab struct { // 32 bytes 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. } 

除此以外 itab 結構體中還包含另外一個表示接口類型的 interfacetype 字段,它就是一個對 _type 類型的簡單封裝。

hash 字段實際上是對 _type.hash 的拷貝,它會在從 interface 到具體類型的切換時用於快速判斷目標類型和接口中類型是否一致;最後的 fun 數組實際上是一個動態大小的數組,若是若是當前數組中內容爲空就表示 _type 沒有實現 inter 接口,雖然這是一個大小固定的數組,可是在使用時會直接經過指針獲取其中的數據並不會檢查數組的邊界,因此該數組中保存的元素數量是不肯定的。

_type 結構體

_type 類型表示的就是 Go 語言中類型的運行時表示,下面其實就是類型在運行期間的結構,咱們能夠看到其中包含了很是多的原信息 — 類型的大小、哈希、對齊以及種類等字段。

type _type struct { size uintptr ptrdata uintptr // size of memory prefix holding all pointers hash uint32 tflag tflag align uint8 fieldalign uint8 kind uint8 alg *typeAlg // gcdata stores the GC type data for the garbage collector. // If the KindGCProg bit is set in kind, gcdata is a GC program. // Otherwise it is a ptrmask bitmap. See mbitmap.go for details. gcdata *byte str nameOff ptrToThis typeOff } 

咱們在這裏其實也還須要簡單瞭解一下 _type 的結構,這一節中後面的內容將詳細介紹該結構體中一些字段的做用和意義。

基本原理

既然咱們已經對接口在運行時的數據結構已經有所瞭解,接下來咱們就會經過幾個例子來深刻理解接口類型是如何初始化和傳遞的,咱們會分別介紹在實現接口時使用指針類型和結構體類型的區別。

這兩種不一樣類型的接口實現方式其實會致使 Go 語言編譯器底層生成的彙編代碼不一樣,在具體的執行過程上也會有一些差別,接下來就會介紹接口常見操做的基本原理。

指針類型

首先咱們從新回到這一節開頭提到的 Duck 接口的例子,簡單修改一下前面提到的這段代碼,刪除 Duck 接口中的 Walk 方法並將 Quack 方法設置成禁止內聯編譯:

package main type Duck interface { Quack() } type Cat struct { Name string } //go:noinline func (c *Cat) Quack() { println(c.Name + " meow") } func main() { var c Duck = &Cat{Name: "grooming"} c.Quack() } 

將上述代碼編譯成彙編語言以後,咱們刪掉其中一些對理解接口原理無用的指令,只保留與賦值語句 var c Duck = &Cat{Name: "grooming"} 相關的代碼,先來了解一下結構體指針被裝到接口變量 c 的過程:

LEAQ    type."".Cat(SB), AX MOVQ AX, (SP) CALL runtime.newobject(SB) MOVQ 8(SP), DI MOVQ $8, 8(DI) LEAQ go.string."grooming"(SB), AX MOVQ AX, (DI) LEAQ go.itab.*"".Cat,"".Duck(SB), AX TESTB AL, (AX) MOVQ DI, (SP) 

這段代碼的第一部分其實就是對 Cat 結構體的初始化,咱們直接展現上述彙編語言對應的僞代碼,幫助咱們更快地理解這個過程:

LEAQ	type."".Cat(SB), AX ;; AX = &type."".Cat MOVQ AX, (SP) ;; SP = &type."".Cat CALL runtime.newobject(SB) ;; SP + 8 = &Cat{} MOVQ 8(SP), DI ;; DI = &Cat{} MOVQ $8, 8(DI) ;; StringHeader(DI.Name).Len = 8 LEAQ go.string."grooming"(SB), AX ;; AX = &"grooming" MOVQ AX, (DI) ;; StringHeader(DI.Name).Data = &"grooming" 
  1. 獲取 Cat 結構體類型指針並將其做爲參數放到棧 SP 上;
  2. 經過 CALL 指定調用 runtime.newobject 函數,這個函數會以 Cat 結構體類型指針做爲入參,分配一片新的內存空間並將指向這片內存空間的指針返回到 SP+8 上;
  3. SP+8 如今存儲了一個指向 Cat 結構體的指針,咱們將棧上的指針拷貝到寄存器 DI 上方便操做;
  4. 因爲 Cat 中只包含一個字符串類型的 Name 變量,因此在這裏會分別將字符串地址 &"grooming" 和字符串長度 8 設置到結構體上,最後三行彙編指令的做用就等價於 cat.Name = "grooming"

字符串在運行時的表示其實就是指針加上字符串長度,在前面的章節 字符串 已經介紹過它的底層表示和實現原理,可是咱們這裏要看一下初始化以後的 Cat 結構體在內存中的表示是什麼樣的:

golang-new-struct-pointer

每個 Cat 結構體在內存中的大小都是 16 字節,這是由於其中只包含一個字符串字段,而字符串在 Go 語言中總共佔 16 字節,初始化 Cat 結構體以後就進入了將 *Cat 轉換成 Duck 類型的過程了:

LEAQ	go.itab.*"".Cat,"".Duck(SB), AX ;; AX = *itab(go.itab.*"".Cat,"".Duck) MOVQ DI, (SP) ;; SP = AX CALL "".(*Cat).Quack(SB) ;; SP.Quack() 

Duck 做爲一個包含方法的接口,它在底層就會使用 iface 結構體進行表示,iface 結構體包含兩個字段,其中一個是指向數據的指針,另外一個是表示接口和結構體關係的 tab 字段,咱們已經經過上一段代碼在棧上的 SP+8 初始化了 Cat 結構體指針,這段代碼其實只是將編譯期間生成的 itab 結構體指針複製到 SP 上:

golang-struct-pointer-to-iface

咱們會發現 SP 和 SP+8 總共 16 個字節共同組成了 iface 結構體,棧上的這個 iface 結構體也就是 Quack方法的第一個入參。

LEAQ	type."".Cat(SB), AX ;; AX = &type."".Cat MOVQ AX, (SP) ;; SP = &type."".Cat CALL runtime.newobject(SB) ;; SP + 8 = &Cat{} MOVQ 8(SP), DI ;; DI = &Cat{} MOVQ $8, 8(DI) ;; StringHeader(DI.Name).Len = 8 LEAQ go.string."grooming"(SB), AX ;; AX = &"grooming" MOVQ AX, (DI) ;; StringHeader(DI.Name).Data = &"grooming" LEAQ go.itab.*"".Cat,"".Duck(SB), AX ;; AX = &(go.itab.*"".Cat,"".Duck) MOVQ DI, (SP) ;; SP = AX CALL "".(*Cat).Quack(SB) ;; SP.Quack() 

到這裏已經完成了對 Cat 指針轉換成 iface 結構體並調用 Quack 方法過程的分析,咱們再從新回顧一下整個調用過程的彙編代碼和僞代碼,其中的大部份內容都是對 Cat 指針和 iface 的初始化,調用 Quack 方法時其實也只執行了一個彙編指令,調用的過程也沒有通過動態派發的過程,這其實就是 Go 語言編譯器幫咱們作的優化了,咱們會在後面詳細介紹動態派發的過程。

結構體類型

咱們將上一小節中的代碼稍做修改 — 使用結構體類型實現 Quack 方法並在初始化變量時也使用結構體類型:

package main type Duck interface { Quack() } type Cat struct { Name string } //go:noinline func (c Cat) Quack() { println(c.Name + " meow") } func main() { var c Duck = Cat{Name: "grooming"} c.Quack() } 

編譯上述的代碼其實會獲得以下所示的彙編指令,須要注意的是爲了代碼更容易理解和分析,這裏的彙編指令依然通過了刪減,不過不會影響具體的執行過程:

XORPS	X0, X0 MOVUPS X0, ""..autotmp_1+32(SP) LEAQ go.string."grooming"(SB), AX MOVQ AX, ""..autotmp_1+32(SP) MOVQ $8, ""..autotmp_1+40(SP) LEAQ go.itab."".Cat,"".Duck(SB), AX MOVQ AX, (SP) LEAQ ""..autotmp_1+32(SP), AX MOVQ AX, 8(SP) CALL runtime.convT2I(SB) MOVQ 16(SP), AX MOVQ 24(SP), CX MOVQ 24(AX), AX MOVQ CX, (SP) CALL AX 

若是咱們在初始化變量時使用指針類型 &Cat{Name: "grooming"} 也可以經過編譯,不過生成的彙編代碼和上一節中的幾乎徹底相同,都會經過 runtime.newobject 建立新的 Cat 結構體指針並設置它的變量,在最後也會使用一樣的方式調用 Quack 方法,因此這裏也就不作額外的分析了。

咱們先來看一下上述彙編代碼中用於初始化 Cat 結構體的部分:

XORPS   X0, X0 ;; X0 = 0 MOVUPS X0, ""..autotmp_1+32(SP) ;; StringHeader(SP+32).Data = 0 LEAQ go.string."grooming"(SB), AX ;; AX = &"grooming" MOVQ AX, ""..autotmp_1+32(SP) ;; StringHeader(SP+32).Data = AX MOVQ $8, ""..autotmp_1+40(SP) ;; StringHeader(SP+32).Len =8 

這段彙編指令的工做其實與上一節中的差很少,這裏會在棧上佔用 16 字節初始化 Cat 結構體,不過而上一節中的代碼在堆上申請了 16 字節的內存空間,棧上只是一個指向 Cat 結構體的指針。

初始化告終構體就進入了類型轉換的階段,編譯器會將 go.itab."".Cat,"".Duck 的地址和指向 Cat 結構體的指針一併傳入 runtime.convT2I 函數:

LEAQ	go.itab."".Cat,"".Duck(SB), AX ;; AX = &(go.itab."".Cat,"".Duck) MOVQ AX, (SP) ;; SP = AX LEAQ ""..autotmp_1+32(SP), AX ;; AX = &(SP+32) = &Cat{Name: "grooming"} MOVQ AX, 8(SP) ;; SP + 8 = AX CALL runtime.convT2I(SB) ;; runtime.convT2I(SP, SP+8) 

這個函數會獲取 itab 中存儲的類型,根據類型的大小申請一片內存空間並將 elem 指針中的內容拷貝到目標的內存空間中:

func convT2I(tab *itab, elem unsafe.Pointer) (i iface) { t := tab._type x := mallocgc(t.size, t, true) typedmemmove(t, x, elem) i.tab = tab i.data = x return } 

convT2I 在函數的最後會返回一個 iface 結構體,其中包含 itab 指針和拷貝的 Cat 結構體,在當前函數返回值以後,main 函數的棧上就會包含如下的數據:

golang-struct-to-iface

SP 和 SP+8 中存儲的 itab 和 Cat 指針就是 runtime.convT2I 函數的入參,這個函數的返回值位於 SP+16,是一個佔 16 字節內存空間的 iface 結構體,SP+32 存儲的就是在棧上的 Cat 結構體,它會在 runtime.convT2I 執行的過程當中被拷貝到堆上。

在最後,咱們會經過如下的操做調用 Cat 實現的接口方法 Quack()

MOVQ	16(SP), AX ;; AX = &(go.itab."".Cat,"".Duck) MOVQ 24(SP), CX ;; CX = &Cat{Name: "grooming"} MOVQ 24(AX), AX ;; AX = AX.fun[0] = Cat.Quack MOVQ CX, (SP) ;; SP = CX CALL AX ;; CX.Quack() 

這幾個彙編指令中的大多數仍是很是好理解的,其中的 MOVQ 24(AX), AX 應該是最重要的指令,它從 itab 結構體中取出 Cat.Quack 方法指針,做爲 CALL 指令調用時的參數,第 24 字節是 itab.fun 字段開始的位置,因爲 Duck 接口只包含一個方法,因此 itab.fun[0] 中存儲的就是指向 Quack 的指針了。

類型斷言

上一節主要介紹的內容實際上是咱們如何把某一個具體類型轉換成一個接口類型,也就是 協變 的過程,而這一節主要想介紹的是如何將一個接口類型轉換成具體類型,也就是從 Duck 轉換回 Cat,這也就是 逆變 的過程:

package main type Duck interface { Quack() } type Cat struct { Name string } //go:noinline func (c *Cat) Quack() { println(c.Name + " meow") } func main() { var c Duck = &Cat{Name: "grooming"} switch c.(type) { case *Cat: cat := c.(*Cat) cat.Quack() } } 

當咱們編譯了上述代碼以後,會獲得以下所示的彙編指令,這裏截取了從建立結構體到執行 switch/case 結構的代碼片斷:

00000 TEXT "".main(SB), ABIInternal, $32-0 ... 00029 XORPS X0, X0 00032 MOVUPS X0, ""..autotmp_4+8(SP) 00037 LEAQ go.string."grooming"(SB), AX 00044 MOVQ AX, ""..autotmp_4+8(SP) 00049 MOVQ $8, ""..autotmp_4+16(SP) 00058 CMPL go.itab.*"".Cat,"".Duck+16(SB), $593696792 00068 JEQ 80 00070 MOVQ 24(SP), BP 00075 ADDQ $32, SP 00079 RET 00080 LEAQ ""..autotmp_4+8(SP), AX 00085 MOVQ AX, (SP) 00089 CALL "".(*Cat).Quack(SB) 00094 JMP 70 

咱們能夠直接跳過初始化 Duck 變量的過程,從 0058 開始分析隨後的彙編指令,須要注意的是 SP+8 ~ SP+24 16 個字節的位置存儲了 Cat 結構體,Go 語言的編譯器作了一些優化,因此咱們沒有看到 iface 結構體的構建過程,可是對於這裏要介紹的類型斷言和轉換其實沒有太多的影響:

00058 CMPL go.itab.*"".Cat,"".Duck+16(SB), $593696792 ;; if (c.tab.hash != 593696792) { 00068 JEQ 80 ;; 00070 MOVQ 24(SP), BP ;; BP = SP+24 00075 ADDQ $32, SP ;; SP += 32 00079 RET ;; return ;; } else { 00080 LEAQ ""..autotmp_4+8(SP), AX ;; AX = &Cat{Name: "grooming"} 00085 MOVQ AX, (SP) ;; SP = AX 00089 CALL "".(*Cat).Quack(SB) ;; SP.Quack() 00094 JMP 70 ;; ... ;; BP = SP+24 ;; SP += 32 ;; return ;; } 

switch/case 語句生成的彙編指令會將目標類型的 hash 與接口變量中的 itab.hash 進行比較,若是二者徹底相等就會認爲接口變量的具體類型是 Cat,這時就會進入 0080 所在的分支,開始類型轉換的過程,咱們會獲取 SP+8 存儲的 Cat 結構體指針、將其拷貝到 SP 上、調用 Quack 方法,最終恢復當前函數的堆棧後返回,不過若是接口中存在的具體類型不是 Cat,就會直接恢復棧指針並返回到調用方。

golang-interface-to-struct

當咱們使用以下所示的代碼,將 Cat 結構體轉換成 interface{} 空接口類型並經過 switch/case 語句進行類型的斷言時,若是不關閉 Go 語言編譯器的優化選項,生成的代碼是差很少的,它們都會省略從 Cat 結構體轉換到 iface 和 eface 的過程:

package main type Anything interface{} type Cat struct { Name string } //go:noinline func (c *Cat) Quack() { println(c.Name + " meow") } func main() { var c Anything = &Cat{Name: "grooming"} switch c.(type) { case *Cat: cat := c.(*Cat) cat.Quack() } } 

若是咱們不使用編譯器優化,這二者的區別也只是分別從 iface.tab._type 和 eface._type 中獲取當前接口變量的類型,彙編指令仍然會經過類型的 hash 對它們進行比較。

動態派發

動態派發是在運行期間選擇具體的多態操做執行的過程,它實際上是一種在面嚮對象語言中很是常見的特性,可是 Go 語言中接口的引入其實也爲它帶來了動態派發這一特性,也就是對於一個接口類型的方法調用,咱們會在運行期間決定具體調用該方法的哪一個實現。

假如咱們有如下的代碼,主函數中調用了兩次 Quack 方法,其中第一次調用是以 Duck 接口類型的方式進行調用的,這個調用的過程須要通過運行時的動態派發,而第二次調用是以 *Cat 類型的身份調用該方法的,最終調用的函數在編譯期間就已經確認了:

package main type Duck interface { Quack() } type Cat struct { Name string } //go:noinline func (c *Cat) Quack() { println(c.Name + " meow") } func main() { var c Duck = &Cat{Name: "grooming"} c.Quack() c.(*Cat).Quack() } 

在這裏咱們須要使用 -N 的編譯參數指定編譯器不要優化生成的彙編指令,若是不指定這個參數,編譯器會對不少可以推測出來的結果進行優化,與咱們理解的執行過程會有一些誤差,例如:

  • 因爲接口類型中的 tab 參數並無被使用,因此優化從 Cat 轉換到 Duck 接口類型的一些編譯指令;
  • 因爲變量的類型是肯定的,因此刪除從 Duck 接口類型轉換到 *Cat 具體類型時可能會發生 panic 的分支;

在具體分析調用 Quack 方法的兩種姿式以前,咱們首先要先了解 Cat 結構體到底是如何初始化的,以及初始化完成後的棧上有哪些數據:

LEAQ	type."".Cat(SB), AX MOVQ AX, (SP) CALL runtime.newobject(SB) ;; SP + 8 = new(Cat) MOVQ 8(SP), DI ;; DI = SP + 8 MOVQ DI, ""..autotmp_2+32(SP) ;; SP + 32 = DI MOVQ $8, 8(DI) ;; StringHeader(cat).Len = 8 LEAQ go.string."grooming"(SB), AX ;; AX = &"grooming" MOVQ AX, (DI) ;; StringHeader(cat).Data = AX MOVQ ""..autotmp_2+32(SP), AX ;; AX = &Cat{...} MOVQ AX, ""..autotmp_1+40(SP) ;; SP + 40 = &Cat{...} LEAQ go.itab.*"".Cat,"".Duck(SB), CX ;; CX = &go.itab.*"".Cat,"".Duck MOVQ CX, "".c+48(SP) ;; iface(c).tab = SP + 48 = CX MOVQ AX, "".c+56(SP) ;; iface(c).data = SP + 56 = AX 

這段代碼的初始化過程其實和上兩節中的初始化過程沒有太多的差異,它先初始化了 Cat 結構體指針,再將 Cat 和 tab 打包成了一個 iface 類型的結構體,咱們直接來看初始化過程結束以後的堆棧數據:

stack-after-initialize

SP 是運行時方法 runtime.newobject 的參數,而 SP+8 是該方法的返回值,即指向剛初始化的 Cat 結構體指針,SP+32SP+40 和 SP+56 是對 SP+8 的拷貝,這兩個指針都會指向棧上的 Cat 結構體,SP+56 的 Cat 結構體指針和 SP+48 的 tab 結構體指針共同構成了接口變量 iface 結構體。

接下來咱們進入 c.Quack() 語句展開後的彙編指令,下面的代碼從接口變量中獲取了 tab.func[0],其中保存了 Cat.Quack 的方法指針,接口變量在中的數據會被拷貝到 SP 上,而方法指針會被拷貝到寄存器中並經過彙編指令 CALL 觸發:

MOVQ	"".c+48(SP), AX ;; AX = iface(c).tab MOVQ 24(AX), AX ;; AX = iface(c).tab.fun[0] = Cat.Quack MOVQ "".c+56(SP), CX ;; CX = iface(c).data MOVQ CX, (SP) ;; SP = CX = &Cat{...} CALL AX ;; SP.Quack() 

另外一個調用 Quack 方法的語句 c.(*Cat).Quack() 生成的彙編指令看起來會有一些複雜,可是其中前半部分都是在作類型的轉換,將接口類型轉換成 *Cat 類型,只有最後的兩行代碼是函數調用相關的指令:

MOVQ	"".c+56(SP), AX ;; AX = iface(c).data = &Cat{...} MOVQ "".c+48(SP), CX ;; CX = iface(c).tab LEAQ go.itab.*"".Cat,"".Duck(SB), DX ;; DX = &&go.itab.*"".Cat,"".Duck CMPQ CX, DX ;; CMP(CX, DX) JEQ 163 JMP 201 MOVQ AX, ""..autotmp_3+24(SP) ;; SP+24 = &Cat{...} MOVQ AX, (SP) ;; SP = &Cat{...} CALL "".(*Cat).Quack(SB) ;; SP.Quack() 

這兩行代碼將 Cat 指針拷貝到了 SP 上並直接調用 Quack 方法,對於這一次的方法調用,待執行的函數其實在編譯期間就已經肯定了,因此運行期間就不須要再動態查找方法地實現:

MOVQ	"".c+48(SP), AX ;; AX = iface(c).tab MOVQ 24(AX), AX ;; AX = iface(c).tab.fun[0] = Cat.Quack MOVQ "".c+56(SP), CX ;; CX = iface(c).data 

兩次方法調用的彙編指令差別其實就是動態派發帶來的額外開銷,咱們須要瞭解一下這些額外的編譯指令對性能形成的影響。

性能測試

下面代碼中的兩個方法 BenchmarkDirectCall 和 BenchmarkDynamicDispatch 分別會調用結構體方法和接口方法,咱們以直接調用做爲基準看一下動態派發帶來了多少額外的性能開銷:

//go:noinline func (c *Cat) Quack() string { return c.Name } func BenchmarkDirectCall(b *testing.B) { c := &Cat{Name: "grooming"} for n := 0; n < b.N; n++ { // MOVQ AX, "".c+24(SP) // MOVQ AX, (SP) // CALL "".(*Cat).Quack(SB) c.Quack() } } func BenchmarkDynamicDispatch(b *testing.B) { c := Duck(&Cat{Name: "grooming"}) for n := 0; n < b.N; n++ { // MOVQ "".d+56(SP), AX // MOVQ 24(AX), AX // MOVQ "".d+64(SP), CX // MOVQ CX, (SP) // CALL AX c.Quack() } } 

直接運行下面的命令,使用 1 個 CPU 運行上述代碼,其中的每個基準測試都會被執行 3 次:

$ go test -gcflags=-N -benchmem -test.count=3 -test.cpu=1 -test.benchtime=1s -bench=. goos: darwin goarch: amd64 pkg: github.com/golang/playground BenchmarkDirectCall 500000000 3.11 ns/op 0 B/op 0 allocs/op BenchmarkDirectCall 500000000 2.94 ns/op 0 B/op 0 allocs/op BenchmarkDirectCall 500000000 3.04 ns/op 0 B/op 0 allocs/op BenchmarkDynamicDispatch 500000000 3.40 ns/op 0 B/op 0 allocs/op BenchmarkDynamicDispatch 500000000 3.79 ns/op 0 B/op 0 allocs/op BenchmarkDynamicDispatch 500000000 3.55 ns/op 0 B/op 0 allocs/op 

若是是直接調用結構體的方法,三次基準測試的平均值其實在 ~3.03ns 左右(關閉編譯器優化),而使用動態派發的方式會消耗 ~3.58ns,動態派發生成的指令會帶來 ~18% 左右的額外性能開銷。

這些性能開銷在一個複雜的系統中其實不會帶來太多的性能影響,由於一個項目中不可能只存在動態派發的調用,因此 ~18% 的額外開銷相比使用接口帶來的好處其實沒有太大的影響,除此以外若是咱們開啓默認的編譯器優化以後,動態派發的額外開銷會下降至 ~5% 左右,對應用性能的總體影響就更小了。

上面的性能測試實際上是在實現和調用接口方法的都是結構體指針,當咱們將結構體指針換成結構體又會有比較大的差別:

//go:noinline func (c Cat) Quack() string { return c.Name } func BenchmarkDirectCall(b *testing.B) { c := Cat{Name: "grooming"} for n := 0; n < b.N; n++ { // MOVQ AX, (SP) // MOVQ $8, 8(SP) // CALL "".Cat.Quack(SB) c.Quack() } } func BenchmarkDynamicDispatch(b *testing.B) { c := Duck(Cat{Name: "grooming"}) for n := 0; n < b.N; n++ { // MOVQ 16(SP), AX // MOVQ 24(SP), CX // MOVQ AX, "".d+32(SP) // MOVQ CX, "".d+40(SP) // MOVQ "".d+32(SP), AX // MOVQ 24(AX), AX // MOVQ "".d+40(SP), CX // MOVQ CX, (SP) // CALL AX c.Quack() } } 

當咱們從新執行相同的命令時,能獲得以下所示的結果:

$ go test -gcflags=-N -benchmem -test.count=3 -test.cpu=1 -test.benchtime=1s . goos: darwin goarch: amd64 pkg: github.com/golang/playground BenchmarkDirectCall 500000000 3.15 ns/op 0 B/op 0 allocs/op BenchmarkDirectCall 500000000 3.02 ns/op 0 B/op 0 allocs/op BenchmarkDirectCall 500000000 3.09 ns/op 0 B/op 0 allocs/op BenchmarkDynamicDispatch 200000000 6.92 ns/op 0 B/op 0 allocs/op BenchmarkDynamicDispatch 200000000 6.91 ns/op 0 B/op 0 allocs/op BenchmarkDynamicDispatch 200000000 7.10 ns/op 0 B/op 0 allocs/op 

直接調用方法須要消耗時間的平均值和使用指針實現接口時差很少,大概在 ~3.09ns 左右,而使用動態派發調用方法卻須要 ~6.98ns 相比直接調用額外消耗了 ~125% 的時間,同時從生成的彙編指令咱們也能看出後者的額外開銷會高不少。

  直接調用 動態派發
Pointer ~3.03ns ~3.58ns
Struct ~3.09ns ~6.98ns

最後咱們從新看一下調用和實現方式的差別組成的耗時矩陣,從這個矩陣咱們能夠看到使用結構體來實現接口帶來的開銷會大於使用指針實現,而動態派發在結構體上的表現很是差,這也是咱們在使用接口時應當儘可能避免的 — 不要使用結構體類型實現接口。

這其實不僅是接口的問題,因爲 Go 語言的函數調用是傳值的,因此會發生參數的拷貝,對於一個大的結構體,參數的拷貝會消耗很是多的資源,咱們應該使用指針來傳遞一些大的結構。

總結

從新回顧一下這一節介紹的內容,咱們在開頭簡單介紹了不一樣編程語言接口實現上的區別以及在使用時的一些常見問題,例如使用不一樣類型實現接口帶來的差別、函數調用時發生的隱式類型轉換,隨後咱們介紹了接口的基本原理、類型斷言和轉換的過程以及接口相關方法調用時的動態派發機制,這對咱們理解 Go 語言的內部實現有着很是大的幫助。

相關文章
相關標籤/搜索