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索引界限值外还指定容量的完整的形式。

  • 简单切片表达式

切片的底层就是一个数组,所以我们可以基于数组通过切片表达式得到切片。 切片表达式中的lowhigh表示一个索引范围(左包含,右不包含),也就是下面代码中从数组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:k],长度为j-i,容量为k-i。
需要注意的是,现在的两个切片共享同一个底层数组,如果一个切片修改了底层数组的共享部分,另一个也会被影响。

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:]...)

切片的遍历

切片是一个集合,那么自然可以迭代其中的元素,那么自然可以迭代其中的元素,切片可以使用forrange两种方式来迭代切片里的元素。

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)

需要注意的是,切片扩容还会根据切片中元素类型的不同而做不同的处理,比如intstring类型的处理方式就不同。

注意:
不同版本slice.go代码可能会有不同。

限制切片容量

在创建切片时,还可以使用之前没有提到的第三个索引选项。第三个索引可以用来控制新切片的容量,其目的并不是要增加容量,而是要限制容量。可以看到,允许限制新切片的容量为底层数组提供了一定的保护,可以更好的控制追加操作。

slice := []int{1, 2, 3, 4, 5}
// 将第三个元素做切片,并限制容量,其长度为1,容量为2
newSlice := slice[2:3:4]

这个切片操作执行后,新切片从底层数组引用了1个元素,容量是2个元素。具体来说,新切片引用了3元素,并将容量扩展到4元素。可以用之前的计算方法来计算新切片的长度和容量:newSlice[i:j:k]([2:3: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 协议》,转载必须注明作者和本文链接
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!