Go语言复合数据类型之slice

1,025 阅读6分钟

slice概念

slice表示一个拥有相同类型元素的可变长度的序列。slice通常写成[]T,其中元素的类型都是T,这点儿类似于java中的泛型,可以接受未知类型的变量。

slice是一种轻量级的数据结构,可以用来访问数组的部分或者全部元素。slice的底层是数组,slice有三个属性:指针、长度、容量;

指针:是指向每个元素的地址;

长度:slice所存储的元素个数;

容量:slice的大小;

slice细节

  • Go内置的len()cap()函数可以返回slice的长度和容量大小。一个底层数组可以对应多个slice,这些slice可以引用数组的任何位置,彼此之间元素也可以重叠。
  • 值得注意的是,如果slice的引用超过了被引用对象的容量,即cap(s),就会导致程序宕机;如果引用超出了被引用对象的长度,即len(s),那么最终slice会比原来的slice长。
  • 注意求字符串(string)子串操作和对字节slice([]byte)做slice操作是相似的,它们都写作x[m:n],都返回原始字节的一个子序列,引用方式也是相同的,两个操作都是消耗常量的时间。
  • 和数组不同,slice无法直接做比较,因此不能用==来比较两个slice是否相同。标准库里面提供了高度优化的函数bytes.Equal来比较两个字节slice。但是对于其他类型的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不可以直接使用==操作符做比较。这里有两个原因,

第一,数组的元素是直接的,而slice的元素是非直接的,有可能slice可以包含它自身。我们虽然有办法处理这种情况,但是没有一种简单、高效、直观的方法。

第二,slice的元素不是直接的,如果底层数组元素改变,同一个slice在不同的时间就会拥有不同的元素。由于散列表仅对元素做浅拷贝,这就要求散列表的键在整个生命周期都保持不变。因为slice需要深度比较,所以就不能用slice作为map的键。对于引用类型的指针和通道,操作符==检查的是引用相等性,即它们是否指向相同的元素。如果有一个相似的slice相等性比较功能,或许会比较有用,也能解决slice作为map键的问题。由于slice涉及的情况比较多,因此最安全的方法就是不要直接比较slice。

  • slice唯一允许的就是和nil进行比较:
 summmer := []int
 if summer == nil{/**...*/}
  • slice类型的零值是nil,值为nil的slice没有对应的底层数组,且长度和容量都是0。但是也有非nil的slice长度和容量是0,例如[]int{}或make([]int,3)[3:]
  • 对于任何类型,如果它们的值是nil,那么则可以这样表示:
 var s []int
 s = nil
 ​
 s = []int(nil)
 s = []int{}
  • 如果需要检查一个slice是否为空,那么可以使用len(s) == 0,不能使用s == nil,因为存在 s != nil,slice也可能是空的情况。
  • 内置函数make可以创建一个具有指定元素类型、长度、容量的slice。其中容量参数可以省略,在这种情况下,slice的长度和容量相等。
 make([]T,len)
 make([]T,len,cap) //等效于make([]T,cap)[:len]

上面的代码中,make创建了一个无名数组并返回了它的一个slice;这个数组仅可以通过slice来访问。上面的第一行代码中,所返回的slice引用了整个数组。第二行代码中,slice只引用了数组的前len个元素。

append函数

Go语言的内置函数append()可以用来将元素追加到slice后面。

 var runes []rune
 for _,r := range "hello world"{
     runes = append(runes,r)
 } 
 fmt.Printf("%q\n,runes")//"['H' 'e' 'l' 'l' 'o' 'w' 'o' 'r' 'l' 'd']"

append()函数对于理解slice的工作原理很重要,我们实现一个appendInt()函数,再来深刻理解一下:

 func appendInt(x []int, y int) []int{
     var z []int
     zlen := len(x)+1
     if zlen <= cap(x){
         //slice仍有增长空间,扩展slice内容
         z = x[:zlen]
     }else{
         //slice已无空间,为它分配一个新的的底层数组
         //为了达到分摊的线性复杂,容量扩展一倍
         zcap := zlen
         if zcap < 2*len(x){
             zcap = 2 * len(x)
         }
         z = make([]int,zlen,cap)
         copy(z,x)//内置的copy函数
     }
     z[len(x)] = y
     return z
 }

每一次调用appendInt都必须检查slice是否仍有足够的容量来存储数组中的新元素。如果slice容量足够,那么它就会定义一个新的slice(仍然引用原始底层数组),然后将新元素与复制到新的位置,并返回这个新的slice。输入参数slice x和函数返回值slice z 拥有相同的底层数组。

如果slice容量不够容纳增长的元素,appendInt函数必须创建一个拥有足够容量的新的底层数组来存储新元素,然后将元素从slice x复制到这个数组,再将新元素y追加到数组后面,返回值slice z 将和输入参数slice x引用不同的底层数组。

copy函数用来为两个拥有相同类型的元素slice复制元素,copy函数的第一个参数时目标slice,第二个参数是源slice。

出于效率的考虑,新创建的数组容量会比实际容纳slice x 和slice y所需要的最小长度更大一点。在每次数组容量扩展时,通过扩展一倍的容量来减少内存分配的次数,这样也可以保证追加一个元素所消耗的时间是固定的。

 func main(){
     var x,y []int
     for i := 0; i < 10; i++{
         y = appendInt(x,i)
         fmt.Printf("%d cap=%d\t%v\n",i,cap(y),y)
         x = y
     }
 }

slice就地修改

slice就地修改在实际开发中能够很方便的帮助我们简化代码,减少出错的机会。

 package main
 import "fmt"
 //nonmpty返回一个新的slice,slice中的元素都是非空字符串
 //在函数调用过程中,底层数组的元素发生了改变
 func nonempty(strings []string) []string{
     i := 0
     for _,s := range strings{
         if s != ""{
             string[i] = s
             i++
         }
     }
     return string[:i]
 }

仔细阅读上述代码,我们就会发现输入的[]string 和返回的[]string是拥有相同的底层数组。

\