极客时间已完结课程限时免费阅读

17|复合数据类型:用结构体建立对真实世界的抽象

17|复合数据类型:用结构体建立对真实世界的抽象-极客时间

17|复合数据类型:用结构体建立对真实世界的抽象

讲述:Tony Bai

时长29:15大小26.72M

你好,我是 Tony Bai。
在前面的几节课中,我们一直在讲数据类型,包括 Go 基本数据类型和三个复合数据类型。我们可以用这些数据类型来建立对真实世界的抽象。
那么什么是对真实世界的抽象呢?我们编写程序的目的就是与真实世界交互,解决真实世界的问题,帮助真实世界提高运行效率与改善运行质量。所以我们就需要对真实世界事物体的重要属性进行提炼,并映射到程序世界中,这就是所谓的对真实世界的抽象。
不同的数据类型具有不同的抽象能力,比如整数类型 int 可以用来抽象一个真实世界物体的长度,string 类型可以用来抽象真实世界物体的名字,等等。
但是光有这些类型的抽象能力还不够,我们还缺少一种通用的、对实体对象进行聚合抽象的能力。你可以回想一下,我们目前可以用学过的各种类型抽象出书名、书的页数以及书的索引,但有没有一种类型,可以抽象出聚合了上述属性的“书”这个实体对象呢?
有的。在 Go 中,提供这种聚合抽象能力的类型是结构体类型,也就是 struct。这一节课,我们就围绕着结构体的使用和内存表示,由外及里来学习 Go 中的结构体类型。
不过,在学习如何定义一个结构体类型之前,我们首先要来看看如何在 Go 中自定义一个新类型。有了这个基础,我们再理解结构体类型的定义方法就十分自然了。

如何自定义一个新类型?

在 Go 中,我们自定义一个新类型一般有两种方法。第一种是类型定义(Type Definition),这也是我们最常用的类型定义方法。在这种方法中,我们会使用关键字type 来定义一个新类型 T,具体形式是这样的:
type T S // 定义一个新类型T
在这里,S 可以是任何一个已定义的类型,包括 Go 原生类型,或者是其他已定义的自定义类型,我们来演示一下这两种情况:
type T1 int
type T2 T1
这段代码中,新类型 T1 是基于 Go 原生类型 int 定义的新自定义类型,而新类型 T2 则是基于刚刚定义的类型 T1,定义的新类型。
这里我们引入一个新概念,底层类型。如果一个新类型是基于某个 Go 原生类型定义的,那么我们就叫 Go 原生类型为新类型的底层类型(Underlying Type)。比如这个例子中,类型 int 就是类型 T1 的底层类型。
那如果不是基于 Go 原生类型定义的新类型,比如 T2,它的底层类型是什么呢?这时我们就要看它定义时是基于什么类型了。这里,T2 是基于 T1 类型创建的,那么 T2 类型的底层类型就是 T1 的底层类型,而 T1 的底层类型我们已经知道了,是类型 int,那么 T2 的底层类型也是类型 int。
为什么我们要提到底层类型这个概念呢?因为底层类型在 Go 语言中有重要作用,它被用来判断两个类型本质上是否相同(Identical)。
在上面例子中,虽然 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,编译器就会给出编译错误的提示。
除了基于已有类型定义新类型之外,我们还可以基于类型字面值来定义新类型,这种方式多用于自定义一个新的复合类型,比如:
type M map[int]string
type S []string
和变量声明支持使用 var 块的方式类似,类型定义也支持通过 type 块的方式进行,比如我们可以把上面代码中的 T1、T2 和 T3 的定义放在同一个 type 块中:
type (
T1 int
T2 T1
T3 string
)
第二种自定义新类型的方式是使用类型别名(Type Alias),这种类型定义方式通常用在项目的渐进式重构,还有对已有包的二次封装方面,它的形式是这样的:
type T = S // type alias
我们看到,与前面的第一种类型定义相比,类型别名的形式只是多了一个等号,但正是这个等号让新类型 T 与原类型 S 完全等价。完全等价的意思就是,类型别名并没有定义出新类型,T 与 S 实际上就是同一种类型,它们只是一种类型的两个名字罢了,就像一个人有一个大名、一个小名一样。我们看下面这个简单的例子:
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 T struct {
Field1 T1
Field2 T2
... ...
FieldN Tn
}
根据这个定义,我们会得到一个名为 T 的结构体类型,定义中 struct 关键字后面的大括号包裹的内容就是一个类型字面值。我们看到这个类型字面值由若干个字段(field)聚合而成,每个字段有自己的名字与类型,并且在一个结构体中,每个字段的名字应该都是唯一的。
通过聚合其他类型字段,结构体类型展现出强大而灵活的抽象能力。我们直接上案例实操,来说明一下。
我们前面提到过对现实世界的书进行抽象的情况,其实用结构体类型就可以实现,比如这里,我就用前面的典型方法定义了一个结构体:
package book
type Book struct {
Title string // 书名
Pages int // 书的页数
Indexes map[string]int // 书的索引
}
在这个结构体定义中,你会发现,我在类型 Book,还有它的各个字段中都用了首字母大写的名字。这是为什么呢?
你回忆一下,我们在第 11 讲中曾提到过,Go 用标识符名称的首字母大小写来判定这个标识符是否为导出标识符。所以,这里的类型 Book 以及它的各个字段都是导出标识符。这样,只要其他包导入了包 book,我们就可以在这些包中直接引用类型名 Book,也可以通过 Book 类型变量引用 Name、Pages 等字段,就像下面代码中这样:
import ".../book"
var b book.Book
b.Title = "The Go Programming Language"
b.Pages = 800
如果结构体类型只在它定义的包内使用,那么我们可以将类型名的首字母小写;如果你不想将结构体类型中的某个字段暴露给其他包,那么我们同样可以把这个字段名字的首字母小写。
我们还可以用空标识符“_”作为结构体类型定义中的字段名称。这样以空标识符为名称的字段,不能被外部包引用,甚至无法被结构体所在的包使用。那这么做有什么实际意义呢?这里先留个悬念,你可以自己先思考一下,我们在后面讲解结构体类型的内存布局时,会揭晓答案。
除了通过类型字面值来定义结构体这种典型操作外,我们还有另外几种特殊的情况。
第一种:定义一个空结构体。
我们可以定义一个空结构体,也就是没有包含任何字段的结构体类型,就像下面示例代码这样:
type Empty struct{} // Empty是一个不包含任何字段的空结构体类型
空结构体类型有什么用呢?我们继续看下面代码:
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 中的字段。关于这种“类型嵌入”特性,我们在以后的课程中还会详细说明,这里就先不深入了。
不过,看到这里,关于结构体定义,你可能还有一个疑问,在结构体类型 T 的定义中是否可以包含类型为 T 的字段呢?比如这样:
type T struct {
t T
... ...
}
答案是不可以的。Go 语言不支持这种在结构体类型定义中,递归地放入其自身类型字段的定义方式。面对上面的示例代码,编译器就会给出“invalid recursive type T”的错误信息。
同样,下面这两个结构体类型 T1 与 T2 的定义也存在递归的情况,所以这也是不合法的。
type T1 struct {
t2 T2
}
type T2 struct {
t1 T1
}
不过,虽然我们不能在结构体类型 T 定义中,拥有以自身类型 T 定义的字段,但我们却可以拥有自身类型的指针类型、以自身类型为元素类型的切片类型,以及以自身类型作为 value 类型的 map 类型的字段,比如这样:
type T struct {
t *T // ok
st []T // ok
m map[string]T // ok
}
你知道为什么这样的定义是合法的吗?我想把这个问题作为这节课的课后思考题留给你,你可以在留言区说一下你的想法。
关于结构体类型的知识我们已经学习得差不多了,接下来我们再来看看如何应用这些结构体类型来声明变量,并进行初始化。

结构体变量的声明与初始化

和其他所有变量的声明一样,我们也可以使用标准变量声明语句,或者是短变量声明语句声明一个结构体类型的变量:
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 之间进行同步的互斥锁。
运用“零值可用”类型,给 Go 语言中的线程互斥锁带来了什么好处呢?我们横向对比一下 C 语言中的做法你就知道了。如果我们要在 C 语言中使用线程互斥锁,我们通常需要这么做:
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);
pthread_mutex_lock(&mutex);
... ...
pthread_mutex_unlock(&mutex);
我们可以看到,在 C 中使用互斥锁,我们需要首先声明一个 mutex 变量。但这个时候,我们不能直接使用声明过的变量,因为它的零值状态是不可用的,我们必须使用 pthread_mutex_init 函数对其进行专门的初始化操作后,它才能处于可用状态。再之后,我们才能进行 lock 与 unlock 操作。
但是在 Go 语言中,我们只需要这几行代码就可以了:
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 类型,它们的零值并非有效值。对于这类类型,我们需要对它的变量进行显式的初始化后,才能正确使用。在日常开发中,对结构体类型变量进行显式初始化的最常用方法就是使用复合字面值,下面我们就来看看这种方法。

使用复合字面值

其实我们已经不是第一次接触复合字面值了,之前我们讲解数组 / 切片、map 类型变量的变量初始化的时候,都提到过用复合字面值的方法。
最简单的对结构体变量进行显式初始化的方式,就是按顺序依次给每个结构体字段进行赋值,比如下面的代码:
type Book struct {
Title string // 书名
Pages int // 书的页数
Indexes map[string]int // 书的索引
}
var book = Book{"The Go Programming Language", 700, make(map[string]int)}
我们依然可以用这种方法给结构体的每一个字段依次赋值,但这种方法也有很多问题:
首先,当结构体类型定义中的字段顺序发生变化,或者字段出现增删操作时,我们就需要手动调整该结构体类型变量的显式初始化代码,让赋值顺序与调整后的字段顺序一致。
其次,当一个结构体的字段较多时,这种逐一字段赋值的方式实施起来就会比较困难,而且容易出错,开发人员需要来回对照结构体类型中字段的类型与顺序,谨慎编写字面值表达式。
最后,一旦结构体中包含非导出字段,那么这种逐一字段赋值的方式就不再被支持了,编译器会报错:
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。这会导致编译错误,因为未导出字段是不可见的。
那么,如果一个结构体类型中包含未导出字段,并且这个字段的零值还不可用时,我们要如何初始化这个结构体类型的变量呢?又或是一个结构体类型中的某些字段,需要一个复杂的初始化逻辑,我们又该怎么做呢?这时我们就需要使用一个特定的构造函数,来创建并初始化结构体变量了。

使用特定的构造函数

其实,使用特定的构造函数创建并初始化结构体变量的例子,并不罕见。在 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 类型指针实例。
像这类通过专用构造函数进行结构体类型变量创建、初始化的例子还有很多,我们可以总结一下,它们的专用构造函数大多都符合这种模式:
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 编译器为什么要在结构体的字段间插入“填充物”呢?这其实是内存对齐的要求。所谓内存对齐,指的就是各种内存对象的内存地址不是随意确定的,必须满足特定要求。
对于各种基本数据类型来说,它的变量的内存地址值必须是其类型本身大小的整数倍,比如,一个 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 语言不是一门面向对象范式的编程语言,它没有 C++ 或 Java 中的那种 class 类型。如果非要在 Go 中选出一个与 class 接近的语法元素,那非结构体类型莫属。Go 中的结构体类型提供了一种聚合抽象能力,开发者可以使用它建立对真实世界的事物的抽象。
在讲解结构体相关知识前,我们在先介绍了如何自定义一个新类型,通常我们会使用类型定义这种标准方式定义新类型另外,我们还可以用类型别名的方式自定义类型,你要多注意这两种方式的区别。
对于结构体这类复合类型,我们通过类型字面值方式来定义,它包含若干个字段,每个字段都有自己的名字与类型。如果不包含任何字段,我们称这个结构体类型为空结构体类型,空结构体类型的变量不占用内存空间,十分适合作为一种“事件”在并发的 Goroutine 间传递。
当我们使用结构体类型作为字段类型时,Go 还提供了“嵌入字段”的语法糖,关于这种嵌入方式,我们在后续的课程中还会有更详细的讲解。另外,Go 的结构体定义不支持递归,这点你一定要注意。
结构体类型变量的初始化有几种方式:零值初始化、复合字面值初始化,以及使用特定构造函数进行初始化,日常编码中最常见的是第二种。支持零值可用的结构体类型对于简化代码,改善体验具有很好的作用。另外,当复合字面值初始化无法满足要求的情况下,我们需要为结构体类型定义专门的构造函数,这种方式同样有广泛的应用。
结构体类型是既数组类型之后,又一个以平铺形式存放在连续内存块中的类型。不过与数组类型不同,由于内存对齐的要求,结构体类型各个相邻字段间可能存在“填充物”,结构体的尾部同样可能被 Go 编译器填充额外的字节,满足结构体整体对齐的约束。正是因为这点,我们在定义结构体时,一定要合理安排字段顺序,要让结构体类型对内存空间的占用最小。
关于结构体类型的知识点比较多,你先消化一下。在后面讲解方法的时候,我们还会继续讲解与结构体类型有关的内容。

思考题

Go 语言不支持在结构体类型定义中,递归地放入其自身类型字段,但却可以拥有自身类型的指针类型、以自身类型为元素类型的切片类型,以及以自身类型作为 value 类型的 map 类型的字段,你能思考一下其中的原因吗?期待在留言区看到你的想法。
欢迎你把这节课分享给更多对 Go 复合数据类型感兴趣的朋友。我是 Tony Bai,我们下节课见。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 35

提建议

上一篇
16|复合数据类型:原生map类型的实现机制是怎样的?
下一篇
18|控制结构:if的“快乐路径”原则
unpreview
 写留言

精选留言(38)

  • Darren
    2021-11-19
    一个类型,它所占用的大小是固定的,因此一个结构体定义好的时候,其大小是固定的。 但是,如果结构体里面套结构体,那么在计算该结构体占用大小的时候,就会成死循环。 但如果是指针、切片、map等类型,其本质都是一个int大小(指针,4字节或者8字节,与操作系统有关),因此该结构体的大小是固定的,记得老师前几节课讲类型的时候说过,类型就能决定内存占用的大小。 因此,结构体是可以接口自身类型的指针类型、以自身类型为元素类型的切片类型,以及以自身类型作为 value 类型的 map 类型的字段,而自己本身不行。
    展开

    作者回复: 正确✅

    共 8 条评论
    81
  • 西红柿牛腩泡饼
    2021-11-19
    因为指针、map、切片的变量元数据的内存占用大小是固定的。

    作者回复: 一语点题,直中要害!

    38
  • Hqudmx1994
    2021-11-27
    老师讲的细节很多、很棒,有和我一样对内存对齐有疑惑的可以看看这篇文章,https://geektutu.com/post/hpg-struct-alignment.html
    共 1 条评论
    10
  • lesserror
    2021-11-21
    Tony Bai 老师这节课的内容很多,尤其是内存对齐这块儿的知识,让我眼前一亮。不过有几处疑惑: 1. i的地址要能被8整除,我的理解是不应该就是图中第8个格子开始计算的么? 那为什么和b之间填充了七个格子,这样i的地址就是从第9个格子开始的,1+7 之后。不应该只需填充6个格子就行了吗? 2. 文中说:“但是,如果把 i 紧邻 b 进行分配,当 i 的地址可以被 8 整除时,b 的地址就无法被 8 整除。这个时候,我们需要在 b 与 i 之间做一些填充,使得 i 的地址可以被 8 整除时,b 的地址也始终可以被 8 整除,于是我们在 i 与 b 之间填充了 7 个字节,此时此刻 sum=1+7+8;” 这里的b只要能被1整除就行了,这里怎么又和8扯上关系了? 反复读了这段话,始终没明白。 3. 文中的这段代码的错误:var t3 = T{11, "hello", 13} // 错误:implicit assignment of unexported field 'f3' in T literal 后面的错误信息是在哪里提示的,我这里运行代码和IDE给出的错误信息都是: too few values in T{...} 并没有这个错误提示:implicit assignment of unexported field 'f3' in T literal 4. 课后问题的标准答案是什么? 我看大家众说纷纭,这里的答案,我认为还是很关键的。
    展开

    作者回复: 1. 可以以一个具体例子来说明。假设b所在内存单元的地址为8,i的地址为16,那么i与b之间是7个格子还是8个格子呢?是不是应该是7个格子? 2. 这算是给后面做铺垫吧。b是结构体的第一个字段,b的地址起始就是结构体变量的地址。虽然b作为byte类型,其自身的对齐约束是1,但是考虑到整个结构体,实际上go编译器为b分配的地址必须是被8整除的。 3. 不要在一个包里用,代码前面说过:“一旦结构体中包含非导出字段,那么这种逐一字段赋值的方式就不再被支持了”。所以建立一个新包,导入T,创建T类型变量并赋值。 4. go是静态语言,对于一个类型,编译器要知道它的大小。如果嵌套T,那么编译器无法知道其大小。但如果是*T或[]T,编译器只需要知道指针大小以及切片这个“描述符”的大小即可。

    共 6 条评论
    6
  • 功夫熊猫
    2021-11-19
    因为指针的值是变量的地址,而变量的地址是一种新的数据类型。

    作者回复: 不错!不过还差那么一点点:所有类型的指针的大小都是固定长度的。所以编译器可以得到这个指针类型的大小。即便在不知道T大小的情况下也可以。

    5
  • 2021-11-19
    如果不需要照顾 “按字段顺序对一个结构体类型变量进行显式初始化” 这种写法,是不是编译器就可以自动做内存对齐优化,即把 `type T struct { b byte i int64 u uint16}` 实质用 `type S struct { b byte u uint16 i int64}` 编译。

    作者回复: 编译器不会改变字段顺序的,只会基于现有次序做缝隙填充与结构体尾部padding,保证各个字段以及整个结构体都是对齐的。

    4
  • DullBird
    2021-11-20
    不知道循环定义是因为初始化的时候需要开辟内存空间。如果是循环变量的依赖的话。内存初始化就无穷无尽了。但是如果是指针,切片,map等。只需要开辟一个引用内存地址就可以了

    作者回复: 思路正确。

    3
  • liaomars
    2021-11-19
    type T struct { t T ... ...} 这种方式,t T是一个新的自定义数据类型了, 而可以接受 指针,切片这些,因为本质上还是指向底层数据是一样的,不知道这样理解对不对。

    作者回复: go是静态语言,对于一个类型,编译器要知道它的大小。如果在T类型的定义中嵌套T,那么编译器无法知道其大小。但如果是*T或[]T,编译器只需要知道指针大小以及切片这个“描述符”的大小即可。而指针与切片的大小都是固定的,对编译器来说是已知的。

    3
  • andox
    2021-11-20
    *T是和T不是一种类型 所以符合不能递归的要求 切片、map都有内部的Header结构体承载 和T也不是一种类型 所以也符合不能递归的要求 T不能包含T类型因为在计算字段大小对齐时 递归计算不出来
    2
  • 小豆子
    2021-11-19
    声明 结构体变量时 需要分配内存,指针/切片/map类型 针对特定架构 占用的内存是已知的,与底层具体类型无关。

    作者回复: ✅

    2
  • zzwdream
    2022-04-16
    关于内存对齐,基于字段的字节数做升序排序,是否就可以做到最优解? 内存的浪费主要是在于填充的冗余,那么可以基于字节数升序,相邻字段的字节数相同,那么就不存在填充;相邻字段的字节数不同,那么又不会因因为字节数差距太大而填充太多。 (比如相邻的字段是 byte和 uint16 ,那么只需要填充一个字节;但是相邻的字段是 byte 和 int,那么就要填充7个字节。)
    展开

    作者回复: 是否是最优还不确认,但这是一种降低struct内存占用的技巧。github有一些struct布局优化的项目,可以探索一下它们使用的算法。

    1
  • Niverkk
    2022-01-27
    关于第二种16字节 =b (1字节) + 填充(1字节) + u(2字节)+ 填充(4字节) + i(8字节) ?

    作者回复: 对的。

    1
  • 2021-12-21
    老师我有个地方不是很没明白 func main() { book := tempconv.Book{ // 这里的book的类型为 tempconv.Book Title: "不想学", Page: 500, Indexes: make(map[string]int), } k := new(tempconv.Book) //这里k是 *tempconv.Book k.Title = "有点蒙蔽" print(k.Title) //指针类型(就是 *tempconv.Book)这里这样写是对的 print(book.Title) //普通类型(就是tempconv.Book)这里这样写也是对的 } 就是 如上这段代码,new函数返回的结构体是指针类型,但是他依然可以通过 k.Title的方式进行取值,指针类型不应该就是一个内存地址么,对于结构体类型来说 这种通过new函数返回的结构体指针类型(*tempconv.Book)和通过 book := tempconv.Book{} 得到的book(tempconv.Book 类型,非指针类型)有什么区别吗
    展开

    作者回复: 首先,无论是通过new得到的指针类型变量,还是通过复合字面值得到了值类型变量,通过“变量.字段名”都可以得到对应的字段值,这可以理解是go的语法糖吧。因为go不像c语言那样:指针类型通过->来访问字段。 它们的区别就是一个是值类型,一个是指针类型。其更多的差异是在作为参数,传递给函数/方法时。由于函数参数是值拷贝,因此值类型变量传递的是拷贝,而指针类型传递的是地址。函数内部对指针类型参数的修改会反映到函数外部的原变量中。

    共 2 条评论
    1
  • 罗杰
    2021-11-21
    我定义一个结构体,需要获取按 1 对齐占用的内存大小,标准库有提供相应的方法吗?

    作者回复: 好像没有啊。c语言中可以通过#pragma pack(1) 来搞。

    1
  • 小戈
    2021-11-19
    当 i 的地址可以被 8 整除时,b 的地址就无法被 8 整除,这句话什么意思?,上面说b地址被1整除,后面为什么说要被8整除

    作者回复: 这块实际上为后续结构体的整体对齐做了一个铺垫。b是结构体T的第一个字段,b的地址起始就是结构体T的变量的地址。b自身的对齐系数是1,但放在结构体中,由于其地址就是结构体的地址,实际上b的地址还要满足更为严格的对齐约束。

    共 4 条评论
    1
  • 罗杰
    2021-11-19
    内存对齐要好好理解
    1
  • Verson
    2023-01-11 来自广东
    老师请教下,“渐进式重构”作为类型别名应用的场景,有没有具体的案例说明下

    作者回复: type alias当初加入go,主要是为一些大型代码仓库的重构提供方便。大型代码仓库重构不是一蹴而就的,需要过渡期。 在过渡期内,API迁移后,应该依旧在原先的位置上可用。比如A-Z包都依赖foo.F1,但foo.F1迁移到了bar.F1,这时如果没有alias,所有依赖foo.F1的包编译都会失败。 但在大型代码仓库中,要想很好的完成这样的重构,更多是协调工作,需要A-Z包同时将依赖foo.F1替换为bar.F1,这很困难,除非是统一行动。于是为了支持一种过渡,原foo包的开发者就希望这个foo.F1 -> bar.F1的迁移对于依赖foo.F1的包来说是透明的,即便迁移完依旧可用。那么有了type alias后,只需在foo包中添加一行 type F1 = bar.F1即可。 这样一来,就满足了过渡期的要求。大型代码仓库重构都是这样循序渐进的。

  • zhu见见
    2023-01-05 来自北京
    go编译器为啥不自动重排结构体字段的顺序,来优化内存占用哦

    作者回复: 关于这个问题,Go官方并没有完全关闭未来对reordering struct fields的支持的可能。只是这个问题从优先级来看还没有处于很高的优先级。

  • 子杨
    2022-12-19 来自辽宁
    老师怎么判断一个类型是不是零值可用的?

    作者回复: 无需显式赋值,声明后即可用,且不会造成问题。 比如var mu Sync.Mutex声明后,即可使用。

  • pythonbug
    2022-10-19 来自北京
    老师好,弱弱的请教一下,为啥说 type M map[int]string type S []string 这种是根据类型字面值定义来新类型呢? map[int]string 和 []string 也是一种复合类型,感觉并不是字面值。。。

    作者回复: map[int]string、[]string这种由已知类型组合而形成的类型被称为type literal,中文“类型字面值", 这个是引用自 go语法规范中的术语。 go spec原文:"A type may also be specified using a type literal, which composes a type from existing types." (go 1.19版本)