Golang 中有两种数据结构可以用来存储一组数据:ArraySlice。它们之间有一些重要的区别和使用场景。因为切片(slice)是更好用、安全,所以在实际开发中更常用切片(slice)。而其实切片(slice)是基于数组(array)实现的。之前开发的过程中也只注重了使用,这次整理一下它们之间的区别和使用场景。

Array(数组)和 Slice(切片)的区别和联系

Go 语言中的切片(slice)结构的本质是对数组的封装,它描述一个数组的片段。无论是数组还是切片,都可以通过下标来访问单个元素。不同的是,数组的长度是固定的,而切片的长度是动态的。切片可以在运行时改变大小,而数组的大小在编译时就已经确定了。

数组的长度固定

一个很典型的例子就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func testArray() [5]int {
var arr [5]int
for i := 0; i < len(arr); i++ {
arr[i] = i
}
return arr
}

func testSlice() []int {
var slice []int
for i := 0; i < 5; i++ {
slice = append(slice, i)
}
return slice
}

上面的代码中,testArray 函数返回一个数组,而 testSlice 函数返回一个切片。可以看到,数组的长度是固定的,而切片的长度是动态的。

如果将切片作为数组返回或者将数组作为切片返回,编译器都会报错:

1
2
3
4
5
6
7
8
func testArray() []int {
var arr [5]int
for i := 0; i < len(arr); i++ {
arr[i] = i
}
return arr
}
// cannot use arr (variable of type [5]int) as []int value in return statement
1
2
3
4
5
6
7
8
func testSlice() [5]int {
var slice []int
for i := 0; i < 5; i++ {
slice = append(slice, i)
}
return slice
}
// cannot use slice (variable of type []int) as [5]int value in return statement

并且由于数组的长度是固定的,所以长度不同的数组就是不同的类型,比如 [5]int[10]int 是不同的类型,不能直接赋值。

反观切片,切片的长度是动态的,所以不同长度的切片是同一种类型,可以直接赋值,也可以动态扩容和截取。

直接申明创建数组和切片

由于数组的长度是固定的,所以在创建数组时需要指定长度。而切片的长度是动态的,所以在创建切片时不需要指定长度,可以使用 make 函数来创建切片,或者使用字面量来创建切片,而数组则只能使用字面量来创建。

1
2
3
4
5
6
7
8
9
10
11
// 创建一个长度为 5 的数组
var arr [5]int
arr := [5]int{1, 2, 3, 4, 5}

// 创建一个切片
var slice []int
slice := []int{1, 2, 3, 4, 5}
// 使用 make 函数创建一个长度为 5 的切片
slice := make([]int, 5)
// 使用 make 函数创建一个长度为 5,容量为 10 的切片
slice := make([]int, 5, 10)

上面的初始化中用到了 make([]int, 5, 10)make 函数是 Go 语言中用于创建切片、映射和通道的内置函数。其中第一个参数是切片的类型,第二个参数是切片的长度,第三个参数是切片的容量。make 函数会返回一个切片,长度和容量都为 5 的切片。而创建切片时可以指定长度和容量的原因,只需要看一下切片的底层实现就一目了然。

切片的底层实现

数组的底层其实和 C 语言中的数组类似,都是连续的内存空间。而切片的本质是一个结构体,它包含了一个指向底层数组的指针、切片的长度和切片的容量。切片的长度是当前切片中元素的个数,而切片的容量是底层数组的长度。其数据结构源码如下:

1
2
3
4
5
6
7
// src/runtime/slice.go

type slice struct {
array unsafe.Pointer // 指向底层数组的指针
len int // 切片的长度
cap int // 切片的容量
}

可以看到切片本身并不直接存储数据,而是通过指针指向底层数组。而底层数组可以被多个切片共享,这就是切片的共享特性。也就是说,切片是对数组的一个视图,它可以动态地改变大小和内容,而不需要复制底层数组的数据。切片的共享特性使得它在性能上比数组更高效,因为它避免了不必要的内存复制和分配。

所以数组是值类型,一个数组变量的值是一块连续的内存空间,而切片是引用类型,一个切片变量的值是一个包含三个成员的切片结构体,分别是指向底层数组的指针、切片的长度和切片的容量。注意,一般我们认为结构体是值类型,但是切片的结构体中包含了一个指向底层数组的指针,所以切片的结构体本身是值类型,但是它的内容是引用类型。而我们说切片是引用类型,指的就是切片在复制的时候不会复制底层数组的数据,而是复制切片的结构体。

当然这也带来了一个问题,就是如果一个切片被修改了,其他共享同一个底层数组的切片也会受到影响。这就是切片的引用特性,比如针对一个数组/切片 x 进行截取,取出一个切片 y,如果对 y 进行修改,那么 x 也会受到影响。比如:

1
2
3
4
5
6
7
func main() {
x := []int{1, 2, 3, 4, 5}
y := x[1:4]
fmt.Println(y) // [2 3 4]
y[0] = 10
fmt.Println(x) // [1 10 3 4 5]
}

拷贝赋值创建数组和切片

Go 语言中的数组和切片都可以通过赋值来创建新的变量,但是它们的拷贝方式是不同的。数组是值拷贝,而切片是引用拷贝。因为数组表示的是一段连续的内存空间,而切片表示的是一个指向底层数组的指针、切片的长度和切片的容量。所以在拷贝数组时,实际上是拷贝了整个数组的内容,而在拷贝切片时,实际上是拷贝了切片的结构体,而不是底层数组的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
arr1 := [5]int{1, 2, 3, 4, 5}
arr2 := arr1
arr2[0] = 10
fmt.Println(arr1) // [1 2 3 4 5]
fmt.Println(arr2) // [10 2 3 4 5]

slice1 := []int{1, 2, 3, 4, 5}
slice2 := slice1
slice2[0] = 10
fmt.Println(slice1) // [10 2 3 4 5]
fmt.Println(slice2) // [10 2 3 4 5]
}

当然当数据作为函数参数传递时,数组也是值拷贝,而切片是引用传递。也就是说,函数内部对切片的修改会影响到外部的切片,而函数内部对数组的修改不会影响到外部的数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func modifyArray(arr [5]int) {
arr[0] = 10
}

func modifySlice(slice []int) {
slice[0] = 10
}

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

slice := []int{1, 2, 3, 4, 5}
modifySlice(slice)
fmt.Println(slice) // [10 2 3 4 5]
}

切片的截取

Go 语言中,切片可以通过切片操作符 [:] 来截取创建一个新的切片。这个新的切片会共享底层数组的内容,而不是复制底层数组的数据。切片操作符的语法是 slice[start:end],表示从 startend 的切片。注意,start 是包含的,而 end 是不包含的。例如:

1
2
3
4
5
6
7
8
9
func main() {
arr := []int{1, 2, 3, 4, 5}
slice1 := arr[1:4] // [2 3 4]
slice2 := arr[:3] // [1 2 3]
slice3 := arr[2:] // [3 4 5]
fmt.Println(slice1)
fmt.Println(slice2)
fmt.Println(slice3)
}

切片操作符的 startend 可以省略,表示从头到尾的切片。例如 arr[:] 表示整个切片。

这里在创建切片的时候还可以额外指定容量,比如 arr[1:4:5],表示从 arr 中截取从 14 的切片,并且这个切片的容量为 5。不过个人目前在实际开发中很少用到这个语法。

值得注意的是,新老 slice 或者新 slice 老数组互相影响的前提是两者共用底层数组,如果因为执行 append 操作使得新 slice 或老 slice 底层数组扩容,移动到了新的位置,两者就不会相互影响了。所以,问题的关键在于两者是否会共用底层数组。

切片的扩容

上面提到了切片的扩容,切片的扩容是通过 append 函数来实现的。append 函数会根据需要自动扩容底层数组,并返回一个新的切片。append 函数的语法是 append(slice, elements...),表示在 slice 的末尾添加 elements 元素。

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

使用 append 函数可以向 slice 追加元素,实际上是往底层数组相应的位置放置要追加的元素。但是底层数组的长度是固定的,如果索引 len-1 所指向的元素已经是底层数组的最后一个元素,那就需要重新分配一个更大的底层数组,并将原有元素复制到新的底层数组中。这样,新的 slice 就会指向新的底层数组,而原有的 slice 仍然指向旧的底层数组。同时,为了应对未来可能再次发生的 append 操作,新的底层数组的长度会预留一定的 buffer,当然这全都是由 Go 语言的编译器和运行时来处理的。

并且在 Go 1.17 及之前的版本和 Go 1.18 及之后的版本中,append 函数的扩容策略是不同的。

  • Go 1.17 及之前的版本中,append 函数的扩容策略是:
    • 如果期望容量大于当前容量的两倍,则直接扩容到期望容量;
    • else if 当前切片容量小于 1024,则扩容到当前容量的两倍;
    • else 每次扩容到当前容量的 1.25 倍,直到达到期望容量。
  • Go 1.18 及之后的版本中,append 函数的扩容策略是:
    • 如果期望容量大于当前容量的两倍,则直接扩容到期望容量;
    • else if 当前切片容量小于 256,则扩容到当前容量的两倍;
    • else 根据公式扩容(newCap = oldCap+(oldCap+3*256)/4),直到达到期望容量。

切片作为函数参数

上面说到,切片其实是一个结构体,包含了一个指向底层数组的指针、切片的长度和切片的容量。所以在函数参数中传递切片时,就是传递了一个普通的结构体。也就是说,如果直接把一个切片作为函数参数传递,那么函数内部的对结构体的修改并不会影响到外部的切片。但是这里要注意,该结构体共有三个字段,对这个结构体的三个字段的修改都不会影响外部切片,具体来说:

  • 扩容:修改了切片的数组指针、长度和容量;
  • 截取:修改了切片的长度和容量;

这些修改都不会影响外部切片。⚠️但是,如果是通过切片的数组指针来访问到底层数组的数据,并且修改了底层数组的数据,那么外部切片也会受到影响。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

func modifySlice(slice []int) {
slice[0] = 10
slice = append(slice, 20)
fmt.Println("Inside modifySlice:", slice) // [10 2 3 4 5 20]
}
func main() {
slice := []int{1, 2, 3, 4, 5}
modifySlice(slice)
fmt.Println("Outside modifySlice:", slice) // [10 2 3 4 5]
}

上面的代码中,modifySlice 函数对切片进行了修改,但是外部的切片的长度并没有受到影响。因为 append 函数会重新分配一个新的底层数组,而不是修改原有的底层数组。但是在 append 函数之前,对切片的第一个元素进行了修改,所以外部的切片的第一个元素也被修改了。

综上,切片作为函数参数时,虽然传递的是结构体,但由于切片的引用特性,函数内部对切片的修改可能会影响到外部切片。

如果在函数内部要直接修改外部的切片,可以使用指针来传递切片的地址。这样就可以直接修改外部切片的内容了。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

func modifySlice(slice *[]int) {
(*slice)[0] = 10
*slice = append(*slice, 20)
fmt.Println("Inside modifySlice:", *slice) // [10 2 3 4 5 20]
}
func main() {
slice := []int{1, 2, 3, 4, 5}
modifySlice(&slice)
fmt.Println("Outside modifySlice:", slice) // [10 2 3 4 5 20]
}

上面的代码中,modifySlice 函数接收一个指向切片的指针,这样就可以直接修改外部切片的内容了。