Golang拾遺之指針和接口的使用詳解
指針和接口
golang的類型系統(tǒng)其實(shí)很有意思,有意思的地方就在于類型系統(tǒng)表面上看起來眾生平等,然而實(shí)際上卻要分成普通類型(types)和接口(interfaces)來看待。普通類型也包含了所謂的引用類型,例如slice和map,雖然他們和interface同為引用類型,但是行為更趨近于普通的內(nèi)置類型和自定義類型,因此只有特立獨(dú)行的interface會被單獨(dú)歸類。
那我們是依據(jù)什么把golang的類型分成兩類的呢?其實(shí)很簡單,看類型能不能在編譯期就確定以及調(diào)用的類型方法是否能在編譯期被確定。
如果覺得上面的解釋太過抽象的可以先看一下下面的例子:
package main import "fmt" func main(){ m := make(map[int]int) m[1] = 1 * 2 m[2] = 2 * 2 fmt.Println(m) m2 := make(map[string]int) m2["python"] = 1 m2["golang"] = 2 fmt.Println(m2) }
首先我們來看非interface的引用類型,m和m2明顯是兩個不同的類型,不過實(shí)際上在底層他們是一樣的,不信我們用objdump工具檢查一下:
go tool objdump -s 'main\.main' a
TEXT main.main(SB) /tmp/a.go
a.go:6 CALL runtime.makemap_small(SB) # m := make(map[int]int)
...
a.go:7 CALL runtime.mapassign_fast64(SB) # m[1] = 1 * 2
...
a.go:8 CALL runtime.mapassign_fast64(SB) # m[2] = 2 * 2
...
...
a.go:10 CALL runtime.makemap_small(SB) # m2 := make(map[string]int)
...
a.go:11 CALL runtime.mapassign_faststr(SB) # m2["python"] = 1
...
a.go:12 CALL runtime.mapassign_faststr(SB) # m2["golang"] = 2
省略了一些寄存器的操作和無關(guān)函數(shù)的調(diào)用,順便加上了對應(yīng)的代碼的原文,我們可以清晰地看到盡管類型不同,但map調(diào)用的方法都是相同的而且是編譯期就已經(jīng)確定的。如果是自定義類型呢?
package main import "fmt" type Person struct { name string age int } func (p *Person) sayHello() { fmt.Printf("Hello, I'm %v, %v year(s) old\n", p.name, p.age) } func main(){ p := Person{ name: "apocelipes", age: 100, } p.sayHello() }
這次我們創(chuàng)建了一個擁有自定義字段和方法的自定義類型,下面再用objdump檢查一下:
go tool objdump -s 'main\.main' b
TEXT main.main(SB) /tmp/b.go
...
b.go:19 CALL main.(*Person).sayHello(SB)
...
用字面量創(chuàng)建對象和初始化調(diào)用堆棧的匯編代碼不是重點(diǎn),重點(diǎn)在于那句CALL,我們可以看到自定義類型的方法也是在編譯期就確定了的。
那反過來看看interface會有什么區(qū)別:
package main import "fmt" type Worker interface { Work() } type Typist struct{} func (*Typist)Work() { fmt.Println("Typing...") } type Programer struct{} func (*Programer)Work() { fmt.Println("Programming...") } func main(){ var w Worker = &Typist{} w.Work() w = &Programer{} w.Work() }
注意!編譯這個程序需要禁止編譯器進(jìn)行優(yōu)化,否則編譯器會把接口的方法查找直接優(yōu)化為特定類型的方法調(diào)用:
go build -gcflags "-N -l" c.go
go tool objdump -S -s 'main\.main' c
TEXT main.main(SB) /tmp/c.go
...
var w Worker = &Typist{}
LEAQ runtime.zerobase(SB), AX
MOVQ AX, 0x10(SP)
MOVQ AX, 0x20(SP)
LEAQ go.itab.*main.Typist,main.Worker(SB), CX
MOVQ CX, 0x28(SP)
MOVQ AX, 0x30(SP)
w.Work()
MOVQ 0x28(SP), AX
TESTB AL, 0(AX)
MOVQ 0x18(AX), AX
MOVQ 0x30(SP), CX
MOVQ CX, 0(SP)
CALL AX
w = &Programer{}
LEAQ runtime.zerobase(SB), AX
MOVQ AX, 0x8(SP)
MOVQ AX, 0x18(SP)
LEAQ go.itab.*main.Programer,main.Worker(SB), CX
MOVQ CX, 0x28(SP)
MOVQ AX, 0x30(SP)
w.Work()
MOVQ 0x28(SP), AX
TESTB AL, 0(AX)
MOVQ 0x18(AX), AX
MOVQ 0x30(SP), CX
MOVQ CX, 0(SP)
CALL AX
...
這次我們可以看到調(diào)用接口的方法會去在runtime進(jìn)行查找,隨后CALL找到的地址,而不是像之前那樣在編譯期就能找到對應(yīng)的函數(shù)直接調(diào)用。這就是interface為什么特殊的原因:interface是動態(tài)變化的類型。
可以動態(tài)變化的類型最顯而易見的好處是給予程序高度的靈活性,但靈活性是要付出代價的,主要在兩方面。
一是性能代價。動態(tài)的方法查找總是要比編譯期就能確定的方法調(diào)用多花費(fèi)幾條匯編指令(mov和lea通常都是會產(chǎn)生實(shí)際指令的),數(shù)量累計(jì)后就會產(chǎn)生性能影響。不過好消息是通常編譯器對我們的代碼進(jìn)行了優(yōu)化,例如c.go中如果我們不關(guān)閉編譯器的優(yōu)化,那么編譯器會在編譯期間就替我們完成方法的查找,實(shí)際生產(chǎn)的代碼里不會有動態(tài)查找的內(nèi)容。然而壞消息是這種優(yōu)化需要編譯器可以在編譯期確定接口引用數(shù)據(jù)的實(shí)際類型,考慮如下代碼:
type Worker interface { Work() } for _, v := workers { v.Work() }
因?yàn)橹灰獙?shí)現(xiàn)了Worker接口的類型就可以把自己的實(shí)例塞進(jìn)workers切片里,所以編譯器不能確定v引用的數(shù)據(jù)的類型,優(yōu)化自然也無從談起了。
而另一個代價,確切地說其實(shí)應(yīng)該叫陷阱,就是接下來我們要探討的主題了。
golang的指針
指針也是一個極有探討價值的話題,特別是指針在reflect以及runtime包里的各種黑科技。不過放輕松,今天我們只用了解下指針的自動解引用。
我們把b.go里的代碼改動一行:
p := &Person{ name: "apocelipes", age: 100, }
p現(xiàn)在是個指針,其余代碼不需要任何改動,程序依舊可以正常編譯執(zhí)行。對應(yīng)的匯編是這樣的畫風(fēng)(當(dāng)然得關(guān)閉優(yōu)化):
p.sayHello() MOVQ AX, 0(SP) CALL main.(*Person).sayHello(SB)
對比一下非指針版本:
p.sayHello() LEAQ 0x8(SP), AX MOVQ AX, 0(SP) CALL main.(*Person).sayHello(SB)
與其說是指針自動解引用,倒不如說是非指針版本先求出了對象的實(shí)際地址,隨后傳入了這個地址作為方法的接收器調(diào)用了方法。這也沒什么好奇怪的,因?yàn)槲覀兊姆椒ㄊ侵羔樈邮掌鳎篜。
如果把接收器換成值類型接收器:
p.sayHello() TESTB AL, 0(AX) MOVQ 0x40(SP), AX MOVQ 0x48(SP), CX MOVQ 0x50(SP), DX MOVQ AX, 0x28(SP) MOVQ CX, 0x30(SP) MOVQ DX, 0x38(SP) MOVQ AX, 0(SP) MOVQ CX, 0x8(SP) MOVQ DX, 0x10(SP) CALL main.Person.sayHello(SB)
作為對比:
p.sayHello() MOVQ AX, 0(SP) MOVQ $0xa, 0x8(SP) MOVQ $0x64, 0x10(SP) CALL main.Person.sayHello(SB)
這時候golang就是先檢查指針隨后解引用了。同時要注意,這里的方法調(diào)用是已經(jīng)在編譯期確定了的。
指向interface的指針
鋪墊了這么久,終于該進(jìn)入正題了。不過在此之前還有一點(diǎn)小小的預(yù)備知識需要提一下:
A pointer type denotes the set of all pointers to variables of a given type, called the base type of the pointer. --- go language spec
換而言之,只要是能取地址的類型就有對應(yīng)的指針類型,比較巧的是在golang里引用類型是可以取地址的,包括interface。
有了這些鋪墊,現(xiàn)在我們可以看一下我們的說唱歌手程序了:
package main import "fmt" type Rapper interface { Rap() string } type Dean struct {} func (_ Dean) Rap() string { return "Im a rapper" } func doRap(p *Rapper) { fmt.Println(p.Rap()) } func main(){ i := new(Rapper) *i = Dean{} fmt.Println(i.Rap()) doRap(i) }
問題來了,小青年Dean能圓自己的說唱夢么?
很遺憾,編譯器給出了反對意見:
# command-line-arguments
./rapper.go:16:18: p.Rap undefined (type *Rapper is pointer to interface, not interface)
./rapper.go:22:18: i.Rap undefined (type *Rapper is pointer to interface, not interface)
也許type *XXX is pointer to interface, not interface這個錯誤你并不陌生,你曾經(jīng)也犯過用指針指向interface的錯誤,經(jīng)過一番搜索后你找到了一篇教程,或者是博客,有或者是隨便什么地方的資料,他們都會告訴你不應(yīng)該用指針去指向接口,接口本身是引用類型無需再用指針去引用。
其實(shí)他們只說對了一半,事實(shí)上只要把i和p改成接口類型就可以正常編譯運(yùn)行了。沒說對的一半是指針可以指向接口,也可以使用接口的方法,但是要繞些彎路(當(dāng)然,用指針引用接口通常是多此一舉,所以聽從經(jīng)驗(yàn)之談也沒什么不好的):
func doRap(p *Rapper) { fmt.Println((*p).Rap()) } func main(){ i := new(Rapper) *i = Dean{} fmt.Println((*i).Rap()) doRap(i) }
go run rapper.go
Im a rapper
Im a rapper
神奇的一幕出現(xiàn)了,程序不僅沒報(bào)錯而且運(yùn)行得很正常。但是這和golang對指針的自動解引用有什么區(qū)別呢?明明看起來都一樣但就是第一種方案會報(bào)
找不到Rap方法?
為了方便觀察,我們把調(diào)用語句單獨(dú)抽出來,然后查看未優(yōu)化過的匯編碼:
s := (*p).Rap()
0x498ee1 488b842488000000 MOVQ 0x88(SP), AX
0x498ee9 8400 TESTB AL, 0(AX)
0x498eeb 488b08 MOVQ 0(AX), CX
0x498eee 8401 TESTB AL, 0(CX)
0x498ef0 488b4008 MOVQ 0x8(AX), AX
0x498ef4 488b4918 MOVQ 0x18(CX), CX
0x498ef8 48890424 MOVQ AX, 0(SP)
0x498efc ffd1 CALL CX
拋開手工解引用的部分,后6行其實(shí)和直接使用interface進(jìn)行動態(tài)查詢是一樣的。真正的問題其實(shí)出在自動解引用上:
p.sayHello() TESTB AL, 0(AX) MOVQ 0x40(SP), AX MOVQ 0x48(SP), CX MOVQ 0x50(SP), DX MOVQ AX, 0x28(SP) MOVQ CX, 0x30(SP) MOVQ DX, 0x38(SP) MOVQ AX, 0(SP) MOVQ CX, 0x8(SP) MOVQ DX, 0x10(SP) CALL main.Person.sayHello(SB)
不同之處就在于這個CALL上,自動解引用時的CALL其實(shí)是把指針指向的內(nèi)容視作_普通類型_,因此會去靜態(tài)查找方法進(jìn)行調(diào)用,而指向的內(nèi)容是interface的時候,編譯器會去interface本身的數(shù)據(jù)結(jié)構(gòu)上去查找有沒有Rap這個方法,答案顯然是沒有,所以爆了p.Rap undefined錯誤。
那么interface的真實(shí)長相是什么呢,我們看看go1.15.2的實(shí)現(xiàn):
// src/runtime/runtime2.go // 因?yàn)檫@邊沒使用空接口,所以只節(jié)選了含數(shù)據(jù)接口的實(shí)現(xiàn) type iface struct { tab *itab data unsafe.Pointer } // src/runtime/runtime2.go type itab struct { 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. } // src/runtime/type.go type imethod struct { name nameOff ityp typeOff } type interfacetype struct { typ _type pkgpath name mhdr []imethod // 類型所包含的全部方法 } // src/runtime/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 // function for comparing objects of this type // (ptr to object A, ptr to object B) -> ==? equal func(unsafe.Pointer, unsafe.Pointer) bool // 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 }
沒有給出定義的類型都是對各種整數(shù)類型的typing alias。interface實(shí)際上就是存儲類型信息和實(shí)際數(shù)據(jù)的struct,自動解引用后編譯器是直接查看內(nèi)存內(nèi)容的(見匯編),這時看到的其實(shí)是iface這個普通類型,所以靜態(tài)查找一個不存在的方法就失敗了。而為什么手動解引用的代碼可以運(yùn)行?因?yàn)槲覀兪謩咏庖煤缶幾g器可以推導(dǎo)出實(shí)際類型是interface,這時候編譯器就很自然地用處理interface的方法去處理它而不是直接把內(nèi)存里的東西尋址后塞進(jìn)寄存器。
總結(jié)
其實(shí)也沒什么好總結(jié)的。只有兩點(diǎn)需要記住,一是interface是有自己對應(yīng)的實(shí)體數(shù)據(jù)結(jié)構(gòu)的,二是盡量不要用指針去指向interface,因?yàn)間olang對指針自動解引用的處理會帶來陷阱。
如果你對interface的實(shí)現(xiàn)很感興趣的話,這里有個reflect+暴力窮舉實(shí)現(xiàn)的乞丐版。
到此這篇關(guān)于Golang拾遺之指針和接口的使用詳解的文章就介紹到這了,更多相關(guān)Golang指針 接口內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
一文帶你深入了解Golang中的參數(shù)傳遞機(jī)制
值傳遞和引用傳遞是編程語言中兩種主要的參數(shù)傳遞方式,決定了函數(shù)調(diào)用過程中實(shí)參如何影響形參以及函數(shù)內(nèi)部對形參的修改是否會影響到原始實(shí)參,下面就跟隨小編一起深入了解下golang中參數(shù)傳遞機(jī)制吧2024-01-01Go語言實(shí)現(xiàn)的web爬蟲實(shí)例
這篇文章主要介紹了Go語言實(shí)現(xiàn)的web爬蟲,實(shí)例分析了web爬蟲的原理與Go語言的實(shí)現(xiàn)技巧,具有一定參考借鑒價值,需要的朋友可以參考下2015-02-02詳解Go語言中new和make關(guān)鍵字的區(qū)別
本篇文章來介紹一道非常常見的面試題,到底有多常見呢?可能很多面試的開場白就是由此開始的。那就是 new 和 make 這兩個內(nèi)置函數(shù)的區(qū)別,希望對大家有所幫助2023-03-03