《快学 Go 语言》第 5 课 —— 灵活的切片

切片无疑是 Go 语言中最重要的数据结构,也是最有趣的数据结构,它的英文词汇叫 slice。全部的 Go 语言开发者都津津乐道地谈论切片的内部机制,它也是 Go 语言技能面试中面试官最爱问的知识点之一。初级用户很容易滥用它,这小小的切片想要完全的理解它是须要花费一番功夫的。在使用切片以前,我以为颇有必要将切片的内部结构作一下说明。面试

学过 Java 语言的人会比较容易理解切片,由于它的内部结构很是相似于 ArrayList,ArrayList 的内部实现也是一个数组。当数组容量不够须要扩容时,就会换新的数组,还须要将老数组的内容拷贝到新数组。ArrayList 内部有两个很是重要的属性 capacity 和 length。capacity 表示内部数组的总长度,length 表示当前已经使用的数组的长度。length 永远不能超过 capacity。数组

上图中一个切片变量包含三个域,分别是底层数组的指针、切片的长度 length 和切片的容量 capacity。切片支持 append 操做能够将新的内容追加到底层数组,也就是填充上面的灰色格子。若是格子满了,切片就须要扩容,底层的数组就会更换。bash

形象一点说,切片变量是底层数组的视图,底层数组是卧室,切片变量是卧室的窗户。经过窗户咱们能够看见底层数组的一部分或所有。一个卧室能够有多个窗户,不一样的窗户能看到卧室的不一样部分。数据结构

切片的建立

切片的建立有多种方式,咱们先看切片最通用的建立方法,那就是内置的 make 函数app

package main

import "fmt"

func main() {
 var s1 []int = make([]int, 5, 8)
 var s2 []int = make([]int, 8) // 满容切片
 fmt.Println(s1)
 fmt.Println(s2)
}

-------------
[0 0 0 0 0]
[0 0 0 0 0 0 0 0]
复制代码

make 函数建立切片,须要提供三个参数,分别是切片的类型、切片的长度和容量。其中第三个参数是可选的,若是不提供第三个参数,那么长度和容量相等,也就是说切片的满容的。切片和普通变量同样,也可使用类型自动推导,省去类型定义以及 var 关键字。好比上面的代码和下面的代码是等价的。函数

package main

import "fmt"

func main() {
 var s1 = make([]int, 5, 8)
 s2 := make([]int, 8)
 fmt.Println(s1)
 fmt.Println(s2)
}

-------------
[0 0 0 0 0]
[0 0 0 0 0 0 0 0]
复制代码

切片的初始化

使用 make 函数建立的切片内容是「零值切片」,也就是内部数组的元素都是零值。Go 语言还提供了另外一个种建立切片的语法,容许咱们给它赋初值。使用这种方式建立的切片是满容的。ui

package main

import "fmt"

func main() {
 var s []int = []int{1,2,3,4,5}  // 满容的
 fmt.Println(s, len(s), cap(s))
}

---------
[1 2 3 4 5] 5 5
复制代码

Go 语言提供了内置函数 len() 和 cap() 能够直接得到切片的长度和容量属性。lua

空切片

在建立切片时,还有两个很是特殊的状况须要考虑,那就是容量和长度都是零的切片,叫着「空切片」,这个不一样于前面说的「零值切片」。spa

package main

import "fmt"

func main() {
 var s1 []int
 var s2 []int = []int{}
 var s3 []int = make([]int, 0)
 fmt.Println(s1, s2, s3)
 fmt.Println(len(s1), len(s2), len(s3))
 fmt.Println(cap(s1), cap(s2), cap(s3))
}

-----------
[] [] []
0 0 0
0 0 0
复制代码

上面三种形式建立的切片都是「空切片」,不过在内部结构上这三种形式是有差别的,甚至第一种都不叫「空切片」,而是叫着「 nil 切片」。可是在形式上它们一摸同样,用起来没有区别。因此初级用户能够没必要区分「空切片」和「 nil 切片」,到后续章节咱们会仔细分析这两种形式的区别。指针

切片的赋值

切片的赋值是一次浅拷贝操做,拷贝的是切片变量的三个域,你能够将切片变量当作长度为 3 的 int 型数组,数组的赋值就是浅拷贝。拷贝先后两个变量共享底层数组,对一个切片的修改会影响另外一个切片的内容,这点须要特别注意。

package main

import "fmt"

func main() {
 var s1 = make([]int, 5, 8)
 // 切片的访问和数组差很少
 for i := 0; i < len(s1); i++ {
  s1[i] = i + 1
 }
 var s2 = s1
 fmt.Println(s1, len(s1), cap(s1))
 fmt.Println(s2, len(s2), cap(s2))
 
 // 尝试修改切片内容
 s2[0] = 255
 fmt.Println(s1)
 fmt.Println(s2)
}

--------
[1 2 3 4 5] 5 8
[1 2 3 4 5] 5 8
[255 2 3 4 5]
[255 2 3 4 5]
复制代码

从上面的输出中能够看到赋值的两切片共享了底层数组。

切片的遍历

切片在遍历的语法上和数组是同样的,除了支持下标遍历外,那就是使用 range 关键字

package main


import "fmt"


func main() {
	var s = []int{1,2,3,4,5}
	for index := range s {
		fmt.Println(index, s[index])
	}
	for index, value := range s {
		fmt.Println(index, value)
	}
}

--------
0 1
1 2
2 3
3 4
4 5
0 1
1 2
2 3
3 4
4 5
复制代码

切片的追加

文章开头提到切片是动态的数组,其长度是能够变化的。什么操做能够改变切片的长度呢,这个操做就是追加操做。切片每一次追加后都会造成新的切片变量,若是底层数组没有扩容,那么追加先后的两个切片变量共享底层数组,若是底层数组扩容了,那么追加先后的底层数组是分离的不共享的。若是底层数组是共享的,一个切片的内容变化就会影响到另外一个切片,这点须要特别注意。

package main

import "fmt"

func main() {
 var s1 = []int{1,2,3,4,5}
 fmt.Println(s1, len(s1), cap(s1))

 // 对满容的切片进行追加会分离底层数组
 var s2 = append(s1, 6)
 fmt.Println(s1, len(s1), cap(s1))
 fmt.Println(s2, len(s2), cap(s2))

 // 对非满容的切片进行追加会共享底层数组
 var s3 = append(s2, 7)
 fmt.Println(s2, len(s2), cap(s2))
 fmt.Println(s3, len(s3), cap(s3))
}

--------------------------
[1 2 3 4 5] 5 5
[1 2 3 4 5] 5 5
[1 2 3 4 5 6] 6 10
[1 2 3 4 5 6] 6 10
[1 2 3 4 5 6 7] 7 10
复制代码

正是由于切片追加后是新的切片变量,Go 编译器禁止追加了切片后不使用这个新的切片变量,以免用户觉得追加操做的返回值和原切片变量是同一个变量。

package main

import "fmt"

func main() {
 var s1 = []int{1,2,3,4,5}
 append(s1, 6)
 fmt.Println(s1)
}

--------------
./main.go:7:8: append(s1, 6) evaluated but not used
复制代码

若是你真的不须要使用这个新的变量,能够将 append 的结果赋值给下划线变量。下划线变量是 Go 语言特殊的内置变量,它就像一个黑洞,能够将任意变量赋值给它,可是却不能读取这个特殊变量。

package main

import "fmt"

func main() {
 var s1 = []int{1,2,3,4,5}
 _ = append(s1, 6)
 fmt.Println(s1)
}

----------
[1 2 3 4 5]
复制代码

还须要注意的是追加虽然会致使底层数组发生扩容,更换的新的数组,可是旧数组并不会当即被销毁被回收,由于老切片还指向这旧数组。

切片的域是只读的

咱们刚才说切片的长度是能够变化的,为何又说切片是只读的呢?这不是矛盾么。这是为了提醒读者注意切片追加后造成了一个新的切片变量,而老的切片变量的三个域其实并不会改变,改变的只是底层的数组。这里说的是切片的「域」是只读的,而不是说切片是只读的。切片的「域」就是组成切片变量的三个部分,分别是底层数组的指针、切片的长度和切片的容量。这里读者须要仔细咀嚼。

切割切割

到目前位置尚未说明切片名字的由来,既然叫着切片,那总得能够切割吧。切割切割,有些人听到这个词汇时身上会起鸡皮疙瘩。切片的切割能够类比字符串的子串,它并非要把切片割断,而是从母切片中拷贝出一个子切片来,子切片和母切片共享底层数组。下面咱们来看一下切片到底是如何切割的。

package main

import "fmt"

func main() {
 var s1 = []int{1,2,3,4,5,6,7}
 // start_index 和 end_index,不包含 end_index
 // [start_index, end_index)
 var s2 = s1[2:5] 
 fmt.Println(s1, len(s1), cap(s1))
 fmt.Println(s2, len(s2), cap(s2))
}

------------
[1 2 3 4 5 6 7] 7 7
[3 4 5] 3 5
复制代码

上面的输出须要特别注意的是,既然切割先后共享底层数组,那为何容量不同呢?解释它我必需要画图了,读者请务必仔细观察下面这张图

咱们注意到子切片的内部数据指针指向了数组的中间位置,而再也不是数组的开头了。子切片容量的大小是从中间的位置开始直到切片末尾的长度,母子切片依旧共享底层数组。

子切片语法上要提供起始和结束位置,这两个位置均可选的,不提供起始位置,默认就是从母切片的初始位置开始(不是底层数组的初始位置),不提供结束位置,默认就结束到母切片尾部(是长度线,不是容量线)。下面咱们看个例子

package main

import "fmt"

func main() {
 var s1 = []int{1, 2, 3, 4, 5, 6, 7}
 var s2 = s1[:5]
 var s3 = s1[3:]
 var s4 = s1[:]
 fmt.Println(s1, len(s1), cap(s1))
 fmt.Println(s2, len(s2), cap(s2))
 fmt.Println(s3, len(s3), cap(s3))
 fmt.Println(s4, len(s4), cap(s4))
}

-----------
[1 2 3 4 5 6 7] 7 7
[1 2 3 4 5] 5 7
[4 5 6 7] 4 4
[1 2 3 4 5 6 7] 7 7
复制代码

细心的同窗可能会注意到上面的 s1[:] 很特别,它和普通的切片赋值有区别么?答案是没区别,这很是让人感到意外,一样的共享底层数组,一样是浅拷贝。下面咱们来验证一下

package main

import "fmt"

func main() {
 var s = make([]int, 5, 8)
 for i:=0;i<len(s);i++ {
  s[i] = i+1
 }
 fmt.Println(s, len(s), cap(s))

 var s2 = s
 var s3 = s[:]
 fmt.Println(s2, len(s2), cap(s2))
 fmt.Println(s3, len(s3), cap(s3))

 // 修改母切片
 s[0] = 255
 fmt.Println(s, len(s), cap(s))
 fmt.Println(s2, len(s2), cap(s2))
 fmt.Println(s3, len(s3), cap(s3))
}

-------------
[1 2 3 4 5] 5 8
[1 2 3 4 5] 5 8
[1 2 3 4 5] 5 8
[255 2 3 4 5] 5 8
[255 2 3 4 5] 5 8
[255 2 3 4 5] 5 8
复制代码

使用过 Python 的同窗可能会问,切片支持负数的位置么,答案是不支持,下标不能够是负数。

数组变切片

对数组进行切割能够转换成切片,切片将原数组做为内部底层数组。也就是说修改了原数组会影响到新切片,对切片的修改也会影响到原数组。

package main

import "fmt"

func main() {
	var a = [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
	var b = a[2:6]
	fmt.Println(b)
	a[4] = 100
	fmt.Println(b)
}

-------
[3 4 5 6]
[3 4 100 6]
复制代码

copy 函数

Go 语言还内置了一个 copy 函数,用来进行切片的深拷贝。不过其实也没那么深,只是深到底层的数组而已。若是数组里面装的是指针,好比 []*int 类型,那么指针指向的内容仍是共享的。

func copy(dst, src []T) int
复制代码

copy 函数不会由于原切片和目标切片的长度问题而额外分配底层数组的内存,它只负责拷贝数组的内容,从原切片拷贝到目标切片,拷贝的量是原切片和目标切片长度的较小值 —— min(len(src), len(dst)),函数返回的是拷贝的实际长度。咱们来看一个例子

package main

import "fmt"

func main() {
 var s = make([]int, 5, 8)
 for i:=0;i<len(s);i++ {
  s[i] = i+1
 }
 fmt.Println(s)
 var d = make([]int, 2, 6)
 var n = copy(d, s)
 fmt.Println(n, d)
}
-----------
[1 2 3 4 5]
2 [1 2]

复制代码

切片的扩容点

当比较短的切片扩容时,系统会多分配 100% 的空间,也就是说分配的数组容量是切片长度的2倍。但切片长度超过1024时,扩容策略调整为多分配 25% 的空间,这是为了不空间的过多浪费。试试解释下面的运行结果。

s1 := make([]int, 6)
s2 := make([]int, 1024)
s1 = append(s1, 1)
s2 = append(s2, 2)
fmt.Println(len(s1), cap(s1))
fmt.Println(len(s2), cap(s2))
-------------------------------------------
7 12
1025 1344
复制代码

上面的结果是在 goplayground 里面运行的,若是在本地的环境运行,结果却不同

$ go run main.go
7 12
1025 1280
复制代码

扩容是一个比较复杂的操做,内部的细节必须经过分析源码才能知晓,不去理解扩容的细节并不会影响到平时的使用,因此关于切片的源码咱们后续在高级内容里面再仔细分析。

扫一扫二维码阅读《快学 Go 语言》更多章节

相关文章
相关标签/搜索