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()
要注意,指针的*只是类型修饰符,并不是类型名的一部分,所以对于形如*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作为嵌入类型,因为嵌入类型字段只需要给出一个类型名,而我们的接口本身也是一个类型,所以可以作为嵌入类型也是顺理成章的。使用接口做为嵌入类型有不少值得探讨的内容,我会在下一篇中详细讨论。
参考
https://golang.org/ref/spec#Method_sets
golang拾遗:嵌入类型的更多相关文章
- golang拾遗:自定义类型和方法集
golang拾遗主要是用来记录一些遗忘了的.平时从没注意过的golang相关知识. 很久没更新了,我们先以一个谜题开头练练手: package main import ( "encoding ...
- Go 语言中的方法,接口和嵌入类型
https://studygolang.com/articles/1113 概述 在 Go 语言中,如果一个结构体和一个嵌入字段同时实现了相同的接口会发生什么呢?我们猜一下,可能有两个问题: 编译器会 ...
- golang拾遗:指针和接口
这是本系列的第一篇文章,golang拾遗主要是用来记录一些遗忘了的.平时从没注意过的golang相关知识.想做本系列的契机其实是因为疫情闲着在家无聊,网上冲浪的时候发现了zhuihu上的go语言爱好者 ...
- Go语言嵌入类型
一.什么是嵌入类型 先看如下代码: type user struct { name string email string } type admin struct { user // Embedded ...
- Go 嵌入类型
文章转载地址:https://www.flysnow.org/2017/04/06/go-in-action-go-embedded-type.html 嵌入类型或嵌套类型,这是一种可以把已有类型的声 ...
- golang 函数作为类型
golang 函数作为类型 package main import "fmt" type A func(int, int) func (f A)Serve() { fmt.Prin ...
- 学习Golang语言(6):类型--切片
学习Golang语言(1): Hello World 学习Golang语言(2): 变量 学习Golang语言(3):类型--布尔型和数值类型 学习Golang语言(4):类型--字符串 学习Gola ...
- golang md5 结果类型
golang md5 结果类型 package main import ( "crypto/md5" "encoding/hex" "fmt&quo ...
- Golang 的 `[]interface{}` 类型
Golang 的 []interface{} 类型 我其实不太喜欢使用 Go 语言的 interface{} 类型,一般情况下我宁愿多写几个函数:XxxInt, XxxFloat, XxxString ...
随机推荐
- [tmp]__URL
常用排序算法稳定性.时间复杂度分析(转,有改动) http://www.cnblogs.com/nannanITeye/archive/2013/04/11/3013737.html http://w ...
- 在Linux下安装C++的OpenCV 3
最近在看<学习OpenCV3>这本书,所以记录下我在ubuntu16.4下搭建C++版本OpenCV 3.4.5的过程.首先请确保cuda,gcc, g++都安装好了,我这里是cuda 1 ...
- IP 层收发报文简要剖析2--ip报文的输入ip_local_deliver
ip报文根据路由结果:如果发往本地则调用ip_local_deliver处理报文:如果是转发出去,则调用ip_forward 处理报文. 一.ip报文转发到本地: /* * Deliver IP Pa ...
- Ceph的Mon数据重新构建工具
关于mon的数据的问题,一般正常情况下都是配置的3个mon的,但是还是有人会担心 Mon 万一三个同时都挂掉了怎么办,那么集群所有的数据是不是都丢了,关于后台真实数据恢复,有去后台取对象,然后一个个拼 ...
- Python_PyQt5_eric6 做省市县筛选框
eric是PyQt5的图形化编辑工具,界面如下(另存为-桌面 查看大图) 下面是用eric6制作的 省市县 三级联动筛选框 (效果图+源码) 1 # -*- coding: utf-8 -*- 2 ...
- 自行实现的jar包中,日志库的适配实现
日常情况下,我们自己都会自行实现一些基础的jar包,如dao包.service包或一些其他完成特定功能的jar包.如果没有一套调试日志信息,出现问题时想查找问题非常不方便.可能大多数小伙伴都会有自 ...
- CSS属性(边框)
1.边框 <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="U ...
- ACCESS渗透测试
access-getshell 直接写shell # 创建临时表 create table test(a varchar(255)); # 插入一句话木马 insert into test(a) va ...
- FL Studio中的Fruity slicer采样器功能介绍
本章节采用图文结合的方式来给大家介绍电音编曲软件FL Studio中的Fruity Slicer采样器的功能,感兴趣的朋友可一起来交流哦. Fruity slicer(水果切片器)插件是FL Stud ...
- Linux安装禅道教程
环境: centos7 64位 禅道11.2 Linux一键安装包64位 下载: 禅道下载地址: http://dl.cnezsoft.com/zentao/11.2/ZenTaoPMS.11.2.s ...