Go語言核心36講(Go語言進階技術八)--學習筆記

MingsonZheng發表於2021-10-27

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/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

相關文章