Go 切片只需这一篇!

304次阅读  |  发布于3年以前

前言

切片在 golang 是一种很重要的数据结构,大家平时工作和面试都会遇到,而且切片需要注意的点比较多,只有深入去理解它,才能避免采坑。下面开始发车。

数组

数组是内置类型,是一组同类型数据的集合,它是值类型,通过从0开始的下标索引访问元素值。

在初始化后长度是固定的,无法修改其长度。当作为方法的参数传入时将复制一份数组而不是引用同一指针。

数组的长度也是其类型的一部分,通过内置函数len(array)获取其长度。

还有几点要注意的:

  1. Go中的数组是值类型,如果你将一个数组赋值给另外一个数组,那么,实际上就是将整个数组拷贝一份。
  2. 如果Go中的数组作为函数的参数,那么实际传递的参数是一份数组的拷贝,而不是数组的指针,修改数组的值需要传递数组的指针。
  3. array的长度也是Type的一部分,这样就说明[1]int和[2]int是不一样的。
//值传递,传的是副本
func updateArr(b [3]int) {
     b[0] = 3
}

//传指针,[3]int是一个类型
func updateArrPoint(b *[3]int) {
     b[0] = 3
}

func main() {
     //常见两种初始化方式
     //var b = [...]int{1, 2, 3}
     var b = [3]int{1, 2, 3}

     updateArr(b)
     fmt.Println(b)
     updateArrPoint(&b)
     fmt.Println(b)
     //计算数组长度和容量
     fmt.Println(len(b))
     fmt.Println(cap(b))
}

打印:
[1 2 3]
[3 2 3]
3
3

切片

Go中提供了一种灵活,功能强悍的内置类型Slices切片(“动态数组"),与数组相比切片的长度是不固定的,可以追加元素,在追加时可能使切片的容量增大。

切片中有两个概念:一是len长度,二是cap容量,长度是指已经被赋过值的最大下标+1,可通过内置函数len()获得。

容量是指切片目前可容纳的最多元素个数,可通过内置函数cap()获得。切片是引用类型,因此在当传递切片时将引用同一指针,修改值将会影响其他的对象。

s := []int {1,2,3 }            //直接初始化切片

s := arr[:]                    //用数组初始化切片

s = make([]int, 3)             //make初始化,有3个元素的切片, len和cap都为3

s = make([]int, 2, 3)          //make初始化,有2个元素的切片, len为2, cap为3

a = append(a, 1)               // 追加1个元素

a = append(a, 1, 2, 3)         // 追加多个元素, 手写解包方式

a = append(a, []int{1,2,3}...) // 追加一个切片, 切片需要解包

不过要注意的是,在容量不足的情况下,append的操作会导致重新分配内存,可能导致巨大的内存分配和复制数据代价。

a = append([]int{0}, a...) 切片头部添加元素。在开头一般都会导致内存的重新分配,而且会导致已有的元素全部复制1次。

因此,从切片的开头添加元素的性能一般要比从尾部追加元素的性能差很多。

//切片是地址传递
func updateSlice(a []int) {
     a[0] = 3
}

func main() {
     //切片
     var a = []int{1, 2, 3}
     c := make([]int, 5)
     copy(c, a)

     updateSlice(c)
     fmt.Println(c)
}
打印
[3 2 3 0 0]

切片的内部实现

切片是一个很小的对象,它对底层的数组(内部是通过数组保存数据的)进行了抽象,并提供相关的操作方法。

切片是一个有三个字段的数据结构,这些数据结构包含 Golang 需要操作底层数组的元数据:

这 3 个字段分别是指向底层数组的指针、切片访问的元素的个数(即长度)和切片允许增长到的元素个数(即容量)。

nil 和空切片

有时,程序可能需要声明一个值为 nil 的切片(也称nil切片)。只要在声明时不做任何初始化,就会创建一个 nil 切片。

var num []int

在 Golang 中,nil 切片是很常见的创建切片的方法。nil 切片可以用于很多标准库和内置函数。在需要描述一个不存在的切片时,nil 切片会很好用。比如,函数要求返回一个切片但是发生异常的时候。下图描述了 nil 切片的状态:

空切片和 nil 切片稍有不同,下面的代码分别通过 make() 函数和字面量的方式创建空切片:

num := make([]int, 0)      // 使用 make 创建空的整型切片

num := []int{}             // 使用切片字面量创建空的整型切片

空切片的底层数组中包含 0 个元素,也没有分配任何存储空间。想表示空集合时空切片很有用,比如,数据库查询返回 0 个查询结果时。

不管是使用 nil 切片还是空切片,对其调用内置函数 append()、len() 和 cap() 的效果都是一样的。

通过切片创建新的切片

切片之所以被称为切片,是因为创建一个新的切片,也就是把底层数组切出一部分。通过切片创建新切片的语法如下:

slice[i:j]
slice[i:j:k]

其中 i 表示从 slice 的第几个元素开始切,j 控制切片的长度(j-i),k 控制切片的容量(k-i),如果没有给定 k,则表示切到底层数组的最尾部。下面是几种常见的简写形式:

slice[i:]  // 从 i 切到最尾部
slice[:j]  // 从最开头切到 j(不包含 j)
slice[:]   // 从头切到尾,等价于复制整个 slice

让我们通过下面的例子来理解通过切片创建新的切片的本质:

// 创建一个整型切片
// 其长度和容量都是 5 个元素
num := []int{1, 2, 3, 4, 5}
// 创建一个新切片
// 其长度为 2 个元素,容量为 4 个元素
myNum := slice[1:3]

执行上面的代码后,我们有了两个切片,它们共享同一段底层数组,但通过不同的切片会看到底层数组的不同部分:

注意:截取新切片时的原则是 "左含右不含"。所以 myNum 是从 num 的 index=1 处开始截取,截取到 index=3 的前一个元素,也就是不包index=3 这个元素。

所以,新的 myNum 是由 num 中的第2个元素、第3个元素组成的新的切片构,长度为 2,容量为 4。切片 num 能够看到底层数组全部 5 个元素的容量,而 myNum 能看到的底层数组的容量只有 4 个元素。num 无法访问到底层数组的第一个元素。所以,对 myNum 来说,那个元素就是不存在的。

共享底层数组的切片

需要注意的是:现在两个切片 num 和 myNum 共享同一个底层数组。如果一个切片修改了该底层数组的共享部分,另一个切片也能感知到:

// 修改 myNum 索引为 1 的元素
// 同时也修改了原切片 num 的索引为 2 的元素
myNum[1] = 35

把 35 赋值给 myNum 索引为 1 的元素的同时也是在修改 num 索引为 2 的元素:

切片只能访问到其长度内的元素

切片只能访问到其长度内的元素,试图访问超出其长度的元素将会导致语言运行时异常。在使用这部分元素前,必须将其合并到切片的长度里。下面的代码试图为 num 中的元素赋值:

// 修改 newNum 索引为 3 的元素
// 这个元素对于 newNum 来说并不存在
newNum[3] = 45

上面的代码可以通过编译,但是会产生运行时错误:panic: runtime error: index out of range

切片扩容

相对于数组而言,使用切片的一个好处是:可以按需增加切片的容量。

Golang 内置的 append() 函数会处理增加长度时的所有操作细节。要使用 append() 函数,需要一个被操作的切片和一个要追加的值,当 append() 函数返回时,会返回一个包含修改结果的新切片。

函数 append() 总是会增加新切片的长度,而容量有可能会改变,也可能不会改变,这取决于被操作的切片的可用容量。

num := []int{1, 2, 3, 4, 5}
// 创建新的切片,其长度为 2 个元素,容量为 4 个元素

myNum := num[1:3]
// 使用原有的容量来分配一个新元素
// 将新元素赋值为 60
myNum = append(myNum, 60)

执行上面的代码后的底层数据结构如下图所示:

此时因为 myNum 在底层数组里还有额外的容量可用,append() 函数将可用的元素合并入切片的长度,并对其进行赋值。

由于和原始的切片共享同一个底层数组,myNum 中索引为 3 的元素的值也被改动了。

如果切片的底层数组没有足够的可用容量,append() 函数会创建一个新的底层数组,将被引用的现有的值复制到新数组里,再追加新的值,此时 append 操作同时增加切片的长度和容量:

// 创建一个长度和容量都是 4 的整型切片
num := []int{1, 2, 3, 4}

// 向切片追加一个新元素
// 将新元素赋值为 5
myNum := append(num, 5)

当这个 append 操作完成后,newSlice 拥有一个全新的底层数组,这个数组的容量是原来的两倍:

函数 append() 会智能地处理底层数组的容量增长。

在切片的容量小于 1000 个元素时,总是会成倍地增加容量。一旦元素个数超过 1000,容量的增长因子会设为 1.25,也就是会每次增加 25%的容量(随着语言的演化,这种增长算法可能会有所改变)。

总结

切片为我们操作集合类型的数据提供了便利的方式,又能够高效的在函数间进行传递,因此在代码中切片类型被使用的相当广泛。

golang 还有其他比较重要的数据结构,更多请关注下期文章。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8