11.12 接口與動態類型
11.12.1 Go 的動態類型
在經典的面嚮對象語言(像 C++,Java 和 C#)中數據和方法被封裝爲 類的概念
:類包含它們兩者,並且不能剝離。
Go 沒有類:數據(結構體或更一般的類型)和方法是一種鬆耦合的正交關係。
Go 中的接口跟 Java/C# 類似:都是必須提供一個指定方法集的實現。但是更加靈活通用:任何提供了接口方法實現代碼的類型都隱式地實現了該接口,而不用顯式地聲明。
和其它語言相比,Go 是唯一結合了接口值,靜態類型檢查(是否該類型實現了某個接口),運行時動態轉換的語言,並且不需要顯式地聲明類型是否滿足某個接口。該特性允許我們在不改變已有的代碼的情況下定義和使用新接口。
接收一個(或多個)接口類型作爲參數的函數,可以被實現了該接口的類型實例調用。實現了某個接口的類型可以被傳給任何以此接口爲參數的函數
。
類似於 Python 和 Ruby 這類動態語言中的 動態類型(duck typing)
;這意味着對象可以根據提供的方法被處理(例如,作爲參數傳遞給函數),而忽略它們的實際類型:它們能做什麼比它們是什麼更重要。
這在程序 duck_dance.go 中得以闡明,函數 DuckDance 接受一個 IDuck 接口類型變量。僅當 DuckDance 被實現了 IDuck 接口的類型調用時程序才能編譯通過。
示例 11.16 duck_dance.go:
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 會以運行時錯誤結束。
11.12.2 動態方法調用
像 Python,Ruby 這類語言,動態類型是延遲綁定的(在運行時進行):方法只是用參數和變量簡單地調用,然後在運行時才解析(它們很可能有像 responds_to
這樣的方法來檢查對象是否可以響應某個方法,但是這也意味着更大的編碼量和更多的測試工作)
Go 的實現與此相反,通常需要編譯器靜態檢查的支持:當變量被賦值給一個接口類型的變量時,編譯器會檢查其是否實現了該接口的所有函數。如果方法調用作用於像 interface{}
這樣的“泛型”上,你可以通過類型斷言(參見 11.3 節)來檢查變量是否實現了相應接口。
例如,你用不同的類型表示 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 接口還能實現 依賴注入模式
。
11.12.3 接口的提取
提取接口
是非常有用的設計模式,可以減少需要的類型和方法數量,而且不需要像傳統的基於類的面嚮對象語言那樣維護整個的類層次結構。
Go 接口可以讓開發者找出自己寫的程序中的類型。假設有一些擁有共同行爲的對象,並且開發者想要抽象出這些行爲,這時就可以創建一個接口來使用。
我們來擴展 11.1 節的示例 11.2 interfaces_poly.go,假設我們需要一個新的接口 TopologicalGenus
,用來給 shape 排序(這裏簡單地實現爲返回 int)。我們需要做的是給想要滿足接口的類型實現 Rank()
方法:
示例 11.17 multi_interfaces_poly.go:
//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
所以你不用提前設計出所有的接口;整個設計可以持續演進,而不用廢棄之前的決定
。類型要實現某個接口,它本身不用改變,你只需要在這個類型上實現新的方法。
11.12.4 顯式地指明類型實現了某個接口
如果你希望滿足某個接口的類型顯式地聲明它們實現了這個接口,你可以向接口的方法集中添加一個具有描述性名字的方法。例如:
type Fooer interface {
Foo()
ImplementsFooer()
}
類型 Bar 必須實現 ImplementsFooer
方法來滿足 Footer
接口,以清楚地記錄這個事實。
type Bar struct{}
func (b Bar) ImplementsFooer() {} func (b Bar) Foo() {}
大部分代碼並不使用這樣的約束,因爲它限制了接口的實用性。
但是有些時候,這樣的約束在大量相似的接口中被用來解決歧義。
11.12.5 空接口和函數重載
在 6.1 節中, 我們看到函數重載是不被允許的。在 Go 語言中函數重載可以用可變參數 ...T
作爲函數最後一個參數來實現(參見 6.3 節)。如果我們把 T 換爲空接口,那麼可以知道任何類型的變量都是滿足 T (空接口)類型的,這樣就允許我們傳遞任何數量任何類型的參數給函數,即重載的實際含義。
函數 fmt.Printf
就是這樣做的:
fmt.Printf(format string, a ...interface{}) (n int, errno error)
這個函數通過枚舉 slice
類型的實參動態確定所有參數的類型。並查看每個類型是否實現了 String()
方法,如果是就用於產生輸出信息。我們可以回到 11.10 節查看這些細節。
11.12.6 接口的繼承
當一個類型包含(內嵌)另一個類型(實現了一個或多個接口)的指針時,這個類型就可以使用(另一個類型)所有的接口方法。
例如:
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 包,多態用得越多,代碼就相對越少(參見 12.8 節)。這被認爲是 Go 編程中的重要的最佳實踐。
有用的接口可以在開發的過程中被歸納出來。添加新接口非常容易,因爲已有的類型不用變動(僅僅需要實現新接口的方法)。已有的函數可以擴展爲使用接口類型的約束性參數:通常只有函數簽名需要改變。對比基於類的 OO 類型的語言在這種情況下則需要適應整個類層次結構的變化。
練習 11.11:map_function_interface.go:
在練習 7.13 中我們定義了一個 map 函數來使用 int 切片 (map_function.go)。
通過空接口和類型斷言,現在我們可以寫一個可以應用於許多類型的 泛型
的 map 函數,爲 int 和 string 構建一個把 int 值加倍和連接字符串值的 map 函數 mapFunc
。
提示:爲了可讀性可以定義一個 interface{} 的別名,比如:type obj interface{}
練習 11.12:map_function_interface_var.go:
稍微改變練習 11.9,允許 mapFunc
接收不定數量的 items。
練習 11.13:main_stack.go—stack/stack_general.go:
在練習 10.10 和 10.11 中我們開發了一些棧結構類型。但是它們被限制爲某種固定的內建類型。現在用一個元素類型是 interface{}(空接口)的切片開發一個通用的棧類型。
實現下面的棧方法:
Len() int
IsEmpty() bool
Push(x interface{})
Pop() (x interface{}, error)
Pop()
改變棧並返回最頂部的元素;Top()
只返回最頂部元素。
在主程序中構建一個充滿不同類型元素的棧,然後彈出並打印所有元素的值。
鏈接
- 目錄
- 上一節:Printf 和反射
- 下一節:總結:Go 中的面向對象