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

相关文章
相关标签/搜索