slice

2021-12-31  本文已影响0人  JunChow520

一般而言,切片是一种操作,切分后获取新的数据对象。但Go中的切片(slice)和其他语言不同,不仅是一种动作还是一种数据结构。它相当于是一个动态的数组,可以按需自动增长和缩小。

切片作为Go的基本数据结构,这种结构可用来管理数据集合。切片最初的设计想法是由动态数组概念来的,为了开发者更加方便的使一种数据结构自动增加和减少。但切片本身并不是动态数组或数组指针。

为什么有数组还需要切片呢?Go中数组是值类型,在函数传参和赋值操作时都会事先拷贝一份,当数组较大时会耗费较多资源,而且使用数组指针也比较复杂。而切片是引用类型,函数传参时无需再使用指针。本质上,切片就是数组的指针,因此函数传参时无需拷贝数组,性能消耗较小。此外还可以动态切片底层数组的大小,使用起来更为便捷。

函数传参时若入参是指针会存在一个弊端,若原指针指向更改,那么函数体中的指针指向都会跟着更改。此时切片的优势就体现出来了,使用切片传递数组参数,即可以达到节约内存的目的,也可以达到合理处理好共享内存的问题。

数据结构

切片是基于数组实现的,是对数组的抽象,因此底层的内存是连续的,效率高。可通过索引获得元素,也可迭代,并支持垃圾回收。

切片本身并不是动态数组或数组指针,内部实现的数据结构是通过指针引用底层数组,再设定相关属性将数据读写操作限定在指定的区域内。

切片本身是一个只读对象,工作机制类似数组指针的一种封装。

切片是对底层数组的一个连续片段的引用,因此切片是引用类型,类似于C/C++中的数组类型或Python中的list列表类型。这个连续片段可以是整个数组,也可以是由起始和终止索引标识的元素的子集。需要注意的是终止索引标识的项并不包含在切片内。总体而言,切片提供了一个与指向数组的动态窗口。

切片是一个具有3个字段的数据结构

type notInHeapSlice struct {
  array *notInHeap
  len int
  cap int
}

切片的数据结构定义

type slice struct {
  array unsafe.Pointer
  len int
  cap int
}

切片的数据结构中包含一个指向底层数组的指针array、当前长度len、最大容量cap

切片结构 名称 描述
pointer 指向底层数组的指针 表示切片结构从底层数组的哪个元素开始,该指针指向该元素。
capacity 最大容量/底层数组的长度/切片占用内存数量 切片目前最多能扩展的长度
length 当前长度/当前元素个数 追加元素长度不够时会自动扩展,最大扩展到最大容量。
切片
s := make([]byte, 6)
ptr := unsafe.Pointer(&s[0])
fmt.Printf("%+v %d %d\n", ptr, len(s), cap(s)) // 0xc0000aa058 6 6

声明切片

若只声明切片不初始化,切片的指针会为nil,此时切片不会指向底层数组,因此又称为nil切片,长度和容量都为0。

nil切片
// 声明nil切片
var ns []int
// nil切片与nil比较
fmt.Printf("%d %d %+v %t\n", len(ns), cap(ns), ns, ns == nil) // 0 0 [] true

nil切片被用在很多标准库和内置函数中,描述一个不存在的切片。比如函数在发生异常时返回的切片就是nil切片,nil切片的指针指向nil。

创建切片

切片依赖于底层的数组,创建切片时会先创建一个具有特定长度和数据类型的底层数组,然后从底层数组中选取一部分元素,最终返回这些元素组成的集合或容器,并将切片指向集合中的第一个元素。也就是说,切片自身维护了一个指针属性,指向底层数组中某些元素的集合。

可使用内置函数make()创建切片,此时会先为底层数组分配内存,然后从底层数组中再额外生成一个切片并初始化。

s := make([]byte, 4, 6)
ptr := unsafe.Pointer(&s[0])
fmt.Printf("%+v %d %d %v\n", ptr, len(s), cap(s), s) // 0xc000014098 4 6 [0 0 0 0]
创建切片

为什么不使用new()创建切片呢?

使用new()会分配内存并会以零值初始化元素。make()允许在运行期动态指定数组长度,以绕开数组类型必须使用编译器常量的限制。make()只能构建切片slice、映射map、通道channel这三种结构的数据对象,因为它们都指向底层数据结构,都需要先为底层数据结构分配好内存并初始化。

使用make()创建切片时,若未指定最大容量则默认为当前长度。

a := make([]int, 128)
fmt.Println(len(a), cap(a)) // 128 128

b := make([]int, 128, 512)
fmt.Println(len(b), cap(b)) // 128 512

字面量

使用字面量创建切片时,数组中每个元素都会被初始化。

s := []int{10, 20, 30, 40, 50, 60}
fmt.Printf("%d %d %v\n", len(s), cap(s), s) // 6 6 [10 20 30 40 50 60]

空切片

空切片一般会用来表示一个空的集合,比如数据查询无结果可返回一个空切片。

空切片(empty slice)的长度为0容量也为0,但却有指向的切片,只不过指向的底层数组暂时是长度为0的空数组,实际上空切片指针会指向一个特殊的zerobase地址。

es := make([]int, 0) //使用make创建空切片
es := []int{} //直接创建空切片

虽然nil切片和空切片的长度和容量都为0,且都不存储任何数据,但它们是不同的:

var ns []int
fmt.Printf("%d %d %v %t\n", len(ns), cap(ns), ns, ns == nil) // 0 0 [] true

es := []int{}
fmt.Printf("%d %d %v %t\n", len(es), cap(es), es, es == nil) // 0 0 [] false
空切片

切片扩容

当切片长度小于容量时,新增元素不会改变数组指针的值。

a := make([]int, 4)
fmt.Println(len(a), cap(a), a) // 4 4 [0 0 0 0]

b := a[0:3]
fmt.Println(len(b), cap(b), b) // 3 4 [0 0 0]

a[0] = 10
b[1] = 20
fmt.Println(a) // [10 20 0 0]
fmt.Println(b) // [10 20 0]

对切片进行追加操作时,长度一旦超出容量会创建新数组,进而导致和原切片分离。当切片的长度已经等同于容量时,若继续使用append()为切片追加元素,会自动扩展底层数组的长度。底层数组扩展时会生成一个新的底层数组,旧底层数组仍然会被旧切片引用,新切片和旧切片不再共享同一个底层数组。

a := make([]int, 4)
fmt.Println(len(a), cap(a), a) // 4 4 [0 0 0 0]

b := a[0:3]
fmt.Println(len(b), cap(b), b) // 3 4 [0 0 0]

a = append(a, 1)

a[0] = 10
b[1] = 20
fmt.Println(a) // [10 0 0 0 1]
fmt.Println(b) // [0 20 0]

使用append()对切片进行扩展,追加元素到切片中会增加切片的长度。但必须注意的是append()的结果必须被使用,若放在空上下中将会报错。

扩容策略

为避免切片因扩展导致的副作用,使用时推荐copy的方式来复制数据以保证得到一个全新的切片。

为什么nil切片和空切片都可以直接append呢?

nil切片和空切片都可以通过调用append()来为底层数组扩容,是因为它们最终都调用mallocgc向Go的内存管理器申请到一块内存,然后再赋予给原切片。

内存对齐

切片拷贝

可使用内置函数copy()将一个切片拷贝到另一个切片中

fun copy(dst, src []Type) int

将源切片拷贝到目标切片时,若源切片比目标切片长就截断,若源切片比目标切片短则只会拷贝源切片那部分。切片拷贝的返回值是拷贝成功的元素数量,也就是源切片或目标切片中最小的那个长度。

切片合并

切片和数组都是一种值,可将一个切片和另一个切片进行合并生成一个全新的切片。合并切片时,只需要将append()的第二个参数加上...即可。

注意append()最多只允许两个参数,所以一次性只能合并两个切片。可将append()作为另一个append()的参数从而实现多级合并。

切片切片

对切片继续切片会生成一个全新的切片,可用来实现切片的缩减。

# 对切片进行切片的语法表示
SLICE[A:B]
SLICE[A:B:C]
标识 描述
A 表示从切片的第几个元素开始切
B 控制切片的长度,长度值为B-A。
C 控制切片的容量,容量值为C-A,若无则表示切到底层数组的最尾部。
简化形式 描述
SLICE[A:] 从A切到最尾部
SLICE[:B] 从最开头切到B,但不包含B。
SLICE[:] 从头切到尾,等价于复制整个切片。

遍历迭代

切片是一个集合因此可以迭代,使用range关键字可以对切片迭代,每次迭代会返回一个索引和对应的元素值。

可将range迭代结合for循环实现对切片的遍历

内存浪费

由于切片的底层是数组,可能会出现底层数组很大但切片获取的元素却很小的情况,这就会导致数组占用的绝大多数空间是被浪费的。

垃圾收集器不会回收正在被引用的对象,当一个函数直接返回指向底层数组的切片时,这个底层数组不会随函数退出被回收,而是因为切片的引用而永远保留,除非返回的切片也消失。

因此,当函数返回值是一个指向底层数组的数据类型时,应当在函数内部将切片拷贝一份保存到一个使用自己底层数组的新切片中,并返回这个新的切片。这样函数一旦退出,原来那个体积庞大的底层数组就会被回收,保留在内存中的只会是一个小的切片。

函数传参

切片的数据结构类似指针,这一特性直接体现在函数参数传值上。

Go中函数参数传递方式是按值传递,调用函数时会复制一个参数的副本传递给函数体。若传递的参数是切片,它会复制该切片副本给函数。由于切片类似指针,传递给函数的切片副本仍然会指向源切片的底层数组。如果在函数体内对切片进行修改,有可能会直接影响函数外部的底层数组,而从影响其他切片。但并不总是如此,比如在函数体内对切片扩容会生成一个新的底层数组,就不会影响到原始的底层数组。

上一篇 下一篇

猜你喜欢

热点阅读