《永劫無間》特木爾沙克洛克達爾捏臉分享
我們都知道,結構體型別表示的是實實在在的資料結構。一個結構體型別可以包含若干個欄位,每個欄位通常都需要有確切的名字和型別。
前導內容:結構體型別基礎知識
當然了,結構體型別也可以不包含任何欄位,這樣並不是沒有意義的,因為我們還可以為型別關聯上一些方法,這裡你可以把方法看做是函式的特殊版本。
函式是獨立的程式實體。我們可以宣告有名字的函式,也可以宣告沒名字的函式,還可以把它們當做普通的值傳來傳去。我們能把具有相同簽名的函式抽象成獨立的函式型別,以作為一組輸入、輸出(或者說一類邏輯元件)的代表。
方法卻不同,它需要有名字,不能被當作值來看待,最重要的是,它必須隸屬於某一個型別。方法所屬的型別會通過其宣告中的接收者(receiver)宣告體現出來。
接收者宣告就是在關鍵字func
和方法名稱之間的圓括號包裹起來的內容,其中必須包含確切的名稱和型別字面量。
接收者的型別其實就是當前方法所屬的型別,而接收者的名稱,則用於在當前方法中引用它所屬的型別的當前值。
我們舉個例子來看一下。
// AnimalCategory 代表動物分類學中的基本分類法。 type AnimalCategory struct { kingdom string // 界。 phylumstring // 門。 classstring // 綱。 orderstring // 目。 familystring // 科。 genusstring // 屬。 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中,希望能對你有所幫助。
知識擴充套件
問題1:Go語言是用嵌入欄位實現了繼承嗎?
這裡強調一下,Go語言中根本沒有繼承的概念,它所做的是通過嵌入欄位的方式實現了型別之間的組合。這樣做的具體原因和理念請見Go語言官網的FAQ中的Why is there no type 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
的基本型別。你可以認為這種指標型別的值表示的是指向某個基本型別值的指標。
我們可以通過把取值操作符*
放在這樣一個指標值的左邊來組成一個取值表示式,以獲取該指標值指向的基本型別值,也可以通過把取址操作符&
放在一個可定址的基本型別值的左邊來組成一個取址表示式,以獲取該基本型別值的指標值。
所謂的指標方法,就是接收者型別是上述指標型別的方法。
那麼值方法和指標方法之間有什麼不同點呢?它們的不同如下所示。
-
值方法的接收者是該方法所屬的那個型別值的一個副本。我們在該方法內對該副本的修改一般都不會體現在原值上,除非這個型別本身是某個引用型別(比如切片或字典)的別名型別。
而指標方法的接收者,是該方法所屬的那個基本型別值的指標值的一個副本。我們在這樣的方法內對該副本指向的值進行修改,卻一定會體現在原值上。 -
一個自定義資料型別的方法集合中僅會包含它的所有值方法,而該型別的指標型別的方法集合卻囊括了前者的所有方法,包括所有值方法和所有指標方法。
嚴格來講,我們在這樣的基本型別的值上只能呼叫到它的值方法。但是,Go語言會適時地為我們進行自動地轉譯,使得我們在這樣的值上也能呼叫到它的指標方法。
比如,在Cat
型別的變數cat
之上,之所以我們可以通過cat.SetName("monster")
修改貓的名字,是因為Go語言把它自動轉譯為了(&cat).SetName("monster")
,即:先取cat
的指標值,然後在該指標值上呼叫SetName
方法。 -
在後邊你會了解到,一個型別的方法集合中有哪些方法與它能實現哪些介面型別是息息相關的。如果一個基本型別和它的指標型別的方法集合是不同的,那麼它們具體實現的介面型別的數量就也會有差異,除非這兩個數量都是零。
比如,一個指標型別實現了某某介面型別,但它的基本型別卻不一定能夠作為該介面的實現型別。
能夠體現值方法和指標方法之間差異的小例子我放在demo30.go檔案裡了,你可以參照一下。
總結
結構體型別的嵌入欄位比較容易讓Go語言新手們迷惑,所以我在本篇文章著重解釋了它的編寫方法、基本的特性和規則以及更深層次的含義。在理解了結構體型別及其方法的組成方式和構造套路之後,這些知識應該是你重點掌握的。
嵌入欄位是其宣告中只有型別而沒有名稱的欄位,它可以以一種很自然的方式為被嵌入的型別帶來新的屬性和能力。在一般情況下,我們用簡單的選擇表示式就可以直接引用到它們的欄位和方法。
不過,我們需要小心可能產生“遮蔽”現象的地方,尤其是當存在多個嵌入欄位或者多層嵌入的時候。“遮蔽”現象可能會讓你的實際引用與你的預期不符。
另外,你一定要梳理清楚值方法和指標方法的不同之處,包括這兩種方法各自能做什麼、不能做什麼以及會影響到其所屬型別的哪些方面。這涉及值的修改、方法集合和介面實現。
最後,再次強調,嵌入欄位是實現型別間組合的一種方式,這與繼承沒有半點兒關係。Go語言雖然支援面向物件程式設計,但是根本就沒有“繼承”這個概念。
思考題
- 我們可以在結構體型別中嵌入某個型別的指標型別嗎?如果可以,有哪些注意事項?
- 字面量
struct{}
代表了什麼?又有什麼用處?