谈谈 go slice中的不确定性

作者: | 更新日期:

如果要说 go 语言里也存在测不准原理的话,那一定是 slice 了。

本文首发于公众号:天空的代码世界,微信号:tiankonguse

一、背景

学习 go 切片 slice 的时候,我深刻的感受到 slice 是完全测不准的,行为是不确定性的。

下面就来深入的谈谈 go 的 slice 为啥会这样吧。

二、基本知识

默认有三种方法创建 slice :对数组切片、不指定长度的声明、 make 创建。

array := [5]int{1, 2, 3, 4, 5}
a := array[:]

b := []int{1, 2, 3, 4, 5}

c := make([]int, 4, 5)

对于一个 slice,我们也可以像对数组切片那样,再次进行切片从而获得新的 slice。

a := []int{1, 2, 3, 4, 5}
a0 := a[0:4]
a1 := a[2:5]

切片 的语法是左开右合的,即[left, right)
如果某部分忽略不填写,就代表从顶端开始。

例如 [:right] 代表从最开始开始算起,等价与 [0:right]
同样,[left:] 代表计算到最后,等价与[left:len()]
如果都不填,[:] 代表复制整个切片。

我们也可以通过数组赋值的方式对切片进行修改。

a0[3] = 111
a1[2] = 222

然后神奇的事情发生了,修改一个切片的时候,另外一个切片的值也被修改了。

fmt.Printf("a=%v type=%T\n", a, a)
fmt.Printf("b=%v type=%T\n", a0, a0)
fmt.Printf("b=%v type=%T\n", a1, a1)

a=[1 2 3 111 222] type=[]int
b=[1 2 3 111] type=[]int
b=[3 111 222] type=[]int

如果我们边循环边修改切片,并向切片里插入新的元素,就会发现更神奇的事情发生。

大家猜测下面的代码会输出什么结果?

a := []int{1, 2, 3}
b := a[1:2]

fmt.Printf("a=%v b=%d\n", a, b)
for i := 1; i < 4; i++ {
    b = append(b, i*111)
    b[0] = i * 100
    fmt.Printf("i=%d a=%v b=%d\n", i, a, b)
}

循环前输出的内容大家应该都没有歧义,看到的是什么,修改的就是什么。

之后循环三次,分别向切片 b 插入了三个值,并修改切片的第一个值。

根据上个话题提到的知识,修改 b 切片,同时会修改 a 切片的内容。
所以循环的时候,切片 a 的第二个数字与 切片 b 的第一个数字应该始终相等。

实际情况确实,刚开始循环的时候相等,最后就不相等了。

a=[1 2 3] b=[2]
i=1 a=[1 100 111] b=[100 111]
i=2 a=[1 100 111] b=[200 111 222]
i=3 a=[1 100 111] b=[300 111 222 333]

这就是为什么我把 slice 称为 go 里的测不准原理。

之所以会有这样的结果,是由 slice 的实现机制决定的。

三、slice 的实现方式

go 的 slice 自身并没有数据储存,底层是基于定长数组实现的。

所以 slice 才会有这么高的性能,也因此才会出现修改一个 slice 影响到另外一个 slice。

slice 内部大概的数据结构如下:

struct Slice{
    int* address
    int offset 
    int len
    int cap
};

address 指向数组的起始位置。
offset 代表当前 slice 在数组中的偏移量 len 代表当前 slice 数值的长度,映射到数组储存的 [offset, offset+len] 区间。
cap 代表当前 slice 储存大小,至于是数组的还是切片的,那无所谓,两个是等价的。

由于 slice 没有实际的储存,所以复制 slice 相等于对储存的引用,所以才会修改一个切片,影响所有切片。

如果对 slice 插入一个元素,cap 还有剩余,那可就可以直接操作下个储存。
这个对应上一小节i=1i=2的结果。

如果 slice 的 cap 没有剩余了,那只能重新创建一个更大的数组了。
这个时候,会进行四步操作:

1、创建一个更大的数组,临时 slice 指向新的数组
2、将当前 slice 的值复制到临时 slice 3、向临时 slice 插入元素
4、返回新的 slice

这也是为啥 append 语法需要传入 slice,然后返回 slice 的缘故。
毕竟有可能返回了新的 slice。

四、问题

了解了 slice 的实现原理,就会发现一旦 slice 使用不当,就可能导致“内存泄露”。

当然,这里并不是真的泄露,但是程序会发现内存使用的很大,但是代码使用的又很小。

举一个例子大家就明白了。

假设我们读了一个 1M 的文件数据到内存,使用切片引用了其中 10 字节的数据,然后释放了文件与相关储存。
对于我们来说,程序最后应该只使用了 10 字节的内存,但实际却发现程序使用了 1M 的内存。

如果批处理了很多文件,会发现内存很快爆了。

五、最后

了解了 slice 的实现方式, slice 的各种侧不准特性也就能够解释了。

但是使用的过程中,需要注意这个实现方式引起的两个问题。

1、切片衍生出来的切片可能指向同一片内存,修改会有影响。
2、切片变更大小时,可能会执行新的内存,此时切片之间将会没有联系。

对于切片,你还有其他相关经验吗?欢迎分享。

《完》

-EOF-

本文公众号:天空的代码世界
个人微信号:tiankonguse
公众号ID:tiankonguse-code

本文首发于公众号:天空的代码世界,微信号:tiankonguse
如果你想留言,可以在微信里面关注公众号进行留言。

关注公众号,接收最新消息

tiankonguse +
穿越