原文 Go Data Structures: Interfaces
作者 Russ Cox
聲明:本文目的僅僅作為個人mark,所以在翻譯的過程中參雜了自己的思想甚至改變了部分內容。但由於譯者水平有限,所寫文字或者代碼可能會誤導讀者,如發現文章有問題,請盡快告知,不勝感激。
一些知識點
- Method Set方法集合,Go中每個類型都有其與之關聯的方法集合,interface類型的方法集合是其接口,除了interface類型的其他類型
T
的方法集合是所有receiver
為T
的所有方法,而類型*T
的方法集合則是receiver
為*T
或者T
的所有方法。 - 方法調用,如果
x
是類型T
的實例,且表達式&x
可以生成一個指向類型*T
的指針,那么:假如*T
的方法集合包含了someMethod
方法而T
沒有,x.someMethod()
是有效的,其本質是(&x).someMethod()
。
正文
Go中的接口是允許我們使用鴨子類型,但他和某些動態語言(比如Python)不同的是:Go編譯時會捕獲那些顯而易見的錯誤,比如當接口中定義了Read()方法時,如果我們傳遞int
類型,或者是即使我們傳遞了一個有Read()方法的類型但參數的數量或者類型和接口中定義的不一致,都會導致報錯。來看一個簡單的接口例子:
type ReadCloser interface {
Read(b []byte) (n int, err os.Error)
Close()
}
然后我們就可以定義一個接收ReadCloser
類型的函數:
// 這個函數先調用Read()方法獲取請求的數據然后調用Close()方法
func ReadAndClose(r ReadCloser, buf []byte) (n int, err os.Error) {
for len(buf) > 0 && err == nil {
var nr int
nr, err = r.Read(buf)
n += nr
buf = buf[nr:]
}
r.Close()
return
}
任何一個實現了ReadCloser
中所定義的方法(不僅僅是方法名相同,方法的參數數量以及對應的類型也要相同,在原文中作者稱之為signatures)的類型都可以傳遞到ReadAndClose
函數中並執行,如果我們在某個地方傳遞了一個int
類型過去,Go在編譯的時候就會報錯,但是像Python則會是在運行時報錯。
同時,接口不僅限於靜態檢查。我們可以動態檢查特定接口值是否具有其他方法。比如:
type Stringer interface {
String() string
}
func ToString(any interface{}) string {
if v, ok := any.(Stringer); ok {
return v.String()
}
switch v := any.(type) {
case int:
return strconv.Itoa(v)
case float:
return strconv.Ftoa(v, 'g', -1)
}
return "???"
}
any
參數被定義為空接口類型,也就是說在其中並沒有限定必須含有哪些方法,更進一步的說:任何類型都可以作為參數傳遞進來。if語句中的comma ok
賦值詢問是否可以將any轉換為具有String
方法的Stringer
類型的接口值。如果是的話,接下來的語句會執行String
方法並返回一個字符串。否則,switch
語句則會在結束前判斷其是否為幾個基本類型然后執行響應的邏輯。
實現一個簡單的例子,有一個新的64位整數類型,他有一個以二進制形式打印值的String
方法,還有一個Get
方法:
type Binary uint64
func (i Binary) String() string {
return strconv.Uitob64(i.Get(), 2)
}
func (i Binary) Get() uint64 {
return uint64(i)
}
Binary
類型的值可以傳遞給ToString
,即使程序從未說明Binary
實現了Stringer
接口,它也會使用String
方法對其進行格式化。因為運行時可以知道Binary
有一個String
方法,所以它實現了Stringer
,即使Binary
的作者從未聽說過Stringer
。
這些示例表明,即使在編譯時檢查了所有隱式轉換,顯式地接口到接口的轉換也可以在運行時查詢方法集。Effective Go中有更多的如何使用接口的例子。
接口值
含有"方法"概念的語言大部分都屬於兩個陣營中的一個(比如:C++ Java),要么是靜態的為所有方法調用預置一個表,要么是調用時再查找(比如:Python)然后將其緩存起來。Go語言則是兩邊都沾一點:雖然他有方法集合表,但這個表是運行時構建。
先來做一個熱身,Binary
是一個由兩個32位"字"組成的64位長的類型
接口類型的值表現為兩個字(假設我們處於32位系統中,那么一個字就是32位,本文中如沒有特別聲明,默認為32位系統),其中第一個字作為指針指向真正的值的元數據(包含類型,方法列表),第二個字作為指針指向真正的值。如下圖所示:s := Stringer(b)
賦值語句會隱式的對這兩個字填充值。
接口的第一個字指向了我比較喜歡用的叫做interface table
或者itable
的東西。itable
開頭是一個存儲了類型相關的元數據信息,接下來就是一個由函數指針組成的列表。注意:itable
和接口類型相對應,而不是和動態類型。就我們的例子而言:Stringer
中的itable
只是為了Stringer
而建立,只關聯了Stringer
中定義的String
方法,而像Binary
中定義的Get
方法則不在其范圍內。
接口的第二個字我稱之為data
,其存儲或者指向了實際的數據,這上面的例子中也就是指向了b
。賦值語句var s Stringer = b
實際上對b做了拷貝,而不是對b進行引用。存儲在接口中的值可能有任意大小,但接口只提供了一個字來專門存儲真實數據,所以賦值語句在堆上分配了一塊內存,並將該字設置為對這塊內存的引用。
ps:++itable
所指向的元數據是可以被同一個類型的不同實例所共享的,而data
則沒法共享。++
如果我們想要知道接口是否內含了一個特定的類型,就像上面代碼中的type swith 那樣,Go編譯器會產生類似於C語言中的s.tab->type
表達式等效的代碼來獲得類型指針然后檢查其是否是我們所期望的類型。如果類型匹配,那么值會通過s.data
解引用copy過去。
如果我們想要調用s.String()
,Go編譯器會產生和C語言中s.tab->fun[0](s.data)
表達式等效的代碼。他會從itable
中找到並調用對應的函數指針,然后將data
中存儲的數據作為第一個參數傳遞過去(僅僅是在本例中)。如果運行 8g -S x.go(在文章末尾有詳解) 就可以看到這個過程。需要注意的是:Go編譯器傳遞到itable
中的是data
中的值(32位)而不是該值所對應的Binary
(64位)。通常負責執行接口調用的組件並不知道這個字表示啥,也不知道這個指針指向了多大的數據,相反的,接口代碼安排itable
中的函數接收接口的data
da這個32位長的指向原始數據的指針的形式來作為參數傳遞。因此,本例中的函數指針是(*Binary).String
而不是Binary.String
。
在本例中,我們僅僅考慮了只有一個方法的接口的情況,而包含多個方法的接口則是在itable
的尾部擁有更長的函數指針列表。
計算itable
現在我們已經知道itable
長啥樣了,但我們還不清楚他們是怎么生成的。Go的動態類型轉換意味着:對於編譯器或者鏈接器來說,因為有太多的接口類型以及具體類型(可以說是除接口類型以外的所有類型),預先計算出所有可能的itable
是不合理的,而且如果這樣做的話很可能絕大多數我們用不到。相反的,Go編譯器為每一個具體類型(Binary, int, func(map[int]string) 等等)生成一個用來描述類型的結構-類型描述結構。在元數據中,類型描述結構包含由該類型所實現的方法列表。相似的,編譯器也會為每一個接口類型(比如說: Stringer)生成一個不同類型的類型描述結構,這個結構里面也包含了一個方法列表。接口運行時通過查找具體類型的方法表,再根據接口類型的方法表中所列出的每個方法來計算itable
。運行時會在計算出itable
后將其緩存起來。所以這樣只需計算一次。
在我們的簡單例子中,Stringer
中的方法表中只有一個方法,而Binary
的方法表中則有兩個方法,通常接口可能會有 ni 個方法,而具體的類型可能會有 nt 個方法,顯然為了找到具體類型方法與接口方法的映射將會需要O(ni * nt)
的時間,但我們可以做一些優化。通過對兩個方法表進行排序並進行同時處理,我們可以用O(ni + nt)
的時間來完成這個映射的構建。
內存優化
我們大體上有兩種方式來進行內存優化。
首先,如果是空接口interface {}
,因為空接口沒有定義任何方法,所以itable
中的方法列表就是一個空的,也就是說其中就僅僅剩下了一個指向原始類型的指針。這種情況下,我們就可以直接丟棄掉itable
然后在第一個字中放一個指向原始類型的指針就可以了。
編譯器根據一個接口是否含有方法,選用不同的接口結構。
然后,如果原始的值可以直接放入字中,也就是說其小於32位,那么我們就不要在堆上申請空間來存儲了,直接把他放到data
中就好了。
data
中是存原始數據的指針還是直接存原始數據取決於原始數據的大小(長度),編譯器管理每個類型的方法列表中的函數,並根據data
中是指針還是原數據作出響應的處理。上面代碼中的Binary
因為是64位的,所以data
存儲的是指針,而itable
的方法中存儲的是(*Binary).String
;如果Binary
是32位的,那么data
中存儲的就是原數據,itable
中方法列表存儲的則是Binary.String
文末總結
- 編譯過程中,編譯器會為每一個類型創建一個類型描述符,該類型描述符包含了該類型的方法集合。
- 除了接口類型的其他類型(我們可以稱之為具體類型)和程序中所定義的所有接口類型存在某些轉換關系,而當某個具體類型
type A
可以轉換為某個接口類型interface B
時,我們可以認為該具體類型A
和該接口類型B
存在轉換關系,轉換關系存儲在itable
中,該itable
對所有的A -> B
轉換都通用。但因為我們在程序中可能定義了許許多多的接口口類型於具體類型,所以我們將"所有的轉換關系在編譯時預先生成出來"這種方式不可取,一是麻煩,二是會生成很多程序根本就用不到的itable
,所以itable
在運行時生成。