golang拾遺:嵌入類型

這裏是golang拾遺系列的第三篇,前兩篇能夠點擊此處連接跳轉:html

golang拾遺:爲何咱們須要泛型golang

golang拾遺:指針和接口c#

今天咱們要討論的是golang中的嵌入類型(embedding types),有時候也被叫作嵌入式字段(embedding fields)。函數

咱們將會討論爲何使用嵌入類型,以及嵌入類型的一些「坑」。oop

本文索引

什麼是嵌入類型

鑑於可能有讀者是第一次據說這個術語,因此容我花一分鐘作個簡短的解釋,什麼是嵌入類型。3d

首先參考如下代碼:指針

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就是一個嵌入類型的字段。code

一個更嚴謹的解釋是:若是一個字段只含有字段類型而沒有指定字段的名字,那麼這個字段就是一個嵌入類型字段。htm

嵌入類型的使用

在深刻了解嵌入類型以前,咱們先來簡單瞭解下如何使用嵌入類型字段。blog

嵌入類型字段引用

嵌入類型只有類型名而沒有字段名,那麼咱們怎麼引用它呢?

答案是嵌入類型字段的類型名會被當成該字段的名字。繼續剛纔的例子,若是我想要在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做爲嵌入類型,由於嵌入類型字段只須要給出一個類型名,而咱們的接口自己也是一個類型,因此能夠做爲嵌入類型也是瓜熟蒂落的。使用接口作爲嵌入類型有很多值得探討的內容,我會在下一篇中詳細討論。

參考

https://golang.org/ref/spec#Method_sets

相關文章
相關標籤/搜索