[譯] 如何在 Go 中使用介面

Aaaaaaaaaaayou發表於2018-01-25

本文翻譯自 How to use interfaces in Go 有部分刪減,請以原文為準

在開始使用 Go 程式設計之前,我的大部分工作都是用 Python 完成的。作為一名 Python 程式設計師,我發現學習使用 Go 中的介面是非常困難的。基礎很簡單,而且我知道如何在標準庫中使用介面,但是我做了很多練習之後才知道如何設計自己的介面。在本文中,我將討論 Go 的型別系統,以解釋如何有效地使用介面。

介面介紹

介面是什麼?一個介面包含兩層意思:它是一個方法的集合,同樣是一個型別。讓我們首先關注介面作為方法的集合這一方面。

通常,我們會用一些假設的例子來介紹介面。讓我們來看看這個例子: Animal 型別是一個介面,我們將定義一個 Animal 作為任何可以說話的東西。這是 Go 型別系統的核心概念:我們根據型別可以執行的操作而不是其所能容納的資料型別來設計抽象。

type Animal interface {
    Speak() string
}
複製程式碼

非常簡單:我們定義 Animal 為任何具有 Speak 方法的型別。Speak 方法沒有引數,返回一個字串。所有定義了該方法的型別我們稱它實現Animal 介面。Go 中沒有 implements 關鍵字,判斷一個型別是否實現了一個介面是完全是自動地。讓我們建立幾個實現這個介面的型別:

type Dog struct {
}

func (d Dog) Speak() string {
    return "Woof!"
}

type Cat struct {
}

func (c Cat) Speak() string {
    return "Meow!"
}

type Llama struct {
}

func (l Llama) Speak() string {
    return "?????"
}

type JavaProgrammer struct {
}

func (j JavaProgrammer) Speak() string {
    return "Design patterns!"
}
複製程式碼

我們現在有四種不同型別的動物:DogCatLlamaJavaProgrammer。在我們的 main 函式中,我們建立了一個 []Animal{Dog{}, Cat{}, Llama{}, JavaProgrammer{}} ,看看每隻動物都說了些什麼:

func main() {
    animals := []Animal{Dog{}, Cat{}, Llama{}, JavaProgrammer{}}
    for _, animal := range animals {
        fmt.Println(animal.Speak())
    }
}
複製程式碼

很好,現在你知道如何使用介面了,我不需要再討論它們了,對吧?不是的。讓我們來看看一些不太明顯的東西。

interface{} 型別

interface{} 型別,空介面,是導致很多混淆的根源。interface{} 型別是沒有方法的介面。由於沒有 implements 關鍵字,所以所有型別都至少實現了 0 個方法,所以 所有型別都實現了空介面。這意味著,如果您編寫一個函式以 interface{} 值作為引數,那麼您可以為該函式提供任何值。例如:

func DoSomething(v interface{}) {
   // ...
}
複製程式碼

這裡是讓人困惑的地方:在 DoSomething 函式內部,v 的型別是什麼?新手們會認為 v任意型別的,但這是錯誤的。v 不是任意型別,它是 interface{} 型別。對的,沒錯!當將值傳遞給DoSomething 函式時,Go 執行時將執行型別轉換(如果需要),並將值轉換為 interface{} 型別的值。所有值在執行時只有一個型別,而 v 的一個靜態型別是 interface{}

這可能讓您感到疑惑:好吧,如果發生了轉換,到底是什麼東西傳入了函式作為 interface{} 的值呢?(具體到上例來說就是 []Animal 中存的是啥?)

一個介面值由兩個字(32 位機器一個字是 32 bits,64 位機器一個字是 64 bits)組成;一個字用於指向該值底層型別的方法表,另一個字用於指向實際資料。我不想沒完沒了地談論這個。如果您理解一個介面值是兩個字,並且它包含指向底層資料的指標,那麼這就足以避免常見的陷阱。如果您想了解更多關於介面實現的知識。這篇文章很有用:Russ Cox’s description of interfaces 。

在我們上面的例子中,當我們初始化變數 animals 時,我們不需要像這樣 Animal(Dog{}) 來顯示的轉型,因為這是自動地。這些元素都是 Animal 型別,但是他們的底層型別卻不相同。

為什麼這很重要呢?理解介面是如何在記憶體中表示的,可以使得一些潛在的令人困惑的事情變得非常清楚。比如,像 “我可以將 []T 轉換為 []interface{} 嗎?” 這種問題就容易回答了。下面是一些爛程式碼的例子,它們代表了對 interface{} 型別的常見誤解:

package main

import (
    "fmt"
)

func PrintAll(vals []interface{}) {
    for _, val := range vals {
        fmt.Println(val)
    }
}

func main() {
    names := []string{"stanley", "david", "oscar"}
    PrintAll(names)
}
複製程式碼

執行這段程式碼你會得到如下錯誤:cannot use names (type []string) as type []interface {} in argument to PrintAll。如果想使其正常工作,我們必須將 []string 轉為 []interface{}

package main

import (
    "fmt"
)

func PrintAll(vals []interface{}) {
    for _, val := range vals {
        fmt.Println(val)
    }
}

func main() {
    names := []string{"stanley", "david", "oscar"}
    vals := make([]interface{}, len(names))
    for i, v := range names {
        vals[i] = v
    }
    PrintAll(vals)
}
複製程式碼

很醜陋,但是生活就是這樣,沒有完美的事情。(事實上,這種情況不會經常發生,因為 []interface{} 並沒有像你想象的那樣有用)

指標和介面

介面的另一個微妙之處是介面定義沒有規定一個實現者是否應該使用一個指標接收器或一個值接收器來實現介面。當給定一個介面值時,不能保證底層型別是否為指標。在前面的示例中,我們將方法定義在值接收者之上。讓我們稍微改變一下,將 CatSpeak() 方法改為指標接收器:

func (c *Cat) Speak() string {
    return "Meow!"
}
複製程式碼

執行上述程式碼,會得到如下錯誤:

cannot use Cat literal (type Cat) as type Animal in array or slice literal:
	Cat does not implement Animal (Speak method has pointer receiver)
複製程式碼

該錯誤的意思是:你嘗試將 Cat 轉為 Animal ,但是隻有 *Cat 型別實現了該介面。你可以通過傳入一個指標 (new(Cat) 或者 &Cat{})來修復這個錯誤。

animals := []Animal{Dog{}, new(Cat), Llama{}, JavaProgrammer{}}
複製程式碼

讓我們做一些相反的事情:我們傳入一個 *Dog 指標,但是不改變 DogSpeak() 方法:

animals := []Animal{new(Dog), new(Cat), Llama{}, JavaProgrammer{}}
複製程式碼

這種方式可以正常工作,因為一個指標型別可以通過其相關的值型別來訪問值型別的方法,但是反過來不行。即,一個 *Dog 型別的值可以使用定義在 Dog 型別上的 Speak() 方法,而 Cat 型別的值不能訪問定義在 *Cat 型別上的方法。

這可能聽起來很神祕,但當你記住以下內容時就清楚了:Go 中的所有東西都是按值傳遞的。每次呼叫函式時,傳入的資料都會被複制。對於具有值接收者的方法,在呼叫該方法時將複製該值。例如下面的方法:

func (t T)MyMethod(s string) {
    // ...
}
複製程式碼

func(T, string) 型別的方法。方法接收器像其他引數一樣通過值傳遞給函式。

因為所有的引數都是通過值傳遞的,這就可以解釋為什麼 *Cat 的方法不能被 Cat 型別的值呼叫了。任何一個 Cat 型別的值可能會有很多 *Cat 型別的指標指向它,如果我們嘗試通過 Cat 型別的值來呼叫 *Cat 的方法,根本就不知道對應的是哪個指標。相反,如果 Dog 型別上有一個方法,通過 *Dog 來呼叫這個方法可以確切的找到該指標對應的 Gog 型別的值,從而呼叫上面的方法。執行時,Go 會自動幫我們做這些,所以我們不需要像 C語言中那樣使用類似如下的語句 d->Speak()

例1:通過 Twitter API 獲取正確的時間戳

Twitter API 使用下面的格式來展示時間戳:

"Thu May 31 00:00:01 +0000 2012"
複製程式碼

Twitter API 返回的是一個 json 字串,這裡我們只考慮解析 created_at 欄位:

package main

import (
    "encoding/json"
    "fmt"
    "reflect"
)

// start with a string representation of our JSON data
var input = `
{
    "created_at": "Thu May 31 00:00:01 +0000 2012"
}
`

func main() {
    // our target will be of type map[string]interface{}, which is a
    // pretty generic type that will give us a hashtable whose keys
    // are strings, and whose values are of type interface{}
    var val map[string]interface{}

    if err := json.Unmarshal([]byte(input), &val); err != nil {
        panic(err)
    }

    fmt.Println(val)
    for k, v := range val {
        fmt.Println(k, reflect.TypeOf(v))
    }
}
複製程式碼

執行上述程式碼,輸出:

map[created_at:Thu May 31 00:00:01 +0000 2012]
created_at Thu May 31 00:00:01 +0000 2012 string
複製程式碼

我們得到了解析後的結果,但是解析出來的時間是字串型別的,作用有限,因此我們想把它解析成 time.Time 型別的,對程式碼做出如下修改:

var val map[string]interface{} -> var val map[string]time.Time
複製程式碼

結果出錯了:

panic: parsing time ""Thu May 31 00:00:01 +0000 2012"" as ""2006-01-02T15:04:05Z07:00"": cannot parse "Thu May 31 00:00:01 +0000 2012"" as "2006"
複製程式碼

出錯的原因是字串格式與 Go 中的時間格式不匹配(因為 Twitter's API 是用 Ruby 寫的,其格式跟 Go 不同)。我們必須定義我們自己的型別來解析時間。encoding/json 在解析時會判斷傳入 json.Unmarshal 的值是否實現了 json.Unmarshaler 介面:

type Unmarshaler interface {
    UnmarshalJSON([]byte) error
}
複製程式碼

如果實現了,就會呼叫 UnmarshalJSON 方法來解析(參考),所以我們需要的是一個實現了 UnmarshalJSON([]byte) error 方法的型別:

type Timestamp time.Time

func (t *Timestamp) UnmarshalJSON(b []byte) error {
    // ...
}
複製程式碼

值得注意的是,我們使用一個指標作為方法接受者,因為我們希望在方法內對接受者進行更改。UnmarshalJSON 中,t 代表指向 Timestamp 型別值的指標,通過 *t 我們可以訪問到這個值,這樣就可以修改它了。

我們可以使用 time.Parse(layout, value string) (Time, error) 來解析時間,該函式的第一個引數是表示時間格式的字串(更多字串格式),第二個是我們要解析的字串。返回 time.Time 型別的值以及 error(如果解析出錯)。解析得到 time.Time 型別的值後,轉換成 Timestamp 型別然後賦值給 *t

func (t *Timestamp) UnmarshalJSON(b []byte) error {
    v, err := time.Parse(time.RubyDate, string(b[1:len(b)-1]))
    if err != nil {
        return err
    }
    *t = Timestamp(v)
    return nil
}
複製程式碼

注意,傳入函式的 []byte 是原始的 JSON 資料,其中包含有引號,所以這裡需要切片去掉引號。

例2:從 HTTP 請求中得到物件

然我們設計一個介面來解決 web 開發中常見的一個問題:我們想解析 HTTP 請求體得到我們需要的物件資料。例如,我們這樣定義我們的介面:

GetEntity(*http.Request) (interface{}, error)
複製程式碼

因為 interface{} 可以有任意的底層型別,所以我們可以解析得到任何我們需要的東西。但是這是一個不好的設計,我們將過多的邏輯引入到 GetEntity 函式中,GetEntity 函式現在需要針對每一種新型別進行修改,我們需要使用型別斷言來處理返回的值。在實踐中,返回 interface{} 的函式往往很煩人,作為一個經驗法則,您只需要記住,將 interface{} 作為引數而不是返interface{} 值通常更好(Postel’s Law)。

我們也可能會嘗試編寫一些返回型別明確的函式,像這樣:

GetUser(*http.Request) (User, error)
複製程式碼

但是這樣又顯得不夠靈活,因為需要對不同的型別寫不同的函式。我們真正需要的是像這樣的一個設計:

type Entity interface {
	UnmarshalerHTTP(*http.Request) error
}

func GetEntity(r *http.Request, v Entity) error {
	return v.UnmarshalerHTTP(r)
}
複製程式碼

GetEntiry 方法需要傳入一個引數,該引數為 Entity 介面型別,確保實現了 UnmarshalHTTP 方法。為了使用該方法,我們需要定義 User 型別並實現 UnmarshalHTTP 方法,並在方法中解析 HTTP 請求:

type User struct {
   ...
}

func (u *User) UnmarshalHTTP(r *http.Request) error {
   // ...
}
複製程式碼

然後,定義一個 User 型別的變數,並將其指標傳遞給 GetEntity 方法:

var u User
if err := GetEntity(req, &u); err != nil {
    // ...
}
複製程式碼

這同解析 JSON 資料類似。這種方式可以始終如一地安全地工作,因為 var u User 將自動地將 User 結構體初始化為零值。Go 不像其他語言一樣宣告和初始化是分開進行的。通過宣告一個值而不初始化它,執行時將為該值分配適當的記憶體空間。即使我們的 UnmarshalHTTP 方法不能使用某些欄位,這些欄位也將包含有效的零資料,而不是垃圾資料。

結語

我希望讀完此文後你可以更加得心應手地使用 Go 中的介面,記住下面這些結論:

  • 通過考慮資料型別之間的相同功能來建立抽象,而不是相同欄位
  • interface{} 的值不是任意型別,而是 interface{} 型別
  • 介面包含兩個字的大小,類似於 (type, value)
  • 函式可以接受 interface{} 作為引數,但最好不要返回 interface{}
  • 指標型別可以呼叫其所指向的值的方法,反過來不可以
  • 函式中的引數甚至接受者都是通過值傳遞
  • 一個介面的值就是就是介面而已,跟指標沒什麼關係
  • 如果你想在方法中修改指標所指向的值,使用 * 操作符

相關文章