這裡是golang拾遺系列的第三篇,前兩篇可以點選此處連結跳轉:
今天我們要討論的是golang中的嵌入型別(embedding types),有時候也被叫做嵌入式欄位(embedding fields)。
我們將會討論為什麼使用嵌入型別,以及嵌入型別的一些“坑”。
本文索引
什麼是嵌入型別
鑑於可能有讀者是第一次聽說這個術語,所以容我花一分鐘做個簡短的解釋,什麼是嵌入型別。
首先參考以下程式碼:
type FileSystem struct {
MetaData []byte
}
func (fs *FileSystem) Read() {}
func (fs *FileSystem) Write() {}
type NTFS struct {
*FileSystem
}
type EXT4 struct {
*FileSystem
}
我們有一個FileSystem
型別作為對檔案系統的抽象,其中包含了所有檔案系統都會存在的後設資料和讀寫檔案的方法。接著我們基於此定義了Windows的NTFS檔案系統和廣泛應用於Linux系統中的EXT4檔案系統。在這裡的*FileSystem
就是一個嵌入型別的欄位。
一個更嚴謹的解釋是:如果一個欄位只含有欄位型別而沒有指定欄位的名字,那麼這個欄位就是一個嵌入型別欄位。
嵌入型別的使用
在深入瞭解嵌入型別之前,我們先來簡單瞭解下如何使用嵌入型別欄位。
嵌入型別欄位引用
嵌入型別只有型別名而沒有欄位名,那麼我們怎麼引用它呢?
答案是嵌入型別欄位的型別名會被當成該欄位的名字。繼續剛才的例子,如果我想要在NTFS
中引用FileSystem
的函式,則需要這樣寫:
type FileSystem struct {
MetaData []byte
}
func (fs *FileSystem) Read() {}
func (fs *FileSystem) Write() {}
type NTFS struct {
*FileSystem
}
// fs 是一個已經初始化了的NTFS例項
fs.FileSystem.Read()
要注意,指標的*
只是型別修飾符,並不是型別名的一部分,所以對於形如*Type
和Type
的嵌入型別,我們都只能通過Type
這個名字進行引用。
通過Type
這個名字,我們不僅可以引用Type裡的方法,還可以引用其中的資料欄位:
type A struct {
Age int
Name string
}
type B struct {
A
}
b := B{}
fmt.Println(b.A.Age, b.A.Name)
嵌入型別的初始化
在知道如何引用嵌入型別後我們想要初始化嵌入型別欄位也就易如反掌了,嵌入型別欄位只是普通的匿名欄位,你可以放在型別的任意位置,也就是說嵌入型別可以不必作為型別的第一個欄位:
type A struct {
a int
b int
}
type B struct {
*A
name string
}
type C struct {
age int
B
address string
}
B和C都是合法的,如果想要初始化B和C,則只需要按欄位出現的順序給出相應的初始化值即可:
// 初始化B和C
b := &B{
&A{1, 2},
"B",
}
c := &C{
30,
B{
&A{1, 2},
"B in C",
},
"my address",
}
由於我們還可以使用對應的型別名來引用嵌入型別欄位,所以初始化還可以寫成這樣:
// 使用欄位名稱初始化B和C
b := &B{
A: &A{1, 2},
name: "B",
}
c := &C{
age: 30,
B: B{
A: &A{1, 2},
name: "B in C",
},
address: "my address",
}
嵌入型別的欄位提升
自所以會需要有嵌入型別,是因為golang並不支援傳統意義上的繼承,因此我們需要一種手段來把父型別的欄位和方法“注入”到子型別中去。
所以嵌入型別就出現了。
然而如果我們只能通過型別名來引用欄位,那麼實際上的效果還不如使用一個具名欄位來的方便。所以為了簡化我們的程式碼,golang對嵌入型別新增了欄位提升的特性。
什麼是欄位提升
假設我們有一個型別Base,它擁有一個Age欄位和一個SayHello方法,現在我們把它嵌入進Drived型別中:
type Base struct {
Age int
}
func (b *Base) SayHello() {
fmt.Printf("Hello! I'm %v years old!", b.Age)
}
type Drived struct {
Base
}
a := Drived{Base{30}}
fmt.Println(a.Age)
a.SayHello()
注意最後兩行,a直接引用了Base裡的欄位和方法而無需給出Base的型別名,就像Age和SayHello是Drived自己的欄位和方法一樣,這就叫做“提升”。
提升是如何影響欄位可見性的
我們都知道在golang中小寫英文字母開頭的欄位和方法是包私有的,而大寫字母開頭的是可以在任意地方被訪問的。
之所以要強調包私有,是因為有以下的程式碼:
package main
import "fmt"
type a struct {
age int
name string
}
type data struct {
obj a
}
func (d *data) Print() {
fmt.Println(d.obj.age, d.obj.name)
}
func main(){
d := data{a{30, "hello"}}
d.Print() // 30 hello
}
在同一個包中的型別可以任意操作其他型別的欄位,包括那些出口的和不出口的,所以在golang中私有的package級別的。
為什麼要提這一點呢?因為這一規則會影響我們的嵌入型別。考慮以下下面的程式碼能不能通過編譯,假設我們有一個叫a
的go module:
// package b 位於a/b目錄下
package b
import "fmt"
type Base struct {
A int
b int
}
func (b *Base) f() {
fmt.Println("from Base f")
}
// package main
package main
import (
"a/b"
)
type Drived struct {
*b.Base
}
func main() {
obj := Drived{&b.Base{}}
obj.f()
}
答案是不能,會收到這樣的錯誤:obj.f undefined (type Drived has no field or method f)
。
同樣,如果我們想以obj.b
的方式進行欄位訪問也會報出一樣的錯誤。
那如果我們通過嵌入型別欄位的欄位名進行引用呢?比如改成obj.Base.f()
。那麼我們會收穫下面的報錯:obj.Base.f undefined (cannot refer to unexported field or method b.(*Base).f)
。
因為Base在package b中,而我們的Drived在package main中,所以我們的Drived只能獲得在package main中可以訪問到的欄位和方法,也就是那些從package b中出口的欄位和方法。因此這裡的Base的f在package b以外是訪問不到的。
當我們把Base移動到package main之後,就不會出現上面的問題了,因為前面說過,同一個包裡的東西是彼此互相公開的。
最後關於可見性還有一個有意思的問題:嵌入欄位本身受可見性影響嗎?
考慮如下程式碼:
package b
type animal struct {
Name string
}
type Dog struct {
animal
}
package main
import "b"
func main() {
dog1 := b.Dog{} // 1
dog2 := b.Dog{b.animal{"wangwang"}} // 2
dog1.Name = "wangwang" // 3
}
猜猜哪行會報錯?
答案是2。有可能你會覺得3應該也會報錯的,畢竟如果2不行的話那麼實際上代表著我們在main裡應該也不能訪問到animals的Name才對,因為正常情況下首先我們要能訪問animal,其次才能訪問到它的Name欄位。
然而你錯了,決定方法提升的是具體的型別在哪定義的,而不是在哪裡被呼叫的,因為Dog
和animal
在同一個包裡,所以它會獲得所有animal的欄位和方法,而其中可以被當前包以外訪問的欄位和方法自然可以在我們的main裡被使用。
當然,這裡只是例子,在實際開發中我不推薦在非出口型別中定義可公開訪問的欄位,這顯然是一種破壞訪問控制的反模式。
提升是如何影響方法集的
方法集(method sets)是一個型別的例項可呼叫的方法的集合,在golang中一個型別的方法可以分為指標接收器和值接收器兩種:
func (v type) ValueReceiverMethod() {}
func (p *type) PointerReceiverMethod() {}
而型別的例項也分為兩類,普通的型別值和指向型別值的指標。假設我們有一個型別T,那麼方法集的規律如下:
- 假設obj的型別是T,則obj的方法集包含接收器是T的所有方法
- 假設obj是*T,則obj的方法集包含接收器是T和*T的所以方法
這是來自golang language spec的定義,然而直覺告訴我們還有點小問題,因為我們使用的obj是值的時候通常也可以呼叫接收器是指標的方法啊?
這是因為在一個為值型別的變數呼叫接收器的指標型別的方法時,golang會進行對該變數的取地址操作,從而產生出一個指標,之後再用這個指標呼叫方法。前提是這個變數要能取地址。如果不能取地址,比如傳入interface(非整數數字傳入interface會導致值被複制一遍)時的值是不可取地址的,這時候就會忠實地反應方法集的確定規律:
package main
import "fmt"
type i interface {
method()
}
type a struct{}
func (_ *a) method() {}
type b struct{}
func (_ b) method() {}
func main() {
var o1 i = a{} // a does not implement i (method method has pointer receiver)
var o2 i = b{}
fmt.Println(o1, o2)
}
那麼同樣的規律是否影響嵌入型別呢?因為嵌入型別也分為指標和值。答案是規律和普通變數一樣。
我們可以寫一個程式簡單驗證下:
package main
import (
"fmt"
)
type Base struct {
A int
b int
}
func (b *Base) PointerMethod() {}
func (b Base) ValueMethod() {}
type DrivedWithPointer struct {
*Base
}
type DrivedWithValue struct {
Base
}
type checkAll interface {
ValueMethod()
PointerMethod()
}
type checkValueMethod interface {
ValueMethod()
}
type checkPointerMethod interface {
PointerMethod()
}
func main() {
var obj1 checkAll = &DrivedWithPointer{&Base{}}
var obj2 checkPointerMethod = &DrivedWithPointer{&Base{}}
var obj3 checkValueMethod = &DrivedWithPointer{&Base{}}
var obj4 checkAll = DrivedWithPointer{&Base{}}
var obj5 checkPointerMethod = DrivedWithPointer{&Base{}}
var obj6 checkValueMethod = DrivedWithPointer{&Base{}}
fmt.Println(obj1, obj2, obj3, obj4, obj5, obj6)
var obj7 checkAll = &DrivedWithValue{}
var obj8 checkPointerMethod = &DrivedWithValue{}
var obj9 checkValueMethod = &DrivedWithValue{}
fmt.Println(obj7, obj8, obj9)
var obj10 checkAll = DrivedWithValue{} // error
var obj11 checkPointerMethod = DrivedWithValue{} // error
var obj12 checkValueMethod = DrivedWithValue{}
fmt.Println(obj10, obj11, obj12)
}
如果編譯程式碼則會得到下面的報錯:
# command-line-arguments
./method.go:50:6: cannot use DrivedWithValue literal (type DrivedWithValue) as type checkAll in assignment:
DrivedWithValue does not implement checkAll (PointerMethod method has pointer receiver)
./method.go:51:6: cannot use DrivedWithValue literal (type DrivedWithValue) as type checkPointerMethod in assignment:
DrivedWithValue does not implement checkPointerMethod (PointerMethod method has pointer receiver)
總結起來和變數那裡的差不多,都是車軲轆話,所以我總結了一張圖:
注意紅色標出的部分。這是你會在嵌入型別中遇到的第一個坑,所以在選擇使用值型別嵌入還是指標型別嵌入的時候需要小心謹慎。
提升和名字遮蔽
最後也是最重要的一點當嵌入型別和當前型別有同名的欄位或方法時會發生什麼?
答案是當前型別的欄位或者方法會遮蔽嵌入型別的欄位或方法。這就是名字遮蔽。
給一個具體的例子:
package main
import (
"fmt"
)
type Base struct {
Name string
}
func (b Base) Print() {
fmt.Println("Base::Print", b.Name)
}
type Drived struct {
Base
Name string
}
func (d Drived) Print() {
fmt.Println("Drived::Print", d.Name)
}
func main() {
obj := Drived{Base: Base{"base"}, Name: "drived"}
obj.Print() // Drived::Print drived
}
在這裡Drived中同名的Name
和Print
遮蔽了Base中的欄位和方法。
如果我們需要訪問Base裡的欄位和方法呢?只需要把Base當成一個普通欄位使用即可:
func (d Drived) Print() {
d.Base.Print()
fmt.Println("Drived::Print", d.Name)
}
func main() {
obj := Drived{Base: Base{"base"}, Name: "drived"}
obj.Print()
// Output:
// Base::Print base
// Drived::Print drived
}
同過嵌入型別欄位的欄位名訪問的方法,其接收器是對於的嵌入型別,而不是當前型別,這也是為什麼可以訪問到Base.Name
的原因。
如果我們的Drived.Print
的簽名和Base的不同,遮蔽也會發生。
還有另外一種情況,當我們有多個嵌入型別,且他們均有相同名字的成員時,會發生什麼?
下面我們改進以下前面的例子:
type Base1 struct {
Name string
}
func (b Base1) Print() {
fmt.Println("Base1::Print", b.Name)
}
type Base2 struct {
Name string
}
func (b Base2) Print() {
fmt.Println("Base2::Print", b.Name)
}
type Drived struct {
Base1
Base2
Name string
}
func (d Drived) Print() {
d.Base1.Print()
fmt.Println("Drived::Print", d.Name)
}
func main() {
obj := Drived{Base1: Base1{"base1"}, Base2: Base2{"base2"}, Name: "drived"}
obj.Print()
}
這樣仍然能正常編譯執行,所以我們再加點料,把Drived的Print註釋掉,接著就會得到下面的錯誤:
# command-line-arguments
./method.go:36:5: ambiguous selector obj.Print
如果我們再把Drived的Name也註釋掉,那麼報錯會變成下面這樣:
# command-line-arguments
./method.go:37:17: ambiguous selector obj.Name
在沒有發生遮蔽的情況下,Base1和Base2的Print和Name都提升到了Drived的欄位和方法集裡,所以在呼叫時發生了二義性錯誤。
要解決問題,加上嵌入型別欄位的欄位名即可:
func main() {
obj := Drived{Base1: Base1{"base1"}, Base2: Base2{"base2"}}
obj.Base1.Print()
fmt.Println(obj.Base2.Name)
// Output:
// Base1::Print base1
// base2
}
這也是嵌入型別帶來的第二個坑,所以一個更有用的建議是最好不要讓多個嵌入型別包含同名欄位或方法。
總結
至此我們已經說完了嵌入型別的相關知識。
通過嵌入型別我們可以模仿傳統oop中的繼承,然而嵌入畢竟不是繼承,還有許多細微的差異。
而在本文中還有一點沒有被提及,那就是interface作為嵌入型別,因為嵌入型別欄位只需要給出一個型別名,而我們的介面本身也是一個型別,所以可以作為嵌入型別也是順理成章的。使用介面做為嵌入型別有不少值得探討的內容,我會在下一篇中詳細討論。