Go語言介面與動態型別

2020-07-16 10:04:56
在經典的物件導向語言(像 C++,Java 和 C#)中資料和方法被封裝為類的概念:類包含它們兩者,並且不能剝離。

Go語言沒有類:資料(結構體或更一般的型別)和方法是一種鬆耦合的正交關係。

Go語言中的介面跟 Java/C# 類似:都是必須提供一個指定方法集的實現。但是更加靈活通用:任何提供了介面方法實現程式碼的型別都隱式地實現了該介面,而不用顯式地宣告。

Go 的動態型別

和其它語言相比,Go語言是唯一結合了介面值,靜態型別檢查(是否該型別實現了某個介面),執行時動態轉換的語言,並且不需要顯式地宣告型別是否滿足某個介面。該特性允許我們在不改變已有的程式碼的情況下定義和使用新介面。

接收一個(或多個)介面型別作為引數的函數,可以被實現了該介面的型別範例呼叫。實現了某個介面的型別可以被傳給任何以此介面為引數的函數。

類似於 Python 和 Ruby 這類動態語言中的動態型別(duck typing);這意味著物件可以根據提供的方法被處理(例如,作為引數傳遞給函數),而忽略它們的實際型別:它們能做什麼比它們是什麼更重要。

這在程式 duck_dance.go 中得以闡明,函數 DuckDance 接受一個 IDuck 介面型別變數。僅當 DuckDance 被實現了 IDuck 介面的型別呼叫時程式才能編譯通過。
package main
import "fmt"
type IDuck interface {
    Quack()
    Walk()
}
func DuckDance(duck IDuck) {
    for i := 1; i <= 3; i++ {
        duck.Quack()
        duck.Walk()
    }
}
type Bird struct {
    // ...
}
func (b *Bird) Quack() {
    fmt.Println("I am quacking!")
}
func (b *Bird) Walk() {
    fmt.Println("I am walking!")
}
func main() {
    b := new(Bird)
    DuckDance(b)
}
輸出:

I am quacking!
I am walking!
I am quacking!
I am walking!
I am quacking!
I am walking!

如果 Bird 沒有實現 Walk()(把它註釋掉),會得到一個編譯錯誤:

cannot use b (type *Bird) as type IDuck in function argument:
*Bird does not implement IDuck (missing Walk method)

如果對 cat 呼叫函數 DuckDance(),Go 會提示編譯錯誤,但是 Python 和 Ruby 會以執行時錯誤結束。

動態方法呼叫

像 Python,Ruby 這類語言,動態型別是延遲系結的(在執行時進行):方法只是用引數和變數簡單地呼叫,然後在執行時才解析(它們很可能有像 responds_to 這樣的方法來檢查物件是否可以響應某個方法,但是這也意味著更大的編碼量和更多的測試工作)。

Go語言的實現與此相反,通常需要編譯器靜態檢查的支援:當變數被賦值給一個介面型別的變數時,編譯器會檢查其是否實現了該介面的所有函數。如果方法呼叫作用於像 interface{} 這樣的“泛型”上,可以通過型別斷言來檢查變數是否實現了相應介面。

例如,用不同的型別表示 XML 輸出流中的不同實體。然後我們為 XML 定義一個如下的“寫”介面(甚至可以把它定義為私有介面):

type xmlWriter interface {
    WriteXML(w io.Writer) error
}

現在我們可以實現適用於該流型別的任何變數的 StreamXML 函數,並用型別斷言檢查傳入的變數是否實現了該介面;如果沒有,我們就呼叫內建的 encodeToXML 來完成相應工作:

// Exported XML streaming function.
func StreamXML(v interface{}, w io.Writer) error {
    if xw, ok := v.(xmlWriter); ok {
        // It’s an xmlWriter, use method of asserted type.
        return xw.WriteXML(w)
    }
    // No implementation, so we have to use our own function (with perhaps reflection):
    return encodeToXML(v, w)
}
// Internal XML encoding function.
func encodeToXML(v interface{}, w io.Writer) error {
    // ...
}

Go 在這裡用了和 gob 相同的機制:定義了兩個介面 GobEncoder 和 GobDecoder。這樣就允許型別自己實現從流編解碼的具體方式;如果沒有實現就使用標準的反射方式。

因此 Go 提供了動態語言的優點,卻沒有其他動態語言在執行時可能發生錯誤的缺點。對於動態語言非常重要的單元測試來說,這樣即可以減少單元測試的部分需求,又可以發揮相當大的作用。

Go 的介面提高了程式碼的分離度,改善了程式碼的複用性,使得程式碼開發過程中的設計模式更容易實現。用 Go 介面還能實現依賴注入模式 。

介面的提取

提取介面是非常有用的設計模式,可以減少需要的型別和方法數量,而且不需要像傳統的基於類的物件導向語言那樣維護整個的類層次結構。

Go 介面可以讓開發者找出自己寫的程式中的型別。假設有一些擁有共同行為的物件,並且開發者想要抽象出這些行為,這時就可以建立一個介面來使用。假設我們需要一個新的介面 TopologicalGenus,用來給 shape 排序(這裡簡單地實現為返回 int)。我們需要做的是給想要滿足介面的型別實現 Rank() 方法:
//multi_interfaces_poly.go
package main
import "fmt"
type Shaper interface {
    Area() float32
}
type TopologicalGenus interface {
    Rank() int
}
type Square struct {
    side float32
}
func (sq *Square) Area() float32 {
    return sq.side * sq.side
}
func (sq *Square) Rank() int {
    return 1
}
type Rectangle struct {
    length, width float32
}
func (r Rectangle) Area() float32 {
    return r.length * r.width
}
func (r Rectangle) Rank() int {
    return 2
}
func main() {
    r := Rectangle{5, 3} // Area() of Rectangle needs a value
    q := &Square{5}      // Area() of Square needs a pointer
    shapes := []Shaper{r, q}
    fmt.Println("Looping through shapes for area ...")
    for n, _ := range shapes {
        fmt.Println("Shape details: ", shapes[n])
        fmt.Println("Area of this shape is: ", shapes[n].Area())
    }
    topgen := []TopologicalGenus{r, q}
    fmt.Println("Looping through topgen for rank ...")
    for n, _ := range topgen {
        fmt.Println("Shape details: ", topgen[n])
        fmt.Println("Topological Genus of this shape is: ", topgen[n].Rank())
    }
}
輸出:

Looping through shapes for area ...
Shape details: {5 3}
Area of this shape is: 15
Shape details: &{5}
Area of this shape is: 25
Looping through topgen for rank ...
Shape details: {5 3}
Topological Genus of this shape is: 2
Shape details: &{5}
Topological Genus of this shape is: 1

所以不用提前設計出所有的介面;整個設計可以持續演進,而不用廢棄之前的決定。型別要實現某個
介面,它本身不用改變,只需要在這個型別上實現新的方法。

顯式地指明型別實現了某個介面

如果希望滿足某個介面的型別顯式地宣告它們實現了這個介面,可以向介面的方法集中新增一個具有描述性名字的方法。例如:

type Fooer interface {
    Foo()
    ImplementsFooer()
}

型別 Bar 必須實現 ImplementsFooer 方法來滿足 Footer 介面,以清楚地記錄這個事實。

type Bar struct{}
func (b Bar) ImplementsFooer() {} func (b Bar) Foo() {}

大部分程式碼並不使用這樣的約束,因為它限制了介面的實用性。但是有些時候,這樣的約束在大量相似的介面中被用來解決歧義。

空介面和函數過載

在 Go語言中函數過載可以用可變引數 ...T 作為函數最後一個引數來實現。如果我們把 T 換為空介面,那麼可以知道任何型別的變數都是滿足 T (空介面) 型別的,這樣就允許我們傳遞任何數量任何型別的引數給函數,即過載的實際含義。

函數 fmt.Printf 就是這樣做的:

fmt.Printf(format string, a ...interface{}) (n int, errno error)

這個函數通過列舉 slice 型別的實參動態確定所有引數的型別。並檢視每個型別是否實現了 String() 方法。

介面的繼承

當一個型別包含(內嵌)另一個型別(實現了一個或多個介面)的指標時,這個型別就可以使用(另一個型別)所有的介面方法。

例如:

type Task struct {
    Command string
    *log.Logger
}

這個型別的工廠方法像這樣:

func NewTask(command string, logger *log.Logger) *Task {
    return &Task{command, logger}
}

當 log.Logger 實現了 Log() 方法後,Task 的範例 task 就可以呼叫該方法:

task.Log()

型別可以通過繼承多個介面來提供像 多重繼承一樣的特性:

type ReaderWriter struct {
    *io.Reader
    *io.Writer
}

上面概述的原理被應用於整個 Go 包,多型用得越多,程式碼就相對越少。這被認為是 Go 程式設計中的重要的最佳實踐。

有用的介面可以在開發的過程中被歸納出來。新增新介面非常容易,因為已有的型別不用變動(僅僅需要實現新介面的方法)。已有的函數可以擴充套件為使用介面型別的約束性引數:通常只有函數簽名需要改變。對比基於類的 OO 型別的語言在這種情況下則需要適應整個類層次結構的變化。