小艾的自留地

Stay foolish, Stay hungry

Go 语言原生内置了多种复合数据类型,包括数组、切片(slice)、map、结构体,以及像 channel 这类用于并发程序设计的高级复合数据类型。

什么是复合类型?

复合类型就是由多个同构类型(相同类型)或异构类型(不同类型)的元素的值组成而成的

这篇笔记先来认识一下最简单的复合类型——数组以及和数组有密切关系的切片。

数组

数组的定义:是一个长度固定由同构类型元素组成的连续序列。

声明一个数组:

1
var arr [N]T

Go 语言的数组有两个重要的属性:元素的类型数组的长度(元素的个数)

在上面的示例代码中,它的类型为[N]T,其中元素的类型为 T,数组的长度为 N。注意,这里用的是 [N]T 描述它的类型,并不是打错字了,而是为了后面做铺垫。

如果两个数组类型的元素类型 T 与数组长度 N 都是一样的,那么这两个数组类型是等价的,如果有一个属性不同,它们就是两个不同的数组类型。

1
2
3
4
5
6
7
8
9
10
func foo(arr [5]int) {}

func main() {
var arr1 [5]int
var arr2 [6]int
var arr3 [5]string
foo(arr1) // 正常编译
foo(arr2) // 编译失败:[6]int与函数foo参数的类型[5]int不是同一数组类型
foo(arr3) // 编译失败:[5]string与函数foo参数的类型[5]int不是同一数组类型
}

在上面的示例代码中,arr2 与 arr3 两个变量的类型分别为[6]int[5]string,前者的长度属性与[5]int 不一致,后者的元素类型属性与[5]int 不一致,因此这两个变量都不能作为调用函数 foo 时的实际参数。

数组在内存中的存储

数组类型不仅是逻辑上的连续序列,而且在实际内存分配时也占据着一整块内存。

Go 编译器在为数组类型的变量实际分配内存时,会为 Go 数组分配一整块、可以容纳它所有元素的连续内存,如下图所示:

从上面这张图中可以看到,这块内存全部空间都被用来表示数组元素,所以可以说这块内存的大小,等同于各数组元素的大小之和(物理上的)。

如果两个数组所分配的内存大小不同,那么它们肯定是不同的数组类型,因为只有两个变量N 和 T完全相同,结果才会相同。

Go 提供了预定义函数 len 可以用于获取一个数组类型变量的长度,通过 unsafe 包提供的 Sizeof 函数,我们可以获得一个数组变量的总大小,如下面代码:

1
2
3
var arr = [6]int{1, 2, 3, 4, 5, 6}
fmt.Println("数组长度:", len(arr)) // 6
fmt.Println("数组大小:", unsafe.Sizeof(arr)) // 48

数组大小就是所有元素的大小之和,这里数组元素的类型为 int。在 64 位平台上,int 类型的大小为 8,数组 arr 一共有 6 个元素,因此它的总大小为 6x8=48 个字节。

显式初始化

和基本数据类型一样,声明一个数组类型变量的同时,也可以显式地对它进行初始化。如果不进行显式初始化,那么数组中的元素值就是它类型的零值。比如下面的数组类型变量 arr1 的各个元素值都为 0:

1
var arr1 [6]int // [0 0 0 0 0 0]

显示初始化,需要在右值中显式放置数组类型,并通过大括号的方式给各个元素赋值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 注意最后面的逗号不能少
var arr2 = [6]int {
11, 12, 13, 14, 15, 16,
} // [11 12 13 14 15 16]

// 忽略掉右值初始化表达式中数组类型的长度,用 … 替代
var arr3 = [...]int {
21, 22, 23,
} // [21 22 23]
fmt.Printf("%T\n", arr3) // [3]int

// 将第100个元素(下标值为99)的值赋值为39,其余元素值均为0
var arr4 = [...]int{
99: 39,
} // [...39]

多维数组

数组出了一维数组,还有多维数组,在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
2
3
4
5
6
7
// $GOROOT/src/runtime/slice.go

type slice struct {
array unsafe.Pointer
len int
cap int
}

每个切片包含三个字段:

  • 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
2
3
arr := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

sl := arr[3:7:9]

基于数组 arr 创建了一个切片 sl,这个切片 sl 在运行时中的表示是这样:


基于数组创建切片时,

  • 起始元素:low 所标识的索引开始
  • len:切片的长度 = high - low
  • cap:最大容量 = max - low
  • array:指向原数组

由于切片 sl 的底层数组就是数组 arr,对切片 sl 中元素的修改将直接影响数组 arr 变量:

1
2
sl[0] += 10
fmt.Println("arr[3] =", arr[3]) // 14

针对一个已存在的数组,可以建立多个操作数组的切片,这些切片共享同一底层数组,所以操作其中一个切片时,会影响其他切片(切片好比打开了一个访问与修改数组的“窗口”)。

1
2
3
4
arr := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

sl := arr[3:7:9]
sl2 = arr[1,4,10]

常见操作:

  • s[n]:切片 s 中索引位置为 n 的项
  • s[:]:从切片 s 的索引位置 0 到 len(s)-1 处所获得的切片
  • s[low:]:从切片 s 的索引位置 low 到 len(s)-1 处所获得的切片
  • s[:high]:从切片 s 的索引位置 0 到 high 处所获得的切片,len = high
  • s[low:high]:从切片 s 的索引位置 low 到 high 处所获得的切片,len = high-low
  • s[low:high:max]:从切片 s 的索引位置 low 到 high 所获得的切片,len = high-low, cap = max-low
  • len(s):切片 s 的长度,总是 <= cap(s)
  • cap(s):切片 s 的容量,总是 >= len(s)

切片创建切片

动态扩容

切片与数组最大的不同,就在于其长度的不定长,这种不定长需要 Go 运行时提供支持,这种支持就是切片的“动态扩容”。

“动态扩容”指的就是,当使用 append 操作向切片追加数据的时候,如果这时切片的 len 值和 cap 值是相等的,也就是说切片底层数组已经没有空闲空间再来存储追加的值了,Go 运行时就会对这个切片做扩容操作,来保证切片始终能存储下追加的新值。

1
2
3
4
5
6
7
8
9
10
11
var s []int
s = append(s, 11)
fmt.Println(len(s), cap(s)) //1 1
s = append(s, 12)
fmt.Println(len(s), cap(s)) //2 2
s = append(s, 13)
fmt.Println(len(s), cap(s)) //3 4
s = append(s, 14)
fmt.Println(len(s), cap(s)) //4 4
s = append(s, 15)
fmt.Println(len(s), cap(s)) //5 8

下面用一张图来解释动态扩容的过程:


其中有几点需要注意:

  • 每一次 cap 的值的变化是按一定规律扩展的(1 => 2 => 4 => 8)
  • 自动扩容触碰到底层数组边界时,再次 append 会导致切片与数组“绑定关系”,也就是修改切片的第一个元素值时,原数组 u 的元素也不会发生改变了

总结

  • 数组是一个长度固定、由相同类型元素组成的连续序列,在内存中占据一块连续的空间
  • 数组的不足:固定的元素个数,以及值拷贝的传值机制导致开销较大
  • 切片与数组最大的不同,就在于其长度的不定长,这种不定长需要 Go 运行时提供支持,这种支持就是切片的“动态扩容”。

参考链接

评论