介面是一種抽象型別,它定義了一組方法的契約,它規定了需要實現的所有方法。是由 type
和 interface
關鍵字定義的一組方法集合,其中,方法集合唯一確定了這個介面型別所表示的介面。
一個介面型別通常由一組方法簽名組成,這些方法定義了物件必須實現的操作。介面的方法簽名包括方法的名稱、輸入引數、返回值等資訊,但不包括方法的實際實現。例如:
type Writer interface {
Write([]byte) (int, error)
}
上面的程式碼定義了一個名為 Writer
的介面,它有一個 Write
方法,該方法接受一個 []byte
型別的引數並返回兩個值,一個整數和一個錯誤。任何型別只要實現了這個 Write
方法的簽名,就可以被認為是 Writer
介面的實現。
總之,Go語言提倡面向介面程式設計。
現在假設我們的程式碼世界裡有很多小動物,下面的程式碼片段定義了貓和狗,它們餓了都會叫。
package main
import "fmt"
type Cat struct{}
func (c Cat) Say() {
fmt.Println("喵喵喵~")
}
type Dog struct{}
func (d Dog) Say() {
fmt.Println("汪汪汪~")
}
func main() {
c := Cat{}
c.Say()
d := Dog{}
d.Say()
}
這個時候又跑來了一隻羊,羊餓了也會發出叫聲。
type Sheep struct{}
func (s Sheep) Say() {
fmt.Println("咩咩咩~")
}
我們接下來定義一個餓肚子的場景。
// MakeCatHungry 貓餓了會喵喵喵~
func MakeCatHungry(c Cat) {
c.Say()
}
// MakeSheepHungry 羊餓了會咩咩咩~
func MakeSheepHungry(s Sheep) {
s.Say()
}
接下來會有越來越多的小動物跑過來,我們的程式碼世界該怎麼拓展呢?
在餓肚子這個場景下,我們可不可以把所有動物都當成一個「會叫的型別」來處理呢?當然可以!使用介面型別就可以實現這個目標。 我們的程式碼其實並不關心究竟是什麼動物在叫,我們只是在程式碼中呼叫它的Say()
方法,這就足夠了。
我們可以約定一個Sayer
型別,它必須實現一個Say()
方法,只要餓肚子了,我們就呼叫Say()
方法。
type Sayer interface {
Say()
}
然後我們定義一個通用的MakeHungry
函數,接收Sayer
型別的引數。
// MakeHungry 餓肚子了...
func MakeHungry(s Sayer) {
s.Say()
}
我們通過使用介面型別,把所有會叫的動物當成Sayer
型別來處理,只要實現了Say()
方法都能當成Sayer
型別的變數來處理。
var c cat
MakeHungry(c)
var d dog
MakeHungry(d)
在電商系統中我們允許使用者使用多種支付方式(支付寶支付、微信支付、銀聯支付等),我們的交易流程中可能不太在乎使用者究竟使用什麼支付方式,只要它能提供一個實現支付功能的Pay
方法讓呼叫方呼叫就可以了。
再比如我們需要在某個程式中新增一個將某些指標資料向外輸出的功能,根據不同的需求可能要將資料輸出到終端、寫入到檔案或者通過網路連線傳送出去。在這個場景下我們可以不關注最終輸出的目的地是什麼,只需要它能提供一個Write
方法讓我們把內容寫入就可以了。
Go語言中為了解決類似上面的問題引入了介面的概念,介面型別區別於我們之前章節中介紹的那些具體型別,讓我們專注於該型別提供的方法,而不是型別本身。使用介面型別通常能夠讓我們寫出更加通用和靈活的程式碼。
PHP、Java等語言中也有介面的概念,不過在PHP和Java語言中需要顯式宣告一個類實現了哪些介面,在Go語言中使用隱式宣告的方式實現介面。只要一個型別實現了介面中規定的所有方法,那麼它就實現了這個介面。
Go語言中的這種設計符合程式開發中抽象的一般規律,例如在下面的程式碼範例中,我們的電商系統最開始只設計了支付寶一種支付方式:
type ZhiFuBao struct {
// 支付寶
}
// Pay 支付寶的支付方法
func (z *ZhiFuBao) Pay(amount int64) {
fmt.Printf("使用支付寶付款:%.2f元。\n", float64(amount/100))
}
// Checkout 結賬
func Checkout(obj *ZhiFuBao) {
// 支付100元
obj.Pay(100)
}
func main() {
Checkout(&ZhiFuBao{})
}
隨著業務的發展,根據使用者需求新增支援微信支付。
type WeChat struct {
// 微信
}
// Pay 微信的支付方法
func (w *WeChat) Pay(amount int64) {
fmt.Printf("使用微信付款:%.2f元。\n", float64(amount/100))
}
在實際的交易流程中,我們可以根據使用者選擇的支付方式來決定最終呼叫支付寶的Pay方法還是微信支付的Pay方法。
// Checkout 支付寶結賬
func CheckoutWithZFB(obj *ZhiFuBao) {
// 支付100元
obj.Pay(100)
}
// Checkout 微信支付結賬
func CheckoutWithWX(obj *WeChat) {
// 支付100元
obj.Pay(100)
}
實際上,從上面的程式碼範例中我們可以看出,我們其實並不怎麼關心使用者選擇的是什麼支付方式,我們只關心呼叫Pay方法時能否正常執行。這就是典型的「不關心它是什麼,只關心它能做什麼」的場景。
在這種場景下我們可以將具體的支付方式抽象為一個名為Payer
的介面型別,即任何實現了Pay
方法的都可以稱為Payer
型別。
// Payer 包含支付方法的介面型別
type Payer interface {
Pay(int64)
}
此時只需要修改下原始的Checkout
函數,它接收一個Payer
型別的引數。這樣就能夠在不修改既有函數呼叫的基礎上,支援新的支付方式。
// Checkout 結賬
func Checkout(obj Payer) {
// 支付100元
obj.Pay(100)
}
func main() {
Checkout(&ZhiFuBao{}) // 之前呼叫支付寶支付
Checkout(&WeChat{}) // 現在支援使用微信支付
}
像類似的例子在我們程式設計過程中會經常遇到:
介面型別是Go語言提供的一種工具,在實際的編碼過程中是否使用它由你自己決定,但是通常使用介面型別可以使程式碼更清晰易讀。
每個介面型別由任意個方法簽名組成,介面的定義格式如下:
type 介面型別名 interface{
方法名1( 參數列1 ) 返回值列表1
方法名2( 參數列2 ) 返回值列表2
…
}
其中:
er
,如有寫操作的介面叫Writer
,有關閉操作的介面叫closer
等。介面名最好要能突出該介面的型別含義。下面是一個典型的介面型別 MyInterface
的定義:
type MyInterface interface {
M1(int) error
M2(io.Writer, ...string)
}
通過這個定義,我們可以看到,介面型別 MyInterface
所表示的介面的方法集合,包含兩個方法 M1
和 M2
。之所以稱 M1
和 M2
為「方法」,更多是從這個介面的實現者的角度考慮的。但從上面介面型別宣告中各個「方法」的形式上來看,這更像是不帶有 func
關鍵字的函數名 + 函數簽名(參數列 + 返回值列表)的組合。
在介面型別的方法集合中宣告的方法,它的參數列不需要寫出形參名字,返回值列表也是如此。也就是說,方法的參數列中形參名字與返回值列表中的具名返回值,都不作為區分兩個方法的憑據。
比如下面的 MyInterface
介面型別的定義與上面的 MyInterface
介面型別定義都是等價的:
type MyInterface interface {
M1(a int) error
M2(w io.Writer, strs ...string)
}
type MyInterface interface {
M1(n int) error
M2(w io.Writer, args ...string)
}
不過,Go 語言要求介面型別宣告中的方法必須是具名的,並且方法名字在這個介面型別的方法集合中是唯一的。前面我們在學習型別嵌入時就學到過:Go 1.14 版本以後,Go 介面型別允許嵌入的不同介面型別的方法集合存在交集,但前提是交集中的方法不僅名字要一樣,它的方法簽名部分也要保持一致,也就是參數列與返回值列表也要相同,否則 Go 編譯器照樣會報錯。
比如下面範例中 Interface3
嵌入了 Interface1
和 Interface2
,但後兩者交集中的 M1
方法的函數簽名不同,導致了編譯出錯:
type Interface1 interface {
M1()
}
type Interface2 interface {
M1(string)
M2()
}
type Interface3 interface{
Interface1
Interface2 // 編譯器報錯:duplicate method M1
M3()
}
上面舉的例子中的方法都是首字母大寫的匯出方法,所以在 Go 介面型別的方法集合中放入首字母小寫的非匯出方法也是合法的,並且我們在 Go 標準庫中也找到了帶有非匯出方法的介面型別定義,比如 context
包中的 canceler
介面型別,它的程式碼如下:
// $GOROOT/src/context.go
// A canceler is a context type that can be canceled directly. The
// implementations are *cancelCtx and *timerCtx.
type canceler interface {
cancel(removeFromParent bool, err error)
Done() <-chan struct{}
}
但這樣的例子並不多。通過對標準庫這為數不多的例子,我們可以看到,如果介面型別的方法集合中包含非匯出方法,那麼這個介面型別自身通常也是非匯出的,它的應用範圍也僅侷限於包內。不過,在日常實際編碼過程中,我們極少使用這種帶有非匯出方法的介面型別,我們簡單瞭解一下就可以了。
除了上面這種常規情況,還有空介面(empty interface)型別這種特殊情況。
空介面是指沒有定義任何方法的介面型別。因此任何型別都可以視為實現了空介面。也正是因為空介面型別的這個特性,空介面型別的變數可以儲存任意型別的值。
比如下面的 EmptyInterface
介面型別:
type EmptyInterface interface {
}
這個方法集合為空的介面型別就被稱為空介面型別,但通常我們不需要自己顯式定義這類空介面型別,我們直接使用 interface{}
這個型別字面值作為所有空介面型別的代表就可以了。
空介面(interface{}
)作為函數的引數是一種非常靈活的方式,因為它可以接受任何型別的引數。這在處理未知型別的資料或編寫通用函數時非常有用。以下是一個範例,展示瞭如何使用空介面作為函數引數:
package main
import "fmt"
func PrintValue(value interface{}) {
fmt.Println(value)
}
func main() {
PrintValue(42) // 整數
PrintValue("Hello, Go!") // 字串
PrintValue(3.14159) // 浮點數
PrintValue([]int{1, 2, 3}) // 切片
}
在上面的範例中,PrintValue
函數接受一個空介面型別的引數,這意味著它可以接受任何型別的值。在 main
函數中,我們呼叫 PrintValue
函數並傳遞不同型別的引數,它們都可以被正確處理和列印。
空介面也可以用作map
的值型別,這使得map
可以儲存不同型別的值。這在需要將各種型別的資料關聯到特定鍵時非常有用。以下是一個範例:
package main
import "fmt"
func main() {
data := make(map[string]interface{})
data["name"] = "Alice"
data["age"] = 30
data["isStudent"] = false
fmt.Println(data["name"]) // 輸出: Alice
fmt.Println(data["age"]) // 輸出: 30
fmt.Println(data["isStudent"]) // 輸出: false
}
在上面的範例中,我們建立了一個map
,其中值的型別是interface{}
,這意味著map
可以儲存不同型別的值。我們使用字串鍵將字串、整數和布林值關聯到map
中,並在後續通過鍵來存取這些值。
介面型別一旦被定義後,它就和其他 Go 型別一樣可以用於宣告變數,比如:
var err error // err是一個error介面型別的範例變數
var r io.Reader // r是一個io.Reader介面型別的範例變數
這些型別為介面型別的變數被稱為介面型別變數,如果沒有被顯式賦予初值,介面型別變數的預設值為 nil
。如果要為介面型別變數顯式賦予初值,我們就要為介面型別變數選擇合法的右值。
Go 規定
:如果一個型別 T
的方法集合是某介面型別 I
的方法集合的等價集合或超集,我們就說型別 T
實現了介面型別 I
,那麼型別 T
的變數就可以作為合法的右值賦值給介面型別 I
的變數。
如果一個變數的型別是空介面型別,由於空介面型別的方法集合為空,這就意味著任何型別都實現了空介面的方法集合,所以我們可以將任何型別的值作為右值,賦值給空介面型別的變數,比如下面例子:
var i interface{} = 15 // ok
i = "hello, golang" // ok
type T struct{}
var t T
i = t // ok
i = &t // ok
空介面型別的這一可接受任意型別變數值作為右值的特性,讓它成為 Go 加入泛型語法之前唯一一種具有「泛型」能力的語法元素,包括 Go 標準庫在內的一些通用資料結構與演演算法的實現,都使用了空型別
interface{}
作為資料元素的型別,這樣我們就無需為每種支援的元素型別單獨做一份程式碼拷貝了。
Go 語言還支援介面型別變數賦值的「逆操作」,也就是通過介面型別變數「還原」它的右值的型別與值資訊,這個過程被稱為「型別斷言(Type Assertion
)」。型別斷言通常使用下面的語法形式:
v, ok := i.(T)
其中 i
是某一個介面型別變數,如果 T
是一個非介面型別且 T
是想要還原的型別,那麼這句程式碼的含義就是斷言儲存在介面型別變數 i
中的值的型別為 T
。
如果介面型別變數 i
之前被賦予的值確為 T
型別的值,那麼這個語句執行後,左側「comma, ok
」語句中的變數 ok
的值將為 true
,變數 v
的型別為 T
,它的值會是之前變數 i
的右值。如果 i
之前被賦予的值不是 T
型別的值,那麼這個語句執行後,變數 ok
的值為 false
,變數 v
的型別還是那個要還原的型別,但它的值是型別 T
的零值。
型別斷言也支援下面這種語法形式:
v := i.(T)
但在這種形式下,一旦介面變數 i
之前被賦予的值不是 T
型別的值,那麼這個語句將丟擲 panic
。如果變數 i
被賦予的值是 T
型別的值,那麼變數 v
的型別為 T
,它的值就會是之前變數 i
的右值。由於可能出現 panic
,所以我們並不推薦使用這種型別斷言的語法形式。
為了加深你的理解,接下來我們通過一個例子來直觀看一下型別斷言的語意:
var a int64 = 13
var i interface{} = a
v1, ok := i.(int64)
fmt.Printf("v1=%d, the type of v1 is %T, ok=%t\n", v1, v1, ok) // v1=13, the type of v1 is int64, ok=true
v2, ok := i.(string)
fmt.Printf("v2=%s, the type of v2 is %T, ok=%t\n", v2, v2, ok) // v2=, the type of v2 is string, ok=false
v3 := i.(int64)
fmt.Printf("v3=%d, the type of v3 is %T\n", v3, v3) // v3=13, the type of v3 is int64
v4 := i.([]int) // panic: interface conversion: interface {} is int64, not []int
fmt.Printf("the type of v4 is %T\n", v4)
你可以看到,這個例子的輸出結果與我們之前講解的是一致的。
在這段程式碼中,如果 v, ok := i.(T)
中的 T
是一個介面型別,那麼型別斷言的語意就會變成:斷言 i
的值實現了介面型別 T
。如果斷言成功,變數 v
的型別為 i
的值的型別,而並非介面型別 T
。如果斷言失敗,v
的型別資訊為介面型別 T
,它的值為 nil
,下面我們再來看一個 T
為介面型別的範例:
type MyInterface interface {
M1()
}
type T int
func (T) M1() {
println("T's M1")
}
func main() {
var t T
var i interface{} = t
v1, ok := i.(MyInterface)
if !ok {
panic("the value of i is not MyInterface")
}
v1.M1()
fmt.Printf("the type of v1 is %T\n", v1) // the type of v1 is main.T
i = int64(13)
v2, ok := i.(MyInterface)
fmt.Printf("the type of v2 is %T\n", v2) // the type of v2 is <nil>
// v2 = 13 // cannot use 1 (type int) as type MyInterface in assignment: int does not implement MyInterface (missing M1 method)
}
我們看到,通過the type of v2 is <nil>
,我們其實是看不出斷言失敗後的變數 v2
的型別的,但通過最後一行程式碼的編譯器錯誤提示,我們能清晰地看到 v2
的型別資訊為 MyInterface
。
其實,介面型別的型別斷言還有一個變種,那就是 type switch ,這個你可以去看看【go 流程控制之switch 語句介紹】。
介面型別的背後,是通過把型別的行為抽象成契約,建立雙方共同遵守的約定,這種契約將雙方的耦合降到了最低的程度。和生活工作中的契約有繁有簡,簽署方式多樣一樣,程式碼間的契約也有多有少,有大有小,而且達成契約的方式也有所不同。 而 Go 選擇了去繁就簡的形式,這主要體現在以下兩點上:
Go 對小介面的青睞在它的標準庫中體現得淋漓盡致,這裡我給出了標準庫中一些我們日常開發中常用的介面的定義:
// $GOROOT/src/builtin/builtin.go
type error interface {
Error() string
}
// $GOROOT/src/io/io.go
type Reader interface {
Read(p []byte) (n int, err error)
}
// $GOROOT/src/net/http/server.go
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
type ResponseWriter interface {
Header() Header
Write([]byte) (int, error)
WriteHeader(int)
}
我們看到,上述這些介面的方法數量在 1~3 個之間,這種「小介面」的 Go 慣例也已經被 Go 社群專案廣泛採用。我統計了早期版本的 Go 標準庫(Go 1.13 版本)、Docker 專案(Docker 19.03 版本)以及 Kubernetes 專案(Kubernetes 1.17 版本)中定義的介面型別方法集合中方法數量,你可以看下:
從圖中我們可以看到,無論是 Go 標準庫,還是 Go 社群知名專案,它們基本都遵循了「儘量定義小介面」的慣例,介面方法數量在 1~3 範圍內的介面佔了絕大多數。那麼在編碼層面,小介面究竟有哪些優勢呢?
計算機程式本身就是對真實世界的抽象與再建構。抽象就是對同類事物去除它具體的、次要的方面,抽取它相同的、主要的方面。不同的抽象程度,會導致抽象出的概念對應的事物的集合不同。抽象程度越高,對應的集合空間就越大;抽象程度越低,也就是越具像化,更接近事物真實面貌,對應的集合空間越小。
我們舉一個生活中的簡單例子。你可以看下這張示意圖,它是對生活中不同抽象程度的形象詮釋:
這張圖中我們分別建立了三個抽象:
我們看到,「會飛的」、「會游泳的」這兩個抽象對應的事物集合,要大於「會飛且會游泳的」所對應的事物集合空間,也就是說「會飛的」、「會游泳的」這兩個抽象程度更高。
我們將上面的抽象轉換為 Go 程式碼看看:
// 會飛的
type Flyable interface {
Fly()
}
// 會游泳的
type Swimable interface {
Swim()
}
// 會飛且會游泳的
type FlySwimable interface {
Flyable
Swimable
}
我們用上述定義的介面替換上圖中的抽象,再得到這張示意圖:
我們可以直觀地看到,這張圖中的 Flyable
只有一個 Fly
方法,FlySwimable
則包含兩個方法 Fly
和 Swim
。我們看到,具有更少方法的 Flyable
的抽象程度相對於 FlySwimable
要高,包含的事物集合(7 種動物)也要比 FlySwimable
的事物集合(4 種動物)大。也就是說,介面越小(介面方法少),抽象程度越高,對應的事物集合越大。
而這種情況的極限恰恰就是無方法的空介面 interface{}
,空介面的這個抽象對應的事物集合空間包含了 Go 語言世界的所有事物。
Go 推崇通過組合的方式構建程式。Go 開發人員一般會嘗試通過嵌入其他已有介面型別的方式來構建新介面型別,就像通過嵌入 io.Reader 和 io.Writer 構建 io.ReadWriter 那樣。
那構建時,如果有眾多候選介面型別供我們選擇,我們會怎麼選擇呢?
顯然,我們會選擇那些新介面型別需要的契約職責,同時也要求不要引入我們不需要的契約職責。在這樣的情況下,擁有單一或少數方法的小介面便更有可能成為我們的目標,而那些擁有較多方法的大介面,可能會因引入了諸多不需要的契約職責而被放棄。由此可見,小介面更契合 Go 的組合思想,也更容易發揮出組合的威力。
保持簡單有時候比複雜更難。小介面雖好,但如何定義出小介面是擺在所有 Gopher 面前的一道難題。這道題沒有標準答案,但有一些點可供我們在實踐中考量遵循。
要設計和定義出小介面,前提是需要先有介面。
Go 語言還比較年輕,它的設計哲學和推崇的程式設計理念可能還沒被廣大 Gopher 100% 理解、接納和應用於實踐當中,尤其是 Go 所推崇的基於介面的組合思想。
儘管介面不是 Go 獨有的,但專注於介面是編寫強大而靈活的 Go 程式碼的關鍵。因此,在定義小介面之前,我們需要先針對問題領域進行深入理解,聚焦抽象並行現介面,就像下圖所展示的那樣,先針對領域物件的行為進行抽象,形成一個介面集合:
初期,我們先不要介意這個介面集合中方法的數量,因為對問題域的理解是循序漸進的,在第一版程式碼中直接定義出小介面可能並不現實。而且,標準庫中的 io.Reader
和 io.Writer
也不是在 Go 剛誕生時就有的,而是在發現對網路、檔案、其他位元組資料處理的實現十分相似之後才抽象出來的。並且越偏向業務層,抽象難度就越高,這或許也是前面圖中 Go 標準庫小介面(1~3 個方法)佔比略高於 Docker 和 Kubernetes 的原因。
有了介面後,我們就會看到介面被用在了程式碼的各個地方。一段時間後,我們就來分析哪些場合使用了介面的哪些方法,是否可以將這些場合使用的介面的方法提取出來,放入一個新的小介面中,就像下面圖示中的那樣:
這張圖中的大介面 1 定義了多個方法,一段時間後,我們發現方法 1 和方法 2 經常用在場合 1 中,方法 3 和方法 4 經常用在場合 2 中,方法 5 和方法 6 經常用在場合 3 中,大介面 1 的方法呈現出一種按業務邏輯自然分組的狀態。
這個時候我們可以將這三組方法分別提取出來放入三個小介面中,也就是將大介面 1 拆分為三個小介面 A、B 和 C。拆分後,原應用場合 1~3 使用介面 1 的地方就可以無縫替換為介面 A、B、C 了。
那麼,上面已經被拆分成的小介面是否需要進一步拆分,直至每個介面都只有一個方法呢?這個依然沒有標準答案,不過你依然可以考量一下現有小介面是否需要滿足單一契約職責,就像 io.Reader
那樣。如果需要,就可以進一步拆分,提升抽象程度。