Vis/skjul meny Yingchi Blog

Go Array 与 Slice 原理

数组 Array

几乎每个常见的编程语言都有数组这个概念,但是每个语言对于数组的定位都不一样,有的语言会把数组用作常用的基本的数据结构,比如 JavaScript,而 Golang 中的数组(Array),更倾向定位于一种底层的数据结构,记录的是一段连续的内存空间数据。但是在 Go 语言中平时直接用数组的时候不多,大多数场景下我们都会直接选用更加灵活的切片(Slice),我这里很谨慎地说“直接”用数组,因为里面有学问,稍后会说。在Go程序中经常看不到数组的一个很重要原因是,数组的大小是固定的… ,所以很多场景下我们无法直接给出数组的确定长度,因此才会选择长度“可变”的切片。变与不变是编程中一个恒久远的话题,牵扯到这个话题的往往是性能灵活性两个关键词,这个话题很庞大,有机会会单独写一篇博客进行探讨。

回到数组中,数组的声明形式:

var arr [5]int
var buffer [256]byte

初始化方式有两种,一种是显示声明长度,另一种是[...]T推断长度,注意,推断长度也是给出了长度,这个和之后 Slice 的[]T的声明方式是不一样的:

arr1 := [3]int{0,1,2}
arr2 := [...]string{"Joey","Sophie"}

第二种初始化属于语法糖,会经过编译器推导,得到数组长度,即最终转换成第一种,显然,两种方式在运行时是没有任何区别的。但是在编译期,Go 为不同类型不同结构的初始化方式进行了优化(不止是数组的初始化这一点上,其它一些代码同样如此),对于优化过程,可以简单概括为下面的话:

  • 如果数组中元素的个数小于或者等于 4 个,那么所有的变量会直接在栈上初始化;
  • 如果数组元素大于 4 个,变量就会在静态存储区初始化然后拷贝到栈上,这些转换后的代码才会继续进入中间代码生成和机器码生成两个阶段,最后生成可以执行的二进制文件。

数组虽然比较重要,但是的概念其实比较简单,还有一个非常需要注意的点是,当你用到 Go 数组的时候,一定要注意一个避不开的问题,一定不要越界访问

切片 Slice 及其与 Array 的关系

刚接触 Go 的一些学习者们肯定会混淆 Array 与 Slice 的用法,我想主要原因是受其它语言影响比较大,比如国内用 Java 的比较多,如果突然换到 Go,一定会对这个 slice 概念一头雾水。

很多人仅仅知道 Slice 与 Array 的区别是:Slice 长度可变,如果仅仅是知道这个的话其实是很危险的,平时有一些错误的用法会直接把你整的找不着北,我们需要从底层了解这个语言特性。

学习 slice,或者说区别 Slice 与 Array 的首要关键是记住下面几点:

  • Slice 不是 Array,它描述一个 Array
  • Slice 的本质是一个 Struct,携带一个数组指针,长度,容量,这是他长度可变的根本原因

可以在 Go 源码中找到 sliceHeader 的定义:

type sliceHeader struct {
    Data unsafe.Pointer // 指向的数组
    Len  int            // 长度,即 Slice 截取 Data 的长度
    Cap  int            // 容量,即 Data 的大小,显然不会小于 Len
}

Slice 的声明方式比较多,我们可以直接构建一个空 Slice 而不需要指定长度,我们也可以直接基于 Array 本身构建一个 Slice,亦可以基于 Slice 构建新的 Slice,我们以一个典型的场景去理解 Slice 与 Array 的关系:

var sli0 = make([]int) // make([]T, Len, Cap)
var sli1 = arr1[5:10]
var sli2 = sli1[2:]

sli1 在 arr1 的左闭右开索引区间 [5, 10) 上构建了切片,而 sli2 又在 sli1 的基础上构建了 [2, 5) 的切片,这里值得记住的一点是,切片结构体里保存的是底层数组的指针(引用),因此他们指向的是同一块底层数组,我们可以知道,sli2[0]的元素就是sli1[2]对应的元素,指向的都是底层数组arr1的arr1[7]元素,此时如果修改 sli2[0] 的话,实际上就是修改的 arr1[7],因此 sli1[2] 也是会变的,这个场景一定要理解。

函数传递 Slice

还是强调开始提到的:记住,Slice 是个结构体,只不过这个结构体里存有数组的指针。

因此切片作为函数参数直接传递时就是个普通的值传递,所有语言都会讲到,函数值传递时只是传递参数值的 Copy 对象,但是 Slice 这个值很特殊,他里面存有数组的指针,又包含了 Slice 的 Len 和数组的 Cap,即又包含指针又包含普通值,因此你也知道我想说什么了,记住:

  • 直接传递 Slice 进函数时,传递的是 Slice 的 copy;
  • 对 Slice 的元素进行修改操作,会通过指针直接修改数组,因此是可以实现的;
  • 对 Slice 的长度修改,修改的是 copy 对象的 Len 字段,因此原 Slice 是长度是不会变的;
  • 想要在函数内修改 Slice 的长度,最好的方式是传递 Slice 的指针;

容量与 append

sliceHeader中还有一个 Cap 变量,这个变量存储了 Slice 的容量,准确的说应该是底层 Data 数组的长度,即记录数组实际使用了多少的空间,这也是 Len 能达到的最大值。

Slice 的元素追加通过 append 操作实现,如:

names = append(names, "Joey")

注意 append 返回的是一个新的 slice,直接 append 而不赋值给原 slice 的话,原 slice 长度是不会改变的,只有把 append 后得到的新 slice 赋值回去才可以实现原 slice 基础上的元素追加。多说一句,从编译器层面,Go 在编译期针对于 append 后是否赋值给原 slice 实现了两种编译方式实现优化。

还有,我这里没写 slice 的移除元素通过什么关键字,显然,不说的话就是同一个关键字,没错,移除的逻辑就很直白,也很反程序员:

ages = append(ages[:5], ages[6:])

明白了吧,别问,问就是 大道至简 less is more,我现在也不明白到底是谁 less 了谁 more 了,想想还挺心酸的。

划重点

关于容量需要记住的就是:当向 Slice 追加元素导致 Len大于 Cap 时,会触发扩容机制,创建一个Cap大于原数组的新数组(首元素地址不一致),并将值拷贝进新数组,之后再改变Slice元素值时改变的是新创建的数组(切断与原数组的引用关系)。是的,当触发扩容机制后,新的 Slice 底层数组已经不再是之前的数组了,对于 Slice 元素的修改都是基于新的底层数组进行。

需要注意的是,涉及到 Copy 这个词,显然是一个影响效率的行为,因此我们如果真的关注性能这一块儿的话,一定要想办法避免频繁的触发扩容机制,比如当我们明确地知道 Slice 容量上限的时候,在声明时就应该通过 make([]T, Len, Cap) 给出明确的 cap 值。


最后,从一段简单的代码入手更直观地去理解

package main

import "fmt"

var (
	sli1      = make([]int, 5)
	sli2From1 = sli1[2:]
)

func main() {
	fmt.Println("初始情况")
	printSliLenCap()

	fmt.Println("修改 sli2")
	sli2From1[0] = 1
	printSliLenCap()

	fmt.Println("sli2 追加元素")
	sli2From1 = append(sli2From1, 2, 2, 2, 2)
	printSliLenCap()

	fmt.Println("再次修改 sli2")
	sli2From1[0] = 3
	printSliLenCap()

}

func printSliLenCap() {
	fmt.Printf("sli1: %v, len: %d, cap: %d \n", sli1, len(sli1), cap(sli1))
	fmt.Printf("sli2: %v, len: %d, cap: %d \n", sli2From1, len(sli2From1), cap(sli2From1))
}

输出结果:

初始情况
sli1: [0 0 0 0 0], len: 5, cap: 5 
sli2: [0 0 0], len: 3, cap: 3 
修改 sli2
sli1: [0 0 1 0 0], len: 5, cap: 5 
sli2: [1 0 0], len: 3, cap: 3 
sli2 追加元素
sli1: [0 0 1 0 0], len: 5, cap: 5 
sli2: [1 0 0 2 2 2 2], len: 7, cap: 8 
再次修改 sli2
sli1: [0 0 1 0 0], len: 5, cap: 5 
sli2: [3 0 0 2 2 2 2], len: 7, cap: 8 

...

可以看到,就像前面说过的:

  • 可以基于已有的 slice 构建新的 slice;

  • 没有触发扩容机制前,slice 的底层数组会指向同一个数组,因此对于其中一个 slice 元素的修改,如果底层和其它 slice 指向的同一个数组元素,那么会影响到其它 slice 元素值;

  • 触发扩容机制后,新的 slice 底层数组改变,因此对其底层数组元素的修改不会影响到之前相关的 slice,因为两个 slice 的底层数组已经不是同一个。