Go 语言原生内置了多种复合数据类型,包括数组、切片(slice)、map、结构体,以及像 channel 这类用于并发程序设计的高级复合数据类型。
什么是复合类型?
复合类型就是由多个同构类型(相同类型)或异构类型(不同类型)的元素的值组成而成的。
这篇笔记先来认识一下最简单的复合类型——数组以及和数组有密切关系的切片。
数组
数组的定义:是一个长度固定、由同构类型元素组成的连续序列。
声明一个数组:
1 | var arr [N]T |
Go 语言的数组有两个重要的属性:元素的类型和数组的长度(元素的个数)。
在上面的示例代码中,它的类型为[N]T
,其中元素的类型为 T
,数组的长度为 N
。注意,这里用的是 [N]T
描述它的类型,并不是打错字了,而是为了后面做铺垫。
如果两个数组类型的元素类型 T 与数组长度 N 都是一样的,那么这两个数组类型是等价的,如果有一个属性不同,它们就是两个不同的数组类型。
1 | func foo(arr [5]int) {} |
在上面的示例代码中,arr2 与 arr3 两个变量的类型分别为[6]int
和 [5]string
,前者的长度属性与[5]int
不一致,后者的元素类型属性与[5]int
不一致,因此这两个变量都不能作为调用函数 foo 时的实际参数。
数组在内存中的存储
数组类型不仅是逻辑上的连续序列,而且在实际内存分配时也占据着一整块内存。
Go 编译器在为数组类型的变量实际分配内存时,会为 Go 数组分配一整块、可以容纳它所有元素的连续内存,如下图所示:
从上面这张图中可以看到,这块内存全部空间都被用来表示数组元素,所以可以说这块内存的大小,等同于各数组元素的大小之和(物理上的)。
如果两个数组所分配的内存大小不同,那么它们肯定是不同的数组类型,因为只有两个变量N 和 T
完全相同,结果才会相同。
Go 提供了预定义函数 len 可以用于获取一个数组类型变量的长度,通过 unsafe 包提供的 Sizeof 函数,我们可以获得一个数组变量的总大小,如下面代码:
1 | var arr = [6]int{1, 2, 3, 4, 5, 6} |
数组大小就是所有元素的大小之和,这里数组元素的类型为 int。在 64 位平台上,int 类型的大小为 8,数组 arr 一共有 6 个元素,因此它的总大小为 6x8=48 个字节。
显式初始化
和基本数据类型一样,声明一个数组类型变量的同时,也可以显式地对它进行初始化。如果不进行显式初始化,那么数组中的元素值就是它类型的零值。比如下面的数组类型变量 arr1 的各个元素值都为 0:
1 | var arr1 [6]int // [0 0 0 0 0 0] |
显示初始化,需要在右值中显式放置数组类型,并通过大括号的方式给各个元素赋值:
1 | // 注意最后面的逗号不能少 |
多维数组
数组出了一维数组,还有多维数组,在Go 语言中,使用如下方式声明一个多维数组:
1 | var mArr [2][3][4]int |
有其他语言基础的话,多维数组并不难理解,上面这段示例代码,可以拆解成这样:
切片
因为数组在使用上有两点不足:固定的元素个数,以及传值机制下导致的开销较大。
于是 Go 设计者们又引入了另外一种同构复合类型——切片(slice),来弥补数组的这两点不足。
切片和数组就长得很像,但又各有各的行为特点。
声明一个切片:
1 | var nums = []int{1, 2, 3, 4, 5, 6} |
可以看到与声明数组相比,切片的声明仅仅只是少了一个长度属性,正因为没有长度的束缚,切片展现出更为灵活的特性。
虽然不需要像数组那样在声明时指定长度,但切片也有自己的长度,只不过这个长度不是固定的,而是随着切片中元素个数的变化而变化的。\
可以通过 len 函数获得切片类型变量的长度。
1 | fmt.Println(len(nums)) // 6 |
切片在内存中的存储
Go 切片在运行时其实是一个三元组结构,它在 Go 运行时中的表示如下:
1 | // $GOROOT/src/runtime/slice.go |
每个切片包含三个字段:
- array:是指向底层数组的指针
- len:是切片的长度,即切片中当前元素的个数
- cap:是底层数组的长度,也是切片的最大容量,cap 值永远大于等于 len 值
示例代码中的 nums 变量,在内存中的表示,如下图所示:
这里有一个概念需要理解
创建一个切片有几种方式,下面一一介绍。
make 函数
通过 make 函数来创建切片,并指定底层数组的长度:
1 | sl := make([]byte, 6, 10) |
如果没有在 make 中指定 cap 参数,那么底层数组长度 cap 就等于 len,比如:
1 | sl := make([]byte, 6) // cap = len = 6 |
数组的切片化
采用 array[low : high : max]
语法基于一个已存在的数组创建切片:
1 | arr := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} |
基于数组 arr 创建了一个切片 sl,这个切片 sl 在运行时中的表示是这样:
基于数组创建切片时,
- 起始元素:low 所标识的索引开始
- len:切片的长度 = high - low
- cap:最大容量 = max - low
- array:指向原数组
由于切片 sl 的底层数组就是数组 arr,对切片 sl 中元素的修改将直接影响数组 arr 变量:
1 | sl[0] += 10 |
针对一个已存在的数组,可以建立多个操作数组的切片,这些切片共享同一底层数组,所以操作其中一个切片时,会影响其他切片(切片好比打开了一个访问与修改数组的“窗口”)。
1 | arr := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} |
常见操作:
s[n]
:切片 s 中索引位置为 n 的项s[:]
:从切片 s 的索引位置 0 到 len(s)-1 处所获得的切片s[low:]
:从切片 s 的索引位置 low 到 len(s)-1 处所获得的切片s[:high]
:从切片 s 的索引位置 0 到 high 处所获得的切片,len = highs[low:high]
:从切片 s 的索引位置 low 到 high 处所获得的切片,len = high-lows[low:high:max]
:从切片 s 的索引位置 low 到 high 所获得的切片,len = high-low, cap = max-lowlen(s)
:切片 s 的长度,总是 <= cap(s)cap(s)
:切片 s 的容量,总是 >= len(s)
切片创建切片
动态扩容
切片与数组最大的不同,就在于其长度的不定长,这种不定长需要 Go 运行时提供支持,这种支持就是切片的“动态扩容”。
“动态扩容”指的就是,当使用 append 操作向切片追加数据的时候,如果这时切片的 len 值和 cap 值是相等的,也就是说切片底层数组已经没有空闲空间再来存储追加的值了,Go 运行时就会对这个切片做扩容操作,来保证切片始终能存储下追加的新值。
1 | var s []int |
下面用一张图来解释动态扩容的过程:
其中有几点需要注意:
- 每一次 cap 的值的变化是按一定规律扩展的(1 => 2 => 4 => 8)
- 自动扩容触碰到底层数组边界时,再次 append 会导致切片与数组“绑定关系”,也就是修改切片的第一个元素值时,原数组 u 的元素也不会发生改变了
总结
- 数组是一个长度固定、由相同类型元素组成的连续序列,在内存中占据一块连续的空间
- 数组的不足:固定的元素个数,以及值拷贝的传值机制导致开销较大
- 切片与数组最大的不同,就在于其长度的不定长,这种不定长需要 Go 运行时提供支持,这种支持就是切片的“动态扩容”。