Go 常见数据类型-02切片
切片Slice
前言
切片是GO语言中比较特殊的数据结构,这种数据结构更便于使用和管理数据集合。
因为数组的长度是固定的并且数组长度属于类型的一部分,所以数组有很多的局限性。
切片是围绕动态数组的概念构建的,可以按需自动增长和缩小。切片的动态增长是通过内置函数append()来实现的,这个函数可以快速且高效地增长切片,也可以通过对切片再次切割,缩小一个切片的大小。因为切片的底层内存也是在连续内存快中分配的,所以切片还能获得索引、迭代以及为垃圾回收优化的好处。
切片是一个引用类型,它的内部结构包括地址
,长度
,容量
。切片一般用于快速的操作一块数据集合
切片的定义
声明切片的基本语法如下:
var name []T
其中:
- name 表示切片名
- T 表示切片中的元素类型
切片拥有自己的长度和容量,我们可以通过使用内置的len()
函数求长度,使用内置的cap()
函数求切片的容量。
创建切片
GO语言中创建和初始化切片的方法有几种,而能否确定切片的容量是创建切片的关键,它决定了以何种方式创建切片。
- make创建切片
一种创建切片的方式使用内置的make()
函数。当使用make()
时,需要传入一个参数,指定切片的长度,如:
// 创建一个长度和容量都是5的字符串切片
slice := make([]string, 5)
如果只指定长度,那么切片的容量和长度也相等。也可以分别指定长度和容量,如:
// 创建一个长度为3容量为5的整形切片
slice := make([]int, 3, 5)
分别指定长度和容量时,创建出来的切片的底层数组长度就是创建时指定的容量,但是初始化后并不能访问所有的数组元素。上面代码中的切片可以访问3个元素,而底层数组拥有5个元素,因此剩余的2个元素可以在后期操作中合并到切片,然后才可以通过切片访问这些元素。
- 切片字面量创建切片
// 长度和容量都是5的字符串切片
slice := []string{"a", "b", "c", "d", "e"}
这种方式和创建数组类似,但是不需要指定[]运算符里面的值,初始的长度和容量会基于初始化提供的元素的个数决定。
当时用切片字面量时,可以设置初始长度和容量,要坐的就是在初始化时给出所需的长度和容量作为索引(下标),下面的代码展示了如何创建长度和容量都是100个元素的切片:
slice := []string{99:""}
- nil和空切片
有时候程序可能需要一个值为nil的切片,只需要在声明时不做任何初始化,就会创建nil切片,如:
var slice []string
在GO语言中,nil切片是常见的创建切片的方法。nil切片多用于标准库和内置函数,在需要描述一个目前暂时不存在的切片时,nil切片十分好用。比如,函数要求返回一个切片但是发生异常的时候,利用初始化,通过声明一个切片可以创建一个nil切片:
// 使用make创建空的整形切片
slice := make([]int, 0)
// 使用切片字面量创建空的字符串切片
slice := []string{}
nil切片在底层数组中包含0个元素,也没有分配任何存储空间。
此外,nil切片还可以表示空集合,比如,数据库查询返回0个查询结果时,nil切片和普通切片一样,调用append、len、cap的效果都是一样的。
切片表达式
切片表达式从字符串、数组、指向数组或切片的指针构造子字符串或切片。它有两种变体:一种指定low和high两个索引界限值的简单的形式,另一种是除了low和high索引界限值外还指定容量的完整的形式。
- 简单切片表达式
切片的底层就是一个数组,所以我们可以基于数组通过切片表达式得到切片。 切片表达式中的low
和high
表示一个索引范围(左包含,右不包含),也就是下面代码中从数组a中选出1<=索引值<4的元素组成切片s,得到的切片长度=high-low
,容量等于得到的切片的底层数组的容量。
a := [5]int{1, 2, 3, 4, 5}
s := a[1:3] // s := a[low:high]
fmt.Printf("s:%v 长度:%v 容量:%v\n", s, len(s), cap(s))
// s:[2 3] 长度:2 容量:4
为了方便起见,可以省略切片表达式中的任何索引。省略了low
则默认为0;省略了high
则默认为切片操作数的长度:
a[2:] // 等同于 a[2:len(a)]
a[:3] // 等同于 a[0:3]
a[:] // 等同于 a[0:len(a)]
注意:
对于数组或字符串,如果0 <= low <= high <= len(a),则索引合法,否则就会索引越界(out of range)。
对切片再执行切片表达式时(切片再切片),high的上限边界是切片的容量cap(a),而不是长度。常量索引必须是非负的,并且可以用int类型的值表示;对于数组或常量字符串,常量索引也必须在有效范围内。如果low和high两个指标都是常数,它们必须满足low <= high。如果索引在运行时超出范围,运行时就会报panic。
a := [5]int{1, 2, 3, 4, 5}
s := a[1:3] // s := a[low:high]
fmt.Printf("s: %v s长度:%v s容量:%v\n", s, len(s), cap(s))
// s: [2 3] s长度:2 s容量:4
s2 := s[3:4] // 索引的上限是cap(s)而不是len(s)
fmt.Printf("s2: %v s2长度:%v s2容量:%v\n", s2, len(s2), cap(s2))
// s2: [5] s2长度:1 s2容量:1
- 完整切片表达式
完整切片表达式可以控制结果 slice 的容量,但是只能用于 array 和指向 array 或 slice 的指针( string 不支持)
a[low : high : max]
上面的代码会构造与简单切片表达式a[low: high]
相同类型、相同长度和元素的切片。另外,它会将得到的结果切片的容量设置为max-low
。在完整切片表达式中只有第一个索引值(low)可以省略;它默认为0。
a := [5]int{1, 2, 3, 4, 5}
t := a[1:3:5]
fmt.Printf("t:%v 长度:%v 容量:%v\n", t, len(t), cap(t))
//t:[2 3] 长度:2 容量:4
完整切片表达式需要满足的条件是0 <= low <= high <= max <= cap(a)
,其他条件和简单切片表达式相同。
切片的本质
切片的本质就是对底层数组的封装,它包含了三个信息:底层数组的指针、切片的长度(len)和切片的容量(cap)。
举个例子,现在有一个数组a := [8]int{0, 1, 2, 3, 4, 5, 6, 7},切片s1 := a[:5],相应示意图如下。
切片s2 := a[3:6]
,相应示意图如下:
切片的操作
切片的比较
切片之间是不能比较的,我们不能使用==
操作符来判断两个切片是否含有全部相等元素。 切片唯一合法的比较操作是和nil比较。 一个nil
值的切片并没有底层数组,一个nil
值的切片的长度和容量都是0。但是我们不能说一个长度和容量都是0的切片一定是nil
,例如下面的示例:
var s1 []int //len(s1)=0;cap(s1)=0;s1==nil
s2 := []int{} //len(s2)=0;cap(s2)=0;s2!=nil
s3 := make([]int, 0) //len(s3)=0;cap(s3)=0;s3!=nil
所以要判断一个切片是否是空的,要是用len(s) == 0
来判断,不应该使用s == nil
来判断。
切片的使用
给切片的某个元素赋值和给数组的某个元素赋值在方法上完全一样,使用[]运算符就可以改变某个元素的值。
slice := []int{1, 2, 3, 4, 5}
// 改变索引为1的值
slice[1] = 6
切片之所以被称之为切片,是因为每创建一个新的切片就是把底层数组切出一部分,如:
slice := []int{1, 2, 3, 4, 5}
// 创建一个新切片,长度和容量分别为2和4
newSlice := slice[1:3] // 等于从2开始切割,取2,3两个值,而容量是除去1以外的原切片的长度作为容量
执行上述代码后,就有个两个切片,它们共享同一段底层数组,但通过不同的切片会看到底层数组的不同部分。
第一个切片slice能访问底层数组全部5个元素的容量,之后的newSlice就不行,newSlice切片的容量为4,无法访问到指向的底层数组第一个元素之前的部分,换而言之,之前的元素是不存在的。
一个常见的描述是,对于底层数组容量是k的切片slice[i
,长度为j-i,容量为k-i。k]
需要注意的是,现在的两个切片共享同一个底层数组,如果一个切片修改了底层数组的共享部分,另一个也会被影响。
slice := []int{1, 2, 3, 4, 5}
newSlice := slice[1:3]
newSlice[1] = 6
// 此时slice输出为:[1 2 6 4 5]
切片只能访问到它自身长度内的元素,试图访问超出其长度的元素将会导致语言运行时异常。
切片的添加
Go语言的内建函数append()
可以为切片动态添加元素。 可以一次添加一个元素,可以添加多个元素,也可以添加另一个切片中的元素(后面加…)。
var s []int
s = append(s, 1) // [1]
s = append(s, 2, 3, 4) // [1 2 3 4]
s2 := []int{5, 6, 7}
s = append(s, s2...) // [1 2 3 4 5 6 7]
注意::通过var声明的零值切片可以在append()
函数直接使用,无需初始化
var s []int
s = append(s, 1, 2, 3)
没有必要像下面的代码一样初始化一个切片再传入append()
函数使用,
s := []int{} // 没有必要初始化
s = append(s, 1, 2, 3)
var s = make([]int) // 没有必要初始化
s = append(s, 1, 2, 3)
每个切片会指向一个底层数组,这个数组的容量够用就添加新增元素。当底层数组不能容纳新增的元素时,切片就会自动按照一定的策略进行“扩容”,此时该切片指向的底层数组就会更换。“扩容”操作往往发生在append()函数调用时,所以我们通常都需要用原变量接收append函数的返回值。
//append()添加元素和切片扩容
var numSlice []int
for i := 0; i < 10; i++ {
numSlice = append(numSlice, i)
fmt.Printf("%v len:%d cap:%d ptr:%p\n", numSlice, len(numSlice), cap(numSlice), numSlice)
}
输出:
[0] len:1 cap:1 ptr:0xc0000a8000
[0 1] len:2 cap:2 ptr:0xc0000a8040
[0 1 2] len:3 cap:4 ptr:0xc0000b2020
[0 1 2 3] len:4 cap:4 ptr:0xc0000b2020
[0 1 2 3 4] len:5 cap:8 ptr:0xc0000b6000
[0 1 2 3 4 5] len:6 cap:8 ptr:0xc0000b6000
[0 1 2 3 4 5 6] len:7 cap:8 ptr:0xc0000b6000
[0 1 2 3 4 5 6 7] len:8 cap:8 ptr:0xc0000b6000
[0 1 2 3 4 5 6 7 8] len:9 cap:16 ptr:0xc0000b8000
[0 1 2 3 4 5 6 7 8 9] len:10 cap:16 ptr:0xc0000b8000
从上面的结构可以看出:
append()
函数将元素追加到切片的最后并返回该切片。- 切片numSlice的容量按照1,2,4,8,16这样的规则自动进行扩容,每次扩容后都是扩容前的2倍。
切片的删除
Go语言中并没有删除切片元素的专用方法,我们可以使用切片本身的特性来删除元素。 代码如下:
a := []int{1, 2, 3, 4, 5}
var b []int // 声明一个新切片(也可以赋值给原有的切片a)
b = append(a[0:2], a[3:]...)
fmt.Println(b)
// [1 2 4 5]
总结一下就是:要从切片a中删除索引为index的元素,操作方法是a = append(a[:index], a[index+1:]...)
切片的遍历
切片是一个集合,那么自然可以迭代其中的元素,那么自然可以迭代其中的元素,切片可以使用for
和range
两种方式来迭代切片里的元素。
s := []int{1, 3, 5}
for i := 0; i < len(s); i++ {
fmt.Println(i, s[i])
}
for index, value := range s {
fmt.Println(index, value)
}
和数组迭代类似,迭代切片时,range会返回两个值。第一个值是当前迭代到的索引位置,第二个值是该位置对应元素的一份副本。
注意,range创建的事每个元素的副本,而不是对该元素的引用,如下面代码所示。如果使用该值变量的地址作为指向每个元素的指针,就会造成错误。
slice := []int{1, 2, 3, 4, 5}
for index, value := range slice {
fmt.Printf("value : %d valueAddr : %X elemAddr : %X\n", value, &value, &slice[index])
}
/*
value : 1 valueAddr : C0000B2008 elemAddr : C0000AA060
value : 2 valueAddr : C0000B2008 elemAddr : C0000AA068
value : 3 valueAddr : C0000B2008 elemAddr : C0000AA070
value : 4 valueAddr : C0000B2008 elemAddr : C0000AA078
value : 5 valueAddr : C0000B2008 elemAddr : C0000AA080
*/
上面的valueAddr之所以全部一样,是因为range执行时返回的这个value的指针实际上是同一个指针,被依次不断输出,所以&value的值总是相同的。
关键字range总是从切片头部开始迭代的,如果想对迭代做更多的控制,可以用传统的for循环:
slice := []int{1, 2, 3, 4, 5}
for index := 2, index < len(slice), index++ {
fmt.PrintF("index %d value %d\n", index, value)
}
复制切片
因为切片是引用类型,所以当一个切片赋值给另一个切片时,他们都指向了同一块内存地址。修改一个切片的值的同时另一个切片的值也会发生变化,如下:
a := []int{1, 2, 3, 4, 5}
b := a
fmt.Println(a) //[1 2 3 4 5]
fmt.Println(b) //[1 2 3 4 5]
b[0] = 1000
fmt.Println(a) //[1000 2 3 4 5]
fmt.Println(b) //[1000 2 3 4 5]
Go语言内建的copy()
函数可以迅速地将一个切片的数据复制到另外一个切片空间中,copy()
函数的使用格式如下:
copy(destSlice, srcSlice []T)
其中:
- srcSlice: 数据来源切片
- destSlice: 目标切片
举个例子:
// copy()复制切片
a := []int{1, 2, 3, 4, 5}
c := make([]int, 5, 5)
copy(c, a) //使用copy()函数将切片a中的元素复制到切片c
fmt.Println(a) //[1 2 3 4 5]
fmt.Println(c) //[1 2 3 4 5]
c[0] = 1000
fmt.Println(a) //[1 2 3 4 5]
fmt.Println(c) //[1000 2 3 4 5]
切片传参
在函数间传递切片是指在函数间以值的方式传递切片。由于切片的尺寸很小,在函数间复制和传递切片成本很低。创建一个大切片,并将这个切片以值的方式传递给函数foo()
:
// 分配包含100万个整形数值的切片
slice := make([]int, 1e6)
slice = foo(slice)
func foo(slice []int) []int {
return slice
}
在64位架构的机器上,一个切片需要24B的内存:指针字段需要8B,长度和容量字段分别需要8B,由于切片关联的数据包含在底层数组里,并不属于切片本身,所以将切片复制到任意函数的时候,对底层数组大小都不会有影响。复制时只会复制切片本身,不会涉及底层数组。
在函数间传递24B的数据会非常快速、简单,这也是切片高效的地方。不需要传递指针和处理复杂的语法,只需要复制切片,按想要的方式修改数据,然后传递回一份新的切片副本。
切片扩容
对于数组而言,因为数组长度是固定的,使用切片的好处是可以按需增加数据集合的容量。GO语言的内置append()
函数可以处理增加长度时所有的操作细节。
当append()
被调用时会返回一个包含修改结果的新切片。函数append()
只能增加新的切片的长度,而容量有可能会改变,也有可能不会改变,因为newSlice在底层数组里还有额外的可用容量,append()
操作将可用的元素合并到切片的长度,并对其赋值。如果切片的底层数组没有足够的可用容量,append()
会创建一个新的底层数组,将被引用的现有的值复制到新数组,再追加新的值。
slice := []int{1, 2, 3, 4, 5}
newSlice := append(slice, 6)
在这个append()操作完成后,newSlice拥有一个全新的底层数组,容量为原有的两倍
可以通过$GOROOT/src/runtime/slice.go
源码,查看GO语言扩容策略:
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.cap < 1024 {
newcap = doublecap
} else {
// Check 0 < newcap to detect overflow
// and prevent an infinite loop.
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
newcap = cap
}
}
}
从上面的代码可以看出:
- 首先判断,如果新申请容量(cap)大于2倍的旧容量(old.cap),最终容量(newcap)就是新申请的容量(cap)。
- 否则判断,如果旧切片的长度小于1024,则最终容量(newcap)就是旧容量(old.cap)的两倍,即(newcap=doublecap)
- 否则判断,如果旧切片大于1024,则最终容量(newcap)就是从旧容量(old.cap)循环增加原来的四分之一,即(newcap=old.cap,for(newcap += newcap/4)),直到最终容量大于新申请的容量(cap),即(newcap >= cap)
- 如果最终值(cap)计算溢出,则最终容量(cap)就是新申请容量(cap)
需要注意的是,切片扩容还会根据切片中元素类型的不同而做不同的处理,比如int
和string
类型的处理方式就不同。
注意:
不同版本slice.go
代码可能会有不同。
限制切片容量
在创建切片时,还可以使用之前没有提到的第三个索引选项。第三个索引可以用来控制新切片的容量,其目的并不是要增加容量,而是要限制容量。可以看到,允许限制新切片的容量为底层数组提供了一定的保护,可以更好的控制追加操作。
slice := []int{1, 2, 3, 4, 5}
// 将第三个元素做切片,并限制容量,其长度为1,容量为2
newSlice := slice[2:3:4]
这个切片操作执行后,新切片从底层数组引用了1个元素,容量是2个元素。具体来说,新切片引用了3
元素,并将容量扩展到4
元素。可以用之前的计算方法来计算新切片的长度和容量:newSlice[ik]([2
4]),长度为j-i(3-2=1),容量为k-i(4-2=2)。
和之前一样,第一个值表示新切片开始的元素的索引位置,这个例子是2。第二个值表示开始的索引位置(2)加上希望包括的元素的个数(1),2+1=3,所以第二个元素就是3。为了设置容量,从索引位置2开始,加上希望容量中包括的元素个数(2),就得到了第三个值4。
如果试图设置的容量比可用的容量还大,就会得到一个语言运行时的错误。
之前有提到过,内置函数append()会首先使用可用容量。一旦没有可用容量,会分配一个新的底层数组,这导致很容易忘记切片间正在共享同一个底层数组,一旦发生这种情况,对切片进行修改,很可能会导致随机且奇怪的问题。
对切片内容的修改会影响多个切片,却很难找到问题的原因。如果在创建切片时设置切片的容量长度一样,就可以强制让切片的第一个append()操作创建新的底层数组,与原有的底层数组分离。
新切片与原有的底层数组分离后,可以安全的进行后续的修改:
source := []string{"a", "b", "c", "d", "e"}
// 对第三个元素做切片并限制长度和容量都为1
slice := source[2:3:3]
slice = append(slice, "f")
如果不追加第三个索引,由于剩下的容量都属于slice,向slice追加f
元素会改变原有底层数组索引为3的元素,不过因为限制了slice的容量为1,当第一次使用append()函数时,会返回一个新的底层数组,这个数组包括两个元素,并将c
复制进来,再追加f
,并返回了一个引用了这个底层数组的新切片。
因为新的切片slice拥有了自己的底层数组,所以杜绝了可能发生的问题。所以可以继续向新切片追加数据,而不会担心不小心修改了其他切片中的数据,同时也保持了为切片申请新的底层数组的便捷性。
多维切片
和数组一样,切片是一维的。不过合之前提过的数组一样,可以组合多个切片形成多维切片。
slice := [][]int{{1}, {2, 3}}
一个包含两个元素的外层切片,每个元素包含一个内层的整形切片,外层的切片包含两个元素,每个元素都是一个切片。
第一个元素中的切片使用单个整数来初始化,第二个元素中的切片包括两个整数,这种组合可以让用户创建非常复杂且强大的数据结构。关于内置函数append()的规则也可以应用到组合后的切片上:
slice := [][]int{{1}, {2, 3}}
slice[0] = append(slice[0], 4)
GO语言中使用append()函数处理追加的方式很简明:先增长切片,再将新的整形切片赋值给外层切片的第一个元素。执行以上代码以后,会为新的整形切片分配新的底层数组,然后将切片赋值到外层切片的索引为0的元素中。
即使是这么简单的多维切片,操作时也会设计众多布局和值。看起来在函数间像这样传递数据结构也会很复杂,不过切片本身结构很简单,可以以很小的成本在函数间传递。
本作品采用《CC 协议》,转载必须注明作者和本文链接