这里是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作为嵌入类型,因为嵌入类型字段只需要给出一个类型名,而我们的接口本身也是一个类型,所以可以作为嵌入类型也是顺理成章的。使用接口做为嵌入类型有不少值得探讨的内容,我会在下一篇中详细讨论。

参考

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

golang拾遗:嵌入类型的更多相关文章

  1. golang拾遗:自定义类型和方法集

    golang拾遗主要是用来记录一些遗忘了的.平时从没注意过的golang相关知识. 很久没更新了,我们先以一个谜题开头练练手: package main import ( "encoding ...

  2. Go 语言中的方法,接口和嵌入类型

    https://studygolang.com/articles/1113 概述 在 Go 语言中,如果一个结构体和一个嵌入字段同时实现了相同的接口会发生什么呢?我们猜一下,可能有两个问题: 编译器会 ...

  3. golang拾遗:指针和接口

    这是本系列的第一篇文章,golang拾遗主要是用来记录一些遗忘了的.平时从没注意过的golang相关知识.想做本系列的契机其实是因为疫情闲着在家无聊,网上冲浪的时候发现了zhuihu上的go语言爱好者 ...

  4. Go语言嵌入类型

    一.什么是嵌入类型 先看如下代码: type user struct { name string email string } type admin struct { user // Embedded ...

  5. Go 嵌入类型

    文章转载地址:https://www.flysnow.org/2017/04/06/go-in-action-go-embedded-type.html 嵌入类型或嵌套类型,这是一种可以把已有类型的声 ...

  6. golang 函数作为类型

    golang 函数作为类型 package main import "fmt" type A func(int, int) func (f A)Serve() { fmt.Prin ...

  7. 学习Golang语言(6):类型--切片

    学习Golang语言(1): Hello World 学习Golang语言(2): 变量 学习Golang语言(3):类型--布尔型和数值类型 学习Golang语言(4):类型--字符串 学习Gola ...

  8. golang md5 结果类型

    golang  md5 结果类型 package main import ( "crypto/md5" "encoding/hex" "fmt&quo ...

  9. Golang 的 `[]interface{}` 类型

    Golang 的 []interface{} 类型 我其实不太喜欢使用 Go 语言的 interface{} 类型,一般情况下我宁愿多写几个函数:XxxInt, XxxFloat, XxxString ...

随机推荐

  1. 蒲公英 · JELLY技术周刊 Vol.29: 前端智能化在阿里的那些事

    蒲公英 · JELLY技术周刊 Vol.29 前端智能化是指借助于 AI 和机器学习的能力拓展前端,使其拥有一些超出现阶段前端能力的特性,这将是未来前端方向中一场重要的变革.目前各家互联网厂商都有自己 ...

  2. 力扣 - 146. LRU缓存机制

    目录 题目 思路 代码 复杂度分析 题目 146. LRU缓存机制 思路 利用双链表和HashMap来解题 看到链表题目,我们可以使用头尾结点可以更好进行链表操作和边界判断等 还需要使用size变量来 ...

  3. 【Java】线程的 6 种状态

    一.线程状态的枚举 Java的线程从创建到销毁总共有6种状态.这些状态被定义在Thread类种的内部枚举 State 中,分别如下: 1.NEW:初始状态. 线程实例已创建,但未启动. // 实例创建 ...

  4. maven profile filter 线上线下分开打包配置

    maven自动选择不同的配置文件打包profile+filter 1. profile: [要点:] activeByDefault默认激活,不用再mvn命令时指定额外参数: [注意:] 使用非默认的 ...

  5. rabbitmq-参考

    rabbitMQ http://lynnkong.iteye.com/blog/1699684 http://jzhihui.iteye.com/category/195005

  6. [转自王垠]完全用GNU/Linux工作,摈弃Windows低效率的工作方式

    ZT (a qinghua student's article) 我已经半年没有使用 Windows 的方式工作了.Linux 高效的完成了我所有的工作. GNU/Linux 不是每个人都想用的.如果 ...

  7. POSIX条件变量

    条件变量: 当一个线程互斥的访问某个变量时,它可能发现其他线程改变状态之前,它什么都做不了例如:一个线程访问队列时,发现队列为空,它只能等待,直到其他线程将一个节点添加到队列中,这种情况就需要使用条件 ...

  8. linux 源码下载和在线查看网站

    下载: https://www.kernel.org/ 查看: https://elixir.bootlin.com/linux/

  9. HBuilderX SVN地址更改(SVN服务器IP地址变更)

    HBuilderX编辑器中无法修改SVN地址,需要手动在SVN工具中修改 修改步骤: 1.右键编辑器中的SVN项目,选择打开文件所在目录 2.目录中空白处右键,选择TortoiseSVN --> ...

  10. mysql学习——数据表基本操作1

    选择数据库 创建数据表 包括字段名和数据类型两部分 查看数据表 使用主键约束 主键又称主码,能够唯一的表示表中的一条记录,分为单字段主键与多字段联合主键 单字段主键 定义主键的两种方式: 1.在最后指 ...