• 0

  • 448

  • 收藏

Golang 笔记(三):一种理解 slice 的模型

智能的司机

我是老司机

2星期前

概述

Golang 中 slice 极似其他语言中数组,但又有诸多不同,因此容易使初学者产生一些误解,并在使用时不易察觉地掉进各种坑中。本篇小文,首先从 Go 语言官方博客出发,铺陈官方给出的 slice 的相关语法;其次以图示的方式给出一种理解 slice 的模型;最后再总结分析一些特殊的使用情况,以期在多个角度对 slice 都有个更清晰侧写。

如不愿看繁琐叙述过程,可直接跳到最后小结看总结。

作者:青藤木鸟 www.qtmuniao.com/2021/01/09/…, 转载请注明出处

基本语法

本部分主要出自 Go 的官方博客。在 Go 语言中,切片(slice)和数组(array)是伴生的,切片基于数组,但更为灵活,因此在 Go 中,作为切片底层的数组反而很少用到。但,要理解切片,须从数组说起。

数组(array)

Go 中的数组由类型+长度构成,与 C 和 C++ 不同的是,Go 中不同长度的数组是为不同的类型,并且变量名并非指向数组首地址的指针。

// 数组的几种初始化方式
var a [4]int             // 变量 a 类型为 [4]int 是一个 type,每个元素自动初始化为 int 的零值(zero-value)
b := [5]int{1,2,3,4}     // 变量 b 类型为 [5]int 是不同于 [4]int 的类型,且 b[4] 会自动初始化为 int 的零值
c := [...]int{1,2,3,4,5} // 变量 c 被自动推导为 [5]int 类型,与 b 类型同

func echo(x [4]int) {
  fmt.Println(x)
}

echo(a)         // echo 调用时,a 中所有元素都会被复制一遍, 因为 Go 函数调用是传值
echo(b)         // error
echo(([4]int)c) // error
复制代码

总结一下,Go 的数组,有以下特点:

  1. 长度属于类型的一部分,因此 [4]int[5]int 类型的变量不能互相赋值,也不能互相强转。
  2. 数组变量并非指针,因此作为参数传递时会引起全量拷贝。当然,可以使用对应指针类型作为参数类型避免此拷贝。

可以看出,由于存在长度这个枷锁,Go 数组的作用大大受限。Go 不能够像 C/C++ 一样,任意长度数组都可以转换为指向相应类型的指针,进而进行下标运算。当然,Go 也不需如此,因为它有更高级的抽象——切片。

切片(slices)

在 Go 代码中,切片使用十分普遍,但切片底层基于数组:

type slice struct {
    array unsafe.Pointer // 指向底层数组的指针;对,golang 也是有指针的
    len   int            // 切片长度
    cap   int            // 底层数组长度
}

// 切片的几种初始化方式
s0 := make([]byte, 5)       // 借助 make 函数,此时 len = cap = 5,每个元素初始化为 byte 的 zero-value
s1 := []byte{0, 0, 0, 0, 0} // 字面值初始化,此时 len = cap = 5
var s2 []byte               // 自动初始化为 slice 的“零值(zero-value)”:nil

// make 方式同时指定 len/cap,需满足 len <= cap
s3 := make([]byte, 0, 5) // 切片长度 len = 0, 底层数组 cap = 5
s4 := make([]byte, 5, 5) // 等价于 make([]byte, 5)
复制代码

相较数组,切片有以下好处:

  1. 操作灵活,顾名思义,支持强大的切片操作。
  2. 脱去了长度的限制,传参时,不同长度的切片都可以以 []T 形式传递。
  3. 切片赋值传参时不会复制整个底层数组,只会复制上述 slice 结构体本身。
  4. 借助一些内置函数,如 append/copy ,可以方便的进行扩展和整体移动。

切片操作。使用切片操作可以对切片进行快速的截取、扩展、赋值和移动。

// 截取操作,左闭右开;若始于起点,或止于终点,则可省略对应下标
// 新得到的切片与原始切片共用底层数组,因此免于元素复制
b := []byte{'g', 'o', 'l', 'a', 'n', 'g'}
b1 := b[1:4] // b1 == []byte{'o', 'l', 'a'}
b2 := b[:2]  // b2 == []byte{'g', 'o'}
b3 := b[2:]  // b3 == []byte{'l', 'a', 'n', 'g'}
b4 := b[:]   // b4 == b

// 扩展操作,需借助 append 函数
// 可能会引起底层数组的重新分配,后面会详细分析
// 等价于 b = append(b, []byte{',', 'h', 'i'}...)
b = append(b, ',', 'h', 'i') // b 现为 {'g', 'o', 'l', 'a', 'n', 'g', ',', 'h', 'i'}

// 赋值操作,需借助 copy 函数
copy(b[:2], []byte{'e', 'r'})  // b 现为 {'e', 'r', 'l', 'a', 'n', 'g', ',', 'h', 'i'}

// 移动操作,需借助 copy
copy(b[2:], b[6:])  // 移动长度取 min(len(dst), len(src))
b = b[:5]           // b 现为 {'e', 'r', ',', 'h', 'i'}
复制代码

参数传递。不同长度、容量的切片都可以通过 []T 形式传递。

b := []int{1,2,3,4}
c := []int{1,2,3,4,5} 

func echo(x []int) {
  fmt.Println(x)
}

echo(b) // 传递参数时,会重新生成一个共享底层数组,len 和 cap 都相同的切片结构体
echo(c)
复制代码

相关函数。切片相关的内置函数主要有:

  1. 用于创建的 make
  2. 用于扩展的 append
  3. 用于移动的 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 函数更像个语法糖,将对切片的批量赋值封装为一个函数,注意拷贝长度会取两个切片中较小者。并且,不用担心同一个切片的子切片移动时出现覆盖现象,举个例子:

package main

import (
	"fmt"
)

// 直觉认为的 copy 函数实现
// 但此种实现会造成同一个切片的子切片进行复制时的覆盖现象
// 因此 copy 在实现时应该借助了额外的空间 or 从后往前复制
func myCopy(dst, src []int) {
	l := len(dst)
	if len(src) < l {
		l = len(src)
	}
	
	for i := 0; i < l; i++ {
		dst[i] = src[i]
	}
}

func main() {
	a := []int{0,1,3,4,5,6}
	
	copy(a[3:], a[2:])      // a = [0 1 3 3 4 5]
	// myCopy(a[3:], a[2:]) // a = [0 1 3 3 3 3]
	fmt.Println(a)
}
复制代码

copy 一个常见的使用场景是,需要往切片中间插入一个元素时,用 copy 将插入点之后的片段整体后移。

切片模型

初用切片时,常常感觉其规则庞杂,难以尽记;于是我常想有没有什么合适的模型来刻画切片的本质。

某天突然冒出个不成熟的想法:切片是隐藏了底层数组的一种线性读写视图。切片这种视图规避了 C/C++ 语言中常见的指针运算操作,因为用户可以通过切片派生来免于算偏移量。

切片仅用 ptr/cap/len 三个变量来刻画一个窗口视图,其中 ptrptr+cap 是窗口的起止界限,len 是当前窗口可见长度。可以通过下标来切出一个新的视图,Go 会自动计算新的 ptr/len/cap ,所有通过切片表达式派生的视图都指向同一个底层数组。

go slice 视图

切片派生会自动共享底层数组,以避免数组拷贝,提升效率;追加元素时,如果底层数组容量不够,append自动创建新数组并返回指向新数组的切片视图,而原来切片视图仍然指向原数组。

切片使用

本小节将汇总一些 slice 使用时的一些有意思的点。

零值(zero-value)和空值(empty-value)。go 中所有类型都是有零值的,并以其作为初始化时的默认值。slice 的零值是 nil。

func add(a []int) []int { // nil 可以作为参数传给 []int 切片类型
	return append(a, 0, 1, 2)
}

func main() {
	fmt.Println(add(nil)) // [0 1 2]
}
复制代码

可以通过 make 创建一个空 slice,其 len/cap 与零值一致,但是也会有如下小小区别,如两者皆可,推荐用 nil。

func main() {
	a := make([]int, 0)
	var b []int
	
	fmt.Println(a, len(a), cap(a)) // [] 0 0
	fmt.Printf("%#v\n", a)         // []int{}
	fmt.Println(a==nil)            // false
	
	fmt.Println(b, len(b), cap(b)) // [] 0 0
  fmt.Printf("%#v\n", b)         // []int(nil)
	fmt.Println(b==nil)            // true
}
复制代码

append 语义。append 会首先将元素追加到底层数组,然后构造一个新的 slice 返回。也就是说,即使我们不使用返回值,相应的值也会被追加到底层数组。

func main() {
	a := make([]int, 0, 5)
	_ = append(a, 0, 1, 2)
	fmt.Println(a)     // []
	fmt.Println(a[:5]) // [0 1 2 0 0];通过切片表达式,扩大窗口长度,就可以看到追加的值
  fmt.Println(a[:6]) // panic;长度越界了
}
复制代码

从 array 生成 slice。可以通过切片语法,通过数组 a 生成所需长度切片 s ,此时:s 底层数组即为 a。换言之,对数组使用切片语法也不会造成数组的拷贝

func main() {
	a := [7]int{1,2,3}
	s := a[:4]
	fmt.Println(s) // [1 2 3 0]
	
	a[3] = 4       // 修改 a,s 相应值也跟着变化,说明 s 的底层就是 a
	fmt.Println(s) // [1 2 3 4]
}
复制代码

切片时修改视图右界。在上述提出的视图模型中,进行切片操作时,新生成的切片左界限会随着 start 参数而变化,但是右界一直未变,即为底层数组结尾。如果我们想修改其右界,可以通过三参数切片(Full slice Expression),增加一个 limited-capacity 参数。

该特性的一个使用场景是,如果我们想让新的 slice 在 append 时不影响原数组,就可以通过修改其右界,在 append 时发现 cap 不够强制生成一个新的底层数组。

go-full-slice-view-derive.png

小结

本文核心目的在于提出一个易于记忆和理解 slice 模型,以拆解 slice 使用时千变万化的复杂度。总结一下,我们在理解 slice 时,可以从两个层面来入手:

  1. 底层数据(底层数组)
  2. 上层视图(切片)

视图有三个关键变量,数组指针(ptr)、有效长度(len)、视图容量(cap)。

通过切片表达式(slice expression)可以从数组生成切片、从切片生成切片,此操作不会发生数组数据的拷贝。通过 append 进行追加操作时,根据本视图的 cap 而定是否进行数组拷贝,并返回一个指向新数组的视图。

参考

  1. 酷壳 coolshell : Go编程模式:切片,接口,时间和性能
  2. The Go Blog: Go slices:usage and internals

欢迎关注公众号“分布式点滴”,获取更多系统文章

免责声明:文章版权归原作者所有,其内容与观点不代表Unitimes立场,亦不构成任何投资意见或建议。

448

相关文章推荐

未登录头像

暂无评论