go——切片(二)

时间:2024-01-19 20:31:32

切片是一种数据结构,这种数据结构便于使用和管理数据集合。

切片是围绕动态数组的概念构建的,可以按需自动增长和缩小。

切片的动态增长是通过内置函数append来实现的。这个函数可以快速且高效地增长切片。

还可以通过对切片再次切片来缩小一个切片地大小。

因为切片的底层内存也是在连续中分配的,所以切片还能获得索引、迭代以及垃圾回收优化的好处。

1.内部实现

切片是一个很小的对象,对底层数组进行了抽象,并提供相关的操作方法。

切片有3个字段的数据结构,这些数据结构包含Go语言需要操作底层数组的元数据

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

go——切片(二)

2.创建和初始化

Go语言中有几种方法可以创建和初始化切片。是否能提前知道切片需要的容量通常会决定要如何创建切片。

(1)make和切片字面量

一种创建切片的方法是使用内置的make函数。当使用make时,需要传入一个参数,指定切片的长度。

//使用长度声明一个字符串切片

//创建一个字符串切片
//其长度和容量都是5个元素
slice := make([]string, 5)

如果只指定长度,那么切片的容量和长度相等。也可以分别指定长度和容量。

//使用长度和容量声明整型切片

//创建一个整型切片
//长度为3,容量为5
slice := make([]int,3,5)  

分别指定长度和容量时,创建的切片,底层数组的长度是指定的容量,但初始化后并不能访问所有的数组元素。

上述代码中的切片可以访问3个元素,而底层数组拥有5个元素。

剩余的2个元素可以在后期操作中合并到切片,可以通过切片访问这些元素。

如果基于这个切片创建新的切片,新切片会和原有的切片共享底层数组,也能通过后期操作来访问多余容量的元素。

不允许创建容量小于长度的切片。

slice := make([]int, 4, 3)  //len larger than cap in make([]int)

另一种常用的创建切片的方法是使用切片字面量。

这种方法和创建数组类似,只是不需要指定[]运算符里的值。

初始的长度和容量会基于初始化时提供的元素个数确定。

//通过切片字面量来声明切片

//创建字符串切片
//其长度和容量都是5个元素
slice := []string{"red","pink",''yellow","blue"} //创建一个整型切片
//其长度和容量都是3个元素
slice := []int{10,20,30}

当使用切片字面量时,可以设置初始长度和容量。要做的就是在初始化时给出所需长度和容量作为索引。

//使用索引声明切片

//创建字符串切片
//使用空字符串初始化第100个元素
slice := []string{99:""}

记住,如果在[]运算符里指定了一个值,那么创建的就是数组而不是切片。

只有不指定值得时候才会创建切片。

//声明数组和声明切片得不同

//创建有3个元素得整型数组
array := [3]int{10,20,30} //创建长度和容量都是3的整型切片
slice := []int{10,20,30}

  

(2)nil和空切片

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

//创建nil整型切片
var slice []int

在Go语言里,nil切片是很常见的创建切片的方法。

nil切片可以用于很多标准库和内置函数。在需要描述一个不存在的切片时,nil切片会很好用。

例如,函数要求返回一个切片但是发生异常的时候。

go——切片(二)

利用初始化,通过声明一个切片可以创建一个空切片。

//声明空切片

//使用make创建空的整型切片
slice := make([]int, 0) //使用切片字面量创建空的整型切片
slice := []int{}

空切片在底层数组0个元素,也没有分配任何存储空间。

想表示空集合空切片很有用,例如数据库查询返回0个查询结果时。

go——切片(二)

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

3.使用切片

(1)赋值和切片

对切片里某个索引指向的元素赋值和对数组里某个索引指向的元素赋值的方法完全一样。

//创建一个整型切片
//其容量和长度都是5个元素
slice := []int{10, 20, 30, 40, 50} //改变索引为1的元素的值
slice[1] = 25

切片之所以被称之为切片,是因为创建一个新的切片就是把底层数组切除一部分

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

我们有两个切片,它们共享一段底层数组,但通过不同的切片会看到底层数组不同的部分。

go——切片(二)

第一个切片slice能够看到底层数组全部5个元素的容量,不过之后的newSlice就不能看到。

对于newSlice,底层数组的容量只有4个元素。newSlice无法访问到它所指向的底层数组的第一个元素之前。

所以对于newSlice来说,之前的那些元素就是不存在的。

如何计算长度和容量

对底层数组容量是k的切片slice[i:j]
长度:j - i
容量:k - i 对底层数组容量是5的切片slice[1:3]来说
长度: 3 - 1 = 2
容量: 5 - 1 = 4

需要记住的是,现在两个切片共享同一个底层数组。

如果一个切片修改了该底层数组的共享部分,另一个切片也能感知到。

//创建一个整型切片
//其长度和容量都是5个元素
slice := []int{10, 20, 30, 40, 50} //创建一个新切片
//其长度是2个元素,容量是4个元素
newSlice := slice[1:3] //修改newSlice索引为1的元素
//同时也修改了原来的slice的索引为2的元素
newSlice[1] = 35

切片只能访问到其长度内的元素。试图访问长度超过其长度的元素将会导致语言运行异常。

//创建一个整型切片
//其长度和容量都是5个元素
slice := []int{10, 20, 30, 40, 50} //创建一个新切片
//其长度是2个元素,容量是4个元素
newSlice := slice[1:3] //修改newSlice索引为3的元素
//这个元素对于newSlice来说不存在
newSlice[3] = 45 //index out of range  

切片有额外的容量是很好的,但是如果不能把这些容量合并到切片的长度里,这些容量就没有用处。

好在可以用Go语言内置函数append来做这种合并很容易。

(2)切片增长

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

Go语言内置的append函数会处理增加长度时的所有操作细节。

要使用append,需要一个被操作的切片和一个追加的值。

//创建一个整型切片
//其长度和容量都是5个元素
slice := []int{10, 20, 30, 40, 50} //创建一个新切片
//其长度是2个元素,容量是4个元素
newSlice := slice[1:3] //使用原有的容量来分配一个新的元素
//将新元素赋值为60
newSlice := append(newSlice, 60)

当append调用返回时,会返回一个包含修改结果的新切片。

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

go——切片(二)

因为newSlice在底层数组里还有额外的容量可用,append操作将可用的元素合并到切片的长度,并对其进行赋值。

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

如果切片的底层数组没有足够的可用容量,append函数会创建一个新的底层数组,将被引用的现有的值复制到新数组里,再追加新的值。

//创建一个整型切片
//其长度和容量都是4个元素
slice := []int{10, 20, 30, 40} //追加一个元素
//将新元素赋值为50
newSlice := append(slice, 50)

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

go——切片(二)

函数append会智能的处理底层数组的容量增长,在切片的容量小于1000个元素时,总是会成倍的增加容量。

一旦元素个数超过1000,容量的增长因子会设为1.25,也就是会每次增加25%的容量。

(3)创建切片时的3个索引

在创建切片时,还可以使用之前我们没有提及的第三个索引选项。

第三个索引可以用来控制新切片的容量。其目的并不是要增加容量,而是要限制容量。

可以看到,允许限制新切片的容量为底层数据提供了一定的保护,可以更好的控制追加操作。

//创建字符串切片
//其长度和容量都是5个长度
source := []string{"apple", "orange", "plum", "banana", "grape"}

go——切片(二)

使用第三索引来完成切片操作。

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

这个切片操作执行后,新切片里从底层数组引用了1个元素,容量是2个元素。

具体来说,新切片引用了plum元素,并将容量扩展到banana元素。

go——切片(二)

我们应用之前定义的公式来计算新切片的长度和容量。

对于slice[i:j:k] 或 slice[2:3:4]
长度: j - i 或 3 - 2 = 1
容量: k - i 或 4 - 2 = 2

如果试图设置的容量比可用容量还大,就会得到一个语言运行时错误。

//这个切片操作试图设置容量为4
//这比可用容量大
slice := source[2:3:6] //slice bounds out of range

内置函数append会首先使用可用容量,一旦没有可用容量,会分配一个新的底层数组。

这导致很容易忘记切片间正在共享同一个底层数组。

一旦发生这种情况,对切片进行修改,很可能会导致随机且奇怪的问题。

对切片内容的修改会影响多个切片,却很难找到问题的原因。

如果在切片时设置切片的容量和长度一样,就可以强制让新切片的第一个append操作创建新的底层数组,与原有底层数组分离。

新切片与原有的底层数组分离后,可以安全的进行后续修改。

//创建字符串切片
//其长度和容量都是5个长度
source := []string{"apple", "orange", "plum", "banana", "grape"} //对第三个元素做切片,并限制容量
//其长度和容量都是一个长度
slice := source[2:3:3] //向slice追加新字符串
slice = append(slice, "Kiwi")

如果不加第三个索引,由于剩余的所有容量都属于slice,向slice追加kiwi会改变原有底层数组索引为3的元素的值Banana。

当我们限制slice容量为1的时候,再进行append操作的时候会创建一个新的底层数组,

这个数组包括两个元素,并将水果pium复制进来,再追加新水果Kiwi,并返回一个引用了这个底层数组的新切片。

go——切片(二)

因为新的切片slice拥有了自己的底层数组,所以杜绝了可能发生的问题。

内置函数append也是一个可变参数的函数。这意味着可以在一次调用传递多个追加多个值。

如果使用...运算符,可以将一个切片的所有元素追加到另一个切片里。

//创建两个切片,并分别用两个整数进行初始化
s1 := []int{1, 2}
s2 := []int{3, 4} //将两个切片追加到一起
fmt.Println(append(s1, s2...)) //[1 2 3 4]

切片s2里的所有值都追加到了切片s1的后面。

(4)迭代切片

既然切片是一个集合,可以迭代其中的元素。Go语言有个特殊的关键字range,它可以配合关键字for来迭代切片里的元素。

package main

import "fmt"

func main() {
//创建一个整型切片
//其长度和容量都是4个元素
slice := []int{10, 20, 30, 40} //迭代每一个元素
for index, value := range slice {
fmt.Printf("Index: %d Value: %d\n", index, value)
}
} /*
Index: 0 Value: 10
Index: 1 Value: 20
Index: 2 Value: 30
Index: 3 Value: 40
*/

当迭代切片时,关键字range会返回两个值。

go——切片(二)

第一个值是当前迭代到的索引位置,第二个值是该位置对应元素值得一个副本。

需要强调得是,range创建了每个元素的副本,而不是直接返回该元素的引用。

如果使用该值变量的地址作为每个元素的指针,就会造成错误。这是为什么了?

package main

import "fmt"

func main() {
//创建一个整型切片
//其长度和容量都是4个元素
slice := []int{10, 20, 30, 40} //迭代每一个元素,并显式值和地址
for index, value := range slice {
fmt.Printf("Value: %d Value-Addr: %X ElemAddr: %X\n", value, &value, &slice[index])
}
} /*
Value: 10 Value-Addr: C00000A168 ElemAddr: C00000E480
Value: 20 Value-Addr: C00000A168 ElemAddr: C00000E488
Value: 30 Value-Addr: C00000A168 ElemAddr: C00000E490
Value: 40 Value-Addr: C00000A168 ElemAddr: C00000E498
*/

因为迭代返回的变量是一个迭代过程中根据切片依次赋值的新变量,所以Value的地址总是相同的。

要想获取每个元素的地址,可以使用切片变量和索引值。

如果不需要索引值,可以使用空白占位符来忽略这个值。

package main

import "fmt"

func main() {
//创建一个整型切片
//其长度和容量都是4个元素
slice := []int{10, 20, 30, 40} //迭代每一个元素
for _, value := range slice {
fmt.Printf("Value: %d\n", value)
}
} /*
Value: 10
Value: 20
Value: 30
Value: 40
*/

关键字range总是会从切片头部开始迭代。如果想对迭代进行控制,依旧可以使用传统的for循环。

package main

import "fmt"

func main() {
//创建一个整型切片
//其长度和容量都是4个元素
slice := []int{10, 20, 30, 40} //迭代每一个元素
for index := 2; index < len(slice); index++ {
fmt.Printf("Index: %d Value: %d\n", index, slice[index])
}
} /*
Index: 2 Value: 30
Index: 3 Value: 40
*/

有两个特殊的内置函数len和cap,可以用于处理数组、切片和通道。

对于切片,函数len返回切片的长度,函数cap返回切片的容量。

4.多维切片

和数组一样,切片也是一维的。不过可以组合多个切片形成多维切片。

//创建一个整型切片的切片
slice := [][]int{{10}, {100, 200}}

我们有了一个包含两个元素的外层切片,每个元素包含一个内层的整型切片。

go——切片(二)

外层切片包含两个元素,每一个元素都是一个切片。

这种组合可以让用户创建非常负责且强大的数据结构。同时append的规则也可以用到组合后的切片上。

//创建一个整型切片的切片
slice := [][]int{{10}, {100, 200}} //为第一个切片追加值为20的元素
slice[0] = append(slice[0], 20)

Go语言里使用append函数处理追加的方式很简明:先增长切片,再将新的整型切片赋值给外层切片的第一个元素。

go——切片(二)

即使是这么简单的多维切片,操作时也会涉及众多布局和值。

不过切片本身结构很简单,可以以很小的成本再函数间传递。

5.在函数间传递切片

在函数间传递切片就是要在函数间以值得方式传递切片。

由于切片的尺寸很小,在函数间复制和传递切片成本也很低。

//分配包含100万个整型值得切片
slice := make([]int, le6) //将slice传递给函数foo
slice = foo(slice) //函数foo接受一个整型切片,并返回这个切片
func foo(slice []int) []int{
...
return slice
}

  

在64位架构的机器上,一个切片需要24字节的内存:指针字段需要8个字节,长度和容量分别需要8个字节。

由于与切片关联的数据包含在底层数组里,不属于切片本身,所以说将切片复制到任意函数的时候,对底层数组大小都不会影响。

复制时只会复制切片本身,不会涉及底层数组。

go——切片(二)

在函数间传递24个字节的数据会非常快速、简单。这也是切片效率高的地方。

不需要传递指针和处理复杂的语法,只需要复制切片。