1. 程式人生 > 其它 >go interface 轉 string_Go 中的資料結構 -- Interface

go interface 轉 string_Go 中的資料結構 -- Interface

技術標籤:go interface 轉 string

Go 中的 interface 可以靜態編譯,動態執行,是最讓我感到興奮的一個特性。如果要讓我推薦一個 Go 語言的特性給其他的語言,那我一定會推薦 interface。

本文是我對於 Go 語言中 interface 型別在 gc 編譯器上實現的一些想法。Ian Lance Taylor 寫了兩篇關於 interface 型別在 gccgo 中實現的文章。本文與之最大的不同是本文有一些圖片可以更形象的說明原理。

在研究具體的實現原理之前,我們一起來看看 interface 需要支援什麼功能。

用法

Go 的 interface 讓你可以像純動態語言一樣使用鴨子型別

,同時編譯器也可以捕獲一些明顯的引數型別錯誤(比如傳給一個希望使用 Read 型別的函式一個 int 型別的引數)。

在使用一個 interface 之前, 我們首先要定義 interface 型別的方法集合(比如下面的 ReadCloser 型別):

type ReadCloser interface {
    Read(b []byte) (n int, err os.Error)
    Close()
}

然後,我們要定義一個使用 ReadCloser 的函式。比如下面的這個函式會不斷呼叫 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
}

呼叫 ReadAndClose 的程式碼可以給第一個引數傳入一個任意型別的值,只要這個值具有 Read 和 Close 方法。另外,當傳入一個錯誤型別的引數時,在編譯階段就可以發現這個錯誤,而不是像 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 的型別是介面型別 interface{},這意味著 any 可以有任何的方法,它可以包含任何型別。if 語句中的 ok 檢視 any 變數是否可以轉化為 Stringer 型別 (包含一個 String 方法)。如果可以,函式會返回一個字串,否則,就會嘗試一些其他的型別。這基本上就是 fmt 包中的一些邏輯。

舉個例子,考慮一個 64-bit 的整數,這個整數有一個 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 函式,然後可以使用 String 方法格式化,儘管程式從未說過 Binary 打算實現了 Stringer 類。其實也不需要完全實現 Stringer 類,執行時可以看到 Binary 有一個 String 方法,所以就人為它實現了 Stringer,即使 Binary 的作者從來沒有聽過 Stringer。

這些例子表明,即使在編譯時檢查所有隱式轉換,顯式的 interface-to-interface 的轉換也可以在執行時通過查詢方法集實現。《Effective Go》 中有更多關於如何使用介面的詳細資訊和示例。

Interface的值

帶有方法的語言通常會有兩種選擇:準備一個所有方法的靜態呼叫表(C++ 和 java),或者在每次呼叫時進行方法查詢(Smalltalk,python 以及 javascript)。

Go 選擇了一種混合的方式:它的確有靜態呼叫表,不過是在執行的時候生成的。我不知道 Go 是否是第一個採用這個技術的語言,但是這種技術的確是不常見的。

舉個例子,型別 Binary 的值是一個由兩個 32-bit 的字組成的 64-bit 整數。

8fef34f4f8a9a4215f544593cb773239.png

對於任何一個 interface, 它的值也是由兩個 32-bit 的字組成,第一個字它提供了一個指向 interface 中資料型別的資訊的指標,第二個字是一個指向相關資料的指標。s := Stringer(b) 將 b 分配給 Stringer 型別 s,會設定 interface 的這兩個字的指標。

16822893a40c27bff735d5096ad2c35f.png

interface 值中的第一個指標指向了一個表(itable 或者 itab)。Itable 由兩部分組成,第一部分是指向原始資料的資料型別,第二部分是一個函式指標的列表。需要注意的是,與 interface 型別相同,itable 也不是一個動態型別的資料。在我們這個例子中,Stringer 型別的 itable 中列出了 Binary 型別中滿足 Stringer 介面的所有函式指標列表( 其實只有 String, Binary 的另一個方法 Get 不會出現在 itable 裡面)。

interface 值中的第二個指標指向了實際的資料,也就是 b 的一份副本。需要注意的是,宣告 s := Stringer(b) 會給 b 創造一份副本,而不是直接指向 b。如果後來 b 的值改變了,那麼 s 還會是 b 之前的值。儲存在 interface 中的值可能是任意大的,但是因為只有一個字專門用於在介面結構中儲存值,所以會在堆上分配一大塊記憶體,並在那一個字的位置中儲存指標。(當然,如果這個值的長度少於一個字,則不需要再在堆上分配記憶體,具體的優化方法會在後面描述)

要檢查介面值是否是特定型別,Go 編譯器生成了表示式 s.tab->type 來獲取型別指標並檢查是否是所需的型別。如果型別匹配,則可以通過取消引用來複制 s.data

在呼叫 s.String() 時,Go 編譯器生成了一份程式碼,相當於 C 語言中的 s.tab->fun[0](s.data):它會從 itable 中選擇合適的函式指標,將 interface 值的資料欄位作為它的第一個引數。如果你執行 8g -S x.go ( 譯者注:8g 是老版本中的一個工具,在 go 1.5 後可以使用 go tool compile -S x.go 來代替),你可以看到這段程式碼。需要注意的是,itable 中的函式的引數只能傳入 32-bit 資料欄位指標,而不能傳入 64-bit 的值。一般來說,在呼叫介面的時候,程式碼是不會知道這個指標的意義,也不知道它所指向的資料有多少。相反,在介面的 itable 中的函式,也都期望接收到一個 32-bit 的指標。因此在這個例項中,函式的指標應該是 (*Binary).String 而不是 Binary.String

這個例子是一個只有一個方法的 interface。一個具有更多方法的 interface 將在 itable 底部有更多條記錄。

計算 Itable

現在,我們已經描述了 itable 的結構,可是它是怎麼生成的呢?

Go 的動態型別轉換使編譯器不可能對所有的 interface 到具體型別的 itables 進行預先計算,但是其實大部分的 itable 也是不需要的(比如程式中只需要計算 Stringer-Binary 的 itable,但是不需要計算 Stringer-string, Stringer-uint64 等對應的 itable)。

因此,在 go 語言中,編譯器為每個具體型別生成一個型別描述,包含了由該型別實現的方法的列表。類似地,編譯器也為每個介面型別生成型別描述,同樣也包含了該介面型別的實現方法列表。在執行時, 編譯器在具體型別的方法表中查詢 interface 型別的方法表中列出的每個方法來計算 itable。當生成了 itable 後,會將其儲存在cache中,所以每個 itable 只需要生成一次。

在本文的例子中, Stringer 的方法表中只有一個方法,而 Binary 的方法表中有兩個方法。假設 interface 型別 ni個方法,具體型別有 nt 個方法,那麼通常來說,檢索的複雜度為 O(ni * nt)。但是 go 採用了一種更好的方法,通過對兩個表的函式進行排序,並且對其進行同步遍歷,檢索的複雜度可以降為 O(ni + nt)

記憶體優化

上述的實現方法所佔用的空間可以使用兩種互補的方法來優化。

第一,當 interface 型別沒有定義任何方法時,itable 除了指向原來的型別外,沒有任何用途。在這種情況下,可以不再使用 itable,其指標直接指向原來的型別。

一個 interface 是否定義任何方法,這是一個靜態的屬性,因此編譯器知道程式用的是哪種指標。

1410ce7851adb3804ced4eda2dab7801.png

第二,如果與 interface 值相關聯的值可以單個字標識,那麼就不需要分配堆空間並使用指標。如果我們使用 Binary32 而不是 Binary, 那麼它的資料就可以直接儲存在 interface 的值中,而不需要再分配堆空間了。interface 的值是堆的指標還是實際的值完全取決於值型別的大小。

編譯器會管理 itable 中的函式,如果傳入的引數在一個字之內,那麼就直接使用這個字,否則,就通過間接引用獲取傳入的值。在上面的例子中, itable 中的方法是 (*Binary).String。但是在 Binary32 的例子中,itable 中的方法就是 (*Binary).String

9d91b3254fada833a72146b374bef80b.png

當然,如果一個空的 interface 並且傳入了一個字的資料,可以使用上面兩種方法同時進行優化。

c90b8223231f0eff4abe95e540cae394.png

方法查詢效能

Smalltalk 和許多其他的動態語言在每次呼叫方法時都會執行方法查詢。為了提高速度,大部分都是在指令流中簡單的加入了單條快取。對於多執行緒的語言,這些快取必須小心的儲存,因為可能存在多個執行緒同時訪問一個函式的情況。

因為 Go 具有靜態型別提示和動態方法查詢的方法,所以它可以將查詢從呼叫的位置移回到值儲存在介面中的位置。

var any interface{}  // initialized elsewhere
s := any.(Stringer)  // dynamic conversion
for i := 0; i < 100; i++ {
    fmt.Println(s.String())
}

在第 2 行的賦值的時候,程式會計算 itable;因此,在第 4 行執行的 s.String() 只需要執行幾次記憶體查詢和一次間接呼叫即可。

與此相反,在像Smalltalk(或JavaScript、Python)這樣的動態語言中,每次執行到第4行時程式都會進行方法查詢,在一次次的迴圈中重複不必要的工作。前面提到的快取可能會讓起稍微快一些,但是它仍然不如一個間接呼叫指令。

當然,這是一篇部落格文章,我沒有任何數字來支援這個討論,但是像 Go 語言這樣減少記憶體競爭可以很好的提高效能。另外,本文主要是介紹體系結構,而不是實現的細節,在實現的過程中可能會使用一些常量的優化。