淺入淺出 Go 語言介面的原理
介面是 Go 語言的重要組成部分,它在 Go 語言中通過一組方法指定了一個物件的行為,介面 interface
的引入能夠讓我們在 Go 語言更好地組織並寫出易於測試的程式碼。然而很多使用 Go 語言的工程師其實對介面的瞭解都非常有限,對於它的底層實現也一無所知,這其實成為了我們使用和理解 interface
的最大阻礙。
在這一節中,我們就會介紹 Go 語言中這個重要型別 interface
的一些常見問題以及它底層的實現,包括介面的基本原理、型別斷言和轉換的過程以及動態派發機制,幫助各位 Go 語言開發者更好地理解 interface
型別。
介面是計算機系統中多個元件共享的邊界,通過定義介面,具體的實現可以和呼叫方完全分離,其本質就是引入一箇中間層對不同的模組進行解耦,上層的模組就不需要依賴某一個具體的實現,而是隻需要依賴一個定義好的介面,這種面向介面的程式設計方式有著非常強大的生命力,無論是從框架還是作業系統中我們都能夠看到使用介面帶來的便利。
POSIX(可移植作業系統介面)就是一個典型的例子,它定義了應用程式介面和命令列等標準,為計算機軟體帶來了可移植性 — 只要作業系統實現了 POSIX,沒有使用作業系統或者 CPU 架構特定功能的計算機軟體就可以無需修改在不同作業系統上執行。
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 { Codeint64 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 語言會編譯期間 對上述程式碼進行型別檢查,這裡總共觸發了三次型別檢查:
- 將
*RPCError
型別的變數賦值給error
型別的變數rpcErr
; - 將
*RPCError
型別的變數rpcErr
傳遞給簽名中引數型別為error
的AsErr
函式; - 將
*RPCError
型別的變數從函式簽名的返回值型別為error
的NewRPCError
函式中返回;
從編譯器型別檢查的過程來看,編譯器僅在需要時才會對型別進行檢查,型別實現介面時其實也只需要隱式的實現介面中的全部方法,不需要像 Java 等程式語言中一樣顯式宣告。
介面也是 Go 語言中的一種型別,它能夠出現在變數的定義、函式的入參和返回值中並對它們進行約束,不過 Go 語言中其實有兩種略微不同的介面,其中一種是帶有一組方法的介面,另一種是不帶有任何方法的 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 語言是一個有指標型別的程式語言,當指標和介面同時出現時就會遇到一些讓人困惑或者感到詭異的問題,介面在定義一組方法時其實沒有對實現的接受者做限制,所以我們其實會在一個型別上看到以下兩種不同的實現方式:
這兩種不同的實現不可以同時存在,Go 語言的編譯器會在遇到這種情況時報錯 method redeclared
。
對於 Cat
結構體來說,它不僅在實現時可以選擇將接受者的型別 — 結構體和結構體指標,在初始化時也可以初始化成結構體或者指標:
我們會在這時得到兩個不同維度的『編碼方式』,實現介面的接受者型別和初始化時返回的型別,這兩個維度總共會產生如下的四種不同情況:
在這四種不同情況中,只有一種會發生編譯不通過的問題,也就是方法接受者是指標型別,變數初始化成結構體型別,其他的三種情況都可以正常通過編譯,下面兩種情況能夠通過編譯其實非常好理解:
- 方法接受者和初始化型別都是結構體;
- 方法接受者和初始化型別都是結構體指標;
而剩下的兩種方式為什麼一種能夠通過編譯,另一種無法通過編譯呢?我們先來看一下能夠通過編譯的情況,也就是方法的接受者是結構體,而初始化的變數是指標型別:
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 語言在進行引數傳遞 時都是值傳遞的。
當代碼中的變數是 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 dataunsafe.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 hashuint32 // 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 { sizeuintptr ptrdatauintptr // size of memory prefix holding all pointers hashuint32 tflagtflag alignuint8 fieldalign uint8 kinduint8 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 strnameOff 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
的過程:
LEAQtype."".Cat(SB), AX MOVQAX, (SP) CALLruntime.newobject(SB) MOVQ8(SP), DI MOVQ$8, 8(DI) LEAQgo.string."grooming"(SB), AX MOVQAX, (DI) LEAQgo.itab.*"".Cat,"".Duck(SB), AX TESTBAL, (AX) MOVQDI, (SP)
這段程式碼的第一部分其實就是對 Cat
結構體的初始化,我們直接展示上述組合語言對應的虛擬碼,幫助我們更快地理解這個過程:
LEAQtype."".Cat(SB), AX;; AX = &type."".Cat MOVQAX, (SP);; SP = &type."".Cat CALLruntime.newobject(SB);; SP + 8 = &Cat{} MOVQ8(SP), DI;; DI = &Cat{} MOVQ$8, 8(DI);; StringHeader(DI.Name).Len = 8 LEAQgo.string."grooming"(SB), AX;; AX = &"grooming" MOVQAX, (DI);; StringHeader(DI.Name).Data = &"grooming"
- 獲取
Cat
結構體型別指標並將其作為引數放到棧SP
上; - 通過
CALL
指定呼叫runtime.newobject
函式,這個函式會以Cat
結構體型別指標作為入參,分配一片新的記憶體空間並將指向這片記憶體空間的指標返回到SP+8
上; -
SP+8
現在儲存了一個指向Cat
結構體的指標,我們將棧上的指標拷貝到暫存器DI
上方便操作; - 由於
Cat
中只包含一個字串型別的Name
變數,所以在這裡會分別將字串地址&"grooming"
和字串長度8
設定到結構體上,最後三行彙編指令的作用就等價於cat.Name = "grooming"
;
字串在執行時的表示其實就是指標加上字串長度,在前面的章節字串 已經介紹過它的底層表示和實現原理,但是我們這裡要看一下初始化之後的 Cat
結構體在記憶體中的表示是什麼樣的:
每一個 Cat
結構體在記憶體中的大小都是 16 位元組,這是因為其中只包含一個字串欄位,而字串在 Go 語言中總共佔 16 位元組,初始化 Cat
結構體之後就進入了將 *Cat
轉換成 Duck
型別的過程了:
LEAQgo.itab.*"".Cat,"".Duck(SB), AX;; AX = *itab(go.itab.*"".Cat,"".Duck) MOVQDI, (SP);; SP = AX CALL"".(*Cat).Quack(SB);; SP.Quack()
Duck
作為一個包含方法的介面,它在底層就會使用 iface
結構體進行表示, iface
結構體包含兩個欄位,其中一個是指向資料的指標,另一個是表示介面和結構體關係的 tab
欄位,我們已經通過上一段程式碼在棧上的 SP+8
初始化了 Cat
結構體指標,這段程式碼其實只是將編譯期間生成的 itab
結構體指標複製到 SP
上:
我們會發現 SP
和 SP+8
總共 16 個位元組共同組成了 iface
結構體,棧上的這個 iface
結構體也就是 Quack
方法的第一個入參。
LEAQtype."".Cat(SB), AX;; AX = &type."".Cat MOVQAX, (SP);; SP = &type."".Cat CALLruntime.newobject(SB);; SP + 8 = &Cat{} MOVQ8(SP), DI;; DI = &Cat{} MOVQ$8, 8(DI);; StringHeader(DI.Name).Len = 8 LEAQgo.string."grooming"(SB), AX;; AX = &"grooming" MOVQAX, (DI);; StringHeader(DI.Name).Data = &"grooming" LEAQgo.itab.*"".Cat,"".Duck(SB), AX;; AX = &(go.itab.*"".Cat,"".Duck) MOVQDI, (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() }
編譯上述的程式碼其實會得到如下所示的彙編指令,需要注意的是為了程式碼更容易理解和分析,這裡的彙編指令依然經過了刪減,不過不會影響具體的執行過程:
XORPSX0, X0 MOVUPSX0, ""..autotmp_1+32(SP) LEAQgo.string."grooming"(SB), AX MOVQAX, ""..autotmp_1+32(SP) MOVQ$8, ""..autotmp_1+40(SP) LEAQgo.itab."".Cat,"".Duck(SB), AX MOVQAX, (SP) LEAQ""..autotmp_1+32(SP), AX MOVQAX, 8(SP) CALLruntime.convT2I(SB) MOVQ16(SP), AX MOVQ24(SP), CX MOVQ24(AX), AX MOVQCX, (SP) CALLAX
如果我們在初始化變數時使用指標型別 &Cat{Name: "grooming"}
也能夠通過編譯,不過生成的彙編程式碼和上一節中的幾乎完全相同,都會通過 runtime.newobject
建立新的 Cat
結構體指標並設定它的變數,在最後也會使用同樣的方式呼叫 Quack
方法,所以這裡也就不做額外的分析了。
我們先來看一下上述彙編程式碼中用於初始化 Cat
結構體的部分:
XORPSX0, X0;; X0 = 0 MOVUPSX0, ""..autotmp_1+32(SP);; StringHeader(SP+32).Data = 0 LEAQgo.string."grooming"(SB), AX;; AX = &"grooming" MOVQAX, ""..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
函式:
LEAQgo.itab."".Cat,"".Duck(SB), AX;; AX = &(go.itab."".Cat,"".Duck) MOVQAX, (SP);; SP = AX LEAQ""..autotmp_1+32(SP), AX;; AX = &(SP+32) = &Cat{Name: "grooming"} MOVQAX, 8(SP);; SP + 8 = AX CALLruntime.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
函式的棧上就會包含以下的資料:
SP
和 SP+8
中儲存的 itab
和 Cat
指標就是 runtime.convT2I
函式的入參,這個函式的返回值位於 SP+16
,是一個佔 16 位元組記憶體空間的 iface
結構體, SP+32
儲存的就是在棧上的 Cat
結構體,它會在 runtime.convT2I
執行的過程中被拷貝到堆上。
在最後,我們會通過以下的操作呼叫 Cat
實現的介面方法 Quack()
:
MOVQ16(SP), AX ;; AX = &(go.itab."".Cat,"".Duck) MOVQ24(SP), CX ;; CX = &Cat{Name: "grooming"} MOVQ24(AX), AX ;; AX = AX.fun[0] = Cat.Quack MOVQCX, (SP);; SP = CX CALLAX;; 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 XORPSX0, X0 00032 MOVUPSX0, ""..autotmp_4+8(SP) 00037 LEAQgo.string."grooming"(SB), AX 00044 MOVQAX, ""..autotmp_4+8(SP) 00049 MOVQ$8, ""..autotmp_4+16(SP) 00058 CMPLgo.itab.*"".Cat,"".Duck+16(SB), $593696792 00068 JEQ80 00070 MOVQ24(SP), BP 00075 ADDQ$32, SP 00079 RET 00080 LEAQ""..autotmp_4+8(SP), AX 00085 MOVQAX, (SP) 00089 CALL"".(*Cat).Quack(SB) 00094 JMP70
我們可以直接跳過初始化 Duck
變數的過程,從 0058
開始分析隨後的彙編指令,需要注意的是 SP+8
~ SP+24
16 個位元組的位置儲存了 Cat
結構體,Go 語言的編譯器做了一些優化,所以我們沒有看到 iface
結構體的構建過程,但是對於這裡要介紹的型別斷言和轉換其實沒有太多的影響:
00058 CMPLgo.itab.*"".Cat,"".Duck+16(SB), $593696792 ;; if (c.tab.hash != 593696792) { 00068 JEQ80;; 00070 MOVQ24(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 MOVQAX, (SP);;SP = AX 00089 CALL"".(*Cat).Quack(SB);;SP.Quack() 00094 JMP70;;... ;;BP = SP+24 ;;SP += 32 ;;return ;; }
switch/case
語句生成的彙編指令會將目標型別的 hash
與介面變數中的 itab.hash
進行比較,如果兩者完全相等就會認為介面變數的具體型別是 Cat
,這時就會進入 0080
所在的分支,開始型別轉換的過程,我們會獲取 SP+8
儲存的 Cat
結構體指標、將其拷貝到 SP
上、呼叫 Quack
方法,最終恢復當前函式的堆疊後返回,不過如果介面中存在的具體型別不是 Cat
,就會直接恢復棧指標並返回到呼叫方。
當我們使用如下所示的程式碼,將 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
結構體究竟是如何初始化的,以及初始化完成後的棧上有哪些資料:
LEAQtype."".Cat(SB), AX MOVQAX, (SP) CALLruntime.newobject(SB);; SP + 8 = new(Cat) MOVQ8(SP), DI;; DI = SP + 8 MOVQDI, ""..autotmp_2+32(SP);; SP + 32 = DI MOVQ$8, 8(DI);; StringHeader(cat).Len = 8 LEAQgo.string."grooming"(SB), AX;; AX = &"grooming" MOVQAX, (DI);; StringHeader(cat).Data = AX MOVQ""..autotmp_2+32(SP), AX;; AX = &Cat{...} MOVQAX, ""..autotmp_1+40(SP);; SP + 40 = &Cat{...} LEAQgo.itab.*"".Cat,"".Duck(SB), CX;; CX = &go.itab.*"".Cat,"".Duck MOVQCX, "".c+48(SP);; iface(c).tab = SP + 48 = CX MOVQAX, "".c+56(SP);; iface(c).data = SP + 56 = AX
這段程式碼的初始化過程其實和上兩節中的初始化過程沒有太多的差別,它先初始化了 Cat
結構體指標,再將 Cat
和 tab
打包成了一個 iface
型別的結構體,我們直接來看初始化過程結束之後的堆疊資料:
SP
是執行時方法 runtime.newobject
的引數,而 SP+8
是該方法的返回值,即指向剛初始化的 Cat
結構體指標, SP+32
、 SP+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 MOVQ24(AX), AX;; AX = iface(c).tab.fun[0] = Cat.Quack MOVQ"".c+56(SP), CX;; CX = iface(c).data MOVQCX, (SP);; SP = CX = &Cat{...} CALLAX;; 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 LEAQgo.itab.*"".Cat,"".Duck(SB), DX;; DX = &&go.itab.*"".Cat,"".Duck CMPQCX, DX;; CMP(CX, DX) JEQ163 JMP201 MOVQAX, ""..autotmp_3+24(SP);; SP+24 = &Cat{...} MOVQAX, (SP);; SP = &Cat{...} CALL"".(*Cat).Quack(SB);; SP.Quack()
這兩行程式碼將 Cat
指標拷貝到了 SP
上並直接呼叫 Quack
方法,對於這一次的方法呼叫,待執行的函式其實在編譯期間就已經確定了,所以執行期間就不需要再動態查詢方法地實現:
MOVQ"".c+48(SP), AX;; AX = iface(c).tab MOVQ24(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++ { // MOVQAX, "".c+24(SP) // MOVQAX, (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 // MOVQ24(AX), AX // MOVQ"".d+64(SP), CX // MOVQCX, (SP) // CALLAX 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 BenchmarkDirectCall5000000003.11 ns/op0 B/op0 allocs/op BenchmarkDirectCall5000000002.94 ns/op0 B/op0 allocs/op BenchmarkDirectCall5000000003.04 ns/op0 B/op0 allocs/op BenchmarkDynamicDispatch 5000000003.40 ns/op0 B/op0 allocs/op BenchmarkDynamicDispatch 5000000003.79 ns/op0 B/op0 allocs/op BenchmarkDynamicDispatch 5000000003.55 ns/op0 B/op0 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++ { // MOVQAX, (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++ { // MOVQ16(SP), AX // MOVQ24(SP), CX // MOVQAX, "".d+32(SP) // MOVQCX, "".d+40(SP) // MOVQ"".d+32(SP), AX // MOVQ24(AX), AX // MOVQ"".d+40(SP), CX // MOVQCX, (SP) // CALLAX 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 BenchmarkDirectCall5000000003.15 ns/op0 B/op0 allocs/op BenchmarkDirectCall5000000003.02 ns/op0 B/op0 allocs/op BenchmarkDirectCall5000000003.09 ns/op0 B/op0 allocs/op BenchmarkDynamicDispatch 2000000006.92 ns/op0 B/op0 allocs/op BenchmarkDynamicDispatch 2000000006.91 ns/op0 B/op0 allocs/op BenchmarkDynamicDispatch 2000000007.10 ns/op0 B/op0 allocs/op
直接呼叫方法需要消耗時間的平均值和使用指標實現介面時差不多,大概在 ~3.09ns
左右,而使用動態派發呼叫方法卻需要 ~6.98ns
相比直接呼叫額外消耗了 ~125%
的時間,同時從生成的彙編指令我們也能看出後者的額外開銷會高很多。
直接呼叫 | 動態派發 | |
---|---|---|
Pointer | ~3.03ns | ~3.58ns |
Struct | ~3.09ns | ~6.98ns |
最後我們重新看一下呼叫和實現方式的差異組成的耗時矩陣,從這個矩陣我們可以看到使用結構體來實現介面帶來的開銷會大於使用指標實現,而動態派發在結構體上的表現非常差,這也是我們在使用介面時應當儘量避免的 — 不要使用結構體型別實現介面。
這其實不只是介面的問題,由於 Go 語言的函式呼叫是傳值的,所以會發生引數的拷貝,對於一個大的結構體,引數的拷貝會消耗非常多的資源,我們應該使用指標來傳遞一些大的結構。
重新回顧一下這一節介紹的內容,我們在開頭簡單介紹了不同程式語言介面實現上的區別以及在使用時的一些常見問題,例如使用不同型別實現介面帶來的差異、函式呼叫時發生的隱式型別轉換,隨後我們介紹了介面的基本原理、型別斷言和轉換的過程以及介面相關方法呼叫時的動態派發機制,這對我們理解 Go 語言的內部實現有著非常大的幫助。
Reference
- How Interfaces Work in Go
- Interfaces and other types · Effective Go
- How to use interfaces in Go
- Go Data Structures: Interfaces
- Duck typing · Wikipedia
- What is POSIX?
- Chapter II: Interfaces
- The Laws of Reflection
關於圖片和轉載
進行許可。 轉載時請註明原文連結,圖片在使用時請保留圖片中的全部內容,可適當縮放並在引用處附上圖片所在的文章連結,圖片使用 Sketch 進行繪製。
微信公眾號
