我们直接撸代码来了解slice的实现细节,首先看下以下代码示例:
package main
import "fmt"
func main() {
s := make([]int, 5, 10)
for i := 0; i < 5; i++ {
s[i] = i
}
change(s)
fmt.Println("s: ", s)
}
func change(s1 []int) {
s1[0] = -1
fmt.Println("s1: ", s1)
}
复制
大家思考下s,s1分别输出什么呢?这个答案应该比较简单,输出的结果为:
s2: [-1 1 2 3 4]
s: [-1 1 2 3 4]
复制
我们再接着看下面这个代码示例:
package main
import "fmt"
func main() {
s := make([]int, 5, 10)
for i := 0; i < 5; i++ {
s[i] = i
}
ap(s)
fmt.Println("s: ", s)
}
func ap(s2 []int) {
s1 = append(s2, 100)
fmt.Println("s2: ", s2)
}
复制
大家可以先不着急往下看,先思考下s2会输出什么?s又会输出什么呢?
避免大家看到答案,我在中间多留白一些空间
答案是:
s1: [0 1 2 3 4 100]
s: [0 1 2 3 4]
复制
很快答对的读者估计对slice的底层实现比较理解,经过一番思考得出答案的读者或许有一部分是蒙对的,而答错的同学需要把该文章下列内容认真地读完了。
为什么会产生上述两个例子的输出结果呢?Go的传参到底是值传递还是引用传递呢?为什么案例1改变了原slice而案例2没有任何改变?我们首先看下slice的结构:
// runtime/slice.go
type slice struct {
array unsafe.Pointer
len int
cap int
}
复制
结构中包含
array:指向一片内存空间指针,用以存储元素;
len:数组存放的元素个数;
cap:数组的容量,大于等于len。
在这里,我们可以肯定的是Go中函数传参数使用的是值传递。
我们再回过头来看下案例1:
func change(s1 []int) {
s1[0] = -1
fmt.Println("s1: ", s1)
}
复制
可以理解为s1是从s的拷贝出来的一个对象,s、s1两个对象的len、cap相等,array指向同一片内存空间。当对s1[0]的值进行改变时,改变的内存空间与s [0]是同一片内存空间,所以s[0]相应的也发生了改变。
在案例2中,同样的s1是从s的拷贝出来的一个对象,s、s1两个对象的len、cap相等,array指向同一片内存空间。当对s1 append一个值时,此时s1的arry在第6个元素空间写入100,同时len变为了6。所以s1输出
s1: [0 1 2 3 4 100]
复制
而,此时s的len仍然是5,所以s输出时只会输出前5个元素空间的内容,输出
s: [0 1 2 3 4]
复制
到这里,通过slice的结构定义,我们已经清楚的知道了上述案例中的输出的原因了。我们再看下下面这个场景s和s3分别输出什么呢?
package main
import "fmt"
func main() {
s := make([]int, 5, 5)
for i := 0; i < 5; i++ {
s[i] = i
}
apAndChange(s)
fmt.Println("s: ", s)
}
func apAndChange(s3 []int) {
s3 = append(s3, 100)
s3[0] = 100
fmt.Println("s1: ", s3)
}
复制
注意,在这个例子中与前两个案例不同,s的cap和len相等都为5。
该代码示例的输出为:
s3: [100 1 2 3 4 100]
s: [0 1 2 3 4]
复制
产生这个输出的原因与append函数的实现有关,我们先看下下append逻辑中发生扩容时的源码一段(growslice):
在growslice函数最后,通过malloc方法新申请了一片内存空间,而且把原来的数据通过memmove方法复制到新的空间。而如果slice没有发生扩容,则只需要将新插入的值插入到array最后。
这就可以回答案例三中,先append后再修改s3[0]不会影响s[0]了,在案例三中s的cap与len相等,当append一个元素时,必定会发生扩容。
我们可以通过如下代码可以了解下slice的扩容策略是怎样的?
这一段表示slice的扩容逻辑,其策略为:
如果新扩容后的cap大于原来的cap*2则直接使用新扩容后的cap
如果新扩容后的cap小于等于原来的cap*2
原来的cap小于1024,则直接扩容为原来cap的两倍大小
如果cap>1024,每次增加原来cap的1/4(newcap = cap*1.25)
而扩容后还需要考虑内存对齐,其代码如下:
这里我们需要注意roundsize,会向上取整。这个“上”是多少呢?我们可以参考go的对象字节占用表,路径为 runtime/sizeclasses.go。以下截图只截取了一部分源码,有兴趣的同学可自行前往查看。
例如某个slice扩容后占用的字节为42,向上取整后实际占用的内存空间则为48。有关为什么要内存对齐,这个后续单独用一节来讲解。
有关slice的介绍就到这里了,通过案例1和案例2,大家可以清楚的避开在函数穿时,由于误对slice的处理导致影响函数外的值。通过案例3,我们也了解了append的实现细节,大家在使用slice时,尽量提前申请append的cap,避免在使用过程中由于append操作的扩容(新申请内存,并拷贝数据)带来影响程序运行效率。
往期回顾:
go语言系列4 - Goroutine、channel的使用还有话说