[Introduction]四千字浅谈Golang中切片和数

最近对切片特别上心,主要是刷题摸鱼和开发中遇到了一些问题,看了很多大佬的博客,准备亲自动手实践一下,探究切片的各种性质。切片和数组是go中比较容易混淆的两个数据结构,所以就浅谈一下切片和数组吧。

开发环境:

Go版本:go version go1.14.2 windows/amd64

编辑器:VScode(特别推荐,go的各种小插件比其他编辑器好用)

全文4000字

预计阅读时间:5-10分钟

数组

在golang中,数组不是一个指向连续地址的指针,和C不同的是,数组是一个值类型。在我的上篇文章中提到,在golang中只存在值传递,也就是说既然只存在值传递,那么数组在作为形参的时候,将产生副本。为了解决这个问题,就需要引入切片。不过在此之前,还是先看看数组的定义和一些特性。

数组定义

数组是由相同类型元素的集合组成的数据结构。小数组存在在栈上,大数组存在于静态存储区。

数组声明

声明数组在go中需要指定数组中的元素类型和元素个数,数组在初始化后大小就无法改变,在声明时,数组的长度可以为一个常量或者一个在编译器可以计算出结果的常量表达式。数组的长度可以通过len函数获取。

示例:

var array1 [2]int        // 数组的声明方式,表示声明了2个int元素的数组,数组长度len(array)为2
var array2 [3*N] structX // struct结构体数组,其中N要求必须是可计算常数
var array3 [4]*int       // 指针数组
var array4 [5][6]int     // 声明二维/多维数组
复制代码

数组的初始化

数组可以先声明,再初始化:

array1 = {1,2}   // 为array2赋值
复制代码

也可以直接声明并初始化:

其中array1的声明使用了显式指定数组的大小,而array2的声明编译期间会自动对数组长度进行推断,至于具体的推导方法可以参考《面向信仰编程》...的使用在golang中是一种语法糖,我在这篇文章中也提及了这种使用方式。

array1 := [3]int{1, 2, 3}            // 声明并初始化一个长度为3的int数组
array2 := [...]int{1, 2, 3}          // 可以省略长度而采用`...`的方式,Go会自动根据元素个数来计算长度
array3 := [2][3]int{[3]int{1,2,3},[3]int{4,5,6}}
array4 := [2][3]int{{1,2,3},{4,5,6}} // 如果内部的元素和外部的一样,那么上面的声明可以简化,直接忽略内部的类型
复制代码

事实上,对go有一些了解的应该知道,数组中元素不足len时,会被当前元素的默认值初始化,对于case1来说,就是{1,0,0}。但是对于超出len的部分就会报错。

array1 := [3]int{1}         // case1 不会报错
array2 := [3]int{1,2,3,4,5} // case2 报错
复制代码

数组的访问

数组在内存中就是一块连续的内存空间。可以使用数组下标来访问数组中的元素。数组下标从0开始,len(arr)-1则表示最后一个元素的下标。发生访问越界则会直接报错。

对于访问越界错误存在两种情况,第一种在编译时就发现的错误,一般常量索引访问越界属于这种情况,第二种变量作为索引访问越界,此时只能运行时才能报错,编译阶段检查不出来。

对于普通的赋值操作,例如a[i] = 2其赋值和寻址是在编译阶段完成的。当然这个地方我自己研究深度还没有这么深,就不多说了。

计算数组长度

通过go语言内置函数len可以对数组长度进行计算。

遍历数组

遍历数组有两种方式,第一种方式:

for i := 0; i < len(arr); i++ {
    log.Println(i, arr[i])
}
复制代码

第二种方式可以通过range访问对数组进行遍历:

for k, v := range arr {
    log.Println(k, v)
}
复制代码

range函数具有两个返回值,第一个返回值k是元素的数组下标,第二个返回值v是元素的值

使用场景不同,可以使用不同的遍历方式。

数组的赋值

数组的赋值常见于函数传参中,在下面这个例子中:

package main

import(
    "log"
)

func modify(arr [5]int){
   arr[0] = 10
   log.Println("In modify(), arr values:", arr)
}

func main(){
    arr := [5]int{1, 2, 3, 4, 5}
    modify(arr)
    log.Println("In main(), arr values:", arr)
}
复制代码

输出结果为:

[root@localhost]# go run test.go 
In modify(), arr values: [10 2 3 4 5]
In main(), arr values: [1 2 3 4 5]
复制代码

对外面的数组没有进行过改动,因为是值传递,修改了副本,在上一篇文章中已经讲解的很清楚了。

切片

在Go中切片的底层就是数组,数组就有的特点切片都有,且切片可以动态扩容,可以再次切片得到更小的切片,同样可以迭代遍历等。

切片定义

切片可以理解为长度不固定的动态数组,但是这种理解并不准确。我认为比较准确清晰且容易理解的定义应该是这样的:

切片本身并不是动态数组或者数组指针。它内部实现的数据结构通过指针引用底层数组,设定相关属性将数据读写操作限定在指定的区域内。切片本身是一个只读对象,其工作机制类似数组指针的一种封装

切片(slice)是对数组一个连续片段的引用,所以切片是一个引用类型(因此更类似于 C/C++ 中的数组类型,或者 Python 中的 list 类型)。这个片段可以是整个数组,或者是由起始和终止索引标识的一些项的子集。需要注意的是,终止索引标识的项不包括在切片内。切片提供了一个与指向数组的动态窗口。

给定项的切片索引可能比相关数组的相同元素的索引小。和数组不同的是,切片的长度可以在运行时修改,最小为 0 最大为相关数组的长度:切片是一个长度可变的数组。

且切片在容量不足时会进行自动扩容。

切片声明

在Go语言中,容易混淆切片和数组的声明方式:

var slice = []int  // 切片的声明方式
var array = [2]int // 数组的声明方式
复制代码

可以看出切片只需要声明切片内的元素类型。

切片结构体

在上篇文章中我提出了一个猜想,我认为切片的声明本质上是先声明了数组,然后再创建了slice结构体,其ptr指向数组中第一个元素的内存地址,今天看到一篇文章,发现Go中确实是这样的:

先创建一个有特定长度和数据类型的底层数组,然后从这个底层数组中选取一部分元素,返回这些元素组成的集合(或容器),并将slice指向集合中的第一个元素。换句话说,slice自身维护了一个指针属性,指向它底层数组中的某些元素的集合。

切片底层结构如下所示:

type Slice struct {
       ptr   unsafe.Pointer // 指向数组的指针,表明数组的起始元素 Array pointer
       len   int            // 长度 slice length
       cap   int            // 容量 slice capacity
}
复制代码

ptr指针指向的底层是个数组,本质上就是使用Slice这个“引用”对数组进行操作。

而长度和容量的区别,长度是当前数组的长度,容量是数组可以达到的最大长度。当使用append函数添加元素时,长度增加,直到容量大小减一,当抵达容量时,自动扩容。容量应当大于等于长度

切片初始化

创建切片有以下几种方式,第一种直接创建:

var slice1 []int         // nil切片
slice2 := []int{1, 2, 3} // 直接创建切片并赋值
复制代码

值得注意的是,不要混淆nil切片和空切片,空切片只是len为0,但是指针是指向一段真正存在的内存空间的。nil切片你可以认为里面的ptr是nil默认值。

第二种是使用make创建:

var slice = make([]int, 3, 5)
复制代码

使用make来创建切片时,可以指定长度和容量,创建时底层会首先分配数组。

第三使用数组创建:通过下标的方式获得数组或者切片的一部分

slice[0:3]
复制代码

切片赋值

切片复制就是go中的值传递,将对应的属性拷贝至结构体中。

切片扩容

使用append函数向slice中追加元素的时候, 如果Slice空间不足,将会触发Slice扩容。

扩容的本质过程:
扩容实际上就是重新分配一块更大的内存,将原先的Slice数据拷贝到新的Slice中,然后返回新Slice,扩容后再将数据追加进去。

例子:

var slice = make([]int, 3, 3)
slice = []int{1, 2}
log.Printf("%p", &slice[0]) // 扩容前的初始元素地址
slice = append(slice, 3)
log.Printf("%p", &slice[0]) // 扩容后的初始元素地址
复制代码

对于cap和len都为3的切片,此时元素有两个,当向切片中添加一个元素3时,即使容量满足要求,但是也会自动扩容。

输出结果:

2020/08/17 16:49:37 0xc0000107e0
2020/08/17 16:49:37 0xc000092aa0
复制代码

可以看到此时地址已经改变,换成了一块内存更大的空间。

例子2:

var slice = make([]int, 3, 3)
slice = []int{1}
log.Printf("%p", &slice[0]) // 扩容前的初始元素地址
slice = append(slice, 2)
log.Printf("%p", &slice[0]) // 扩容后的初始元素地址
复制代码

未达到容量减一时,不进行自动扩容。

输出结果:

2020/08/17 16:49:47 0xc0000a07b0
2020/08/17 16:49:47 0xc0000a07d0
复制代码

扩容操作遵循的原则:

  1. 如果原Slice容量小于1024,则新Slice容量将扩大为原来的2倍;
  2. 如果原Slice容量大于等于1024,则新Slice容量将扩大为原来的1.25倍;

扩容的实现步骤:

  1. 假如Slice容量够用,则将新元素追加进去,Slice.len++,返回原Slice
  2. 原Slice容量不够,则将Slice先扩容,扩容后得到新Slice
  3. 将新元素追加进新Slice,Slice.len++,返回新的Slice。

切片总结

  1. 每个切片都指向一个底层数组
  2. 每个切片都保存了当前切片的长度、底层数组可用容量
  3. 使用len()计算切片长度时间复杂度为O(1),不需要遍历切片
  4. 使用cap()计算切片容量时间复杂度为O(1),不需要遍历切片
  5. 通过函数传递切片时,不会拷贝整个切片,因为切片本身只是个结构体而已
  6. 使用append()向切片追加元素时有可能触发扩容,扩容后将会生成新的切片

使用切片的注意项

  1. 预分配:创建切片时可根据实际需要预分配容量,尽量避免追加过程中的扩容操作
  2. 拷贝:切片拷贝时需要判断实际拷贝的元素个数(见下文“切片拷贝”)
  3. 数组冲突:使用多个切片操作同一个数组,防止读写冲突。

切片和数组区别

Go的设计模式中(非常值得一读!)Alex Lockwood非常明确的告诉我们,Go中的数组是values,数组既不是引用,也不是指针,这和《面向信仰编程》中作者认为数组是指针是不同的,以Alex为准。

数组是值类型有两个含义:第一,当数组分配给另一个副本时会复制所有元素,例如:

array1 := [2]int{1, 2} // 一个数组
array2 := array1       // 副本
log.Printf("%p", &array1)
log.Printf("%p", &array2)
复制代码
2020/08/17 15:19:45 0xc0000107e0
2020/08/17 15:19:45 0xc0000107f0
复制代码

可以发现所有元素被拷贝到另一空间中。

第二:数组传递给函数是值传递。、

作者提出了当使用大量数组时,因为副本的产生,对于内存实际上是一种浪费,因此切片是十分必要的,切片比数组更加灵活也更加强大,切片拥有数组的全部特性,且操作起来更加轻便快捷。

切片提供了append函数(append属于切片而不是数组!)来动态调整切片的大小,此外切片是引用类型(特殊的引用类型),而无需创建新副本。作者还提到Go标准库中的函数使用切片而不是数组。

应当尽可能使用切片而非数组。

切片拷贝

至于为什么把切片拷贝单独拎出来单列一章,源自于我摸鱼刷leetcode时做的一道题:

func duplicateZeros(arr []int) { // 1089. 复写零
	var temparr []int       // 临时切片用于复制
	for _, v := range arr { // 遍历找0
		if v == 0 {
			temparr = append(temparr, v)
			temparr = append(temparr, 0)
		} else {
			temparr = append(temparr, v)
		}
	}
	// copy(arr, temparr)      // case1
	arr = temparr[:] // 截断赋值 // case2
	log.Println(arr)
}
复制代码

在最后把满足条件的切片赋值返回时,发现了大问题:

case2无法改变函数外的值,是因为只在函数体内更改了arr这个结构体,而函数体外的ptr依旧是指向原数组,所以需要使用copy函数来彻底对内存地址的值进行改变,也就是case2,但是对于copy的使用还有一些陷阱,在这个情景中是正确的,但是在其他情景中就不一定了。

copy函数

Slice的拷贝过程

使用内置函数copy()拷贝切片时,会将源切片的数据逐个拷贝到目的切片指向的数组中,拷贝数量取两个切片长度的最小值。

copy过程中不会发生扩容。

这也是最容易让新手犯错和调入陷阱的地方,copy并不是单纯的深拷贝!

其他语言中的copy应该是字面意思,完全复制一份相同的切片或者数组拷贝。但是在golang中,copy只能复制两个切片中较小长度数量的元素。至于剩下的元素不会发生拷贝。

参考链接

[Introduction]Go特殊的引用类型:值传递/指针传递/引用传递

GO DESIGN PATTERNS

go语言数据类型-数组(array)

Go语言参数传递是传值还是传引用

Go语言设计与实现

Go slice扩容深度分析

【重温Golang】常见的数据结构的实现原理与用法——Slice

golang 中神奇的 slice

深入解析 Go 中 Slice 底层实现

结语

感谢掘金给了这次机会让我好好整理了golang中最频繁使用到的数据结构

🏆 技术专题第二期 | 我与 Go 的那些事......