13 | 結構體及其方法的使用法門
我們都知道,結構體類型表示的是實實在在的數據結構。一個結構體類型可以包含若干個字段,每個字段通常都需要有確切的名字和類型。
前導內容:結構體類型基礎知識
當然了,結構體類型也可以不包含任何字段,這樣並不是沒有意義的,因為我們還可以為類型關聯上一些方法,這里你可以把方法看做是函數的特殊版本。
函數是獨立的程序實體。我們可以聲明有名字的函數,也可以聲明沒名字的函數,還可以把它們當做普通的值傳來傳去。我們能把具有相同簽名的函數抽象成獨立的函數類型,以作為一組輸入、輸出(或者說一類邏輯組件)的代表。
方法卻不同,它需要有名字,不能被當作值來看待,最重要的是,它必須隸屬於某一個類型。方法所屬的類型會通過其聲明中的接收者(receiver)聲明體現出來。
接收者聲明就是在關鍵字func和方法名稱之間的圓括號包裹起來的內容,其中必須包含確切的名稱和類型字面量。
接收者的類型其實就是當前方法所屬的類型,而接收者的名稱,則用於在當前方法中引用它所屬的類型的當前值。
我們舉個例子來看一下。
// AnimalCategory 代表動物分類學中的基本分類法。
type AnimalCategory struct {
kingdom string // 界。
phylum string // 門。
class string // 綱。
order string // 目。
family string // 科。
genus string // 屬。
species string // 種。
}
func (ac AnimalCategory) String() string {
return fmt.Sprintf("%s%s%s%s%s%s%s",
ac.kingdom, ac.phylum, ac.class, ac.order,
ac.family, ac.genus, ac.species)
}
結構體類型AnimalCategory代表了動物的基本分類法,其中有 7 個string類型的字段,分別表示各個等級的分類。
下邊有個名叫String的方法,從它的接收者聲明可以看出它隸屬於AnimalCategory類型。
通過該方法的接收者名稱ac,我們可以在其中引用到當前值的任何一個字段,或者調用到當前值的任何一個方法(也包括String方法自己)。
這個String方法的功能是提供當前值的字符串表示形式,其中的各個等級分類會按照從大到小的順序排列。使用時,我們可以這樣表示:
category := AnimalCategory{species: "cat"}
fmt.Printf("The animal category: %s\n", category)
這里,我用字面量初始化了一個AnimalCategory類型的值,並把它賦給了變量category。為了不喧賓奪主,我只為其中的species字段指定了字符串值"cat",該字段代表最末級分類“種”。
在 Go 語言中,我們可以通過為一個類型編寫名為String的方法,來自定義該類型的字符串表示形式。這個String方法不需要任何參數聲明,但需要有一個string類型的結果聲明。
正因為如此,我在調用fmt.Printf函數時,使用占位符%s和category值本身就可以打印出后者的字符串表示形式,而無需顯式地調用它的String方法。
fmt.Printf函數會自己去尋找它。此時的打印內容會是The animal category: cat。顯而易見,category的String方法成功地引用了當前值的所有字段。
方法隸屬的類型其實並不局限於結構體類型,但必須是某個自定義的數據類型,並且不能是任何接口類型。
一個數據類型關聯的所有方法,共同組成了該類型的方法集合。同一個方法集合中的方法不能出現重名。並且,如果它們所屬的是一個結構體類型,那么它們的名稱與該類型中任何字段的名稱也不能重復。
我們可以把結構體類型中的一個字段看作是它的一個屬性或者一項數據,再把隸屬於它的一個方法看作是附加在其中數據之上的一個能力或者一項操作。將屬性及其能力(或者說數據及其操作)封裝在一起,是面向對象編程(object-oriented programming)的一個主要原則。Go 語言攝取了面向對象編程中的很多優秀特性,同時也推薦這種封裝的做法。從這方面看,Go 語言其實是支持面向對象編程的,但它選擇摒棄了一些在實際運用過程中容易引起程序開發者困惑的特性和規則。
現在,讓我們再把目光放到結構體類型的字段聲明上。我們來看下面的代碼:
type Animal struct {
scientificName string // 學名。
AnimalCategory // 動物基本分類。
}
我聲明了一個結構體類型,名叫Animal。它有兩個字段。一個是string類型的字段scientificName,代表了動物的學名。而另一個字段聲明中只有AnimalCategory,它正是我在前面編寫的那個結構體類型的名字。這是什么意思呢?
那么,我們今天的問題是:Animal類型中的字段聲明AnimalCategory代表了什么?
更寬泛地講,如果結構體類型的某個字段聲明中只有一個類型名,那么該字段代表了什么?
這個問題的典型回答是:字段聲明AnimalCategory代表了Animal類型的一個嵌入字段。Go 語言規范規定,如果一個字段的聲明中只有字段的類型名而沒有字段的名稱,那么它就是一個嵌入字段,也可以被稱為匿名字段。我們可以通過此類型變量的名稱后跟“.”,再后跟嵌入字段類型的方式引用到該字段。也就是說,嵌入字段的類型既是類型也是名稱。
問題解析
說到引用結構體的嵌入字段,Animal類型有個方法叫Category,它是這么寫的:
func (a Animal) Category() string {
return a.AnimalCategory.String()
}
Category方法的接收者類型是Animal,接收者名稱是a。在該方法中,我通過表達式a.AnimalCategory選擇到了a的這個嵌入字段,然后又選擇了該字段的String方法並調用了它。
順便提一下,在某個代表變量的標識符的右邊加“.”,再加上字段名或方法名的表達式被稱為選擇表達式,它用來表示選擇了該變量的某個字段或者方法。
這是 Go 語言規范中的說法,與“引用結構體的某某字段”或“調用結構體的某某方法”的說法是相通的。我在以后會混用這兩種說法。
實際上,把一個結構體類型嵌入到另一個結構體類型中的意義不止如此。嵌入字段的方法集合會被無條件地合並進被嵌入類型的方法集合中。例如下面這種:
animal := Animal{
scientificName: "American Shorthair",
AnimalCategory: category,
}
fmt.Printf("The animal: %s\n", animal)
我聲明了一個Animal類型的變量animal並對它進行初始化。我把字符串值"American Shorthair"賦給它的字段scientificName,並把前面聲明過的變量category賦給它的嵌入字段AnimalCategory。
我在后面使用fmt.Printf函數和%s占位符試圖打印animal的字符串表示形式,相當於調用animal的String方法。雖然我們還沒有為Animal類型編寫String方法,但這樣做是沒問題的。因為在這里,嵌入字段AnimalCategory的String方法會被當做animal的方法調用。
那如果我也為Animal類型編寫一個String方法呢?這里會調用哪一個呢?
答案是,animal的String方法會被調用。這時,我們說,嵌入字段AnimalCategory的String方法被“屏蔽”了。注意,只要名稱相同,無論這兩個方法的簽名是否一致,被嵌入類型的方法都會“屏蔽”掉嵌入字段的同名方法。
類似的,由於我們同樣可以像訪問被嵌入類型的字段那樣,直接訪問嵌入字段的字段,所以如果這兩個結構體類型里存在同名的字段,那么嵌入字段中的那個字段一定會被“屏蔽”。這與我們在前面講過的,可重名變量之間可能存在的“屏蔽”現象很相似。
正因為嵌入字段的字段和方法都可以“嫁接”到被嵌入類型上,所以即使在兩個同名的成員一個是字段,另一個是方法的情況下,這種“屏蔽”現象依然會存在。
不過,即使被屏蔽了,我們仍然可以通過鏈式的選擇表達式,選擇到嵌入字段的字段或方法,就像我在Category方法中所做的那樣。這種“屏蔽”其實還帶來了一些好處。我們看看下面這個Animal類型的String方法的實現:
func (a Animal) String() string {
return fmt.Sprintf("%s (category: %s)",
a.scientificName, a.AnimalCategory)
}
在這里,我們把對嵌入字段的String方法的調用結果融入到了Animal類型的同名方法的結果中。這種將同名方法的結果逐層“包裝”的手法是很常見和有用的,也算是一種慣用法了。
(結構體類型中的嵌入字段)
最后,我還要提一下多層嵌入的問題。也就是說,嵌入字段本身也有嵌入字段的情況。請看我聲明的Cat類型:
type Cat struct {
name string
Animal
}
func (cat Cat) String() string {
return fmt.Sprintf("%s (category: %s, name: %q)",
cat.scientificName, cat.Animal.AnimalCategory, cat.name)
}
結構體類型Cat中有一個嵌入字段Animal,而Animal類型還有一個嵌入字段AnimalCategory。
在這種情況下,“屏蔽”現象會以嵌入的層級為依據,嵌入層級越深的字段或方法越可能被“屏蔽”。
例如,當我們調用Cat類型值的String方法時,如果該類型確有String方法,那么嵌入字段Animal和AnimalCategory的String方法都會被“屏蔽”。
如果該類型沒有String方法,那么嵌入字段Animal的String方法會被調用,而它的嵌入字段AnimalCategory的String方法仍然會被屏蔽。
只有當Cat類型和Animal類型都沒有String方法的時候,AnimalCategory的String方法菜會被調用。最后的最后,如果處於同一個層級的多個嵌入字段擁有同名的字段或方法,那么從被嵌入類型的值那里,選擇此名稱的時候就會引發一個編譯錯誤,因為編譯器無法確定被選擇的成員到底是哪一個。
以上關於嵌入字段的所有示例都在 demo29.go 中,希望能對你有所幫助。
package main
import "fmt"
// 示例1。
// AnimalCategory 代表動物分類學中的基本分類法。
type AnimalCategory struct {
kingdom string // 界。
phylum string // 門。
class string // 綱。
order string // 目。
family string // 科。
genus string // 屬。
species string // 種。
}
func (ac AnimalCategory) String() string {
return fmt.Sprintf("%s%s%s%s%s%s%s",
ac.kingdom, ac.phylum, ac.class, ac.order,
ac.family, ac.genus, ac.species)
}
// 示例2。
type Animal struct {
scientificName string // 學名。
AnimalCategory // 動物基本分類。
}
// 該方法會"屏蔽"掉嵌入字段中的同名方法。
func (a Animal) String() string {
return fmt.Sprintf("%s (category: %s)",
a.scientificName, a.AnimalCategory)
}
// 示例3。
type Cat struct {
name string
Animal
}
// 該方法會"屏蔽"掉嵌入字段中的同名方法。
func (cat Cat) String() string {
return fmt.Sprintf("%s (category: %s, name: %q)",
cat.scientificName, cat.Animal.AnimalCategory, cat.name)
}
func main() {
// 示例1。
category := AnimalCategory{species: "cat", genus: "dog"}
fmt.Printf("The animal category: %s\n", category)
// 示例2。
animal := Animal{
scientificName: "American Shorthair",
AnimalCategory: category,
}
fmt.Printf("The animal: %s\n", animal)
// 示例3。
cat := Cat{
name: "little pig",
Animal: animal,
}
fmt.Printf("The cat: %s\n", cat)
}
知識擴展
問題 1:Go 語言是用嵌入字段實現了繼承嗎?
這里強調一下,Go 語言中根本沒有繼承的概念,它所做的是通過嵌入字段的方式實現了類型之間的組合。這樣做的具體原因和理念請見 Go 語言官網的 FAQ 中的Why is there no type inheritance? https://golang.org/doc/faq#inheritance。
簡單來說,面向對象編程中的繼承,其實是通過犧牲一定的代碼簡潔性來換取可擴展性,而且這種可擴展性是通過侵入的方式來實現的。
類型之間的組合采用的是非聲明的方式,我們不需要顯式地聲明某個類型實現了某個接口,或者一個類型繼承了另一個類型。
同時,類型組合也是非侵入式的,它不會破壞類型的封裝或加重類型之間的耦合。
我們要做的只是把類型當做字段嵌入進來,然后坐享其成地使用嵌入字段所擁有的一切。如果嵌入字段有哪里不合心意,我們還可以用“包裝”或“屏蔽”的方式去調整和優化。
另外,類型間的組合也是靈活的,我們總是可以通過嵌入字段的方式把一個類型的屬性和能力“嫁接”給另一個類型。
這時候,被嵌入類型也就自然而然地實現了嵌入字段所實現的接口。再者,組合要比繼承更加簡潔和清晰,Go 語言可以輕而易舉地通過嵌入多個字段來實現功能強大的類型,卻不會有多重繼承那樣復雜的層次結構和可觀的管理成本。
接口類型之間也可以組合。在 Go 語言中,接口類型之間的組合甚至更加常見,我們常常以此來擴展接口定義的行為或者標記接口的特征。與此有關的內容我在下一篇文章中再講。
在我面試過的眾多 Go 工程師中,有很多人都在說“Go 語言用嵌入字段實現了繼承”,而且深信不疑。
要么是他們還在用其他編程語言的視角和理念來看待 Go 語言,要么就是受到了某些所謂的“Go 語言教程”的誤導。每當這時,我都忍不住當場糾正他們,並建議他們去看看官網上的解答。
問題 2:值方法和指針方法都是什么意思,有什么區別?
我們都知道,方法的接收者類型必須是某個自定義的數據類型,而且不能是接口類型或接口的指針類型。所謂的值方法,就是接收者類型是非指針的自定義數據類型的方法。
比如,我們在前面為AnimalCategory、Animal以及Cat類型聲明的那些方法都是值方法。就拿Cat來說,它的String方法的接收者類型就是Cat,一個非指針類型。那什么叫指針類型呢?請看這個方法:
func (cat *Cat) SetName(name string) {
cat.name = name
}
方法SetName的接收者類型是Cat。Cat左邊再加個代表的就是Cat類型的指針類型。
這時,Cat可以被叫做*Cat的基本類型。你可以認為這種指針類型的值表示的是指向某個基本類型值的指針。
我們可以通過把取值操作符*放在這樣一個指針值的左邊來組成一個取值表達式,以獲取該指針值指向的基本類型值,也可以通過把取址操作符&放在一個可尋址的基本類型值的左邊來組成一個取址表達式,以獲取該基本類型值的指針值。
所謂的指針方法,就是接收者類型是上述指針類型的方法。
那么值方法和指針方法之間有什么不同點呢?它們的不同如下所示。
1、值方法的接收者是該方法所屬的那個類型值的一個副本。我們在該方法內對該副本的修改一般都不會體現在原值上,除非這個類型本身是某個引用類型(比如切片或字典)的別名類型。
而指針方法的接收者,是該方法所屬的那個基本類型值的指針值的一個副本。我們在這樣的方法內對該副本指向的值進行修改,卻一定會體現在原值上。
2、一個自定義數據類型的方法集合中僅會包含它的所有值方法,而該類型的指針類型的方法集合卻囊括了前者的所有方法,包括所有值方法和所有指針方法。
嚴格來講,我們在這樣的基本類型的值上只能調用到它的值方法。但是,Go 語言會適時地為我們進行自動地轉譯,使得我們在這樣的值上也能調用到它的指針方法。
比如,在Cat類型的變量cat之上,之所以我們可以通過cat.SetName("monster")修改貓的名字,是因為 Go 語言把它自動轉譯為了(&cat).SetName("monster"),即:先取cat的指針值,然后在該指針值上調用SetName方法。
3、在后邊你會了解到,一個類型的方法集合中有哪些方法與它能實現哪些接口類型是息息相關的。如果一個基本類型和它的指針類型的方法集合是不同的,那么它們具體實現的接口類型的數量就也會有差異,除非這兩個數量都是零。
比如,一個指針類型實現了某某接口類型,但它的基本類型卻不一定能夠作為該接口的實現類型。
能夠體現值方法和指針方法之間差異的小例子我放在 demo30.go 文件里了,你可以參照一下。
package main
import "fmt"
type Cat struct {
name string // 名字。
scientificName string // 學名。
category string // 動物學基本分類。
}
func New(name, scientificName, category string) Cat {
return Cat{
name: name,
scientificName: scientificName,
category: category,
}
}
func (cat *Cat) SetName(name string) {
cat.name = name
}
func (cat Cat) SetNameOfCopy(name string) {
cat.name = name
}
func (cat Cat) Name() string {
return cat.name
}
func (cat Cat) ScientificName() string {
return cat.scientificName
}
func (cat Cat) Category() string {
return cat.category
}
func (cat Cat) String() string {
return fmt.Sprintf("%s (category: %s, name: %q)",
cat.scientificName, cat.category, cat.name)
}
func main() {
cat := New("little pig", "American Shorthair", "cat")
cat.SetName("monster") // (&cat).SetName("monster")
fmt.Printf("The cat: %s\n", cat)
cat.SetNameOfCopy("little pig")
fmt.Printf("The cat: %s\n", cat)
type Pet interface {
SetName(name string)
Name() string
Category() string
ScientificName() string
}
_, ok := interface{}(cat).(Pet)
fmt.Printf("Cat implements interface Pet: %v\n", ok)
_, ok = interface{}(&cat).(Pet)
fmt.Printf("*Cat implements interface Pet: %v\n", ok)
}
總結
結構體類型的嵌入字段比較容易讓 Go 語言新手們迷惑,所以我在本篇文章着重解釋了它的編寫方法、基本的特性和規則以及更深層次的含義。在理解了結構體類型及其方法的組成方式和構造套路之后,這些知識應該是你重點掌握的。
嵌入字段是其聲明中只有類型而沒有名稱的字段,它可以以一種很自然的方式為被嵌入的類型帶來新的屬性和能力。在一般情況下,我們用簡單的選擇表達式就可以直接引用到它們的字段和方法。
不過,我們需要小心可能產生“屏蔽”現象的地方,尤其是當存在多個嵌入字段或者多層嵌入的時候。“屏蔽”現象可能會讓你的實際引用與你的預期不符。
另外,你一定要梳理清楚值方法和指針方法的不同之處,包括這兩種方法各自能做什么、不能做什么以及會影響到其所屬類型的哪些方面。這涉及值的修改、方法集合和接口實現。
最后,再次強調,嵌入字段是實現類型間組合的一種方式,這與繼承沒有半點兒關系。Go 語言雖然支持面向對象編程,但是根本就沒有“繼承”這個概念。
思考題
- 我們可以在結構體類型中嵌入某個類型的指針類型嗎?如果可以,有哪些注意事項?
- 字面量struct{}代表了什么?又有什么用處?
本作品采用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。
歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含鏈接: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改后的作品務必以相同的許可發布。