golang拾遺:指針和接口

這是本系列的第一篇文章,golang拾遺主要是用來記錄一些遺忘了的、平時從沒注意過的golang相關知識。想作本系列的契機實際上是由於疫情閒着在家無聊,網上衝浪的時候發現了zhuihu上的go語言愛好者週刊Go 101,讀之如醍醐灌頂,受益不淺,因而本系列的文章就誕生了。拾遺主要是收集和golang相關的瑣碎知識,固然也會對週刊和101的內容作一些補充說明。好了,題外話就此打住,下面該進入今天的正題了。html

指針和接口

golang的類型系統其實頗有意思,有意思的地方就在於類型系統表面上看起來衆平生等,然而實際上卻要分紅普通類型(types)和接口(interfaces)來看待。普通類型也包含了所謂的引用類型,例如slicemap,雖然他們和interface同爲引用類型,可是行爲更趨近於普通的內置類型和自定義類型,所以只有特立獨行的interface會被單獨歸類。python

那咱們是依據什麼把golang的類型分紅兩類的呢?其實很簡單,看類型能不能在編譯期就肯定以及調用的類型方法是否能在編譯期被肯定。golang

若是以爲上面的解釋太過抽象的能夠先看一下下面的例子:api

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的引用類型,mm2明顯是兩個不一樣的類型,不過實際上在底層他們是同樣的,不信咱們用objdump工具檢查一下:bash

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

省略了一些寄存器的操做和無關函數的調用,順便加上了對應的代碼的原文,咱們能夠清晰地看到儘管類型不一樣,但map調用的方法都是相同的並且是編譯期就已經肯定的。若是是自定義類型呢?數據結構

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()
}

此次咱們建立了一個擁有自定義字段和方法的自定義類型,下面再用objdump檢查一下:app

go tool objdump -s 'main\.main' b

TEXT main.main(SB) /tmp/b.go
  ...
  b.go:19   CALL main.(*Person).sayHello(SB)
  ...

用字面量建立對象和初始化調用堆棧的彙編代碼不是重點,重點在於那句CALL,咱們能夠看到自定義類型的方法也是在編譯期就肯定了的。函數

那反過來看看interface會有什麼區別:工具

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()
}

注意!編譯這個程序須要禁止編譯器進行優化,不然編譯器會把接口的方法查找直接優化爲特定類型的方法調用:性能

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
  ...

此次咱們能夠看到調用接口的方法會去在runtime進行查找,隨後CALL找到的地址,而不是像以前那樣在編譯期就能找到對應的函數直接調用。這就是interface爲何特殊的緣由:interface是動態變化的類型。

能夠動態變化的類型最顯而易見的好處是給予程序高度的靈活性,但靈活性是要付出代價的,主要在兩方面。

一是性能代價。動態的方法查找老是要比編譯期就能肯定的方法調用多花費幾條彙編指令(mov和lea一般都是會產生實際指令的),數量累計後就會產生性能影響。不過好消息是一般編譯器對咱們的代碼進行了優化,例如c.go中若是咱們不關閉編譯器的優化,那麼編譯器會在編譯期間就替咱們完成方法的查找,實際生產的代碼裏不會有動態查找的內容。然而壞消息是這種優化須要編譯器能夠在編譯期肯定接口引用數據的實際類型,考慮以下代碼:

type Worker interface {
    Work()
}

for _, v := workers {
    v.Work()
}

由於只要實現了Worker接口的類型就能夠把本身的實例塞進workers切片裏,因此編譯器不能肯定v引用的數據的類型,優化天然也無從談起了。

而另外一個代價,確切地說其實應該叫陷阱,就是接下來咱們要探討的主題了。

golang的指針

指針也是一個極有探討價值的話題,特別是指針在reflect以及runtime包裏的各類黑科技。不過放輕鬆,今天咱們只用瞭解下指針的自動解引用。

咱們把b.go裏的代碼改動一行:

p := &Person{
    name: "apocelipes",
    age: 100,
}

p如今是個指針,其他代碼不須要任何改動,程序依舊能夠正常編譯執行。對應的彙編是這樣的畫風(固然得關閉優化):

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)

與其說是指針自動解引用,倒不如說是非指針版本先求出了對象的實際地址,隨後傳入了這個地址做爲方法的接收器調用了方法。這也沒什麼好奇怪的,由於咱們的方法是指針接收器:P。

若是把接收器換成值類型接收器:

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就是先檢查指針隨後解引用了。同時要注意,這裏的方法調用是已經在編譯期肯定了的。

指向interface的指針

鋪墊了這麼久,終於該進入正題了。不過在此以前還有一點小小的預備知識須要提一下:

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

換而言之,只要是能取地址的類型就有對應的指針類型,比較巧的是在golang裏引用類型是能夠取地址的,包括interface。

有了這些鋪墊,如今咱們能夠看一下咱們的說唱歌手程序了:

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這個錯誤你並不陌生,你曾經也犯過用指針指向interface的錯誤,通過一番搜索後你找到了一篇教程,或者是博客,有或者是隨便什麼地方的資料,他們都會告訴你不該該用指針去指向接口,接口自己是引用類型無需再用指針去引用。

其實他們只說對了一半,事實上只要把i和p改爲接口類型就能夠正常編譯運行了。沒說對的一半是指針能夠指向接口,也可使用接口的方法,可是要繞些彎路(固然,用指針引用接口一般是畫蛇添足,因此遵從經驗之談也沒什麼很差的):

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

神奇的一幕出現了,程序不只沒報錯並且運行得很正常。可是這和golang對指針的自動解引用有什麼區別呢?明明看起來都同樣但就是第一種方案會報
找不到Rap方法?

爲了方便觀察,咱們把調用語句單獨抽出來,而後查看未優化過的彙編碼:

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行其實和直接使用interface進行動態查詢是同樣的。真正的問題其實出在自動解引用上:

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實際上是把指針指向的內容視做_普通類型_,所以會去靜態查找方法進行調用,而指向的內容是interface的時候,編譯器會去interface自己的數據結構上去查找有沒有Rap這個方法,答案顯然是沒有,因此爆了p.Rap undefined錯誤。

那麼interface的真實長相是什麼呢,咱們看看go1.15.2的實現:

// src/runtime/runtime2.go
// 由於這邊沒使用空接口,因此只節選了含數據接口的實現
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
}

沒有給出定義的類型都是對各類整數類型的typing alias。interface實際上就是存儲類型信息和實際數據的struct,自動解引用後編譯器是直接查看內存內容的(見彙編),這時看到的實際上是iface這個普通類型,因此靜態查找一個不存在的方法就失敗了。而爲何手動解引用的代碼能夠運行?由於咱們手動解引用後編譯器能夠推導出實際類型是interface,這時候編譯器就很天然地用處理interface的方法去處理它而不是直接把內存裏的東西尋址後塞進寄存器。

總結

其實也沒什麼好總結的。只有兩點須要記住,一是interface是有本身對應的實體數據結構的,二是儘可能不要用指針去指向interface,由於golang對指針自動解引用的處理會帶來陷阱。

若是你對interface的實現很感興趣的話,這裏有個reflect+暴力窮舉實現的乞丐版

理解了乞丐版的基礎上若是有興趣還能夠看看真正的golang實現,數據的層次結構上更細化,並且有使用指針和內存偏移等的聰明辦法,不說是否會有收穫,起碼研究起來不會無聊:P。

相關文章
相關標籤/搜索