14 | 接口類型的合理運用
前導內容:正確使用接口的基礎知識
在 Go 語言的語境中,當我們在談論“接口”的時候,一定指的是接口類型。因為接口類型與其他數據類型不同,它是沒法被實例化的。
更具體地說,我們既不能通過調用new函數或make函數創建出一個接口類型的值,也無法用字面量來表示一個接口類型的值。
對於某一個接口類型來說,如果沒有任何數據類型可以作為它的實現,那么該接口的值就不可能存在。
我已經在前面展示過,通過關鍵字type和interface,我們可以聲明出接口類型。
接口類型的類型字面量與結構體類型的看起來有些相似,它們都用花括號包裹一些核心信息。只不過,結構體類型包裹的是它的字段聲明,而接口類型包裹的是它的方法定義。
這里你要注意的是:接口類型聲明中的這些方法所代表的就是該接口的方法集合。一個接口的方法集合就是它的全部特征。
對於任何數據類型,只要它的方法集合中完全包含了一個接口的全部特征(即全部的方法),那么它就一定是這個接口的實現類型。比如下面這樣:
type Pet interface {
SetName(name string)
Name() string
Category() string
}
我聲明了一個接口類型Pet,它包含了 3 個方法定義,方法名稱分別為SetName、Name和Category。這 3 個方法共同組成了接口類型Pet的方法集合。
只要一個數據類型的方法集合中有這 3 個方法,那么它就一定是Pet接口的實現類型。這是一種無侵入式的接口實現方式。這種方式還有一個專有名詞,叫“Duck typing”,中文常譯作“鴨子類型”。你可以到百度的百科頁面 https://baike.baidu.com/item/%E9%B8%AD%E5%AD%90%E7%B1%BB%E5%9E%8B 上去了解一下詳情。
順便說一句,怎樣判定一個數據類型的某一個方法實現的就是某個接口類型中的某個方法呢?
這有兩個充分必要條件,一個是“兩個方法的簽名需要完全一致”,另一個是“兩個方法的名稱要一模一樣”。顯然,這比判斷一個函數是否實現了某個函數類型要更加嚴格一些。
如果你查閱了上篇文章附帶的最后一個示例的話,那么就一定會知道,雖然結構體類型Cat不是Pet接口的實現類型,但它的指針類型*Cat卻是這個的實現類型。
如果你還不知道原因,那么請跟着我一起來看。我已經把Cat類型的聲明搬到了 demo31.go 文件中,並進行了一些簡化,以便你看得更清楚。對了,由於Cat和Pet的發音過於相似,我還把Cat重命名為了Dog。
package main
import "fmt"
type Pet interface {
SetName(name string)
Name() string
Category() string
}
type Dog struct {
name string // 名字。
}
func (dog *Dog) SetName(name string) {
dog.name = name
}
func (dog Dog) Name() string {
return dog.name
}
func (dog Dog) Category() string {
return "dog"
}
func main() {
// 示例1。
dog := Dog{"little pig"}
_, ok := interface{}(dog).(Pet)
fmt.Printf("Dog implements interface Pet: %v\n", ok)
_, ok = interface{}(&dog).(Pet)
fmt.Printf("*Dog implements interface Pet: %v\n", ok)
fmt.Println()
// 示例2。
var pet Pet = &dog
fmt.Printf("This pet is a %s, the name is %q.\n",
pet.Category(), pet.Name())
}
我聲明的類型Dog附帶了 3 個方法。其中有 2 個值方法,分別是Name和Category,另外還有一個指針方法SetName。
這就意味着,Dog類型本身的方法集合中只包含了 2 個方法,也就是所有的值方法。而它的指針類型*Dog方法集合卻包含了 3 個方法,
也就是說,它擁有Dog類型附帶的所有值方法和指針方法。又由於這 3 個方法恰恰分別是Pet接口中某個方法的實現,所以*Dog類型就成為了Pet接口的實現類型。
dog := Dog{"little pig"}
var pet Pet = &dog
正因為如此,我可以聲明並初始化一個Dog類型的變量dog,然后把它的指針值賦給類型為Pet的變量pet。
這里有幾個名詞需要你先記住。對於一個接口類型的變量來說,例如上面的變量pet,我們賦給它的值可以被叫做它的實際值(也稱動態值),而該值的類型可以被叫做這個變量的實際類型(也稱動態類型)。
比如,我們把取址表達式&dog的結果值賦給了變量pet,這時這個結果值就是變量pet的動態值,而此結果值的類型*Dog就是該變量的動態類型。
動態類型這個叫法是相對於靜態類型而言的。對於變量pet來講,它的靜態類型就是Pet,並且永遠是Pet,但是它的動態類型卻會隨着我們賦給它的動態值而變化。
比如,只有我把一個Dog類型的值賦給變量pet之后,該變量的動態類型才會是Dog。如果還有一個Pet接口的實現類型Fish,並且我又把一個此類型的值賦給了pet,那么它的動態類型就會變為Fish。
還有,在我們給一個接口類型的變量賦予實際的值之前,它的動態類型是不存在的。
你需要想辦法搞清楚接口類型的變量(以下簡稱接口變量)的動態值、動態類型和靜態類型都是什么意思。因為我會在后面基於這些概念講解更深層次的知識。
好了,我下面會就“怎樣用好 Go 語言的接口”這個話題提出一系列問題,也請你跟着我一起思考這些問題。
那么今天的問題是:當我們為一個接口變量賦值時會發生什么?
為了突出問題,我把Pet接口的聲明簡化了一下。
type Pet interface {
Name() string
Category() string
}
我從中去掉了Pet接口的那個名為SetName的方法。這樣一來,Dog類型也就變成Pet接口的實現類型了。你可以在 demo32.go 文件中找到本問題的代碼。
package main
import (
"fmt"
)
type Pet interface {
Name() string
Category() string
}
type Dog struct {
name string // 名字。
}
func (dog *Dog) SetName(name string) {
dog.name = name
}
func (dog Dog) Name() string {
return dog.name
}
func (dog Dog) Category() string {
return "dog"
}
func main() {
// 示例1。
dog := Dog{"little pig"}
fmt.Printf("The dog's name is %q.\n", dog.Name())
var pet Pet = dog
dog.SetName("monster")
fmt.Printf("The dog's name is %q.\n", dog.Name())
fmt.Printf("This pet is a %s, the name is %q.\n",
pet.Category(), pet.Name())
fmt.Println()
// 示例2。
dog1 := Dog{"little pig"}
fmt.Printf("The name of first dog is %q.\n", dog1.Name())
dog2 := dog1
fmt.Printf("The name of second dog is %q.\n", dog2.Name())
dog1.name = "monster"
fmt.Printf("The name of first dog is %q.\n", dog1.Name())
fmt.Printf("The name of second dog is %q.\n", dog2.Name())
fmt.Println()
// 示例3。
dog = Dog{"little pig"}
fmt.Printf("The dog's name is %q.\n", dog.Name())
pet = &dog
dog.SetName("monster")
fmt.Printf("The dog's name is %q.\n", dog.Name())
fmt.Printf("This pet is a %s, the name is %q.\n",
pet.Category(), pet.Name())
}
現在,我先聲明並初始化了一個Dog類型的變量dog,這時它的name字段的值是"little pig"。然后,我把該變量賦給了一個Pet類型的變量pet。最后我通過調用dog的方法SetName把它的name字段的值改成了"monster"。
dog := Dog{"little pig"}
var pet Pet = dog
dog.SetName("monster")
所以,我要問的具體問題是:在以上代碼執行后,pet變量的字段name的值會是什么?
這個題目的典型回答是:pet變量的字段name的值依然是"little pig"。
問題解析
首先,由於dog的SetName方法是指針方法,所以該方法持有的接收者就是指向dog的指針值的副本,因而其中對接收者的name字段的設置就是對變量dog的改動。那么當dog.SetName("monster")執行之后,dog的name字段的值就一定是"monster"。如果你理解到了這一層,那么請小心前方的陷阱。
為什么dog的name字段值變了,而pet的卻沒有呢?這里有一條通用的規則需要你知曉:如果我們使用一個變量給另外一個變量賦值,那么真正賦給后者的,並不是前者持有的那個值,而是該值的一個副本。
例如,我聲明並初始化了一個Dog類型的變量dog1,這時它的name是"little pig"。然后,我在把dog1賦給變量dog2之后,修改了dog1的name字段的值。這時,dog2的name字段的值是什么?
dog1 := Dog{"little pig"}
dog2 := dog1
dog1.name = "monster"
這個問題與前面那道題幾乎一樣,只不過這里沒有涉及接口類型。這時的dog2的name仍然會是"little pig"。這就是我剛剛告訴你的那條通用規則的又一個體現。
當你知道了這條通用規則之后,確實可以把前面那道題做對。不過,如果當我問你為什么的時候你只說出了這一個原因,那么,我只能說你僅僅答對了一半。
那么另一半是什么?這就需要從接口類型值的存儲方式和結構說起了。我在前面說過,接口類型本身是無法被值化的。在我們賦予它實際的值之前,它的值一定會是nil,這也是它的零值。
反過來講,一旦它被賦予了某個實現類型的值,它的值就不再是nil了。不過要注意,即使我們像前面那樣把dog的值賦給了pet,pet的值與dog的值也是不同的。這不僅僅是副本與原值的那種不同。
當我們給一個接口變量賦值的時候,該變量的動態類型會與它的動態值一起被存儲在一個專用的數據結構中。
嚴格來講,這樣一個變量的值其實是這個專用數據結構的一個實例,而不是我們賦給該變量的那個實際的值。所以我才說,pet的值與dog的值肯定是不同的,無論是從它們存儲的內容,還是存儲的結構上來看都是如此。不過,我們可以認為,這時pet的值中包含了dog值的副本。
我們就把這個專用的數據結構叫做iface吧,在 Go 語言的runtime包中它其實就叫這個名字。
iface的實例會包含兩個指針,一個是指向類型信息的指針,另一個是指向動態值的指針。這里的類型信息是由另一個專用數據結構的實例承載的,其中包含了動態值的類型,以及使它實現了接口的方法和調用它們的途徑,等等。
總之,接口變量被賦予動態值的時候,存儲的是包含了這個動態值的副本的一個結構更加復雜的值。你明白了嗎?
知識擴展
問題 1:接口變量的值在什么情況下才真正為nil?
這個問題初看起來就不是個問題。對於一個引用類型的變量,它的值是否為nil完全取決於我們賦給它了什么,是這樣嗎?我們先來看一段代碼:
var dog1 *Dog
fmt.Println("The first dog is nil. [wrap1]")
dog2 := dog1
fmt.Println("The second dog is nil. [wrap1]")
var pet Pet = dog2
if pet == nil {
fmt.Println("The pet is nil. [wrap1]")
} else {
fmt.Println("The pet is not nil. [wrap1]")
}
在 demo33.go 文件的這段代碼中,我先聲明了一個*Dog類型的變量dog1,並且沒有對它進行初始化。這時該變量的值是什么?顯然是nil。然后我把該變量賦給了dog2,后者的值此時也必定是nil,對嗎?
現在問題來了:當我把dog2賦給Pet類型的變量pet之后,變量pet的值會是什么?答案是nil嗎?
如果你真正理解了我在上一個問題的解析中講到的知識,尤其是接口變量賦值及其值的數據結構那部分,那么這道題就不難回答。你可以先思考一下,然后再接着往下看。
當我們把dog2的值賦給變量pet的時候,dog2的值會先被復制,不過由於在這里它的值是nil,所以就沒必要復制了。
然后,Go 語言會用我上面提到的那個專用數據結構iface的實例包裝這個dog2的值的副本,這里是nil。
雖然被包裝的動態值是nil,但是pet的值卻不會是nil,因為這個動態值只是pet值的一部分而已。
順便說一句,這時的pet的動態類型就存在了,是*Dog。我們可以通過fmt.Printf函數和占位符%T來驗證這一點,另外reflect包的TypeOf函數也可以起到類似的作用。
換個角度來看。我們把nil賦給了pet,但是pet的值卻不是nil。
這很奇怪對嗎?其實不然。在 Go 語言中,我們把由字面量nil表示的值叫做無類型的nil。這是真正的nil,因為它的類型也是nil的。雖然dog2的值是真正的nil,但是當我們把這個變量賦給pet的時候,Go 語言會把它的類型和值放在一起考慮。
也就是說,這時 Go 語言會識別出賦予pet的值是一個*Dog類型的nil。然后,Go 語言就會用一個iface的實例包裝它,包裝后的產物肯定就不是nil了。
只要我們把一個有類型的nil賦給接口變量,那么這個變量的值就一定不會是那個真正的nil。因此,當我們使用判等符號==判斷pet是否與字面量nil相等的時候,答案一定會是false。
那么,怎樣才能讓一個接口變量的值真正為nil呢?要么只聲明它但不做初始化,要么直接把字面量nil賦給它。
demo33
package main
import (
"fmt"
"reflect"
)
type Pet interface {
Name() string
Category() string
}
type Dog struct {
name string // 名字。
}
func (dog *Dog) SetName(name string) {
dog.name = name
}
func (dog Dog) Name() string {
return dog.name
}
func (dog Dog) Category() string {
return "dog"
}
func main() {
// 示例1。
var dog1 *Dog
fmt.Println("The first dog is nil.")
dog2 := dog1
fmt.Println("The second dog is nil.")
var pet Pet = dog2
if pet == nil {
fmt.Println("The pet is nil.")
} else {
fmt.Println("The pet is not nil.")
}
fmt.Printf("The type of pet is %T.\n", pet)
fmt.Printf("The type of pet is %s.\n", reflect.TypeOf(pet).String())
fmt.Printf("The type of second dog is %T.\n", dog2)
fmt.Println()
// 示例2。
wrap := func(dog *Dog) Pet {
if dog == nil {
return nil
}
return dog
}
pet = wrap(dog2)
if pet == nil {
fmt.Println("The pet is nil.")
} else {
fmt.Println("The pet is not nil.")
}
}
問題 2:怎樣實現接口之間的組合?
接口類型間的嵌入也被稱為接口的組合。我在前面講過結構體類型的嵌入字段,這其實就是在說結構體類型間的嵌入。
接口類型間的嵌入要更簡單一些,因為它不會涉及方法間的“屏蔽”。只要組合的接口之間有同名的方法就會產生沖突,從而無法通過編譯,即使同名方法的簽名彼此不同也會是如此。因此,接口的組合根本不可能導致“屏蔽”現象的出現。
與結構體類型間的嵌入很相似,我們只要把一個接口類型的名稱直接寫到另一個接口類型的成員列表中就可以了。比如:
type Animal interface {
ScientificName() string
Category() string
}
type Pet interface {
Animal
Name() string
}
接口類型Pet包含了兩個成員,一個是代表了另一個接口類型的Animal,一個是方法Name的定義。它們都被包含在Pet的類型聲明的花括號中,並且都各自獨占一行。此時,Animal接口包含的所有方法也就成為了Pet接口的方法。
Go 語言團隊鼓勵我們聲明體量較小的接口,並建議我們通過這種接口間的組合來擴展程序、增加程序的靈活性。
這是因為相比於包含很多方法的大接口而言,小接口可以更加專注地表達某一種能力或某一類特征,同時也更容易被組合在一起。
Go 語言標准庫代碼包io中的ReadWriteCloser接口和ReadWriter接口就是這樣的例子,它們都是由若干個小接口組合而成的。以io.ReadWriteCloser接口為例,它是由io.Reader、io.Writer和io.Closer這三個接口組成的。
這三個接口都只包含了一個方法,是典型的小接口。它們中的每一個都只代表了一種能力,分別是讀出、寫入和關閉。我們編寫這幾個小接口的實現類型通常都會很容易。並且,一旦我們同時實現了它們,就等於實現了它們的組合接口io.ReadWriteCloser。
即使我們只實現了io.Reader和io.Writer,那么也等同於實現了io.ReadWriter接口,因為后者就是前兩個接口組成的。可以看到,這幾個io包中的接口共同組成了一個接口矩陣。它們既相互關聯又獨立存在。
我在 demo34.go 文件中寫了一個能夠體現接口組合優勢的小例子,你可以去參看一下。總之,善用接口組合和小接口可以讓你的程序框架更加穩定和靈活。
package main
import (
"fmt"
)
type Animal interface {
// ScientificName 用於獲取動物的學名。
ScientificName() string
// Category 用於獲取動物的基本分類。
Category() string
}
type Named interface {
// Name 用於獲取名字。
Name() string
}
type Pet interface {
Animal
Named
}
type PetTag struct {
name string
owner string
}
func (pt PetTag) Name() string {
return pt.name
}
func (pt PetTag) Owner() string {
return pt.owner
}
type Dog struct {
PetTag
scientificName string
}
func (dog Dog) ScientificName() string {
return dog.scientificName
}
func (dog Dog) Category() string {
return "dog"
}
func main() {
petTag := PetTag{name: "little pig"}
_, ok := interface{}(petTag).(Named)
fmt.Printf("PetTag implements interface Named: %v\n", ok)
dog := Dog{
PetTag: petTag,
scientificName: "Labrador Retriever",
}
_, ok = interface{}(dog).(Animal)
fmt.Printf("Dog implements interface Animal: %v\n", ok)
_, ok = interface{}(dog).(Named)
fmt.Printf("Dog implements interface Named: %v\n", ok)
_, ok = interface{}(dog).(Pet)
fmt.Printf("Dog implements interface Pet: %v\n", ok)
}
總結
Go 語言的接口常用於代表某種能力或某類特征。首先,我們要弄清楚的是,接口變量的動態值、動態類型和靜態類型都代表了什么。這些都是正確使用接口變量的基礎。當我們給接口變量賦值時,接口變量會持有被賦予值的副本,而不是它本身。
更重要的是,接口變量的值並不等同於這個可被稱為動態值的副本。它會包含兩個指針,一個指針指向動態值,一個指針指向類型信息。
基於此,即使我們把一個值為nil的某個實現類型的變量賦給了接口變量,后者的值也不可能是真正的nil。雖然這時它的動態值會為nil,但它的動態類型確是存在的。
請記住,除非我們只聲明而不初始化,或者顯式地賦給它nil,否則接口變量的值就不會為nil。
后面的一個問題相對輕松一些,它是關於程序設計方面的。用好小接口和接口組合總是有益的,我們可以以此形成接口矩陣,進而搭起靈活的程序框架。如果在實現接口時再配合運用結構體類型間的嵌入手法,那么接口組合就可以發揮更大的效用。
思考題
如果我們把一個值為nil的某個實現類型的變量賦給了接口變量,那么在這個接口變量上仍然可以調用該接口的方法嗎?如果可以,有哪些注意事項?如果不可以,原因是什么?
本作品采用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。
歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含鏈接: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改后的作品務必以相同的許可發布。