光有砖和瓦还不够,我们还需要墙、门、板这些模型,才能快速构建起我们的大楼。将基本类型组合起来,这就是复合类型。
使用struct
来定义一个结构体类型。结构体是多个相关属性的集合,比如,人有年龄, 体重这些属性。
type 定义类型
你可以使用type
关键字创建一个新的类型,例如给int32
类型起一个别名。
myint
类型的底层是int32
, 但这两个不是一个类型。
- 声明一个结构体,指定它各个字段的值:
- 如果有的字段不指定,会赋予该字段零值:
- 也可以使用 var 声明:
- 通过
.
来读取和修改结构体的字段
运行,输出
结构体是可比较的,要求:
- 相同的结构体类型才能比较。
- 其所有字段都是可比的(不是切片,map, 函数等引用类型)。
比较的规则是,两个结构体的各个字段都相等,它们才相等。例如下列代码p1 == p2
:
输出
运行,输出(64 位系统):
person
结构体占用 8 个字节。我们知道age
和height
是int32类型
,都占 4 个字节, 加起来正好 8 个字节。
我们可以设想,不包含字段的结构体即 空结构体不占内存,验证一下
运行,输出
果然,空结构体不占内存!, 这在并发编程传递取消信号时很有用,因为不占内存,就减少了复制开销。我们在传递取消信号时,不关心传递了什么类型的变量,只关心有东西传递过来了。
如果把 person 结构体的age
的类型改为int8
, 结构体会占用多少内存呢? 根据上面的经验,应该占 1+4=5 byte
运行,输出
然而更改后的结构体占 8 个字节,而不是 5 个字节。因为这里涉及到内存对齐。
规则 1: 整个结构体的大小必须是其最大字段大小的整数倍。 这里的最大字段必须是基本类型(如整数,布尔, 浮点数等,不包括string
)
person结构体
占内存最大的是height
, 4 个字节,整体必须是 4 的倍数,又要加 1 个字节,所以是 8 个字节。
规则 2:
我们看两个有趣的现象,看看结构体的字段不同的排列方式如何影响占用内存:
按照规则1
, BadLayout
结构体应该占 8 的倍数,即 16 个字节,但实际上却占 24 个字节。
原因是还有规则 2:每个字段的起始位置必须是其类型大小的整数倍。 BadLayout
结构体中, b
字段内存的起始位置必须是8
的整数倍,即不能紧挨着a
字段,需要填充 7 个字节,如上图。
结构体内存对齐受到两个规则的约束,这启示我们,把占内存大的字段排在前面,(其实咋排都没关系,影响不大,我们知道原理就好。)
为什么会有数值类型?当我们需要一组有顺序的元素,同时能快速地读取和修改第几个元素时 (例如书架上的书), 就需要用到数组类型。
- 数组的类型是
[len]T
, len 为数组的长度,T 为包含元素的类型:
输出
- 也可以省略长度,用
...
代替,go 会帮我们自动推断出长度
等价于
- 声明空数组会默认初始化为它的零值
输出
使用arr[索引]
的方式读取和修改数组的元素。
输出
假设数组的长度是len
, 那么它的第一个元素的索引是 0, 最后一个元素的索引是len-1
; 如果索引不在[0, len-1]这个范围,就会发生索引越界的错误。 ,如下图:
运行, 编译期 panic:
下面将[2]int
类型的数组arr2
赋值给[5]int
类型的数组arr
,会发送编译期错误,因为这两个不是相同的类型(虽然都是int
类型的数组,但是长度不同)。
运行,编译期 panic:
运行,输出
每个 int 类型占 8 个字节,一共有 5 个,共占 5*8=40 个字节。数组在内存中是连续分布的,方便根据索引来快速访问和修改元素。
所以数组占用内存 = 元素占用的内存 * 数组长度。
有意思的部分来了。因为数组的长度就是它类型的一部分,我们不能增加和删除元素,所以 Go 在数组的基础上 创造了切片。我们使用最多的就是切片,而不是数组。
切片的类型是[]T
, T 可以是任何类型。和数组不同,切片的长度不是类型的一部分。
- 声明一个切片但没初始化,go 会赋予切片的零值
nil
, nil
就是没有的意思。
运行,输出
输出
[]int{1, 2, 3}
就是切片的字面量,它定义了切片的类型和包含的元素。切片有两个属性,长度和容量。长度就是切片的元素的个数,容量是切片的底层数组的长度。
- 使用
make([]T, len, cap)
声明并初始化
输出
make
会初始化对应长度的零值。
- 也可以省略容量,
make([]T, len)
这时容量等于长度。
输出
切片的读取、修改和索引越界与数组相同,这里不再赘述。(切片的底层就是数组)
append
函数接收切片和要添加的元素,返回添加后的新切片。
运行,输出
- 你可以一次性增加多个元素:
输出
记得把append
返回的新切片赋值给原切片ls
- 也可以把一个切片的元素添加到另一个切片,使用
...
操作符解开
输出
使用unsafe.Sizeof()
查看切片占多少字节的内存
输出
其实,所有的切片都占24
个字节。为什么呢? 我们看看切片是怎么实现的
切片是一个结构体,有三个字段:
- array: 是一个指针,存储底层数组的内存地址,即指向底层数组。
- len: 切片的长度,即切片的元素的个数。
- cap: 切片的容量,即切片的底层数组的长度。
根据结构体内存对齐的规则,切片就占8+8+8=24
个字节。
已下面的切片ls
为例
它在内存中是这样的:
使用append
函数每次添加一个元素,添加完后打印新切片的长度和容量。
输出
可以发现,append
函数会首先检查切片的容量是不是还够放进去一个元素,如果不够放,就进行扩容,每次容量都是扩大两倍。
切片的扩容策略是怎样的呢?
对应扩容前的切片(ls
), 设
-
它的长度len0 = len(ls)
-
容量cap0 = cap(ls)
-
append()
函数要添加的元素个数为n
扩容前切片的容量 | 扩容后切片的容量 |
---|
小于 256 | max(len0+n, 2 * cap0) |
中间地带 | 2*cap0 -> 1.25*cap0 过渡 |
大容量数组 | 约等于 1.25*cap0 |
简单来说,小容量切片扩容容量翻倍,大容量切片扩容容量翻 1.25 倍。中间地带是平滑过渡的曲线。有runtime
包为证:
为什么要设置这样的扩容策略?切片的底层是数组,每次扩容都要新建一个数组,把旧的数组复制到新的数组,所以要尽量扩容大一些。但当容量很大时,扩容两倍的数组又很浪费空间,所以扩容 1.25 倍。
可以通过切片操作符[start:end]
来创建新的切片(子切片), 其中end
取不到,只取到end-1
- 从数组中取子切片
- 从切片中取子切片
子切片的长度 = end-start
, 容量 = 底层数组容量-start
思考一个问题,下面的切片ls
会改变吗
我们将ls
传入x2
函数,这个函数作用是将ls
的每个元素乘 2。
运行, 输出
ls 改变了,为什么会改变呢,我们并没有传入ls
的指针*[]int
呀。原因是切片的实现就包含有指针指向底层数组。我们知道,Go 中一切传输都是进行复制:
聪明的你,下面的ls
会改变吗?
输出
为什么 ls 不会改变呢。首先append
函数会先判断ls
底层还有没有足够的容量来存4
。显然是没有的,因为ls
的长度和容量都是3
。那么append
就会新建一个底层数组,如图:
底层数组没有改变,所以 main 函数里的ls
不会改变。
为了使外面的ls
改变,我们让add
函数返回ls
即可,像append
函数一样:
输出
将接片作为参数传入函数要特别小心,函数有没有append
操作,如果有,切片是否有足够的容量来存放新的切片。如果没有,那么append
会进行扩容,返回一个新的切片,外面的切片就不会改变。
字符串虽然是基本类型,但它的实现和切片差不多,我们来看看吧。
运行,输出
我们发现,所以的string
都占 16 个字节,为什么呢?我们来看它的实现:
string 是一个结构体,根据结构体内存对齐的规则,它占8+8=16
个字节。
已下面的str
为例:
它在内存中是这样的:
和切片不同的是,string
是没有容量的。为什么呢,Go 在设计时就让string
不能变:
输出,编译期错误 panic:
聪明的你,两个字符串相加会发生什么?
字符串是不能变的,相加会创建新的底层数组:
最后的重头戏map
来了, map
就像和切片一样。切片可以根据索引拿出值,而map
更强大,可以通过自定义的键拿出对应的值。
map
在声明时指定键和值的类型: map[K]V , K
是键的类型,V
是值的类型。键和值的类型是map
类型的一部分:
map的键必须是可比较的
“可比较”的意思是可以通过比较运算符(如 ==
和 !=
)进行比较。
为什么键必须是可比较的? 因为map
是用哈希表和溢出链表实现的,如果键是不可比较的,就无法通过键拿出对应的值。
可比较的类型 | 不可比较的类型 |
---|
string | 切片类型 |
bool | map |
chan | 函数类型(func) |
数值类型: int, float64等 | 结构体类型,其字段至少有一个为不可比较的类型 |
数组类型 | |
接口类型 | |
结构体类型,但其字段必须全部为可比较的类型 | |
指针类型 | |
如果你提前知道键值对的数量,可以在make
中指定创建的map
的初始容量。如果不指定,则默认分配一个初始容量较小的map
。
为什么要指定初始容量?
指定初始容量可以提高性能,避免 map 随着数据增加频繁地扩容(扩容涉及内存分配和数据迁移)。如果你大概知道要存储的键值对数量,指定容量是一个好的实践。
map
和切片一样都是引用类型。它的零值是nil
,还没有分配底层的hmap
内存,不能直接使用。
可以在初始化时就指明map
中的键值对。
我们传入一个不在map
中的key
,看看会发送什么
输出
会返回其值类型的零值, int类型
的零值是0
。
使用val, ok := map[key]
的方式,(接收两个变量), ok
表示key
在不在map
中:
输出:
输出:
注意,map
中键值对是无序的,每次循环时键值对的顺序都不同。
for-range 循环中也可以缺省值v
, 只需要键k
输出
输出
可以发现,所有的map
类型的变量都占 8 个字节。该变量本质上是一个指针,指向底层的hmap
, hmap
存放实际的键值对数据。