golang拾遺:指標和介面

apocelipes發表於2020-10-11

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

指標和介面

golang的型別系統其實很有意思,有意思的地方就在於型別系統表面上看起來眾生平等,然而實際上卻要分成普通型別(types)和介面(interfaces)來看待。普通型別也包含了所謂的引用型別,例如slicemap,雖然他們和interface同為引用型別,但是行為更趨近於普通的內建型別和自定義型別,因此只有特立獨行的interface會被單獨歸類。

那我們是依據什麼把golang的型別分成兩類的呢?其實很簡單,看型別能不能在編譯期就確定以及呼叫的型別方法是否能在編譯期被確定。

如果覺得上面的解釋太過抽象的可以先看一下下面的例子:

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工具檢查一下:

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檢查一下:

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。

相關文章