切片是一个很小的对象,它对底层的数组(内部是通过数组保存数据的)进行了抽象,并提供相关的操作方法。切片是一个有三个字段的数据结构,这些数据结构包含 Golang 需要操作底层数组的元数据:指向底层数组的指针、切片长度(即元素个数)和切片容量。
1. 切片创建和初始化
在 Golang 中可以通过多种方式创建和初始化切片。是否提前知道切片所需的容量通常会决定如何创建切片。
- 通过 make() 函数创建切片
使用 Golang 内置的 make() 函数创建切片,需要传入一个参数来指定切片的长度。Golang 不允许创建容量小于长度的切片。
// 1.创建一个整型切片,其长度和容量都是5个元素
slice1 := make([]int, 5)
// 2、分别指定切片长度和容量,长度为3个元素,容量为5个元素
slice2 := make([]int, 3, 5)
- 通过字面量创建切片
这种方法和创建数组类似,只是不需要指定[]运算符里的值。初始的长度和容量会基于初始化时提供的元素的个数确定:
// 创建字符串切片,长度和容量都是 3 个元素
myStr := []string{"Jack", "Mark", "Nick"}
// 创建一个整型切片,长度和容量都是 4 个元素
myNum := []int{10, 20, 30, 40}
当使用切片字面量创建切片时,还可以设置初始长度和容量。要做的就是在初始化时给出所需的长度和容量作为索引。下面的语法展示了如何使用索引方式创建长度和容量都是100个元素的切片:
// 创建字符串切片,使用空字符串初始化第100个元素
myStr := []string{99: ""}
- 数组和切片声明方式的区别
如果在[]运算符里指定了一个值,那么创建的就是数组而不是切片。只有在[]中不指定值的时候,创建的才是切片。
// 创建有 3 个元素的整型数组
myArray := [3]int{10, 20, 30}
// 创建长度和容量都是 3 的整型切片
mySlice := []int{10, 20, 30}
2. nil 和空切片
- nil切片
在 Golang 中,nil 切片是很常见的创建切片的方法。nil切片主要用途:nil 切片可以用于很多标准库和内置函数。在需要描述一个不存在的切片时,nil 切片会很好用。比如,函数要求返回一个切片但是发生异常的时候。
// 创建 nil 整型切片
var myNum []int
- 空切片
空切片和 nil 切片稍有不同,下面的代码分别通过 make() 函数和字面量的方式创建空切片。
// 使用 make 创建空的整型切片
myNum := make([]int, 0)
// 使用切片字面量创建空的整型切片
myNum := []int{}
空切片主要用途:空切片的底层数组中包含 0 个元素,也没有分配任何存储空间。想表示空集合时空切片很有用,比如,数据库查询返回 0 个查询结果时。
3. 为切片中的元素赋值
对切片里某个索引指向的元素赋值和对数组里某个索引指向的元素赋值的方法完全一样。使用[]操作符就可以改变某个元素的值,下面是使用切片字面量来声明切片:
// 创建一个整型切片
// 其容量和长度都是 5 个元素
myNum := []int{10, 20, 30, 40, 50}
// 改变索引为 1 的元素的值
myNum[1] = 25
4. 通过切片创建新的切片
切片之所以被称为切片,是因为创建一个新的切片,也就是把底层数组切出一部分。通过切片创建新切片的语法如下:
// i 表示从 slice 的第几个元素开始切,j 控制切片的长度(j-i)
slice[i:j]
//k 控制切片的容量(k-i),如果没有给定 k,则表示切到底层数组的最尾部
slice[i:j:k]
slice[i:] // 从 i 切到最尾部
slice[:j] // 从最开头切到 j(不包含 j)
slice[:] // 从头切到尾,等价于复制整个 slice
让我们通过下面的例子来理解通过切片创建新的切片的本质:
// 创建一个整型切片
// 其长度和容量都是 5 个元素
myNum := []int{10, 20, 30, 40, 50}
// 创建一个新切片
// 其长度为 2 个元素,容量为 4 个元素
newNum := slice[1:3]
执行上面的代码后,我们有了两个切片,它们共享同一段底层数组,但通过不同的切片会看到底层数组的不同部分:
注意,截取新切片时的原则是 "左含右不含"。所以 newNum 是从 myNum 的 index=1 处开始截取,截取到 index=3 的前一个元素,也就是不包含 index=3 这个元素。所以,新的 newNum 是由 myNum 中的第2个元素、第3个元素组成的新的切片构,长度为 2,容量为 4。切片 myNum 能够看到底层数组全部 5 个元素的容量,而 newNum 能看到的底层数组的容量只有 4 个元素。newNum 无法访问到底层数组的第一个元素。所以,对 newNum 来说,那个元素就是不存在的。
- 共享底层数组的切片
需要注意的是:现在两个切片 myNum 和 newNum 共享同一个底层数组。如果一个切片修改了该底层数组的共享部分,另一个切片也能感知到(请参考前图):
// 修改 newNum 索引为 1 的元素,同时也修改了原切片 myNum 的索引为 2 的元素
newNum[1] = 35
把 35 赋值给 newNum 索引为 1 的元素的同时也是在修改 myNum 索引为 2 的元素:
- 切片只能访问到其长度内的元素
切片只能访问到其长度内的元素,试图访问超出其长度的元素将会导致语言运行时异常,panic: runtime error: index out of range
。
// 修改 newNum 索引为 3 的元素
// 这个元素对于 newNum 来说并不存在
newNum[3] = 45
5. 切片扩容
切片相对于数组的一个好处是:可以按需增加切片的容量。Golang 内置的 append() 函数会处理增加长度时的所有操作细节, 而容量有可能会改变,也可能不会改变,这取决于被操作的切片的可用容量。
myNum := []int{10, 20, 30, 40, 50}
// 创建新的切片,其长度为 2 个元素,容量为 4 个元素
newNum := myNum[1:3]
// 使用原有的容量来分配一个新元素,将新元素赋值为 60
newNum = append(newNum, 60)
执行上面的代码后的底层数据结构如下图所示:
此时因为 newNum 在底层数组里还有额外的容量可用,append() 函数将可用的元素合并入切片的长度,并对其进行赋值。由于和原始的切片共享同一个底层数组,myNum 中索引为 3 的元素的值也被改动了。
如果切片的底层数组没有足够的可用容量,append() 函数会创建一个新的底层数组,将被引用的现有的值复制到新数组里,再追加新的值,此时 append 操作同时增加切片的长度和容量:
// 创建一个长度和容量都是 4 的整型切片
myNum := []int{10, 20, 30, 40}
myNum2 := []int{50, 60, 70, 80}
// 向切片追加一个新元素
// 将新元素赋值为 50
newNum := append(myNum, 50)
// 将myNum2和myNum组合为newNum2. 在返回的新的切片newNum2中,切片 myNum2 里的所有值都追加到了切片 myNum 中的元素后面。
newNum2 := append(myNum, myNum2...)
当这个 append 操作完成后,newSlice 拥有一个全新的底层数组,这个数组的容量是原来的两倍:
函数 append() 会智能地处理底层数组的容量增长。在切片的容量小于 1000 个元素时,总是会成倍地增加容量。一旦元素个数超过 1000,容量的增长因子会设为 1.25,也就是会每次增加 25%的容量(随着语言的演化,这种增长算法可能会有所改变)。
6. 限制切片的容量
在创建切片时,使用第三个索引选项引可以用来控制新切片的容量。其目的并不是要增加容量,而是要限制容量。允许限制新切片的容量为底层数组提供了一定的保护,可以更好地控制追加操作。
// 创建长度和容量都是 5 的字符串切片
fruit := []string{"Apple", "Orange", "Plum", "Banana", "Grape"}
// 将第三个元素切片,并限制容量;其长度为 1 个元素,容量为 2 个元素
myFruit := fruit[2:3:4]
//设置的容量比可用的容量还大,就会得到一个运行时错误:
// myFruit := fruit[2:3:6]
// panic: runtime error: slice bounds out of range
内置函数 append() 在操作切片时会首先使用可用容量。一旦没有可用容量,就会分配一个新的底层数组。这导致很容易忘记切片间正在共享同一个底层数组。一旦发生这种情况,对切片进行修改,很可能会导致随机且奇怪的问题,这种问题一般都很难调查。如果在创建切片时设置切片的容量和长度一样,就可以强制让新切片的第一个 append 操作创建新的底层数组,与原有的底层数组分离。这样就可以安全地进行后续的修改操作了。
myFruit := fruit[2:3:3]
// 向 myFruit 追加新字符串
myFruit = append(myFruit, "Kiwi")
这里,我们限制了 myFruit 的容量为 1。当我们第一次对 myFruit 调用 append() 函数的时候,会创建一个新的底层数组,这个数组包括 2 个元素,并将水果 Plum 复制进来,再追加新水果 Kiwi,并返回一个引用了这个底层数组的新切片。因为新的切片 myFruit 拥有了自己的底层数组,所以杜绝了可能发生的问题。我们可以继续向新切片里追加水果,而不用担心会不小心修改了其他切片里的水果。可以通过下图来理解此时内存中的数据结构:
7. 遍历切片
切片是一个集合,可以迭代其中的元素。
- for+range方式
Golang 有个特殊的关键字 range,它可以配合关键字 for 来迭代切片里的元素。当迭代切片时,关键字 range 会返回两个值。第一个值是当前迭代到的索引位置,第二个值是该位置对应元素值的一份副本。需要强调的是,range 创建了每个元素的副本,而不是直接返回对该元素的引用。要想获取每个元素的地址,可以使用切片变量和索引值:
myNum := []int{10, 20, 30, 40, 50}
// 迭代每一个元素,并显示其值
for index, value := range myNum {
fmt.Printf("index: %d value: %d\n", index, value)
}
//输出结果
index: 0 value: 10
index: 1 value: 20
index: 2 value: 30
index: 3 value: 40
index: 4 value: 50
// 修改切片元素的值
// 使用空白标识符(下划线)来忽略原始值
for index, _ := range myNum {
myNum[index] += 1
}
//myNum := []int{11, 21, 31, 41, 51}
- for+len方式遍历
如果想对遍历做更多的控制,可以使用传统的 for 循环配合 len() 函数实现:
myNum := []int{10, 20, 30, 40, 50}
// 从第三个元素开始迭代每个元素
for index := 2; index < len(myNum); index++ {
...
}
8. 切片间的拷贝操作
Golang 内置的 copy() 函数可以将一个切片中的元素拷贝到另一个切片中,其函数声明为:
func copy(dst, src []Type) int
它表示把切片 src 中的元素拷贝到切片 dst 中,返回值为拷贝成功的元素个数。如果 src 比 dst 长,就截断;如果 src 比 dst 短,则只拷贝 src 那部分:
num1 := []int{10, 20, 30}
num2 := make([]int, 5)
count := copy(num2, num1)
fmt.Println(count)
fmt.Println(num2)
运行这段单面,输出的结果为:
3
[10 20 30 0 0]
3 表示拷贝成功的元素个数。
9. 把切片传递给函数
函数间传递切片就是要在函数间以值的方式传递切片。由于切片的尺寸很小,在函数间复制和传递切片成本也很低。
让我们创建一个包含 100 万个整数的切片,并将这个切片以值的方式传递给函数 foo():
myNum := make([]int, 1e6)
// 将 myNum 传递到函数 foo()
slice = foo(myNum)
// 函数 foo() 接收一个整型切片,并返回这个切片
func foo(slice []int) []int {
...
return slice
}
在 64 位架构的机器上,一个切片需要 24 字节的内存:指针字段需要 8 字节,长度和容量字段分别需要 8 字节。由于与切片关联的数据包含在底层数组里,不属于切片本身,所以将切片复制到任意函数的时候,对底层数组大小都不会有影响。复制时只会复制切片本身,不会涉及底层数组:
在函数间传递 24 字节的数据会非常快速、简单。这也是切片效率高的地方。不需要传递指针和处理复杂的语法,只需要复制切片,按想要的方式修改数据,然后传递回一份新的切片副本。
10. 总结
切片是 Golang 中比较有特色的一种数据类型,既为我们操作集合类型的数据提供了便利的方式,又能够高效的在函数间进行传递,因此在代码中切片类型被使用的相当广泛。