在Go中,可以通过使用关键字type
来自定义一个新类型,有两种常见的方法:类型定义(Type Definition)和类型别名(Type Alias)。
类型定义是通过将一个已有的类型声明为一个新的类型的方式,可以将一个现有类型的特性和方法继承给新类型。这个新类型具有原有类型的所有方法和属性,但它们是不同的类型。
// 使用类型定义创建新类型
type MyInt int
MyInt
就是一种新的类型,它具有int
的特性。
如果一个新类型是基于某个 Go 原生类型定义的,那么我们就叫 Go 原生类型为新类型的底层类型(Underlying Type),它被用来判断两个类型本质上是否相同(Identical)。比如上面这个例子中类型 int
就是类型 MyInt
的底层类型。
这里我们定义两个类型,新类型 T1 是基于 Go 原生类型 int 定义的新自定义类型,而新类型 T2 则是基于刚刚定义的类型 T1,定义的新类型:
type T1 int
type T2 T1
在上面例子中,虽然 T1
和 T2
是不同类型,但因为它们的底层类型都是类型 int
,所以它们在本质上是相同的。而本质上相同的两个类型,它们的变量可以通过显式转型进行相互赋值,相反,如果本质上是不同的两个类型,它们的变量间连显式转型都不可能,更不要说相互赋值了。
比如下面这个代码示例:
type T1 int
type T2 T1
type T3 string
func main() {
var n1 T1
var n2 T2 = 5
n1 = T1(n2) // ok
var s T3 = "hello"
n1 = T1(s) // 错误:cannot convert s (type T3) to type T1
}
这段代码中,T1 和 T2 本质上是相同的类型,所以我们可以将 T2 变量 n2 的值,通过显式转型赋值给 T1 类型变量 n1。而类型 T3 的底层类型为类型 string,与 T1/T2 的底层类型不同,所以它们本质上就不是相同的类型。这个时候,如果我们把 T3 类型变量 s 赋值给 T1 类型变量 n1,编译器就会给出编译错误的提示。
类型别名是在已有类型的基础上创建一个新类型,它们是同一类型的两个名称,所以它们可以互相赋值,它们只是为了提供更清晰的语义或用于解决包之间的命名冲突。
类型别名规定:TypeAlias只是Type的别名,本质上TypeAlias与Type是同一个类型。就像一个孩子小时候有小名、乳名,上学后用学名,英语老师又会给他起英文名,但这些名字都指的是他本人。
type TypeAlias = Type
type T = string
var s string = "hello"
var t T = s // ok
fmt.Printf("%T\n", t) // string
因为类型 T 是通过类型别名的方式定义的,T 与 string 实际上是一个类型,所以这里,使用 string 类型变量 s 给 T 类型变量 t 赋值的动作,实质上就是同类型赋值。另外我们也可以看到,通过 Printf 输出的变量 t 的类型信息也是 string
类型别名与类型定义表面上看只有一个等号的差异,我们通过下面的这段代码来理解它们之间的区别。
//类型定义
type NewInt int
//类型别名
type MyInt = int
func main() {
var a NewInt
var b MyInt
fmt.Printf("type of a:%T\n", a) //type of a:main.NewInt
fmt.Printf("type of b:%T\n", b) //type of b:int
}
结果显示a的类型是main.NewInt
,表示main包下定义的NewInt
类型。b的类型是int
。MyInt
类型只会在代码中存在,编译完成时并不会有MyInt
类型。
struct
。 也就是我们可以通过struct
关键字来定义结构体类型。struct
来实现面向对象。使用type
和struct
关键字来定义结构体,通常遵循以下格式:
type T struct {
Field1 T1
Field2 T2
... ...
FieldN Tn
}
其中:
T
为类型名:标识自定义结构体的名称,在同一个包内不能重复。Field1
、Field2
为字段名:表示结构体字段名。结构体中的字段名必须唯一。T1
、T2
为字段类型:表示结构体字段的具体类型。根据上面的这个定义,我们会得到一个名为 T 的结构体类型,定义中 struct 关键字后面的大括号包裹的内容就是一个类型字面值。我们看到这个类型字面值由若干个字段(field)聚合而成,每个字段有自己的名字与类型,并且在一个结构体中,每个字段的名字应该都是唯一的。
比如这里,我就用对现实世界的书(Book
)进行抽象定义了一个结构体:
type Book struct {
Title string // 书名
Pages int // 书的页数
Indexes int // 书的索引
}
这样我们就拥有了一个Book
的结构体类型,它有Title
、Pages
、Indexes
三个字段,分别表示书名、页数和索引。这样我们使用这个Book
结构体就能够很方便的在程序中表示和存储书信息了。
var book Book
book.Title = "The Go Programming Language"
book.Pages = 800
我们通过.
来访问结构体的字段(成员变量),例如book.Title
和book.Pages
等。
空结构体,也就是没有包含任何字段的结构体类型,就像下面示例代码这样:
type Empty struct{} // Empty是一个不包含任何字段的空结构体类型
空结构体类型在Go语言中有一些特殊的用途,尽管它们不包含任何字段,但由于其特殊性质,可以用于以下几种情况:
尽管空结构体类型没有实际的数据存储,但由于其在内存中的大小为0字节,因此使用空结构体可以节省内存,特别是在需要大量实例的情况下,这种节省可以显著减少内存开销。
我们来看下面示例代码这样:
var s Empty
println(unsafe.Sizeof(s)) // 0
我们看到,输出的空结构体类型变量的大小为 0,也就是说,空结构体类型变量的内存占用为 0。基于空结构体类型内存零开销这样的特性,我们在日常 Go 开发中会经常使用空结构体类型元素,作为一种“事件”信息进行 Goroutine 之间的通信,就像下面示例代码这样:
var c = make(chan Empty) // 声明一个元素类型为Empty的channel
c<-Empty{} // 向channel写入一个“事件”
这种以空结构体为元素类建立的 channel
,是目前能实现的、内存占用最小的 Goroutine
间通信方式。
比如,这段代码,这里结构体类型 Book 的字段 Author 的类型,就是另外一个结构体类型 Person:
type Person struct {
Name string
Phone string
Addr string
}
type Book struct {
Title string
Author Person
... ...
}
如果我们要访问 Book 结构体字段 Author 中的 Phone 字段,我们可以这样操作:
var book Book
println(book.Author.Phone)
同时,Go 还提供了一种更为简便的定义方法,那就是我们可以无需提供字段的名字,只需要使用其类型就可以了,以上面的 Book 结构体定义为例,我们可以用下面的方式提供一个等价的定义:
type Book struct {
Title string
Person
... ...
}
以这种方式定义的结构体字段,我们叫做嵌入字段(Embedded Field)。我们也可以将这种字段称为匿名字段,或者把类型名看作是这个字段的名字。如果我们要访问 Person 中的 Phone 字段,我们可以通过下面两种方式进行:
var book Book
println(book.Person.Phone) // 将类型名当作嵌入字段的名字
println(book.Phone) // 支持直接访问嵌入字段所属类型中字段
第一种方式显然是通过把类型名当作嵌入字段的名字来进行操作的,而第二种方式更像是一种“语法糖”,我们可以“绕过”Person 类型这一层,直接访问 Person 中的字段。
Go 语言不支持这种在结构体类型定义中,递归地放入其自身类型字段的定义方式。面。比如下面这段代码:
type T struct {
t T
... ...
}
面对上面的示例代码,编译器就会给出“invalid recursive type T”的错误信息。
不过,虽然我们不能在结构体类型 T 定义中,拥有以自身类型 T 定义的字段,但是你可以在结构体中包含自身类型的指针、切片和map类型的字段,这是允许的。这种方式通常用于构建递归数据结构或树状结构。比如这样:
type T struct {
t *T // ok
st []T // ok
m map[string]T // ok
}
这是因为指针、切片和map这些类型并没有真正包含自身类型T,只是间接引用了结构体类型 T,所以不会造成递归定义的问题。
使用其他结构体作为字段类型的好处包括:
在定义一些临时数据结构等场景下还可以使用匿名结构体。
package main
import (
"fmt"
)
func main() {
var user struct{Name string; Age int}
user.Name = "Jarvis"
user.Age = 18
fmt.Printf("%#v\n", user)
}
只有当结构体实例化时,才会真正地分配内存。也就是必须实例化后才能使用结构体的字段。
可以使用标准变量声明语句,或者是短变量声明语句声明一个结构体类型的变量:
type Book struct {
...
}
var book Book // 标准变量声明
var book = Book{}
book := Book{} //短变量声明
零值初始化说的是使用结构体的零值作为它的初始值。“零值”指的是一个类型的默认值。对于 Go 原生类型来说,这个默认值也称为零值。Go 结构体类型由若干个字段组成,当这个结构体类型变量的各个字段的值都是零值时,我们就说这个结构体类型变量处于零值状态。
结构体类型的变量通常都要被赋予适当的初始值后,才会有合理的意义。通常不具有或者很难具有合理的意义,比如通过下面代码得到的零值 book 变量就是这样:
var book Book // book为零值结构体变量
想象一下,一本书既没有书名,也没有作者、页数、索引等信息,那么通过 Book 类型对这本书的抽象就失去了实际价值。所以对于像 Book 这样的结构体类型,使用零值初始化并不是正确的选择。
那么采用零值初始化的零值结构体变量就真的没有任何价值了吗?恰恰相反。如果一种类型采用零值初始化得到的零值变量,是有意义的,而且是直接可用的,这种类型为“零值可用”类型。可以说,定义零值可用类型是简化代码、改善开发者使用体验的一种重要的手段。
在 Go 语言标准库和运行时的代码中,有很多践行“零值可用”理念的好例子,最典型的莫过于 sync 包的 Mutex 类型了。Mutex 是 Go 标准库中提供的、用于多个并发 Goroutine 之间进行同步的互斥锁。我们来看下下面这段代码:
var mu sync.Mutex
mu.Lock()
mu.Unlock()
Go 标准库的设计者很贴心地将 sync.Mutex 结构体的零值状态,设计为可用状态,这样开发者便可直接基于零值状态下的 Mutex 进行 lock 与 unlock 操作,而且不需要额外显式地对它进行初始化操作了。
Go 标准库中的 bytes.Buffer 结构体类型,也是一个零值可用类型的典型例子,这里我演示了 bytes.Buffer 类型的常规用法:
var b bytes.Buffer
b.Write([]byte("Hello, Go"))
fmt.Println(b.String()) // 输出:Hello, Go
可以看到,我们不需要对 bytes.Buffer 类型的变量 b 进行任何显式初始化,就可以直接通过处于零值状态的变量 b,调用它的方法进行写入和读取操作。
对于我们前面的 Book 类型,确实不能设计为零值可用类型,因为它们的零值并非有效值。对于这类类型,我们需要对它的变量进行显式的初始化后,才能正确使用。在日常开发中,对结构体类型变量进行显式初始化的最常用方法就是使用复合字面值。
最简单的对结构体变量进行显式初始化的方式,就是按顺序依次给每个结构体字段进行赋值,比如下面的代码:
type Book struct {
Title string // 书名
Pages int // 书的页数
Indexes int // 书的索引
}
var book = Book{"The Go Programming Language", 700, 9}
我们依然可以用这种方法给结构体的每一个字段依次赋值,但这种方法也有很多问题:
type T struct {
F1 int
F2 string
f3 int
F4 int
F5 int
}
var t = T{11, "hello", 13} // 错误:implicit assignment of unexported field 'f3' in T literal
或
var t = T{11, "hello", 13, 14, 15} // 错误:implicit assignment of unexported field 'f3' in T literal
事实上,Go语言并不推荐我们按照字段顺序对一个结构体变量进行显示初始化。而是要带上字段名,甚至 Go 官方还在提供的 go vet 工具中专门内置了一条检查规则:“composites”,用来静态检查代码中结构体变量初始化是否使用了这种方法,一旦发现,就会给出警告。
那么我们应该用哪种形式的复合字面值给结构体变量赋初值呢?
Go 推荐我们用“field:value”形式的复合字面值,对结构体类型变量进行显式初始化,这种方式可以降低结构体类型使用者和结构体类型设计者之间的耦合,这也是 Go 语言的惯用法。这里,我们用“field:value”形式复合字面值,对上面的类型 T
的变量进行初始化看看:
var t = T{
F2: "hello",
F1: 11,
F4: 14,
}
我们看到,使用这种“field:value”形式的复合字面值对结构体类型变量进行初始化,非常灵活。和之前的顺序复合字面值形式相比,“field:value”形式字面值中的字段可以以任意次序出现。未显式出现在字面值中的结构体字段(比如上面例子中的 F5)将采用它对应类型的零值。
复合字面值作为结构体类型变量初值被广泛使用,即便结构体采用类型零值时,我们也会使用复合字面值的形式:
t := T{}
而比较少使用 new
这一个 Go 预定义的函数来创建结构体变量实例:
tp := new(T)
这里值得我们注意的是,我们不能用从其他包导入的结构体中的未导出字段,来作为复合字面值中的 field。这会导致编译错误,因为未导出字段是不可见的。
那么,如果一个结构体类型中包含未导出字段,并且这个字段的零值还不可用时,我们要如何初始化这个结构体类型的变量呢?又或是一个结构体类型中的某些字段,需要一个复杂的初始化逻辑,我们又该怎么做呢?这时我们就需要使用一个特定的构造函数,来创建并初始化结构体变量了。
通过专用构造函数进行结构体类型变量创建、初始化大多都符合这种模式:
func NewT(field1, field2, ...) *T {
... ...
}
这里,NewT
是结构体类型 T
的专用构造函数,它的参数列表中的参数通常与 T 定义中的导出字段相对应,返回值则是一个 T 指针类型的变量。T 的非导出字段在 NewT 内部进行初始化,一些需要复杂初始化逻辑的字段也会在 NewT
内部完成初始化。这样,我们只要调用 NewT
函数就可以得到一个可用的 T
指针类型变量了。
其实,使用特定的构造函数创建并初始化结构体变量的例子,并不罕见。在 Go 标准库中就有很多,其中 time.Timer 这个结构体就是一个典型的例子,它的定义如下:
// $GOROOT/src/time/sleep.go
type runtimeTimer struct {
pp uintptr
when int64
period int64
f func(interface{}, uintptr)
arg interface{}
seq uintptr
nextwhen int64
status uint32
}
type Timer struct {
C <-chan Time
r runtimeTimer
}
我们看到,Timer 结构体中包含了一个非导出字段 r,r 的类型为另外一个结构体类型 runtimeTimer。这个结构体更为复杂,而且我们一眼就可以看出来,这个 runtimeTimer 结构体不是零值可用的,那我们在创建一个 Timer 类型变量时就没法使用显式复合字面值的方式了。这个时候,Go 标准库提供了一个 Timer 结构体专用的构造函数 NewTimer,它的实现如下:
// $GOROOT/src/time/sleep.go
func NewTimer(d Duration) *Timer {
c := make(chan Time, 1)
t := &Timer{
C: c,
r: runtimeTimer{
when: when(d),
f: sendTime,
arg: c,
},
}
startTimer(&t.r)
return t
}
我们看到,NewTimer
这个函数只接受一个表示定时时间的参数 d,在经过一个复杂的初始化过程后,它返回了一个处于可用状态的 Timer 类型指针实例。
Go 结构体类型是既数组类型之后,第二个将它的元素(结构体字段)一个接着一个以“平铺”形式,存放在一个连续内存块中的。下图是一个结构体类型 T 的内存布局:
我们看到,结构体类型 T 在内存中布局是非常紧凑的,Go 为它分配的内存都用来存储字段了,没有被 Go 编译器插入的额外字段。我们可以借助标准库 unsafe 包提供的函数,获得结构体类型变量占用的内存大小,以及它每个字段在内存中相对于结构体变量起始地址的偏移量:
var t T
unsafe.Sizeof(t) // 结构体类型变量占用的内存大小
unsafe.Offsetof(t.Fn) // 字段Fn在内存中相对于变量t起始地址的偏移量
不过,上面这张示意图是比较理想的状态,真实的情况可能就没那么好了:
在真实情况下,虽然 Go 编译器没有在结构体变量占用的内存空间中插入额外字段,但结构体字段实际上可能并不是紧密相连的,中间可能存在“缝隙”。这些“缝隙”同样是结构体变量占用的内存空间的一部分,它们是 Go 编译器插入的“填充物(Padding)”。
那么,Go 编译器为什么要在结构体的字段间插入“填充物”呢?这其实是内存对齐的要求。所谓内存对齐,指的就是各种内存对象的内存地址不是随意确定的,必须满足特定要求。
对于各种基本数据类型来说,它的变量的内存地址值必须是其类型本身大小的整数倍,比如,一个 int64 类型的变量的内存地址,应该能被 int64 类型自身的大小,也就是 8 整除;一个 uint16 类型的变量的内存地址,应该能被 uint16 类型自身的大小,也就是 2 整除。
这些基本数据类型的对齐要求很好理解,那么像结构体类型这样的复合数据类型,内存对齐又是怎么要求的呢?是不是它的内存地址也必须是它类型大小的整数倍呢?
实际上没有这么严格。对于结构体而言,它的变量的内存地址,只要是它最长字段长度与系统对齐系数两者之间较小的那个的整数倍就可以了。但对于结构体类型来说,我们还要让它每个字段的内存地址都严格满足内存对齐要求。
这么说依然比较绕,我们来看一个具体例子,计算一下这个结构体类型 T 的对齐系数:
type T struct {
b byte
i int64
u uint16
}
计算过程是这样的:
我们简单分析一下,整个计算过程分为两个阶段。第一个阶段是对齐结构体的各个字段。
首先,我们看第一个字段 b 是长度 1 个字节的 byte 类型变量,这样字段 b 放在任意地址上都可以被 1 整除,所以我们说它是天生对齐的。我们用一个 sum 来表示当前已经对齐的内存空间的大小,这个时候 sum=1;
接下来,我们看第二个字段 i,它是一个长度为 8 个字节的 int64 类型变量。按照内存对齐要求,它应该被放在可以被 8 整除的地址上。但是,如果把 i 紧邻 b 进行分配,当 i 的地址可以被 8 整除时,b 的地址就无法被 8 整除。这个时候,我们需要在 b 与 i 之间做一些填充,使得 i 的地址可以被 8 整除时,b 的地址也始终可以被 8 整除,于是我们在 i 与 b 之间填充了 7 个字节,此时此刻 sum=1+7+8;
再下来,我们看第三个字段 u,它是一个长度为 2 个字节的 uint16 类型变量,按照内存对其要求,它应该被放在可以被 2 整除的地址上。有了对其的 i 作为基础,我们现在知道将 u 与 i 相邻而放,是可以满足其地址的对齐要求的。i 之后的那个字节的地址肯定可以被 8 整除,也一定可以被 2 整除。于是我们把 u 直接放在 i 的后面,中间不需要填充,此时此刻,sum=1+7+8+2。
现在结构体 T 的所有字段都已经对齐了,我们开始第二个阶段,也就是对齐整个结构体。
我们前面提到过,结构体的内存地址为 min(结构体最长字段的长度,系统内存对齐系数)的整数倍,那么这里结构体 T 最长字段为 i,它的长度为 8,而 64bit 系统上的系统内存对齐系数一般为 8,两者相同,我们取 8 就可以了。那么整个结构体的对齐系数就是 8。
这个时候问题就来了!为什么上面的示意图还要在结构体的尾部填充了 6 个字节呢?
我们说过结构体 T 的对齐系数是 8,那么我们就要保证每个结构体 T 的变量的内存地址,都能被 8 整除。如果我们只分配一个 T 类型变量,不再继续填充,也可能保证其内存地址为 8 的倍数。但如果考虑我们分配的是一个元素为 T 类型的数组,比如下面这行代码,我们虽然可以保证 T[0]这个元素地址可以被 8 整除,但能保证 T[1]的地址也可以被 8 整除吗?
var array [10]T
我们知道,数组是元素连续存储的一种类型,元素 T[1]的地址为 T[0]地址 +T 的大小 (18),显然无法被 8 整除,这将导致 T[1]及后续元素的地址都无法对齐,这显然不能满足内存对齐的要求。
问题的根源在哪里呢?问题就在于 T 的当前大小为 18,这是一个不能被 8 整除的数值,如果 T 的大小可以被 8 整除,那问题就解决了。于是我们才有了最后一个步骤,我们从 18 开始向后找到第一个可以被 8 整除的数字,也就是将 18 圆整到 8 的倍数上,我们得到 24,我们将 24 作为类型 T 最终的大小就可以了。
为什么会出现内存对齐的要求呢?这是出于对处理器存取数据效率的考虑。在早期的一些处理器中,比如 Sun 公司的 Sparc 处理器仅支持内存对齐的地址,如果它遇到没有对齐的内存地址,会引发段错误,导致程序崩溃。我们常见的 x86-64 架构处理器虽然处理未对齐的内存地址不会出现段错误,但数据的存取性能也会受到影响。
从这个推演过程中,你应该已经知道了,Go 语言中结构体类型的大小受内存对齐约束的影响。这样一来,不同的字段排列顺序也会影响到“填充字节”的多少,从而影响到整个结构体大小。比如下面两个结构体类型表示的抽象是相同的,但正是因为字段排列顺序不同,导致它们的大小也不同:
type T struct {
b byte
i int64
u uint16
}
type S struct {
b byte
u uint16
i int64
}
func main() {
var t T
println(unsafe.Sizeof(t)) // 24
var s S
println(unsafe.Sizeof(s)) // 16
}
所以,你在日常定义结构体时,一定要注意结构体中字段顺序,尽量合理排序,降低结构体对内存空间的占用。
另外,前面例子中的内存填充部分,是由编译器自动完成的。不过,有些时候,为了保证某个字段的内存地址有更为严格的约束,我们也会做主动填充。比如 runtime 包中的 mstats 结构体定义就采用了主动填充:
// $GOROOT/src/runtime/mstats.go
type mstats struct {
... ...
// Add an uint32 for even number of size classes to align below fields
// to 64 bits for atomic operations on 32 bit platforms.
_ [1 - _NumSizeClasses%2]uint32 // 这里做了主动填充
last_gc_nanotime uint64 // last gc (monotonic time)
last_heap_inuse uint64 // heap_inuse at mark termination of the previous GC
... ...
}
通常我们会通过空标识符来进行主动填充,因为填充的这部分内容我们并不关心。
结构体中字段大写开头表示可公开访问,小写表示私有(仅在定义当前结构体的包中可访问)。
Go语言中使用结构体也可以实现其他编程语言中面向对象的继承。
//Animal 动物
type Animal struct {
name string
}
func (a *Animal) move() {
fmt.Printf("%s会动!\n", a.name)
}
//Dog 狗
type Dog struct {
Feet int8
*Animal //通过嵌套匿名结构体实现继承
}
func (d *Dog) wang() {
fmt.Printf("%s会汪汪汪~\n", d.name)
}
func main() {
d1 := &Dog{
Feet: 4,
Animal: &Animal{ //注意嵌套的是结构体指针
name: "乐乐",
},
}
d1.wang() //乐乐会汪汪汪~
d1.move() //乐乐会动!
}