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

简介: Golang 笔记(三):一种理解 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 中不同长度的数组是为不同的类型,并且变量名并非指向数组首地址的指针。

// 数组的几种初始化方式
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 ,所有通过切片表达式派生的视图都指向同一个底层数组。

image.png

切片派生会自动共享底层数组,以避免数组拷贝,提升效率;追加元素时,如果底层数组容量不够,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 不够强制生成一个新的底层数组。

image.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
相关文章
|
Go 索引
Go 语言中同一 slice 上的切片其底层数组是否是同一个
Go 语言中同一 slice 上的切片其底层数组是否是同一个
109 0
|
5月前
|
人工智能 Go
[go]Slice 切片原理
本文详细介绍了Go语言中的切片(slice)数据结构,包括其定义、创建方式、扩容机制及常见操作。切片是一种动态数组,依托底层数组实现,具有灵活的扩容和传递特性。文章解析了切片的内部结构(包含指向底层数组的指针、长度和容量),并探讨了通过`make`创建切片、基于数组生成切片以及切片扩容的规则。此外,还分析了`append`函数的工作原理及其可能引发的扩容问题,以及切片拷贝时需要注意的细节。最后,通过典型面试题深入讲解了切片在函数间传递时的行为特点,帮助读者更好地理解和使用Go语言中的切片。
120 0
|
7月前
|
存储 算法 Java
GoLang GPM模型
本文介绍了 Go 语言中的 goroutine 及其调度器(Go Scheduler)的工作原理。goroutine 并非传统意义上的协程,而是基于两级线程模型实现的轻量级并发单元。文章详细解释了三种主流线程模型(内核级、用户级和两级线程模型)的特点,并重点阐述了 G-P-M 模型(Goroutine、Processor、Machine)的工作机制,包括调度算法、阻塞处理等。通过动态栈管理和高效的调度器,Go 程序能够轻松支持成千上万个并发任务。
146 0
GoLang GPM模型
|
11月前
|
存储 安全 测试技术
GoLang协程Goroutiney原理与GMP模型详解
本文详细介绍了Go语言中的Goroutine及其背后的GMP模型。Goroutine是Go语言中的一种轻量级线程,由Go运行时管理,支持高效的并发编程。文章讲解了Goroutine的创建、调度、上下文切换和栈管理等核心机制,并通过示例代码展示了如何使用Goroutine。GMP模型(Goroutine、Processor、Machine)是Go运行时调度Goroutine的基础,通过合理的调度策略,实现了高并发和高性能的程序执行。
598 29
|
11月前
|
存储 安全 Linux
Golang的GMP调度模型与源码解析
【11月更文挑战第11天】GMP 调度模型是 Go 语言运行时系统的核心部分,用于高效管理和调度大量协程(goroutine)。它通过少量的操作系统线程(M)和逻辑处理器(P)来调度大量的轻量级协程(G),从而实现高性能的并发处理。GMP 模型通过本地队列和全局队列来减少锁竞争,提高调度效率。在 Go 源码中,`runtime.h` 文件定义了关键数据结构,`schedule()` 和 `findrunnable()` 函数实现了核心调度逻辑。通过深入研究 GMP 模型,可以更好地理解 Go 语言的并发机制。
328 1
|
11月前
|
负载均衡 算法 Go
GoLang协程Goroutiney原理与GMP模型详解
【11月更文挑战第4天】Goroutine 是 Go 语言中的轻量级线程,由 Go 运行时管理,创建和销毁开销小,适合高并发场景。其调度采用非抢占式和协作式多任务处理结合的方式。GMP 模型包括 G(Goroutine)、M(系统线程)和 P(逻辑处理器),通过工作窃取算法实现负载均衡,确保高效利用系统资源。
212 3
|
12月前
|
存储 缓存 测试技术
golang slice相关常见的性能优化手段
【10月更文挑战第23天】本文介绍了 Go 语言中切片使用的四个优化技巧:预分配容量、减少中间切片的创建、利用切片的复用特性和合理使用 `copy` 函数。通过这些方法,可以有效提高程序性能,减少不必要的内存分配和数据复制操作。每个技巧都附有详细的原理说明和代码示例,帮助开发者更好地理解和应用。
198 2
|
Go
Golang语言之切片(slice)快速入门篇
这篇文章是关于Go语言中切片(slice)的快速入门教程,详细介绍了切片的概念、定义方式、遍历、扩容机制、使用注意事项以及相关练习题。
242 6
|
人工智能 Go
go slice 扩容实现
go slice 扩容实现
119 3
|
人工智能 编译器 Go
go slice 基本用法
go slice 基本用法
129 1

推荐镜像

更多