一种理解 Slice 的模型
概述
Golang 中 slice 极似其他语言中数组,但又有诸多不同,因此容易使初学者产生一些误解,并在使用时不易察觉地掉进各种坑中。本篇小文,首先从 Go 语言官方博客出发,铺陈官方给出的 slice 的相关语法;其次以图示的方式给出一种理解 slice 的模型;最后再总结分析一些特殊的使用情况,以期在多个角度对 slice 都有个更清晰侧写。
如不愿看繁琐叙述过程,可直接跳到最后小结看总结。
作者:木鸟杂记 https://www.qtmuniao.com/2021/01/09/go-slice/, 转载请注明出处
基本语法
本部分主要出自 Go 的官方博客。在 Go 语言中,切片(slice)和数组(array)是伴生的,切片基于数组,但更为灵活,因此在 Go 中,作为切片底层的数组反而很少用到。但,要理解切片,须从数组说起。
数组(array)
Go 中的数组由类型 + 长度构成,与 C 和 C++ 不同的是,Go 中不同长度的数组是为不同的类型,并且变量名并非指向数组首地址的指针。
1
|
// 数组的几种初始化方式
|
总结一下,Go 的数组,有以下特点:
- 长度属于类型的一部分,因此
[4]int
和[5]int
类型的变量不能互相赋值,也不能互相强转。 - 数组变量并非指针,因此作为参数传递时会引起全量拷贝。当然,可以使用对应指针类型作为参数类型避免此拷贝。
可以看出,由于存在长度这个枷锁,Go 数组的作用大大受限。Go 不能够像 C/C++ 一样,任意长度数组都可以转换为指向相应类型的指针,进而进行下标运算。当然,Go 也不需如此,因为它有更高级的抽象 —— 切片。
切片(slices)
在 Go 代码中,切片使用十分普遍,但切片底层基于数组:
1
|
type slice struct {
|
相较数组,切片有以下好处:
- 操作灵活,顾名思义,支持强大的切片操作。
- 脱去了长度的限制,传参时,不同长度的切片都可以以
[]T
形式传递。 - 切片赋值、传参时不会复制整个底层数组,只会复制上述 slice 结构体本身。
- 借助一些内置函数,如 append/copy ,可以方便的进行扩展和整体移动。
切片操作。使用切片操作可以对切片进行快速的截取、扩展、赋值和移动。
1
|
// 截取操作,左闭右开;若始于起点,或止于终点,则可省略对应下标
|
参数传递。不同长度、容量的切片都可以通过 []T
形式传递。
1
|
b := []int{1,2,3,4}
|
相关函数。切片相关的内置函数主要有:
- 用于创建的 make
- 用于扩展的 append
- 用于移动的 copy
下面分别说说其特点。
make 函数在创建切片时(它还可以用来创建很多其他内置结构体)的签名为 func make([]T, len, cap) []T
。该函数会首先创建一个 cap 长度的数组,然后新建一个 slice 结构体,指向该数组,并根据参数初始化 len 和 cap。
append 在修改切片底层数组后,但不会改变原切片,而是返回一个具有新长度新的切片结构体。为什么不在原地修改原切片呢?因为 Go 中函数是传值的,当然这也体现了 Go 中某种函数式思想的偏好。因此,append(s, 'a', b'')
并不会修改切片 s 本身,需要对 s 重新赋值:s = append(s, 'a', b'')
才能达到对变量 s 的修改目的。
需注意,append 时,如果底层数组容量(cap) 不够,会按类似于 C++ 中的 vector
底层机制,新建一个足够容纳所有元素的数组,并将原数组值复制过去后,再进行追加。原切片底层数组如果没有其他切片变量引用后,会由在 GC 时进行回收。
copy 函数更像个语法糖,将对切片的批量赋值封装为一个函数,注意拷贝长度会取两个切片中较小者。并且,不用担心同一个切片的子切片移动时出现覆盖现象,举个例子:
1
|
package main
|
copy
一个常见的使用场景是,需要往切片中间插入一个元素时,用 copy
将插入点之后的片段整体后移。
切片模型
初用切片时,常常感觉其规则庞杂,难以尽记;于是我常想有没有什么合适的模型来刻画切片的本质。
某天突然冒出个不成熟的想法:切片是隐藏了底层数组的一种线性读写视图。切片这种视图规避了 C/C++ 语言中常见的指针运算操作,因为用户可以通过切片派生来免于算偏移量。
切片仅用 ptr/cap/len
三个变量来刻画一个窗口视图,其中 ptr
和 ptr+cap
是窗口的起止界限,len
是当前窗口可见长度。可以通过下标来切出一个新的视图,Go 会自动计算新的 ptr/len/cap ,所有通过切片表达式派生的视图都指向同一个底层数组。
切片派生会自动共享底层数组,以避免数组拷贝,提升效率;追加元素时,如果底层数组容量不够,append
会自动创建新数组并返回指向新数组的切片视图,而原来切片视图仍然指向原数组。
切片使用
本小节将汇总一些 slice 使用时的一些有意思的点。
零值(zero-value)和空值(empty-value)。go 中所有类型都是有零值的,并以其作为初始化时的默认值。slice 的零值是 nil。
1
|
func add(a []int) []int { // nil 可以作为参数传给 []int 切片类型
|
可以通过 make 创建一个空 slice,其 len/cap 与零值一致,但是也会有如下小小区别,如两者皆可,推荐用 nil。
1
|
func main() {
|
append 语义。append 会首先将元素追加到底层数组,然后构造一个新的 slice 返回。也就是说,即使我们不使用返回值,相应的值也会被追加到底层数组。
1
|
func main() {
|
从 array 生成 slice。可以通过切片语法,通过数组 a 生成所需长度切片 s ,此时:s 底层数组即为 a。换言之,对数组使用切片语法也不会造成数组的拷贝。
1
|
func main() {
|
切片时修改视图右界。在上述提出的视图模型中,进行切片操作时,新生成的切片左界限会随着 start 参数而变化,但是右界一直未变,即为底层数组结尾。如果我们想修改其右界,可以通过三参数切片(Full slice Expression),增加一个 limited-capacity 参数。
该特性的一个使用场景是,如果我们想让新的 slice 在 append 时不影响原数组,就可以通过修改其右界,在 append 时发现 cap 不够强制生成一个新的底层数组。
func main() {
a := make([]int, 0, 5)
_ = append(a, 0, 1, 2)
fmt.Println(a) // []
b := a[:2]
c := a[:2:3]
fmt.Println(b)
fmt.Println(c)
//b = append(b, 3, 4)
c = append(c, 4, 5)
fmt.Println(a[:3]) // [0 1 2 0 0];通过切片表达式,扩大窗口长度,就可以看到追加的值
fmt.Println(b)
fmt.Println(c)
fmt.Println(a[:3:5])// panic;长度越界了
}
小结
本文核心目的在于提出一个易于记忆和理解 slice 模型,以拆解 slice 使用时千变万化的复杂度。总结一下,我们在理解 slice 时,可以从两个层面来入手:
- 底层数据(底层数组)
- 上层视图(切片)
视图有三个关键变量,数组指针(ptr)、有效长度(len)、视图容量(cap)。
通过切片表达式(slice expression)可以从数组生成切片、从切片生成切片,此操作不会发生数组数据的拷贝。通过 append 进行追加操作时,根据本视图的 cap 而定是否进行数组拷贝,并返回一个指向新数组的视图。
go 切片意识:
本质是结构体
底层基于数组
a := make([]int, 0, 5) 创建len=0的切片,底层数组容量为5
append(a, 1,2,3) 会在切片a的后面追加数据1,2,3,即时不被赋值,底层数组也会存放1,2,3到切片0:3位置
full slice Expression 最后一个参数 Limited Capacity 可以指定当前切片的最大容量,如果append数据可以存储,不会拷贝,否则就会拷贝重新分配空间
本文来自博客园,作者:易先讯,转载请注明原文链接:https://www.cnblogs.com/gongxianjin/p/17070407.html