這是一個建立於
的文章,其中的資訊可能已經有所發展或是發生改變。
go對我而言其最令人激動的地方就是介面的設計,例如靜態類型、編譯時間檢查。如果非要讓我將go的一個特性運用到其它語言的設計當中,那一定非介面莫屬。
這篇文章描述了我對介面值在“ GC ”的編譯器的實現。 Ian Lance Taylor已經寫了兩篇關於介面值在gccgo中如何?的文章。這篇文章與其大同小異:最大的區別是,本文有圖片。
Usgae
go介面的使用讓你感覺像是在用python這樣純粹的動態語言一樣,但不同的是仍然有編譯器幫你進行靜態檢查,例如傳了一個int型變數給Read方法,或者給Read方法傳了錯誤的參數個數。要使用go的介面,首先得定義一個介面類型(比如下面的ReadCloser)
1 2 3 4
|
type ReadCloser interface { Read(b []byte) (n int, err os.Error) Close() }
|
然後定義一個帶有ReadCloser參數的函數。例如,下面這個函數通過迴圈調用Read方法擷取所有的請求資料,然後再調用Close方法
1 2 3 4 5 6 7 8 9 10
|
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 }
|
調用ReadAndClose函數的時候,只要任何實現了Read和Close方法的類型,都可以作為該函數的第一個參數傳遞進去。而且不像python,如果你傳遞了一個錯誤的類型,那麼編譯器就會報告類型錯誤,而不是等到運行時才報錯。
當然,介面並不局限於靜態檢查,你也可以在運行時動態檢查一個介面的實際類型。例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
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參數的類型是interface{},即該介面沒有任何的方法,這意味著它可以接收任何類型的參數。在if語句中的”comma ok”運算式嘗試將any轉成Stringer介面類型,這個介面類型包含了String方法,如果轉換成功,在if的中括弧內,將調用String方法擷取字串值並返回回去。如果轉換失敗,在接下來的的switch語句中,又驗證了了兩個基礎類型。這個函數就像是fmt包的一個精簡版的實現。(這裡那個if語句實際上可以轉化為case Stringer放到swtich頂部中去,這裡為了示範故意寫成這樣)
舉一個簡單的例子:考慮定義一個底層類型為unit64的Binary類型,並為它寫一個String()方法和Get()方法
1 2 3 4 5 6 7 8 9
|
type Binary uint64
func (i Binary) String() string { return strconv.Uitob64(i.Get(), 2) }
func (i Binary) Get() uint64 { return uint64(i) }
|
假設將類型為Binary的值傳給ToString函數,那麼在ToString內部將調用String方法並返回。在程式啟動並執行過程中,運行時知道Binary類型有String方法,所以,程式認為它實現了Stringer介面。這是一種奇妙的設計,因為有時候甚至連Binary的作者都不知道Stringer介面的存在。
以上這些例子表明即使所有的隱式轉換是在編譯時間進行檢查,顯示的介面到介面的轉換也可以在運行時進行。Effective Go有更多關於介面的詳細例子。
Interface Values
擁有方法的語言通常分為兩大陣營:
- 擁有方法表的靜態語言(如C++和Java)
- 每次調用都進行方法地址查詢的動態語言(如Smalltalk和它的眾多模仿者、javascript和python等,有的會通過緩衝提高它調用的效率)。
Go則位於兩者的中間,它擁有方法表,但卻是在運行時計算它們。我不知道go是不是第一個使用該技術的語言,但這種方式肯定是不常見的。
舉個例子,一個Binary類型的值是一個64位整型,它由兩個32位的字組成(假設我們現在使用的是32位的機器)
Interface由兩個字長組成,其中一個儲存指向介面類型表的指標,另一個儲存指向資料的指標。如果將b賦值給一個Stringer類型的介面變數,其記憶體結構如所示
(介面中儲存的指標對程式是不可見的,它不會直接暴露給使用者)
第一個位元組中的指標指向一個名字叫作itable的記憶體塊(在C中叫作Itab)。itable頭部儲存了一些與類型相關的中繼資料,然後才是函數指標列表。itable對應的是介面類型,而不是動態類型。以我們的例子來講,Stringer的itable儲存了類型Binary的中繼資料,然後才是那些滿足Stringer介面的函數指標列表,在這個例子中就只有一個String方法,而Binary的其它方法則不會在itable中出現。
第二個位元組中的指標則指向b的一個副本。下面這個指派陳述式 var s Stringer = b 將會申請一個b的副本,並將第二個位元組中的指標指向該副本,其原理和 var c unit64 = b 一樣。當我們改變b的值的時候,s和c的值將不會被改變。在介面中儲存的值可能是任意大小的,但是介面本身卻只有一個字來儲存資料,所以程式會在堆上申請一組記憶體來儲存該資料,然後再將第二個字上的指標指向該記憶體組。(如果資料的大小剛才等於或小於一個字,我們在後面會討論該情況)
像上面的switch語句所進行的特定類型檢查,go編譯器會產生相當於C中 s.tag->type這樣的代碼來檢查實際類型和期望類型是否相同,如果相同的話,那麼s.data將被拷貝並賦值給期望值。
當調用s.String()時,Go編譯器產生相當於C語言中的 s.tab->fun[0](s.data)它調用了函數指標指向的方法,並將介面第二個字作為第一個參數傳遞進去。值得注意的是,它傳遞的是第二個字中的32位的指標的值,而不是指標所指向的那個64位的值。一般情況下,介面調用並不知道該位元組代表什麼也不知道它所指向的資料有多大。而方法調用時會嚴格尊守itable裡面所儲存的函數指標的簽名,因此在本例中該方法的簽名應該是(*Binary) string而不是(Binary) string。
在該樣本中只有一個方法,如果有多個方法的時候,在itable表的底部將會有多個函數指標。
Computing the Itable
現在我們瞭解了itable的結構,但它是怎麼產生的呢?go語言的動態類型轉換特性決定了它不可能在編譯時間就產生它,因為會有太多的(interface type和concrete type)組成的對,並且大多數不會被使用到。取而代之的是,編譯器會為各個實際類型產生一個類型描述結構,該結構就包含了該類型所實現的方法列表。同樣的,編譯器也會每個介面類型產生這樣一個類型描述結構,它也同樣包含了介面方法列表。介面在運行時才會產生itable,通過尋找介面的類型描述結構和具體類型的類型描述結構,並且將itable緩衝起來,因此,這個計算只會被執行一次。
在我們的例子中,Stringer的類型描述結構中只有一個方法,而Binary中有兩個方法。假設介面類型擁有ni個方法,實際類型擁有nt個方法,那麼找到它所相互匹配的方法所花費的時間複雜度為O(ni x nt),我們可以能過Map儲存它們,然後尋找過程中時間複雜度則變成O(ni + nt)。
Memory Optimizations
介面的記憶體使用量在兩種情況下可以被最佳化。
- 如果介面類型是interface{}的情況下,也就是沒有任何方法的情況下,itable也就沒有存在的必要。在這種情況下,第一個字就可以儲存實際類型了:
一個介面有沒有方法go將用一個靜態屬性表示,因此編譯器知道第一個字表示的是哪一種情況。
- 如果介面所關聯的類型的值大小剛好是一個機器字長,那就沒有必要進行堆記憶體的申請了。如果我們像Binary一樣定義一個Binary32,將uint32作為它的底層類型,那麼它的值就可以直接儲存在第二個字中:
在這種情況下,String的函數簽名將變成(Binary) string,而不是原來的(*Binary) string。
因此,當給一個空介面賦值一個大小為一個機器字長的值(或更小)時,它將同時採用上面這兩種最佳化方式。