如何自定义一个新类型?
type T S // 定义一个新类型T
S 可以是任何一个已定义的类型,包括 Go 原生类型,或者是其他已定义的自定义类型,我们来演示一下这两种情况:
type T1 int
type T2 T1
虽然 T1 和 T2 是不同类型,但因为它们的底层类型都是类型 int,所以它们在本质上是相同的。而本质上相同的两个类型,它们的变量可以通过显式转型进行相互赋值,相反,如果本质上是不同的两个类型,它们的变量间连显式转型都不可能,更不要说相互赋值了。
除了基于已有类型定义新类型之外,我们还可以基于类型字面值来定义新类型,这种方式多用于自定义一个新的复合类型,比如:
type M map[int]string
type S []string
第二种自定义新类型的方式是使用类型别名(Type Alias),这种类型定义方式通常用在项目的渐进式重构,还有对已有包的二次封装方面,它的形式是这样的:
type T = S // type alias
类型别名并没有定义出新类型,T 与 S 实际上就是同一种类型。
type T = string
var s string = "hello"
var t T = s // ok
fmt.Printf("%T\n", t) // string
学习了两种新类型的自定义方法后,我们再来看一下如何定义一个结构体类型。
如何定义一个结构体类型?
type T struct {
Field1 T1
Field2 T2
... ...
FieldN Tn
}
package book
type Book struct {
Title string // 书名
Pages int // 书的页数
Indexes map[string]int // 书的索引
}
除了通过类型字面值来定义结构体这种典型操作外,我们还有另外几种特殊的情况。
第一种:定义一个空结构体。
type Empty struct{} // Empty是一个不包含任何字段的空结构体类型
var s Empty
println(unsafe.Sizeof(s)) // 0
空结构体类型变量的内存占用为 0。基于空结构体类型内存零开销这样的特性,我们在日常 Go 开发中会经常使用空结构体类型元素,作为一种“事件”信息进行 Goroutine 之间的通信,就像下面示例代码这样:
var c = make(chan Empty) // 声明一个元素类型为Empty的channel
c<-Empty{} // 向channel写入一个“事件”
这种以空结构体为元素类建立的 channel,是目前能实现的、内存占用最小的 Goroutine 间通信方式。
第二种情况:使用其他结构体作为自定义结构体中字段的类型。
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
... ...
}
在结构体类型 T 的定义中是否可以包含类型为 T 的字段呢?比如这样:
type T struct {
t T
... ...
}
答案是不可以的。Go 语言不支持这种在结构体类型定义中,递归地放入其自身类型字段的定义方式。面对上面的示例代码,编译器就会给出“invalid recursive type T”的错误信息。
不过,虽然我们不能在结构体类型 T 定义中,拥有以自身类型 T 定义的字段,但我们却可以拥有自身类型的指针类型、以自身类型为元素类型的切片类型,以及以自身类型作为 value 类型的 map 类型的字段,比如这样:
type T struct {
t *T // ok
st []T // ok
m map[string]T // ok
}
结构体变量的声明与初始化
我把结构体类型变量的初始化大致分为三种情况,我们逐一看一下。
零值初始化
var 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
不过有些类型确实不能设计为零值可用类型,就比如我们前面的 Book 类型,它们的零值并非有效值。对于这类类型,我们需要对它的变量进行显式的初始化后,才能正确使用。在日常开发中,对结构体类型变量进行显式初始化的最常用方法就是使用复合字面值,下面我们就来看看这种方法。
使用复合字面值
最简单的对结构体变量进行显式初始化的方式,就是按顺序依次给每个结构体字段进行赋值,比如下面的代码:
type Book struct {
Title string // 书名
Pages int // 书的页数
Indexes map[string]int // 书的索引
}
var book = Book{"The Go Programming Language", 700, make(map[string]int)}
Go 推荐我们用“field:value”形式的复合字面值,对结构体类型变量进行显式初始化,这种方式可以降低结构体类型使用者和结构体类型设计者之间的耦合,这也是 Go 语言的惯用法。这里,我们用“field:value”形式复合字面值,对上面的类型 T 的变量进行初始化看看:var t = T{ F2: "hello", F1: 11, F4: 14,}
var t = T{
F2: "hello",
F1: 11,
F4: 14,
}
使用特定的构造函数
// $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
}
像这类通过专用构造函数进行结构体类型变量创建、初始化的例子还有很多,我们可以总结一下,它们的专用构造函数大多都符合这种模式:
func NewT(field1, field2, ...) *T {
... ...
}
这里,NewT 是结构体类型 T 的专用构造函数,它的参数列表中的参数通常与 T 定义中的导出字段相对应,返回值则是一个 T 指针类型的变量。T 的非导出字段在 NewT 内部进行初始化,一些需要复杂初始化逻辑的字段也会在 NewT 内部完成初始化。这样,我们只要调用 NewT 函数就可以得到一个可用的 T 指针类型变量了。
结构体类型的内存布局
Go 结构体类型是既数组类型之后,第二个将它的元素(结构体字段)一个接着一个以“平铺”形式,存放在一个连续内存块中的。下图是一个结构体类型 T 的内存布局:
我们看到,结构体类型 T 在内存中布局是非常紧凑的,Go 为它分配的内存都用来存储字段了,没有被 Go 编译器插入的额外字段。我们可以借助标准库 unsafe 包提供的函数,获得结构体类型变量占用的内存大小,以及它每个字段在内存中相对于结构体变量起始地址的偏移量:
var t T
unsafe.Sizeof(t) // 结构体类型变量占用的内存大小
unsafe.Offsetof(t.Fn) // 字段Fn在内存中相对于变量t起始地址的偏移量
不过,上面这张示意图是比较理想的状态,真实的情况可能就没那么好了:
在真实情况下,虽然 Go 编译器没有在结构体变量占用的内存空间中插入额外字段,但结构体字段实际上可能并不是紧密相连的,中间可能存在“缝隙”。这些“缝隙”同样是结构体变量占用的内存空间的一部分,它们是 Go 编译器插入的“填充物(Padding)”。
那么,Go 编译器为什么要在结构体的字段间插入“填充物”呢?这其实是内存对齐的要求。所谓内存对齐,指的就是各种内存对象的内存地址不是随意确定的,必须满足特定要求。
我们来看一个具体例子,计算一下这个结构体类型 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 最终的大小就可以了。
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
... ...
}
此文章为3月Day8学习笔记,内容来源于极客时间《Tony Bai · Go 语言第一课》。