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

你好,我是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} // 错误:too few values in struct literal of type T
var t = T{11, "hello", 13, 14, 15} // 正确

事实上,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,我们下节课见。

精选留言

  • Darren

    2021-11-19 22:34:36

    一个类型,它所占用的大小是固定的,因此一个结构体定义好的时候,其大小是固定的。

    但是,如果结构体里面套结构体,那么在计算该结构体占用大小的时候,就会成死循环。

    但如果是指针、切片、map等类型,其本质都是一个int大小(指针,4字节或者8字节,与操作系统有关),因此该结构体的大小是固定的,记得老师前几节课讲类型的时候说过,类型就能决定内存占用的大小。

    因此,结构体是可以接口自身类型的指针类型、以自身类型为元素类型的切片类型,以及以自身类型作为 value 类型的 map 类型的字段,而自己本身不行。
    作者回复

    正确✅

    2021-11-22 15:42:22

  • 西红柿牛腩泡饼

    2021-11-19 11:22:34

    因为指针、map、切片的变量元数据的内存占用大小是固定的。
    作者回复

    一语点题,直中要害!

    2021-11-22 15:15:27

  • Hqudmx1994

    2021-11-27 10:08:48

    老师讲的细节很多、很棒,有和我一样对内存对齐有疑惑的可以看看这篇文章,https://geektutu.com/post/hpg-struct-alignment.html
  • lesserror

    2021-11-21 11:11:58

    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,编译器只需要知道指针大小以及切片这个“描述符”的大小即可。

    2021-11-22 15:02:04

  • 2021-11-19 16:53:38

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

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

    2021-11-22 15:59:03

  • 功夫熊猫

    2021-11-19 03:15:24

    因为指针的值是变量的地址,而变量的地址是一种新的数据类型。
    作者回复

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

    2021-11-22 15:07:01

  • liaomars

    2021-11-19 14:29:23

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

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

    2021-11-22 15:13:06

  • zzwdream

    2022-04-16 23:30:45

    关于内存对齐,基于字段的字节数做升序排序,是否就可以做到最优解?

    内存的浪费主要是在于填充的冗余,那么可以基于字节数升序,相邻字段的字节数相同,那么就不存在填充;相邻字段的字节数不同,那么又不会因因为字节数差距太大而填充太多。
    (比如相邻的字段是 byte和 uint16 ,那么只需要填充一个字节;但是相邻的字段是 byte 和 int,那么就要填充7个字节。)
    作者回复

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

    2022-04-19 08:56:37

  • 2021-12-21 23:16:36

    老师我有个地方不是很没明白
    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语言那样:指针类型通过->来访问字段。

    它们的区别就是一个是值类型,一个是指针类型。其更多的差异是在作为参数,传递给函数/方法时。由于函数参数是值拷贝,因此值类型变量传递的是拷贝,而指针类型传递的是地址。函数内部对指针类型参数的修改会反映到函数外部的原变量中。

    2022-01-12 11:03:33

  • Calvin

    2021-11-22 22:12:25

    “在日常定义结构体时,一定要注意结构体中字段顺序,尽量合理排序,降低结构体对内存空间的占用。”

    说到“内存对齐”顺序,老师有没有什么比较通用的方法论?比如长字段放在结构体后面或前面来定义?
    作者回复

    有现成的工具 github.com/orijtech/structslop。如果对其算法感兴趣,可以阅读其源码。也欢迎将你的成果发表在评论区。

    2021-11-30 15:00:47

  • DullBird

    2021-11-20 21:56:42

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

    思路正确。

    2021-11-22 15:59:31

  • Verson

    2023-01-11 17:06:04

    老师请教下,“渐进式重构”作为类型别名应用的场景,有没有具体的案例说明下
    作者回复

    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即可。

    这样一来,就满足了过渡期的要求。大型代码仓库重构都是这样循序渐进的。

    2023-01-12 06:01:07

  • 2022-07-09 14:24:58

    老师,麻烦问一下,结构体中使用空标识符“_”来做作为结构体字段,具体有什么用呢?
    作者回复

    占位符。让编译器分配对应的内存空间。

    2022-07-13 16:13:53

  • Niverkk

    2022-01-27 14:55:09

    关于第二种16字节 =b (1字节) + 填充(1字节) + u(2字节)+ 填充(4字节) + i(8字节) ?
    作者回复

    对的。

    2022-01-30 19:54:01

  • return

    2021-11-25 11:20:24

    老师讲的太好了, 这一篇解答了我之前 内存填充的疑问, 赞。
    请教老师, 您文中说的 Type Alias 常用于 对 已有包的 二次封装。
    我现在就遇到这种问题, 我要基于一个已有包来二次封装, 我有这3个诉求。
    1. 要扩展原包没有的功能。
    2. 要兼容原包。
    这里的兼容, 就是 我希望 使用方调用一个类型, 就能即调用原包能力又内调用封装包的扩展能力。
    有一个可行方法,就是 封装包 把所有原包的功能全部包装一遍, 但我感觉大部分功能都仅仅是加了个嵌套,没必要。
    由于go限制 只有类型所在的包才能基于这个类型写方法,导致封装包的扩展功能只能基于新类型。这样又导致 使用方,需要new 封装包类型和原包类型 分别调用对应的方法。 感觉不兼容了。
    请教老师 这种 有啥好的实践, 或好的实例 参考吗
    作者回复

    我假设你的已有包为A,基于A新封装的包为B。你希望B继承A的功能,然后在B中扩展A的功能。用户使用B时完全不需要知晓A的存在,至少在源文件中不用导入A包。那么就用type alias做啊。A包中的导出函数Foo,在B包中如果想不经封装的暴露给用户,直接在B中定义var Foo = A.Foo。对于A中的自定义类型,要扩展其中的方法,可以用嵌入字段方式。这样方法实现可以继承下来,如果要扩展,添加方法即可或override已有方法。

    2021-11-30 16:17:46

  • Expanse

    2021-11-20 11:11:00

    因为那些都是引用,指向另一块内存地址
  • andox

    2021-11-20 10:32:36

    *T是和T不是一种类型 所以符合不能递归的要求
    切片、map都有内部的Header结构体承载 和T也不是一种类型 所以也符合不能递归的要求
    T不能包含T类型因为在计算字段大小对齐时 递归计算不出来
  • 小豆子

    2021-11-19 10:01:55

    声明 结构体变量时 需要分配内存,指针/切片/map类型 针对特定架构 占用的内存是已知的,与底层具体类型无关。
    作者回复

    2021-11-22 15:15:55

  • Balaam

    2025-02-16 15:31:19

    直接包含自身类型:会导致无限递归的定义问题,因此是非法的。
    包含自身类型的指针、切片或映射:是合法的,因为这些类型是引用类型,它们的大小是固定的,不会导致无限递归。
    作者回复

    👍

    2025-02-19 23:13:41

  • 安石

    2024-07-28 09:01:31

    Go 语言不支持结构体直接包含其自身类型字段,是为了避免编译器无法确定结构体大小的问题。通过使用自身类型的指针、切片和 map,可以避免这种无限递归的问题,因为这些类型的大小是固定的,从而确保编译器可以正确地确定结构体的大小。
    作者回复

    2024-08-04 22:21:52