You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

33 KiB

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} // 错误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结构体中包含了一个非导出字段rr的类型为另外一个结构体类型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我们下节课见。