深入剖析golang中的slice

234 阅读6分钟

什么是切片

slice 翻译成中文就是切片,它和数组(array)很类似,可以用下标的方式进行访问,如果越界,就会产生 panic。

数组在使用上有两点不足:

  • 固定的元素个数,

  • 传值机制下导致的开销较大。

于是 Go 设计者们引入了另外一种同构复合类型:切片(slice),来弥补数组的这两处不足。

总结,数组与slice的区别

  • 数组类型的值(以下简称数组)的长度是固定的

  • 切片类型的值(以下简称切片)是可变长的。

切片的创建与初始化

直接声明

// names为切片名称,[]string为切片类型
var names []string

这种方式创建出来的 slice 其实是一个 nil slice。它的长度和容量都为 0。和nil比较的结果为true

Nil slice 可以直接调用 append 函数来获得底层数组的扩容而不会panic。这是因为go的底层会帮助我们调用 mallocgc 来向 Go 的内存管理器申请到一块内存,然后再赋给原来的nil slice ,然后摇身一变,成为 “真正” 的 slice 了。

但是,直接对nil slice 赋值还是会panic

var sl []int
sl[0] = 13 // panic

sl = append(sl, 13) // ok

注意:

  • nil slice不等于empty slice

  • 对nil slice 可以进行append操作

  • 对nil slice 不可以赋值操作,会panic

通过make函数创建切片

// 创建一个整型切片,其长度为 3 个元素,容量为 5 个元素
slice := make([]int, 3, 5)

// len: the number of elements in v; if v is nil, len(v) is zero.
len(slice) = 3

如果没有在 make 中指定 cap 参数,那么底层数组长度 cap 就等于 len,比如:

// 创建一个整型切片,其长度和容量都是 5 个元素
slice := make([]int, 5)

通过字面量创建切片

// 创建字符串切片 其长度和容量都是3个元素
myStr := []string{"Jack", "Mark", "Nick"}
// 创建一个整型切片 其长度和容量都是4个元素
myNum := []int{10, 20, 30, 40}
// 设定切片长度为0,注意和nil slice的区别
myNum := []int{}

切片的常用操作

动态扩容

“动态扩容”指的就是,当我们通过 append 操作向切片追加数据的时候,如果这时切片的 len 值和 cap 值是相等的,也就是说切片底层数组已经没有空闲空间再来存储追加的值了,Go 运行时就会对这个切片做扩容操作,来保证切片始终能存储下追加的新值。

func append(slice []Type, elems ...Type) []Type

append 函数的参数长度可变,因此可以追加多个值到 slice 中,还可以用 ... 传入 slice,直接追加一个切片。

slice = append(slice, elem1, elem2)
slice = append(slice, anotherSlice...)

执行 append()操作,如果slice的容量仍然能够容纳新的元素,则slice不会发生扩容。否则底层数组就会扩容

  • 在切片的容量小于 1000 个元素时,总是会成倍地增加容量。

  • 一旦元素个数超过 1000,容量的增长因子会设为 1.25,也就是会每次增加 25%的容量

需要注意的是,slice在扩容后会新建新的slice,仍然指向原底层数组。因此我们通常需要使用原slice来接收append的结果

在 golang 的文档中关于 append 方法有这样一句话,“It is therefore necessary to store the result of append, often in the variable holding the slice itself”,即执行append 操作,一般会将append的结果赋值给原来的 slice。

遍历切片

Go的SDK中提供了两种遍历slice的方式

image.png

Index for loop

nums := []int{1, 2, 3}
for i := 0; i < len(nums); i++ {
    fmt.Println(nums[i])
}

// ouput: 
// 1 
// 2 
// 3 

for range 遍历

使用 for…range 对切片中的元素进行遍历:

num1 := []int{1, 2, 3}
for index, value := range num1 {
    fmt.Printf("index: %d value: %d\\n", index, value)
}

// output:
// index: 0 value: 1
// index: 1 value: 2
// index: 2 value: 3

当迭代切片时,关键字 range 会返回两个值。第一个值是当前迭代到的索引位置,第二个值是该位置对应元素值的一份副本。

不过需要特别注意的是, for range 属于go sdk中的语法糖,其底层源码如下:

// len_temp := len(range)
// range_temp := range
// for index_temp = 0; index_temp < len_temp; index_temp++ {
//     value_temp = range_temp[index_temp]
//     index = index_temp
//     value = value_temp
//     original body
//   }

特别注意:

  • For range操作会首先copy一份新的slice,其pointer 指向原底层数组

  • value值在遍历过程中,始终是一个变量。

求切片长度

len(xxx)

查看源码注解:

Slice, or map: the number of elements in v; if v is nil, len(v) is zero.

slice源码

Slice底层表示如下

// runtime/slice.go
type slice struct {
    array unsafe.Pointer // 元素指针
    len   int // 长度 
    cap   int // 容量
}

slice 共有三个属性:

  • 指针: 指向底层数组;

  • 长度: 表示切片可用元素的个数。使用下标对 slice 的元素进行访问时,下标不能超过 slice 的长度;

  • 容量: 底层数组的元素个数

注意,底层数组是可以被多个 slice 同时指向的,因此对一个 slice 的元素进行操作是有可能影响到其他 slice 的。

Slice 注意点

empty slice和nil slice

// nil slice
var s1 []int
// empty slice
var s2 = []int{}

slice作为参数传递

如下代码


func main() {
    nums := make([]string, 2, 6)
    nums[0] = "a"
    nums[1] = "b"
    // 0x140000a6108
    fmt.Println(fmt.Sprintf("%p", &nums))
    modify(nums)
    // [c b]
    fmt.Println(nums)
}

func modify(nums[]string) {
    // 0x140000a6120
    fmt.Println(fmt.Sprintf("%p", &nums)) // 这里的nums是copy的main函数中的nums 因此指针不一样
    nums[0] = "c"
    nums = append(nums, "d")
}

输出结果说明:

  1. Slice 作为参数传递,其传递的是slice引用的副本。

  2. modifySlice函数中,nums[0] = "c" 会修改slice底层指向的数组,因此也会影响到main函数中的slice

  3. Append 函数会首先判断是否需要扩容,如果需要则会新建slice,并且指向原底层数组

If it has sufficient capacity, the destination is resliced to accommodate the new elements. If it does not, a new underlying array will be allocated.

Ref: juejin.cn/post/706493…

for range中并发执行

下面的例子中预期在for range 遍历中输出[1,2,3,4]。 但是遗憾的是输出结果为[4,4,4,4]。原因正如3.2.1中所述,for range遍历过程只会存在一个num 变量。每次循环都共享同一个num变量。因此即使第一个协程拿到的num值为1,但是真正执行fmt.println 时 num的值已经被更新为4了。因此所有协程打印的num值都为4。

func main() {
    nums := []int64{1, 2, 3, 4}
    for _, num := range nums {
       go func() {
          fmt.Println(num)
       }()
    }

    // output: [4,4,4,4]
    time.Sleep(time.Second)
}