1 切片详解
1.1 切片概念
slice 表示一个拥有相同数据类型元素的可变长度序列。slice 通常写成 [ ]T,其中元素的类型都是 T;因此它看上去像是没有长度限制的数组类型。数组和切片是紧密关联的。slice是一种轻量级数据结构,可以用来访问数组的部分或者全部元素,而这个数组称为slice的底层数组。
切片是对数组一个连续片段的引用,因此切片是一个引用类型(类似于Python中的list类型),这个片段可以是整个数组,也可以是由起始和终止索引标识的一个数组子集,需要注意的是,终止索引标识的项不包括在切片内。
1.2 内部实现
slice有3个属性:指针、长度和容量。slice数据结构的定义如下:
// src/runtime/slice.go
type slice struct {
array unsafe.Pointer
len int
cap int
}
通过切片的结构体定义可以看出,它内部是通过指针引用底层数组。如下图所示:
- 指针:指向底层数组的第一个可以从slice中访问的元素,这个元素不一定是数组的第一个元素。
- 长度:是指slice中元素的个数。可以使用Go的内置函数len()来获取当前slice的长度。
- 容量:通常是从slice的起始元素到底层数组的最后一个元素间的元素个数。可以使用Go的内置函数cap()来获取slice的容量。
一个底层数组可以对应有多个slice,这些slice可以引用数组的任何位置,彼此之间的元素还可以 重叠。
<说明> 切片本身是个只读对象,其工作机制类似于数组指针的一种包装。
2 创建切片
2.1 从数组或切片生成新的切片
切片默认指向一段连续的内存空间,可以是数组,也可以是切片本身。从连续内存区域生成切片的是常见的操作,格式如下:
slice [start: end]
- slice:表示目标切片对象,这个对象可以是数组也可以是切片本身。
- start:目标切片的起始索引。
- end:目标切片的结束索引。
1、从数组生成切片
var a = [3]int{1, 2, 3}
fmt.Println(a, a[1:2]) //[1 2 3] [2]
从数组或切片生成新的切片拥有如下特性:
- 取出的元素数量为:结束索引-开始索引。
- 取出的元素不包含结束索引的元素,切片最后一个元素使用 slice[len(slice)] 的方式获取。
- 当缺省开始索引时,表示从连续区域首元素位置到结束位置。
- 当缺省结束索引时,表示从开始位置到整个连续区域的结尾位置。
- 当开始索引和结束索引同时缺省时,与切片本身等效。
- 当开始索引和结束索引都为0时,等效于空切片,一般用于切片复位。
- 根据索引位置去切片的元素值时,取值范围是(0~len(slice)-1),越界会报运行时错误。
//由数组创建切片
var arr = [...]int{0, 1, 2, 3, 4, 5, 6} //创建一个有7个int型元素的数组
s1 := arr[0:4]
s2 := arr[:4]
s3 := arr[2:]
s4 := arr[:]
fmt.Printf("%v\n", s1) //[0 1 2 3]
fmt.Printf("%v\n", s2) //[0 1 2 3]
fmt.Printf("%v\n", s3) //[2 3 4 5 6]
fmt.Printf("%v\n", s4) //[0 1 2 3 4 5 6]
//清空切片
fmt.Printf("%v\n", s4[0:0]) //[]
2.2 声明切片
每一种类型都可以拥有其切片类型,表示多个相同类型元素的连续集合。因此切片类型也可以被声明。切片类型声明格式如下:
var name []T
- name:表示切片类型的变量名。
- T:表示切片中的元素类型。
//声明字符串切片
var strSlice []string
//声明整型切片
var intSlice []int
//声明并初始化一个空切片
var intSliceEmpty = []int{}
//输出3个切片
fmt.Println(strSlice, intSlice, intSliceEmpty) //[] [] []
//输出3个切片大小
fmt.Println(len(strSlice), len(intSlice), len(intSliceEmpty)) //0 0 0
//切片判定是否为空
fmt.Println(strSlice == nil) //true
fmt.Println(intSlice == nil) //true
fmt.Println(intSliceEmpty == nil) //false
代码说明如下:
- 声明但未使用的切片的默认值是nil。
- intSliceEmpty 已经被分配内存空间了,但没有元素,因此和 nil 进行比较时是false。
- 切片是动态结构,只能与 nil 判定相等,不能用 == 来判定两个slice是否相等,这一点和数组是不同的。Go标准库里面提供了高度优化的函数 bytes.Equal() 函数来比较两个字节slice([ ]byte),但是对于其他类型的slice,我们必须自己写函数来比较。例如:
func equal(x, y []string) bool {
if(len(x) != len(y)){
return false
}
//遍历切片
for i := range x {
if x[i] != y[i] {
return false
}
}
return true
}
<备注> 和数组一样,slice也可以通过索引来访问元素。
Q:为什么slice比较不可以直接使用 == 操作符做比较呢?
答:首先,和数组元素不同,slice的元素是非直接的,有可能slice可以包含它自身,虽然有办法处理这种特殊的情况,但是没有一种办法是简单、高效、直观的。
其次,因为slice的元素不是直接的,如果底层数组元素改变,同一个slice在不同时刻会拥有不同的元素。由于散列表(例如Go的map类型)仅对元素的键做浅拷贝,这就要求散列表里面的键在散列表整个生命周期内必须保持不变。因为slice需要深度比较,所以不能用slice作为map的键。对于引用类型,例如指针和通道,操作符== 检查的是引用相等性,即它们是否指向相同的元素。如果有一个相似的slice相等性比较功能,它或许会比较有用,也能解决slice作为map键的问题。但是如果操作符== 对slice和数组的行为不一致,会带来困扰。所以最安全的方法就是不允许直接比较slice。
slice类型的零值是 nil。值为nil 的slice没有对应的底层数组。值为nil 的slice长度和容量都是零,但是也有非nil 的slice,长度和容量也为零,例如:
var s []int
s = []int{} //len(s)==0, cap(s)==0, s != nil
2.3 使用make()函数构造切片
如果需要动态地创建一个切片,可以使用Go的内置函数 make() 创建一个指定元素类型、长度和容量的切片。其中,容量参数可以省略,在这种情况下,slice的长度和容量相等。
make([]T, len)
make([]T, len, cap)
- T:切片的元素类型。
- len:分配的元素个数。
- cap:切片的容量大小。
a := make([]int, 2)
b := make([]int, 2, 10)
fmt.Println(a, b) //[0 0] [0 0]
fmt.Println(len(a), len(b)) //2 2
fmt.Println(cap(a), cap(b)) //2 10
fmt.Println(a==nil, b==nil) //false false
深入研究下,其实make 创建了一个无名数组并返回了它的一个slice;这个数组只可以通过这个slice来访问,这个无名数组的长度和创建的slice的容量是相等的。
<提示> 使用make() 函数生成的切片一定发生了内存分配操作,因此切片不为nil。但给定开始和结束位置(包括切片复位)的切片只是将新的切片结构指向已经分配好的内存区域,设定开始和结束位置,不会发生内存分配操作。
2.4 使用append()函数为切片添加元素
Go语言的内建函数append() 可以为切片动态添加元素,即追加元素到slice中。
//使用append 为一个rune类型的slice添加元素
var runes []rune
for _, r := range "Hello, Golang" {
runes = append(runes, r)
}
fmt.Printf("%q\n", runes) //['H' 'e' 'l' 'l' 'o' ',' ' ' 'G' 'o' 'l' 'a' 'n' 'g']
每个切片会指向一个内存空间,这片空间能容纳一定数量的元素。当空间不能容纳足够多的元素时,切片就会进行“扩容”。“扩容”操作往往发生在append()函数调用时。
切片在扩容时,容量的扩展规律按容量的2倍数扩充,例如:1、2、4、8、16......
var num []int
for i:=0; i<10; i++ {
num= append(num, i)
fmt.Printf("len: %d, cap: %d, pointer: %p\n", len(num),cap(num),num)
}
运行结果:
len: 1, cap: 1, pointer: 0xc00010c000
len: 2, cap: 2, pointer: 0xc00010c030
len: 3, cap: 4, pointer: 0xc000114020
len: 4, cap: 4, pointer: 0xc000114020
len: 5, cap: 8, pointer: 0xc000116040
len: 6, cap: 8, pointer: 0xc000116040
len: 7, cap: 8, pointer: 0xc000116040
len: 8, cap: 8, pointer: 0xc000116040
len: 9, cap: 16, pointer: 0xc000118000
len: 10, cap: 16, pointer: 0xc000118000
通过查看代码输出,可以看到,切片的长度并不总是等于切片的容量。
<提示> 往一个切片中添加元素的过程中,当切片容量不足时,会按原来容量的2倍数进行扩充,与此同时,切片的地址也会随之发生改变,也就是说扩充后的切片指向的是一片新的内存区域。这类似于公司搬家。公司发展初期,资金紧张,人员很少,所以只需要很小的办公地就可以容纳所有的员工。随着公司业务的拓展和收入的增加需要扩招员工,但是办公地的大小是固定的,无法改变。因此公司选择搬家,每次搬家就需要将所有的人员转移到新的办公地点。
- 员工和工位就是切片中的元素。
- 办公地就是分配好的内存区域。
- 搬家就是重新分配内存区域。
- 无论搬多少次家,公司名称始终是不会变的,代表外部使用切片的变量名不会修改。
- 因为搬家后地址发生改变,因此内存“地址”也会有修改。
append()函数除了一次添加一个元素外,也可以一次性添加多个元素,甚至还可以添加另一个slice里的所有元素。
var months []string
//添加一个元素
months = append(months, "January")
//添加多个元素
months = append(months, "February", "March", "April")
//添加切片
summer := []string{"May", "June", "July"}
months = append(months, summer...)
fmt.Println(months) // [January February March April May June July]
代码说明:在summer后面加上"...",表示将summer整个添加到months的后面。
2.5 复制切片元素到另一个切片中
使用go语言内建的 copy() 函数,可以迅速地将一个切片的元素复制到另一个切片中,copy() 函数的使用格式如下:
copy(destSlice, srcSlice, []T) int
- srcSlice 为数据来源切片。
- destSlice 为复制目标的切片。目标切片必须分配过内存空间且有足够容量来装载复制的元素。源切片和目标切片的类型必须一致,copy 函数的返回值表示实际发生复制的元素个数。
package main
import "fmt"
func main(){
//设置元素数量为1000
const elementCount int = 1000 //类型int也可以省略
//预分配切片内存空间
srcData := make([]int, elementCount)
//对切片元素赋值
for i:=0; i<elementCount; i++ {
srcData[i] = i
}
//引用切片数据
refData := srcData
//预分配足够多的切片内存空间
copyData := make([]int, elementCount)
//将srcData切片复制到新的切片空间中
copy(copyData, srcData)
//修改元素数据的第一个元素
srcData[0] = 999
//打印引用切片的第一个元素
fmt.Println(refData[0]) //999
//打印复制切片的第一个和最后一个元素
fmt.Println(copyData[0], copyData[elementCount-1]) //0 999
copy(copyData, srcData[4:6])
for i:=0; i<5; i++ {
fmt.Printf("%d ", copyData[i]) //4 5 2 3 4
}
fmt.Println()
}
【##】在两个切片对象间复制数据,允许指向同一底层数组,允许目标区间重叠。最终所复制长度以较短的切片长度(len)为准。
s := []int{0,1,2,3,4,5,6,7,8,9} //声明并初始化一个int型切片
s1 := s[5:8] //使用切片创建切片
n := copy(s[4:], s1) //在同一底层数组的不同区间复制
fmt.Println(n, s) //3 [0 1 2 3 5 6 7 7 8 9]
s2 := make([]int, 6) //使用make创建一个int型切片
n = copy(s2, s) //在不同底层数组间复制
fmt.Println(n, s2) //6 [0 1 2 3 5 6]
代码说明如下:
- 切片s和s1都指向同一底层数组{0,1,2,3,4,5,6,7,8,9},s1={5,6,7},s[4:]={4,5,6,7,8,9},执行复制操作后,切片s的元素发生了变化。
- 将切片s的元素复制到切片s2中,但是切片s2的容量只有6,而切片s的元素个数是10个,所以最终只复制s中的前6个元素到s2中,即以较短的切片长度为准来复制数据。
【##】使用copy()函数还可以直接将字符串复制到字节切片([ ]byte) 中。
s := make([]byte, 5) //声明1个长度和容量均为5的字节切片
n := copy(s, "abcde") //复制字符串到字节切片中
fmt.Println(n, s) //5 [97 98 99 100 101]
fmt.Println(string(s)) //abcde
代码说明:直接打印切片s的结果为ASCII码值,如果要以字符串形式输出,需要使用Go内置的string()函数,将字节切片转换为字符串。
2.6 从切片中删除元素
Go语言并没有对删除切片元素提供专用的语法或接口,需要使用切片本身的特性来删除元素。
seq := []string{"a", "b", "c", "d", "e"}
//指定删除的元素位置
index := 2
//查看删除元素之前的元素和之后的元素
fmt.Println(seq[:index], seq[index+1:]) //[a b] [d e]
//将删除位置前后的元素连接起来
seq = append(seq[:index], seq[index+1:]...)
fmt.Println(seq) //[a b d e]
代码说明:上例中删除切片元素的过程可以使用下图来描述。
Go语言中切片删除元素的本质是:以被删除元素为分界点,将前后两部分的内存重新连接起来。
<提示> Go语言中切片的删除过程并没有提供任何的语法糖或者方法封装。连续容器的元素删除,无论在任何语言中,都要将删除点前后的元素移动到新的位置。随着元素的增加,这个过程将会变得极为耗时。因此,当业务需要大量、频繁地从一个切片中删除元素时,如果对性能要求较高,就需要反思是否需要更换成其他的容器(如双列表等能快速从删除点删除元素的数据结构)。
2.7 多维切片
和数组一样,切片是一维的。不过可以组合多个切片形成多维切片。
//创建一个二维的整型切片
slice := [][]int{{10}, {100, 200}}
我们有了一个包含两个元素的外层切片,每个元素包含一个内层的整型切片。切片 slice 的值如下图展示的样子。
在上图中,我们可以看到组合切片的操作是如何将一个切片嵌入到另一个切片中的。外层的切片包括两个元素,每个元素是一个切片。第一个元素中的切片使用单个整数10来初始化,第二个元素中的切片包括两个整数,即100和200。
//创建一个整型切片的切片
slice := [][]int{{10}, {100, 200}}
//为第一个切片追加值为20的元素
slice[0] = append(slice[0], 20)
//遍历切片
for i := range slice {
fmt.Println(slice[i])
}
运行结果:
[10 20]
[100 200]
Go语言中使用append()函数处理追加元素的方式很简明:先增长切片,再将新的整型切片赋值给外层切片的第一个元素,即slice[0]。然后,会为新的整型切片分配新的底层数组,最后让slice[0]切片指向这个底层数组的起始位置。如下图所示:
我们修改上面的代码:
//创建一个整型切片的切片
slice := [][]int{{10}, {100, 200}}
//打印切片本身的值和地址
fmt.Printf("&slice=%p, slice=%p\n", &slice, slice)
//遍历切片
for i := range slice {
fmt.Printf("&slice[%d]=%p, slice[%d]=%p\n", i,&slice[i], i,slice[i])
}
//为第一个切片追加值为20的元素
slice[0] = append(slice[0], 20)
for i := range slice {
fmt.Println(slice[i])
}
//追加元素后,打印切片本身的值和地址
fmt.Printf("&slice=%p, slice=%p\n", &slice, slice)
//遍历切片
for i := range slice {
fmt.Printf("&slice[%d]=%p, slice[%d]=%p\n", i,&slice[i], i,slice[i])
}
程序运行结果:
&slice=0xc00010c000, slice=0xc00010e000
&slice[0]=0xc00010e000, slice[0]=0xc000110000
&slice[1]=0xc00010e018, slice[1]=0xc000110010
[10 20]
[100 200]
&slice=0xc00010c000, slice=0xc00010e000
&slice[0]=0xc00010e000, slice[0]=0xc000110030
&slice[1]=0xc00010e018, slice[1]=0xc000110010
从运行结果我们可以看到,&slice[0]的值没有改变,但是slice[0]的值发生了改变,因为它指向了新的底层数组,这个新的底层数组的长度为2,有两个元素{10, 20}。
2.8 切片做函数参数
由于切片是引用类型,切片作函数参数,只是传递切片本身的值。切片的尺寸很小,在函数间复制和传递的成本也很低。在一个64位架构的机器上,一个切片需要24字节的内存空间:指针字段8字节,长度和容量字段分别8字节。由于与切片关联的数据存放在底层数组中,不属于切片本身,所以将切片复制到任意函数的时候,只会复制切片本身,不会涉及底层数组。
示例,在函数间传递切片。
//分配包含100万个整型数据的切片
slice := make([]int, 1e6)
//将slice传递到foo函数中
slice = foo(slice)
//函数foo接收一个整型切片,并返回这个切片
func foo(slice []int) []int {
...
return slice
}
上面代码中,向函数foo()传递切片slice的过程示意图如下:
从上图中可以看到,传递给函数foo的只是切片本身的值,即24字节的数据,然后在函数中通过切片的副本间接访问底层数组的数据,操作完成后,返回一份切片的副本即可,这就是切片效率高的地方。
3 参考
《Go语言从入门到进阶实战(视频教学版)》
《Go语言实战》
《Go程序设计语言》