掘金 后端 ( ) • 2024-03-27 09:52

写在文章开头

这篇文章来讲讲go语言中一个类似于java中的ArrayList的数据结构——切片,我们会从切片的内部实现、创建和使用等多个角度获得切片的最佳实践。

Hi,我是 sharkChili ,是个不断在硬核技术上作死的 java coder ,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili

因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。

详解go语言切片

切片的内部实现

学过Java的读者肯定都了解过ArrayList,笔者认为切片的概念我们可以拿ArrayList进行类比,它也是围绕动态数组的概念而创建的,它可以按需自动增长和缩小。因为其底层就是通过数组来存储元素的,这种内存连续分配的特性使得切片在进行索引、迭代以及垃圾回收时都有着不错的性能表现。

以下图为例,这就是切片底层的结构,可以看到它通过一个指针array指向一段连续的数组,而数组有3个空间被填满,所以len字段为3,而数组长度为5,所以容量cap为5:

对应我们也给出go语言中对于切片的源码定义:

type slice struct {
 array unsafe.Pointer
 len   int
 cap   int
}

基于make创建切片

对于切片的创建我们可以直接使用内置的make函数,例如我们像创建一个长度和容量都为5的整型切片,就可以按照下述所示语法:

func main() {
 //使用make创建切片
 s := make([]int, 5)
 fmt.Println(s)
 //打印使用长度
 fmt.Println(len(s))
 //打印容量
 fmt.Println(cap(s))
}

对应输出结果和我们预期一样:

[0 0 0 0 0]
5
5

同理如果我们希望设置使用长度为3,容量为5的切片,则可以追加两个参数完成创建:

func main() {
 //使用make创建切片
 s := make([]int, 3, 5)
 fmt.Println(s)
 //打印使用长度
 fmt.Println(len(s))
 //打印容量
 fmt.Println(cap(s))
}

字面量创建切片

同样的假如我们希望通过字面量的方式完成创建,例如我们希望通过字面量的方式创建一个长度为7的切片,那么我们就可以给索引6的元素设置一个值,让编译器自动根据我们字面量声明的上限设置一个lencap都是7的切片:

func main() {
 //设置索引6位置的元素为
 s := []int{6: 100}
 fmt.Println(s)
 //打印使用长度
 fmt.Println(len(s))
 //打印容量
 fmt.Println(cap(s))
}

创建空切片

声明空切片的方式也很简单,要么使用var表达式显示设置size,要么用字面量表达式声明一个全空的切片:

var s = make([]int, 0)
 
s1 := []int{}

切片赋值

再来说说切片的赋值,和其他变量的语法类似,切片的赋值可以直接通过索引定位变量空间进行赋值:

func main() {

 s := []int{1, 2, 3, 4, 5}
 fmt.Println(s[0])
 //通过索引定位赋值
 s[0] = 100
 fmt.Println(s[0])

}

输出结果如下:

1
100

切片截取

我们也可以从一个切片上截取一部分元素生成一个新的切片,假设我们的源切片有元素1-5,我们希望从截取长度为2,容量为4的切片。这里我们给出切片的创建语法为为slice[i:j],已知原有切片容量为5,即k为5,要想符合长度为2,容量为4,我们就需要得到一个符合以下公式的数组:

j-i=2
k-i=4

因为k=5
所以i=1
由此可知j为3

最终推算出i=1 k=3

对应的图解步骤如下:

这里也贴出我们最终的代码:

func main() {

 s := []int{1, 2, 3, 4, 5}
 //切除一个长度为2,容量为4的切片,即内部范围相减为2,原切片容量减去左边为4
 s1 := s[1:3]
 fmt.Println(len(s1))
 fmt.Println(cap(s1))

}

可以看到输出结果也符合我们的最终预期:

2
4

需要注意的是该操作截取的元素都是来自源切片,这意味着两个切片底层共享一个数组的指针,所以一个切片的值就该了,则另一个切片的值也会同步:

func main() {

 s := []int{1, 2, 3, 4, 5}
 //切除一个长度为2,容量为4的切片,即内部范围相减为2,原切片容量减去左边为4
 s1 := s[1:3]
 //修改s1索引0的值
 s1[0] = s1[0] * 10
 fmt.Println(s1[0])
 //同步修改源索引1的值
 fmt.Println(s[1])

}

输出结果:

20
20

切片定制化截取

其实切片语法含有3个参数,我们可以通过第3个参数获进行容量设置,例如我们希望从一个容量为5的切片自索引2开始截取一个长度为1,容量为2的切片。 go语言对于切片截取规则都是左闭右开的,所以我们设slice[i:j:k],由题目可知i为2,得出j为i+1(长度为1)即3,然后i+2(容量为2),从而得出i、j、k为2、3、4。

func main() {

 s := []int{1, 2, 3, 4, 5}
 //获取slice 索引 3-2=1 容量 4-3=>1再加索引的1元素,即从1-2的元素
 s1 := s[2:3:4]
 //最终长度为1,容量为2
 fmt.Println(s1)
 fmt.Println(len(s1))
 fmt.Println(cap(s1))
}

对应输出结果如下:

[3]
1
2

切片动态扩容

当使用append追加一个新的元素达到cap的上限之后,切片就会进行动态扩容:

func main() {

 s := []int{1, 2, 3, 4, 5}
 fmt.Println(len(s))
 fmt.Println(cap(s))
 
 //追加切片会导致切片动态扩容,1000一下2倍扩容,1000后就是x1.25
 s1 := append(s, 6)
 fmt.Println(len(s1))
 fmt.Println(cap(s1))

}

从输出结果可以看出,达到cap上限后的追加会使得len加上1,而cap为原来的cap翻倍:

5
5 
6 
10

切片的迭代

切片的遍历语法如下,注意切片在遍历的时候底层是会复制这些副本的,并且会同一个迭代指针不断获取下一个遍历的值:

对应的我们给出示例代码:

func main() {

 s := []int{1, 2, 3, 4, 5}
 //切片遍历语法,以及会创建副本,可以看到遍历的value地址都是一样,其本质就是返回的值是底层的一个固定迭代指针拿到遍历的值返回的
 for i, v := range s {
  fmt.Println("index:", i, "v:", v)
  fmt.Println("addressV:", &v, "addressSliceEle:", &s[i])
 }
}

输出结果如下,可以看到s切片的元素地址和指针迭代到的元素地址完全是不一样的:

index: 0 v: 1
addressV: 0xc00001c098 addressSliceEle: 0xc00000e3f0
index: 1 v: 2                                       
addressV: 0xc00001c098 addressSliceEle: 0xc00000e3f8
index: 2 v: 3                                       
addressV: 0xc00001c098 addressSliceEle: 0xc00000e400
index: 3 v: 4                                       
addressV: 0xc00001c098 addressSliceEle: 0xc00000e408
index: 4 v: 5                                       
addressV: 0xc00001c098 addressSliceEle: 0xc00000e410

若读者不需要用到索引,我们也可以使用下划线方式进行省略索引:

func main() {

 s := []int{1, 2, 3, 4, 5}
 //忽略索引的语法
 for _, v := range s {
  fmt.Println(v)
 }
}

同样切片支持for语法迭代元素:

func main() {
 slice := []int{1, 2, 3, 4, 5}
 for i := 0; i < len(slice); i++ {
  fmt.Println(slice[i])
 }
}

多维切片

因为是对数组的封装,对应的访问、追加、动态扩容也很上述类型,这里就不多赘述了,笔者可自行查阅代码示例了解一下:

func main() {

 //二维切片,即切片中存切片
 s := [][]int{{10, 20}, {100, 200}}
 fmt.Println(s)
 //第一个维度添加30
 s1 := append(s[0], 30)
 fmt.Println(s1)
 //最终s1因为append导致动态扩容使得长度+1=3,容量*2=4
 fmt.Println(len(s1))
 fmt.Println(cap(s1))
}

切片作为函数参数

因为切片是对数组的封装,在函数传递时复制的是切片的引用,这使得切片作为参数时,操作的元素就是入参的值,这也就意味着切片在函数间的传递是没有大量元素拷贝的开销的。

func main() {
 slice := []int{1, 2, 3, 4, 5}
 foo(slice)
 fmt.Println(slice)
}

// 切片本身仅仅8数组指针+len、cap各自8指针,所以总的长度为24字节,所谓形参复制性能不会有影响
func foo(slice []int) {
 slice[0] = 10
}

小结

本文从切片的创建、赋值、使用、截取、迭代、传递等多个角度介绍了切片的常见的实践案例,希望对你有帮助!

我是 sharkchiliCSDN Java 领域博客专家开源项目—JavaGuide contributor,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili 。 因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。

参考

《Go 语言实战》

本文使用 markdown.com.cn 排版