2. 数组上: 深入理解 slice
注:本文已发布超过一年,请注意您所使用工具的相关版本是否适用
序
- Go 数据结构与算法系列文章,本系列文章主要会包括常见的数据结构与算法实现,同时会包括 Go 标准库代码的分析理解,讲到对应章节的时候优先学习分析 Go 的源码实现,例如 slice、list、sort 等,然后可能会有一些常见的案例实现,同时这也是 极客时间-数据结构与算法之美 的课程笔记
- 本文代码仓库: https://github.com/mohuishou/go-algorithm 🌟🌟🌟🌟🌟
- **RoadMap: **持续更新中,预计一周更新 1 ~ 2 篇文章,预计到 202101 月底前更新完成
- 获取更新: Github、知乎、RSS、开发者头条
- 上一个系列刚刚完成了 Go 设计模式,如果感兴趣也可以进行查看
上期答案
Q: 通过 Element 节点中的 List 结构来判断节点是否属于当前链表的方式,在某些情况下会出现 bug
1 |
|
接着看下面之前,先想一想,你觉得这段代码会输出什么?
这个问题来自: https://github.com/golang/go/issues/39014
这段代码会输出 -1,但是我们期望的不应该是输出 0 么?
我们来看看发生了什么?
- 当我们执行完
l.PushBack(10)
这段代码直接,就如左图所示了,节点 e 指向了链表 l,链表 l 的值的长度是 1 - 但是当我们执行完
l = list.List{}
我们重置了链表 l 的值,并没有修改他的地址,所以 e 还是指向的链表 l,当执行Remove
方法的时候,e 的链表和当前执行的 l 的地址判断是可以对应的上的,但是实际上链表的值已经发生了变化,链表的长度已经不为 1 的,并且新的链表的根节点也没有指向 e - 所以最后得到的长度才是 -1
- 最后细心的同学应该已经发现,这里其实还可能存在内存泄漏的问题,元素 e -> root(0x2) 其实已经没有用到了
什么是数组?
- 数组大家应该都非常属性我们来简单的回顾一下
- 数组是一个具有连续的内存空间和相同类型的数据的数据结构
- 我们随机访问任意一个下标的数据的时间复杂度都是 O(1)
- 但是正是由于这种特性,导致它插入和删除元素的效率比较低,是 O(n)
Go 中的数组
1 |
|
Q: 为什么我们在 remove 函数当中的参数使用的是指针
这是因为在 go 中参数的传递都是值传递,如果不用指针的话,那么就会复制一份数据到函数中,这样就无法做到删除的作用
1 |
|
输出:
1 |
|
通过上面的例子我们就可以发现,在函数 p 中我们直接传递的数组,最后打印的变量地址是和 main 中不同的,就印证了我们之前的说法
在实际使用过程中,我们其实是很少使用到固定长度的数组的,而是使用可以自动扩容的 slice,接下来我们就深入的看一下 slice 当中的一些细节
深入理解 Slice
使用
基础使用
1 |
|
- 可以发现,通过 append 之后 s1 的容量和长度都发生了变化,说明完成了自动扩容
- 删除元素之后我们的长度发生了变化,但是容量还是原本不变
常见坑
1 |
|
- 这是一个常见的例子,我们从 s1 复制了一个 s2
- 修改 s1 的第一个元素之后,s2 的一个元素也被修改了
- 但是我们触发了 s1 的自动扩容之后,s2 的第一个元素就不会随着 s1 的修改而变化了
- 这也是当函数的参数是 slice 时我们不允许直接修改,如果需要修改需要返回这个 slice 的原因,因为函数的参数也是值的复制
1 |
|
结构
大家如果使用过一段时间 golang 应该知道 slice 的底层结构其实是一个 struct
https://github.com/golang/go/blob/go1.15/src/runtime/slice.go
1 |
|
- 如图所示我们可以发现,slice 的底层结构是一个结构体
- 它包含了一个指向一个数组的指针,数据实际上存储在这个指针指向的数组上
- len 表示当前 slice 使用到的长度
- cap 表示当前 slice 的容量,同时也是底层数组 array 的长度
- 这也就回答了我们上面的发现的现象,在复制 slice 的时候,slice 中数组的指针也被复制了,在出发扩容逻辑之前,两个 slice 指向的是相同的数组,出发扩容逻辑之后指向的就是不同的数组了
- 同时因为结构体中 array 是一个指针所以在 slice 作为参数传递的时候,这个指针也会被复制一份,所以也会有相同的问题
创建
1 |
|
还有一个 64 位的
- 64 位对比默认的只是进行了一下数据格式转换,但是这个转换的对比还是很有意思的
- 如果是在 64 位的机器上,那么 int == int64 的
- 如果是在 32 位的机器上,那么 int == int32,如果 int64(int(len64)) != len64,那么就说明这个长度超出了当前机器的内存位数,直接抛出异常错误就好了
1 |
|
扩容
1 |
|
我们重点看一下扩容的算法,可以发现有三种逻辑
- 如果需要的最小容量比两倍原有容量大,那么就取需要的容量
- 如果原有 slice 长度小于 1024 那么每次就扩容为原来的两倍
- 如果原 slice 大于等于 1024 那么每次扩容就扩为原来的 1.25 倍
1 |
|
空 Slice
我们先看一下 Slice 初始化的方式
1 |
|
- tips:
%p
打印 slice 会打印 slice 底层指向的数组的地址 - 我们可以发现,第一种方式进行初始化,出现的也就是 slice 的零值,底层的数组指针是一个 nil
- 第二种和第三种的底层指针都有一个值,不过没有实际分配内存
- 这三种方式初始化出来的 cap、和 len 的长度都是 0
规范技巧
- slice 作为参数时,不要直接存储它的引用,而是通过
copy
复制一份- 原因请查看上文,Slice 的结构
- 示例:Uber Go 规范: nil-是一个有效的-slice
- 要检查切片是否为空,请始终使用
len(s) == 0
。而非nil
。- 原因请查看上文,Slice 初始化
- 示例:Uber Go 规范: 在边界处拷贝-Slices-和-Maps
Question
- 在讲解 slice 初始化的过程中,为什么
s2
,s3
打印的数组指针都是0x587450
? - 请查看下面一段代码,会输出什么,为什么?
- A: 5 8 B: 8 8 C: 5 5 D: 5 6
1 |
|
关注我获取更新
猜你喜欢
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议,转载请注明出处,禁止全文转载