《GO语言实战》学习笔记四:数组、切片、映射

本章开始,进入GO语言的基本数据结构的学习。我认为无论如何还是要仔细读一遍第4-6章,和其他“胎教”类的教科书不一样,这几章内容并不是教你:int表示一种整数、汽车摩托车都是车类,轮子是一种属性,跑是一种方法、数组就是一种连续的空间。相反,以本章内容为例,单刀直入告诉你数组、切片、映射的内存结构,实现原理,以及一些高级用法,不论新手老手,读一读总能有所收获。

数组

GO语言的数组类型和绝大多数其他编程语言的思想是类似的,没有太多新东西,把基本用法掌握即可。

基本定义

1
2
3
4
5
6
7
8
// 创建并初始化一个长度为3的数组
a1 := [3]int{1, 2, 3}
// 创建并初始化一个数组,长度根据初始化元素决定
a2 := [...]int{1, 2, 3, 4, 5}
// 声明一个长度为5的数组,元素为0值
var a3 [5]int
// 创建一个长度为5的数组,初始化个别元素
a4 := [5]int{1: 3, 4: 55}

看上边的代码,其他都很好理解,但是a2 = [...]这种形式很怪异,道理很好理解,就是a2的长度取决于后边定义的元素个数,但为什么要多三个点呢?能不能写成这样nums := []int{1, 2, 3, 4, 5}

答:不能🙅!!省略三个点后,nums就声明为切片了,切片可以理解为动态长度的数组,但内存结构和数组完全不同!不要图方便就彼此混淆。

元素访问

1
2
3
4
5
6
7
8
a := [...]int{1, 2, 3, 4, 5}
// 索引访问
a[1] = 22
a[3] = 44
// 遍历访问
for i, v := range a {
fmt.Printf("a[%d] = %d\n", i, v)
}

数组克隆

1
2
3
4
5
a := [...]int{1, 2, 3, 4, 5}
// 完全复制
b := a
// 从a[1]开始,复制长度为3-1=2个
c := a[1:3]

指针数组

1
2
3
4
5
6
7
8
9
10
11
// 和数组复制类似,nums2复制了nums1的内容
// 但由于其元素都是指针类型
// 所以修改nums2时,nums1也会受影响
nums1 := [...]*int{new(int), new(int), new(int)}
*nums1[0] = 10
*nums1[1] = 20
*nums1[2] = 30

var nums2 [3]*int
nums2 = nums1
*nums2[2] = 99 // 相当于修改nums1[2]

多维数组

1
2
3
4
5
6
7
8
9
10
11
12
// 1. 声明一个多维(4行2列)数组
var array [4][2]int

// 2. 声明并初始化一个多维数组
array := [4][2]int{{1, 2}, {3, 4}, {5, 6}, {7, 8}}

// 3. 声明一个多维数组,且仅初始化第一行所有列、第四行第二列
array := [4][2]int{1: {88, 99}, 3: {1: 66}}

// 4. 数组的复制和一维数组一样
var arr2 [4][2]int
arr2 = array

函数中数组传参 👈 务必要留意这条!

数组有非常多的应用场景:内容缓存、请求/应答消息等,此类数据有可能体积较为庞大,比如默认给某个buffer开2M的空间,让它在不同的函数间传递,然而GO语言函数参数中,数组是以复制的形式传递的,这就会导致每次调用函数压栈的开销特别大,如果您在来几个递归…那后果就是灾难性的。

所以,日常开发中,如果数组长度非常大,且作为一个”输入输出”参数,最好不要直接定义为数组,而是定义为数组指针。

切片

切片是对数组的进一步抽象,以便于达到“动态”增长数组的效果。但不要简单的理解为就是在数组的连续内存尾巴后“追加”新的内存,那是不阔愣滴~

切片的内存结构

切片是一个很小的对象,如下图所示,它有3个字段:地址、长度、容量

  • 地址:很好理解——指向数组的首地址,真正存储切片数据的地方
  • 长度:切片实际上能索引的元素个数
  • 容量:切片允许增长的元素个数

切片的内存结构

✍️划重点✍️

对我这种新手而言,容量和长度刚开始不太好理解,感觉就是一回事。其实不然,先记住一句话:长度和访问相关,容量和增长相关

比如slice := make([]int, 3, 5),我们创建了一个长度为3,容量为5的切片:

  • 如果我们试图访问第4个元素(即slice[3])时,就会报错,因为超出长度。
  • 我们可以用append追加一个新元素——newSlice := append(slice, 666),此时切片长度为4,但容量依旧为5。因为长度没有超过容量,不会分配新内存。
  • 同理,我们在追加两个新元素,当长度超过了容量,容量就会直接翻倍,此时长度为6,容量为10。

👀向右看齐👀:切片容量小于1000是,每次都是成倍增长。超过1000时,每次增长25%,当然,这种增长算法可能会在未来作出改变。

1. 切片创建和初始化

1
2
3
4
5
6
// 创建一个空切片
var s1 []string
// 创建并初始化一个切片,注意它和数组初始化的区别
s2 := []string{"hello", "world"}
// 创建一个长度为3、容量为5的切片
s3 := make([]string, 3, 5)

关于切片的元素访问,表面上就是个动态数组,所以它的使用和数组的形式几乎无二,这部分参考数组的内容即可。

2. 切片复制

切片复制和前边的数组复制大体上类似,使用方式上就不重复说明了。

关键是要弄懂切片内部的“数组地址”,从“切片的内存结构”可以了解,切片本身只有3个字段,其中第1个字段是数组的地址,所以复制一个切片后,修改副本的同时,原始切片的元素也会改变。

但是!!! ✍️你懂的✍️

依旧是长度容量的问题,如果不把这里搞懂,一定会遇上各种诡异的事情!请看大屏幕代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 定义一个切片,长度和容量均为5
a := []int{1, 2, 3, 4, 5}

// 以a[2]为起始地址,长度为4-2=2,将a切片复制给b
// 注意b的容量为3,因为a的实际容量是5,减去前面两个元素就为3
b := a[2:4]

// 注意个基本概念,尽管b的容量为3,也就是说一直延伸到a的末尾
// a[4] = 111 ✅ 因为a的长度为5,所以访问第五元素没问题
// b[2] = 111 ❌ 因为b的长度为2,所以最多访问到b[1]

// 因为a和b指向同一个数组的不同地址 👈 务必理解
b[1] = 666 // 相当于a[3] = 666
b = append(b, 777) // 相当于追加b[2] = 777,也相当于 a[4] = 777
b = append(b, 888) // 相当于追加b[3] = 888,已经超出a的边界了😏

// 返回来修改b[0],见证诡异的时刻!
b[0] = 555 // 按照上边的惯例,应该也相当于a[2] = 555

// 但实际情况...a[2] == 3,并没有被修改
fmt.Printf("a(%d): %v\n", len(a), a) // a(5): [1 2 3 666 777]
fmt.Printf("b(%d): %v\n", len(b), b) // b(5): [555 666 777 888]

从上边的例子可以看出,当切片副本增长超出容量时,就会分配新内存空间,完全脱离原始切片。就会看到上面,时而访问的是同一个数组,时而访问的是两个不相干的数组。如果不把这里搞懂,在各种数据缓存、函数参数传递等情况下,无疑会留下巨大的坑。

3. 指定切片复制的容量

其实理解了切片对底层数组的内存管理(长度和容量),其实用dst := src[i:j]的拷贝方式也无妨,小心一点即可。

但GO语言提供了更健全的拷贝方式:dst := src[i:j:k]

  • 'i':表示要拷贝的原切片“地址”索引
  • 'j':表示要拷贝的原切片“长度”索引
  • 'k':表示要考呗的原切片“容量”索引

拷贝的时候限定新切片的容量是个不错的习惯,可能更好地管理内存空间,减少触发很多莫名其妙的情况。但要注意,指定的容量索引不可以超出原始切片的边界,否则就报错。

1
2
3
4
5
6
7
8
9
10
// 以a[2]为起始地址,长度为3-2=1,容量为3-2=1,将切片复制给b
a := []int{1, 2, 3, 4, 5}
b := a[2:3:3]

// 由于b的长度和容量一样,一旦append就会分配新内存,脱离a
b = append(b, 666)
b[0] = 555 // a[2]不再受影响

fmt.Printf("a(%d): %v\n", len(a), a) // a(5): [1 2 3 4 5]
fmt.Printf("b(%d): %v\n", len(b), b) // b(5): [555 666]

多维切片、函数参数

多维切片同样可以理解为多维动态数组,例如二维切片的定义:

1
2
3
4
// 定义一个二维
slice := [][]int{{1}, {2, 3}, {4, 5, 6}}
// 给第一个切片追加一个33的元素
slice[0] = append(slice[0], 33)

同时,切片本身只是个很小的对象,作为参数在函数间传递开销是很小的。就算我们定义了一个容量为100M的切片,但归根结底切片里存的仅仅是个数组的地址而已,根本不浪费栈空间。

映射(map)

映射就是一个存储键值对的无序集合。可以把它简单比作字典、哈希表之类的数据结构。

基本使用

map的使用是非常简单且灵活的,没什么需要特别注意的地方,此外作为函数参数进行传递时,并不会创造一个副本,在函数内部修改某个映射,外部也能察觉到修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 创建一个空的映射
a := make(map[string]int)
// 创建一个映射,并初始化两个元素
b := map[string]string{"Red": "#da1337", "Orange": "#e95a22"}

// 直接插入一个新元素
a["hello"] = 5

// 使用delete删除一个元素
delete(b, "Red")

// 通过键索引访问,可获取对象、是否存在两个值
value, exists := b["Blue"]
if !exists {
b["Blue"] = "#1e90ff"
} else {
println(value)
}

colors := map[string]string{
"AliceBlue": "#f0f8ff",
"Coral": "#ff7F50",
"DarkGray": "#a9a9a9",
}

// 通过for-range访问map
for key, value := range colors {
fmt.Printf("colors[%s] = %s\n", key, value)
}

内部实现

有关映射的内部实现书中讲的不多,也不是特别深入,可能因为背后的原理比较复杂,我只能把自己的理解大体做个总结,不一定准确!!

映射的内部结构

如上图所示,map的内部就是一堆散列表

  • 桶:是多个键值对“打包”存放的一个连续的内存空间。
  • 散列表:是根据某种散列算法,记录着不同的“键值”存储在哪个桶里。

简单来说,通过一个键可以计算出散列值——根据散列值找到对应的“桶”——从“桶”里把键所对应的那个值取出来。如下图所示:

映射的工作原理

这个过程可能是非常复杂的,只需要记住:随着映射存储的增加,索引分布越均匀,访问值的速度就越快。

小结一下

  1. 数组是实现切片和映射的基石,后两者本质都是对数组的封装
  2. 切片为了“动态数组”,映射为了“数据字典”
  3. len函数用于返回数组、切片、映射的长度
  4. cap函数用于返回切片容量,且仅作用于切片
  5. make函数用于创建切片和映射,可以指定其长度(容量)
  6. append函数用于追加切片元素,且仅作用于切片
  7. delete函数用于删除映射元素,且仅作用于映射
  8. 作为函数参数传递时,数组的开销可能是巨大的,切片和映射不存在这个问题
  9. 切片追加元素,长度超过容量时,会重新分配内存,不再指向原来的
0%