這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
Go介面的設計和實現是Go整個類型系統的一大特點。介面組合和嵌入、duck typing等實現了優雅的代碼複用、解耦、模組化的特性,而且介面是方法動態指派、反射的實現基礎(當然更基礎的是編譯期為運行時提供的類型資訊)。理解了介面的實現之後,就不難理解"著名"的nil傳回值問題以及反射、type switch、type assertion等原理。本文主要基於Go1.8.1的源碼介紹介面的內部實現及其使用相關的問題。
1. 介面的實現
(1) 下面是介面在runtime中的實現,注意其中包含了介面本身和實際資料類型的類型資訊:
// src/runtime/runtime2.gotype iface struct { // 包含介面的靜態類型資訊、資料的動態類型資訊、函數表 tab *itab // 指向具體資料的記憶體位址比如slice、map等,或者在介面 // 轉換時直接存放小資料(一個指標的長度) data unsafe.Pointer}type itab struct { // 介面的類型資訊 inter *interfacetype // 具體資料的類型資訊 _type *_type link *itab hash uint32 bad bool inhash bool unused [2]byte // 函數地址表,這裡放置和介面方法對應的具體資料類型的方法地址 // 實現介面調用方法的動態指派,一般在每次給介面賦值發生轉換時 // 會更新此表,或者直接拿緩衝的itab fun [1]uintptr // variable sized}
(2) 另外,需要注意與介面相關的兩點最佳化,會影響到反射等的實現:
當將某個類型賦值給空介面時,由於空介面沒有方法,所以空介面eface的tab會直接指向資料的具體類型。在Go的reflect包中,reflect.TypeOf和reflect.ValueOf的參數都是空介面,因此所有參數都會先轉換為空白介面類型。這樣反射就實現了對所有參數類型擷取實際資料類型的統一。這在後面反射的基本實現中會分析到。
當被轉換為介面的資料的類型長度不超過一個指標的長度時(比如pointer、map、func、chan、[1]int等類型),介面轉換時會將資料直接拷貝存放到介面的data欄位中(DirectIface),而不再額外分配記憶體並拷貝。另外,從go1.8+的源碼來看除了DirectIface的最佳化以外,還對長度較小(不超過64位元組,未初始化資料記憶體的array,Null 字元串等)的零值做了最佳化,也不會重新分配記憶體,而是直接指向一個包級全域陣列變數zeroVal的首地址。注意這裡的最佳化發生在介面轉換時產生的臨時介面上,而不是被賦值的介面左值上。
(3) 再者,在Go中只有值傳遞(包括介面類型),與具體的類型實現無關,但是某些類型具有引用的屬性。典型的9種非基礎類型中:
- array傳遞會拷貝整塊資料記憶體,傳遞長度為len(arr) * Sizeof(elem)
- string、slice、interface傳遞的是其runtime的實現,所以長度是固定的,分別為16、24、16位元組(amd64)
- map、func、chan、pointer傳遞的是指標,所以長度固定為8位元組(amd64)
- struct傳遞的是所有欄位的記憶體拷貝,所以長度是所有欄位的長度和
- 詳細的測試可以參考[這段程式](pass_by_value_main.go)
2. runtime中介面的轉換操作
介面相關的操作主要在於對其內部欄位itab的操作,因為介面轉換最重要的是類型資訊。這裡簡單分析幾個runtime中相關的函數。主要實現在`src/runtime/iface.go`中。值得注意的是,介面的類型轉換在編譯期會產生一個函數調用的文法樹節點(OCALL),調用runtime提供的相應介面轉換函式完成介面的類型設定,所以介面的轉換是在運行時發生的,其具體類型的方法地址表也是在運行時填寫的,這一點和C++的虛函數表不太一樣。另外,由於在運行時轉換會產生開銷,所以對轉換的itab做了緩衝。
type MyReader struct {}func (r MyReader) Read(b []byte) (n int, err error) {}// 介面的相關轉換編譯成對相關runtime函數的調用,比如convI2I/assertI2I等var i io.Reader = MyReader{}realReader := i.(MyReader)var ei interface{} = interface{}(realReader)
下面以convI2I為例來說明,編譯時間產生OCALL文法樹節點的過程。
// src/cmd/compile/internal/gc/walk.gofunc convFuncName(from, to *types.Type) string {tkind := to.Tie()switch from.Tie() { // 將介面轉換為另一介面,返回需要在runtime中調用的函數名case 'I':switch tkind {case 'I':return "convI2I"}case 'T':/* ... */}// src/cmd/compile/internal/gc/walk.go// 這裡只給出節點操作類型為OCONVIFACE(即inerface轉換)的處理邏輯func walkexpr(n *Node, init *Nodes) *Node { case OCONVIFACE: n.Left = walkexpr(n.Left, init) /* 這裡省略了很多特殊的處理邏輯,比如空介面相關的最佳化 */ // 到這裡開始進入一般的介面轉換 // 尋找需要調用的runtime的函數,在Runtimepkg中尋找 fn := syslook(convFuncName(n.Left.Type, n.Type))fn = substArgTypes(fn, n.Left.Type, n.Type)dowidth(fn.Type) // 產生函數調用節點n = nod(OCALL, fn, nil)n.List.Set(ll)n = typecheck(n, Erv)n = walkexpr(n, init)}
一旦itab的函數表設定後,後面的介面的方法調用只需要一次間接調用的開銷,不需要反覆尋找方法的地址。關於介面的實現,Russ Cox寫過一篇很好的文章。
下面分析runtime中介面相關的幾個主要函數:
// 根據介面類型和實際資料類型產生itabfunc getitab(inter *interfacetype, typ *_type, canfail bool) *itab { // 先從緩衝中找 h := itabhash(inter, typ) // look twice - once without lock, once with. // common case will be no lock contention. var m *itab var locked int for locked = 0; locked < 2; locked++ { if locked != 0 { lock(&ifaceLock) } for m = (*itab)(atomic.Loadp(unsafe.Pointer(&hash[h]))); m != nil; m = m.link { // 找到 if m.inter == inter && m._type == typ { if m.bad { if !canfail { // 檢查並Binder 方法地址表 additab(m, locked != 0, false) } m = nil } if locked != 0 { unlock(&ifaceLock) } return m } } } // 緩衝中沒找到則分配itab的記憶體: itab結構本身記憶體 + 末尾存方法地址表的可變長度 m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*sys.PtrSize, 0, &memstats.other_sys)) m.inter = inter // 設定介面類型資訊 m._type = typ // 設定實際資料類型資訊 additab(m, true, canfail) // 設定itab函數調用表 unlock(&ifaceLock) if m.bad { return nil } return m}
// 檢查具體類型是否實現了介面規定的方法,並使用具體類型的方法// 地址填充方法表。func additab(m *itab, locked, canfail bool) { inter := m.inter typ := m._type x := typ.uncommon() ni := len(inter.mhdr) // 介面方法數量 nt := int(x.mcount) // 實際資料類型方法數量 xmhdr := (*[1 << 16]method)(add(unsafe.Pointer(x), uintptr(x.moff)))[:nt:nt] j := 0 for k := 0; k < ni; k++ { // 對每個介面方法的地址 i := &inter.mhdr[k] // 使用介面的類型資訊擷取實際類型, 函數名字,包名字 itype := inter.typ.typeOff(i.ityp) name := inter.typ.nameOff(i.name) iname := name.name() ipkg := name.pkgPath() if ipkg == "" { ipkg = inter.pkgpath.name() } for ; j < nt; j++ { // 對每個具體類型的方法 t := &xmhdr[j] tname := typ.nameOff(t.name) // 具體類型的方法類型和介面方法的類型相同,並且名字相同,則匹配成功 if typ.typeOff(t.mtyp) == itype && tname.name() == iname { pkgPath := tname.pkgPath() if pkgPath == "" { pkgPath = typ.nameOff(x.pkgpath).name() } if tname.isExported() || pkgPath == ipkg { if m != nil { // 具體類型的某個方法地址 ifn := typ.textOff(t.ifn) // 填充itab的func表地址 *(*unsafe.Pointer)(add(unsafe.Pointer(&m.fun[0]), uintptr(k)*sys.PtrSize)) = ifn } goto nextimethod } } } // didn't find method // 不匹配panic if !canfail { if locked { unlock(&ifaceLock) } panic(&TypeAssertionError{"", typ.string(), inter.typ.string(), iname}) } // 或者設定失敗標識 m.bad = true break nextimethod: } if !locked { throw("invalid itab locking") } h := itabhash(inter, typ) m.link = hash[h] m.inhash = true // 存到itab的hash表緩衝 atomicstorep(unsafe.Pointer(&hash[h]), unsafe.Pointer(m))}
// 將已有的介面,轉換為新的介面類型,失敗panic// 比如:// var rc io.ReadCloser// var r io.Reader// rc = io.ReadCloser(r)func convI2I(inter *interfacetype, i iface) (r iface) { tab := i.tab if tab == nil { return } // 介面類型相同直接賦值即可 if tab.inter == inter { r.tab = tab r.data = i.data return } // 否則重建itab r.tab = getitab(inter, tab._type, false) // 注意這裡沒有分配記憶體拷貝資料 r.data = i.data return}
// 使用itab並拷貝資料,得到ifacefunc convT2I(tab *itab, elem unsafe.Pointer) (i iface) { t := tab._type if raceenabled { raceReadObjectPC(t, elem, getcallerpc(unsafe.Pointer(&tab)), funcPC(convT2I)) } if msanenabled { msanread(elem, t.size) } // 注意這裡發生了記憶體配置和資料拷貝 x := mallocgc(t.size, t, true) // memmove內部的拷貝對大塊記憶體做了最佳化 typedmemmove(t, x, elem) i.tab = tab i.data = x return}
從上面convX2I我們可以看到,在介面類型之間轉換時,並沒有分配記憶體和拷貝資料,但是將非介面類型轉換為介面類型時,卻發生了記憶體配置和資料拷貝。這裡的原因是Go介面的資料不能被改變,所以介面之間的轉換可以使用同一塊記憶體,但是其他情況為了避免外部改變導致介面內資料改變,所以會進行記憶體配置和資料拷貝。另外,這也是反射非指標變數時無法直接改變變數資料的原因,因為反射會先將變數轉換為空白介面類型。可以參考go-nuts。這裡我們用一個簡單的程式測試一下。
package mainimport "fmt"type Data struct { n int}func main() { d := Data{10} fmt.Printf("address of d: %p\n", &d) // assign not interface type variable to interface variable // d will be copied var i1 interface{} = d // assign interface type variable to interface variable // the data of i1 will directly assigned to i2.data and will not be copied var i2 interface{} = i1 fmt.Println(d) fmt.Println(i1) fmt.Println(i2)}// 關掉最佳化和inlinego build -gcflags "-N -l" interface.go// 可以看到介面變數i1和i2的資料地址是相同的,但是d和i1的資料地址不相同(gdb) info locals &d = 0xc420074168i2 = {_type = 0x492e00, data = 0xc4200741a0}i1 = {_type = 0x492e00, data = 0xc4200741a0}
3. type assertion與type switch
理解了介面的實現,不難猜測type assertion和type switch的實現邏輯,我們只需要取出介面的動態類型(資料類型)與目標類型做比較即可,而目標類型的資訊在編譯期是可以確定下來的。可以參考Effective Go中的簡單例子。
4. nil介面的問題
具體的代碼可參考nil介面傳回值測試。理解了介面的底層實現,這個問題其實也比較好理解了。需要說明的是nil在Go中既指空值,也指空類型。這裡的空值並非零值,空值是指未初始化,比如slice沒有分配底層的記憶體。只有chan、interface、func、slice、map、pointer可直接與nil比較和用nil賦值。對於非介面類型來說,對其賦值nil的語義是將其資料變為未初始化的狀態,而給介面類型來說,還會將介面的類型資訊欄位itab置nil。所以:
type MyReader interface {}var r MyReader // (nil, nil)var n *int = nilvar r1 MyReader = n // (*int, nil)var r2 MyReader // (nil, nil)var inter interface{} = r2 // (nil, nil)
5. 介面與反射
反射實現的一個基本前提是編譯期為運行時提供足夠的類型資訊,一般來說都會使用一個基本類型(比如Go中的interface、Java中的Object)來存放具體類型的資訊,以便在運行時使用。C++到目前為止也沒有比較成熟的反射庫,大部分原因就是沒有比較好的方法提供運行時所需的類型資訊,typeid等運行時資訊遠遠不夠。Go的反射的實現就是基於interface的。這裡簡單分析兩個常用方法`reflect.TypeOf, reflect.ValueOf`的實現。
// src/reflect/value.go// 注意: 從前面的分析可知當轉換為空白介面的時候,itab指標會直接// 指向資料的實際類型,所以反射的入口函數參數類型是interface{},// 轉換後,emptyInterface的rtype欄位會直接指向資料類型,所以// 整個反射才能直接得到資料類型,不然itab指向記憶體的前面部分包含// 的是介面的靜態類型資訊type emptyInterface struct { typ *rtype word unsafe.Pointer}// src/reflect/type.gofunc TypeOf(i interface{}) Type { // 參數i已經是空介面類型 eface := *(*emptyInterface)(unsafe.Pointer(&i)) return toType(eface.typ)}// src/reflect/value.gofunc ValueOf(i interface{}) Value { if i == nil { return Value{} } escapes(i) return unpackEface(i)}// src/reflect/value.gofunc unpackEface(i interface{}) Value { // 參數i已經是空介面類型 e := (*emptyInterface)(unsafe.Pointer(&i)) t := e.typ if t == nil { return Value{} } f := flag(t.Kind()) if ifaceIndir(t) { f |= flagIndir } return Value{t, e.word, f}}
6. 介面與duck typing
嚴格說來,Go的介面可能並不算真正的duck typing,看一個Python和Go對比的例子。在這個例子中我們並不管傳入的類型是什麼,也不用在乎Say方法返回的類型是什麼。而在Go中,實現介面的Say方法的傳回值類型也必須相同。但是,這兩個例子中都不需要顯式指定實現的介面,這對於代碼的重構極其有利,這也是Go的介面相對於Java等介面的優勢。
# python duck typingdef callSay(a): ret = a.Say() print retclass SayerInt(object): def Say(): return 1class SayerString(object): def Say(): return "string"si = SayerInt()ss = SayerString()callSay(si)callSay(ss)// Gotype Sayer interface { Say() int}func callSay(sayer Sayer) { sayer.Say()}type Say1Struct struct {}func (s Say1Struct) Say() int { return 1}type Say2Struct struct {}func (s Say2Struct) Say() int { return 2}s1 := &Say1Struct{}s2 := &Say2Struct{}callSay(s1)callSay(s2)
7. 總結
綜上,介面在Go的整個類型系統起到重要的作用,而且是反射、方法動態指派、type switch、type assertion等的實現基礎。另外,介面組合和duck typing特性也讓整個類型層次變得更加扁平,寫起來更加簡潔且有利於重構。理解了介面的底層實現,也更容易避免Go使用中的很多問題。