slice 是一种灵活、高效的数据结构, 在日常开发中经常使用,如果不理解 slice 背后的一些工作原理,还是经常会踩坑的,所以本篇着重挖掘 slice 背后的一些逻辑。
slice 是构建于 array 之上的一种数据结构,可以这样说,没有 array,就没有 slice,所以我们首先认识下 array。
Array
array 是由指定长度的同类型的元素组成的,它包括两个属性
- 元素类型
- array 长度
以下代码定义了一个长度是 5,类型是 int 的 array
var a = [5]int{1, 2, 3, 4, 5}
以上 a 变量在内存的数据结构如下图所示
Talk is cheap. Show me the code. 为了更加清晰的了解 array 的内存分布,可以编写以下代码 main.go ,然后通过 debug 工具 delve 分析下 array 的内存组成。
首先通过 break main.go:7
打断点,然后 continue
执行程序,最后通过 examinemem -count 3 -size 8 -x &s
打印内存分布。
➜ test-array dlv debug main.go
Type 'help' for list of commands.
(dlv) break main.go:7
Breakpoint 1 set at 0x10abc53 for main.main() ./main.go:7
(dlv) continue
> main.main() ./main.go:7 (hits goroutine(1):1 total:1) (PC: 0x10abc53)
2:
3: import "fmt"
4:
5: func main() {
6: s := [5]int{1, 2, 3, 4, 5}
=> 7: fmt.Println(s)
8: }
(dlv) print s
[5]int [1,2,3,4,5]
(dlv) examinemem -count 5 -size 8 -x &s
0xc00009def0: 0x0000000000000001 0x0000000000000002 0x0000000000000003 0x0000000000000004 0x0000000000000005
最后打印的结果显示 array 在内存就是一段连续的值(ps: 而 slice 的显示,会有明显的差异)。
silce 的诞生
虽然 array 是 golang 提供的基本的数据类型,但是有两个劣势
- 固定的长度,这意味着初始化 array 后,不能再 push 超过 len(array) 长度的元素
- array 作为参数在函数之间传递时,具有很大的性能浪费
第二点说明下: 因为 golang 中函数之间的调用不存在引用传递,都是值传递的。所以使用 array 作为参数时,意味着在调用函数时需要 copy 下这个参数, 这样会造成性能浪费。
为了消除这两个劣势,golang 引入一种新的类型:slice,slice 和 array 形态上的的差异是: slice 仅仅指定元素类型,无须指定长度。
比如以下定义的一个 slice。
var nums = []int{1, 2, 3, 4, 5, 6}
由于无需关心 slice 的长度,所以 slice 可以通过 append 方法随意 push 元素,彻底消除劣势一。
但是劣势二中提到的性能问题,slice 是如何解决的呢,我们看下 slice 的实现。
slice 的实现
slice 底层的数据结构是一个结构体,包括三个元素
type slice struct {
array unsafe.Pointer
len int
cap int
}
- array(Pointer) 是指向一个数组的指针
- len 是当前 slice 的长度
- cap 是当前 slice 的容量
比如以下代码定义了一个 len 是 3,cap 是 5 的 slice。
s := make([]int, 3, 5)
s[0] = 1
s[1] = 2
s[2] = 3
以上 slice 在内存中的数据结构如下图所示:
同样为了分析 slice 的内存组成,可以编写以下代码 main.go ,然后通过 debug 工具 delve 分析 slice 的内存组成。
首先通过 break main.go:8
打断点,然后 continue
执行程序,最后通过 examinemem -count 3 -size 8 -x &s
打印内存分布。
➜ test-slice dlv debug main.go
Type 'help' for list of commands.
(dlv) break main.go:8
Breakpoint 1 set at 0x1058e9f for main.main() ./main.go:8
(dlv) continue
> main.main() ./main.go:8 (hits goroutine(1):1 total:1) (PC: 0x1058e9f)
3: func main() {
4: s := make([]int, 3, 5)
5: s[0] = 1
6: s[1] = 2
7: s[2] = 3
=> 8: }
(dlv) print s
[]int len: 3, cap: 5, [1,2,3]
(dlv) examinemem -count 3 -size 8 -x &s
0xc00004a758: 0x000000c00004a730 0x0000000000000003 0x0000000000000005
(dlv) examinemem -count 3 -size 8 0x000000c00004a730
0xc00004a730: 0x0000000000000001 0x0000000000000002 0x0000000000000003
(dlv) examinemem -count 5 -size 8 0x000000c00004a730
0xc00004a730: 0x0000000000000001 0x0000000000000002 0x0000000000000003 0x0000000000000000 0x0000000000000000
分析下打印结果:
- 内存 s 中第一个值是 0x000000c00004a730,打印下这个地址,里面存储的是1,2,3;而这些则是 slice 的 pointer 指向的值,是一个数组。
- 内存 s 中第二个值是 0x0000000000000003,这个值是 len。
- 内存 s 中第三个值是 0x0000000000000005,这个值是 cap。
对比下 array 和 slice 的内存分布,可以得出结论: 在 slice 内部的确存在一个 pointer,而这个 pointer 则指向实际的 array。
认识到 slice 的内存分布,就可以回答上一 part 的问题了,如果 array 作为参数在函数调中使用,由于值传递,性能的确很浪费,但是如果把 slice 作为参数,copy 的就是 slice 内置的三个属性,内存占用率得到很大的降低。
slice 的截取
注: slice 有多种截取方式,但底层原理都是类似的,这里分析一种常见的方式 [i:j]
通过 [i:j]
对原 slice 进行截取,截取的原理是:初始化一个结构体,然后把 slice 中的 array 指针指向底层 array 的 i 元素,截取之后的新的 slice 的 len 和 cap 会发生变化。
- len 的大小是: j - i,表示当前的 slice 的长度
- cap 的大小是: 原来cap - i,表示当前 slice 的 cap
例如我们有以下代码: main.go,然后截取 s[2:4],这里 s 和 s1 在内存中的指针指向的是同一个 array。
这两个 slice 在内存中的分布如下:
同样可以使用 delve 工具分析下内存
➜ test-slice dlv debug .
Type 'help' for list of commands.
(dlv) break main.go:8
Breakpoint 1 set at 0x10abcb9 for main.main() ./main.go:8
(dlv) continue
> main.main() ./main.go:8 (hits goroutine(1):1 total:1) (PC: 0x10abcb9)
3: import "fmt"
4:
5: func main() {
6: s := []int{1, 2, 3, 4, 5}
7: s1 := s[2:4]
=> 8: fmt.Println(s, s1)
9: }
(dlv) print s
[]int len: 5, cap: 5, [1,2,3,4,5]
(dlv) print s1
[]int len: 2, cap: 3, [3,4]
(dlv) examinemem -count 3 -size 8 -x &s
0xc000079f20: 0x000000c000020120 0x0000000000000005 0x0000000000000005
(dlv) examinemem -count 3 -size 8 -x &s1
0xc000079f08: 0x000000c000020130 0x0000000000000002 0x0000000000000003
(dlv) examinemem -count 5 -size 8 -x 0x000000c000020120
0xc000020120: 0x0000000000000001 0x0000000000000002 0x0000000000000003 0x0000000000000004 0x0000000000000005
(dlv) examinemem -count 3 -size 8 -x 0x000000c000020130
0xc000020130: 0x0000000000000003 0x0000000000000004 0x0000000000000005
可以看到通过 examinemem -count 5 -size 8 -x 0x000000c000020120
和 examinemem -count 3 -size 8 -x 0x000000c000020130
获取的 array 就是一个。
slice 的扩容
首先要明确,只有 append 操作可以触发 slice 的扩容,但是为什么会有扩容这个动作呢?
原因是 slice 在初始化时只会申请有限的内存空间,而随着 append 元素的增多,当元素超过当前 slice 的 cap ,就会重新申请一段新内存,把原数据 copy 到这个新内存上,然后 slice 把内部的指针指向这段新内存。
slice 的扩容是一个很智能的动作,当发生扩容时, slice 会有两个动作:
- 扩大 slice 内部的 cap 值(按照 2 倍或者 1.25 倍扩大)。
- 重新申请一个 array,把原数据 copy 到这个新 array 上,然后把 slice 的 array 指针指向这个申请的 array。
以下是个会发生扩容的例子,比如我们有以下代码: main.go,声明一个 slice,len = 3, cap = 4,然后 append 两个元素。
首先,可以先大胆假设下。当 1,2,3 赋值到 s(slice) 后, s 中的 len、cap 没发生变化: len = 3,cap = 4;当 append 5 时,由于 len < cap,所以还能放一个值,也就不会发生扩容,放入后 cap = len = 4;然而当 append 6 时,原底层 array 已经没有足够的空间了,这时会发生扩容。
代码执行后,s、s1、s2 在内存中的分布应该如下:
我们再次小心求证下,同样使用 delve 工具分析下内存。
➜ test-slice dlv debug .
Type 'help' for list of commands.
(dlv) break main.go:12
Breakpoint 1 set at 0x10abd70 for main.main() ./main.go:12
(dlv) continue
> main.main() ./main.go:12 (hits goroutine(1):1 total:1) (PC: 0x10abd70)
7: s[0] = 1
8: s[1] = 2
9: s[2] = 3
10: s1 := append(s, 5)
11: s2 := append(s1, 6)
=> 12: fmt.Println(s, s1, s2)
13: }
(dlv) examinemem -count 3 -size 8 -x &s
0xc00007bf10: 0x000000c00001a0e0 0x0000000000000003 0x0000000000000004
(dlv) examinemem -count 3 -size 8 -x &s1
0xc00007bef8: 0x000000c00001a0e0 0x0000000000000004 0x0000000000000004
(dlv) examinemem -count 3 -size 8 -x &s2
0xc00007bee0: 0x000000c00001c240 0x0000000000000005 0x0000000000000008
(dlv)
把上面的 debug 信息录入到以下表格中,可以发现:
内存值 | 地址 | len | Cap |
---|---|---|---|
s | 0x000000c00001a0e0 | 0x0000000000000003 | 0x0000000000000004 |
s1 | 0x000000c00001a0e0 | 0x0000000000000004 | 0x0000000000000004 |
s2 | 0x000000c00001c240 | 0x0000000000000005 | 0x0000000000000008 |
- s 和 s1 中的地址没有发生变化,说明底层指向的是同一个 array,而 s2 地址发生了变化,说明发生了扩容。
- s 和 s1 的 cap 值是 4,当再次 append 一个元素时,当前 cap = 原 cap * 2,也能说明发生了扩容。
所以,小心验证的结论没问题,slice 可以很"智能"的进行扩容,每次扩容是原来 cap 的 2 倍或者 1.25 倍,网上已经有一些文章在讲述扩容的原理了,所以就不多赘述了。
使用 slice 遇到的"坑"
使用 slice 时,如果不清楚 slice 的底层原理,还是会遇到一些意想不到的问题,而有些问题有时会很隐蔽,这里总结下,避免踩坑。
slice 初始化的"坑"
初始化一个 slice 有两种方式:
- 直接声明: 比如
var s []int
- 使用 make 关键字,比如:
s := make([]int, 0)
这两种方式有什么区别呢?在日常 coding 中应该如何选择,这是一个容易被忽视的问题。
首先,先说下区别:
- 直接声明 slice 的方式内部是不申请内存空间的,slice 内部 array 指针指向 null。
- 使用 make 关键字会申请包含 0 个元素的内存空间,底层 array 指针指向申请的内存。
两者的内存分布如下图所示:
同样对以下代码使用 delve 工具分析下内存。
分析结果如下: 直接声明的 array 指针指向 0x0000000000000000,也就是 null,而使用 make 方式则会指向一个确定的地址 0x0000000001180e80,但是这个地址是没有元素的。
➜ test-array-slice dlv debug .
Type 'help' for list of commands.
(dlv) break main.go:9
Breakpoint 1 set at 0x10abd2b for main.main() ./main.go:9
(dlv) continue
[] []
> main.main() ./main.go:9 (hits goroutine(1):1 total:1) (PC: 0x10abd2b)
4:
5: func main() {
6: var a []int
7: b := make([]int, 0)
8: fmt.Println(a, b)
=> 9: }
(dlv) examinemem -count 3 -size 8 -x &a
0xc00007bf20: 0x0000000000000000 0x0000000000000000 0x0000000000000000
(dlv) examinemem -count 3 -size 8 -x &b
0xc00007bf08: 0x0000000001180e80 0x0000000000000000 0x0000000000000000
清楚两者的区别后,再说下遇到的"坑"。有次在开发服务接口声明 slice 之后,然后通过 json.Marshal 序列化 slice,但是序列化的结果是有区别的。
- json.Marshal(直接声明): 返回 null
- json.Marshal(make关键字初始化): 返回 []
本来和前端定义的的某个返回字段是 slice ,但是最后返回的是 null,感觉很奇怪,最后定位到是使用直接声明的方式初始化 slice 的,下面是出问题代码的最小 case。
package main
import (
"encoding/json"
"fmt"
)
func main() {
var a []int
b := make([]int, 0)
aResult, _ := json.Marshal(a)
bResult, _ := json.Marshal(b)
fmt.Println(string(aResult)) //null
fmt.Println(string(bResult)) // []
}
slice 扩容的"坑"
这个坑在面试中经常会遇到,当 slice 作为函数参数时,如果在函数内部发生了扩容,这时再修改 slice 中的值是不起作用的,因为修改发生在新的 array 内存中,对老的 array 内存不起作用,以下是代码的最小 case。
package main
import "fmt"
func main() {
var s = []int{1, 2, 3}
modifySlice(s)
fmt.Println(s) // 打印 [1 2 3]
}
func modifySlice(s []int) {
s = append(s, 4)
s[0] = 4
}
同样感兴趣的可以使用 delve 工具分析下内存,这样理解的更加清晰,这里不再赘述。
总结
本篇文章首先分析了 array,然后指出 array 的两个劣势,从而引出 slice,slice 通过优化内部的结构,解决了使用 array 的问题。但是也正是因为 slice 独特的内部结构,让我们在开发中可能会遇到一些"坑",所以分析了一些易出错的点。
最后,希望本篇文章真的能给你带来收获,感谢🙏。