golang拾遺:嵌入類型

這裡是golang拾遺系列的第三篇,前兩篇可以點擊此處鏈接跳轉:

golang拾遺:為什麼我們需要泛型

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()

要注意,指針的*只是類型修飾符,並不是類型名的一部分,所以對於形如*TypeType的嵌入類型,我們都只能通過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欄位。

然而你錯了,決定方法提升的是具體的類型在哪定義的,而不是在哪裡被調用的,因為Doganimal在同一個包里,所以它會獲得所有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中同名的NamePrint屏蔽了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作為嵌入類型,因為嵌入類型欄位只需要給出一個類型名,而我們的介面本身也是一個類型,所以可以作為嵌入類型也是順理成章的。使用介面做為嵌入類型有不少值得探討的內容,我會在下一篇中詳細討論。

參考

//golang.org/ref/spec#Method_sets

Tags: