golang基础-深入理解 slice

2,417 阅读13分钟

slice 是一种灵活、高效的数据结构, 在日常开发中经常使用,如果不理解 slice 背后的一些工作原理,还是经常会踩坑的,所以本篇着重挖掘 slice 背后的一些逻辑。

slice 是构建于 array 之上的一种数据结构,可以这样说,没有 array,就没有 slice,所以我们首先认识下 array。

Array

array 是由指定长度的同类型的元素组成的,它包括两个属性

  • 元素类型
  • array 长度

以下代码定义了一个长度是 5,类型是 int 的 array

var a = [5]int{1, 2, 3, 4, 5}

以上 a 变量在内存的数据结构如下图所示

Arboard.png

Talk is cheap. Show me the code. 为了更加清晰的了解 array 的内存分布,可以编写以下代码 main.go ,然后通过 debug 工具 delve 分析下 array 的内存组成。

test-array-memory.png

首先通过 break main.go:7 打断点,然后 continue 执行程序,最后通过 examinemem -count 3 -size 8 -x &s 打印内存分布。

➜  test-array dlv debug main.go 
Type 'help' for list of commands.
(dlv) break main.go:7
Breakpoint 1 set at 0x10abc53 for main.main() ./main.go:7
(dlv) continue
> main.main() ./main.go:7 (hits goroutine(1):1 total:1) (PC: 0x10abc53)
     2: 
     3: import "fmt"
     4: 
     5: func main() {
     6:         s := [5]int{1, 2, 3, 4, 5}
=>   7:         fmt.Println(s)
     8: }
(dlv) print s 
[5]int [1,2,3,4,5]
(dlv) examinemem -count 5 -size 8 -x &s 
0xc00009def0:   0x0000000000000001   0x0000000000000002   0x0000000000000003   0x0000000000000004   0x0000000000000005  

最后打印的结果显示 array 在内存就是一段连续的值(ps: 而 slice 的显示,会有明显的差异)。

silce 的诞生

虽然 array 是 golang 提供的基本的数据类型,但是有两个劣势

  • 固定的长度,这意味着初始化 array 后,不能再 push 超过 len(array) 长度的元素
  • array 作为参数在函数之间传递时,具有很大的性能浪费

第二点说明下: 因为 golang 中函数之间的调用不存在引用传递,都是值传递的。所以使用 array 作为参数时,意味着在调用函数时需要 copy 下这个参数, 这样会造成性能浪费。

为了消除这两个劣势,golang 引入一种新的类型:slice,slice 和 array 形态上的的差异是: slice 仅仅指定元素类型,无须指定长度。

比如以下定义的一个 slice。

var nums = []int{1, 2, 3, 4, 5, 6}

由于无需关心 slice 的长度,所以 slice 可以通过 append 方法随意 push 元素,彻底消除劣势一。

但是劣势二中提到的性能问题,slice 是如何解决的呢,我们看下 slice 的实现。

slice 的实现

slice 底层的数据结构是一个结构体,包括三个元素

type slice struct {
    array unsafe.Pointer 
    len int 
    cap int
}
  • array(Pointer) 是指向一个数组的指针
  • len 是当前 slice 的长度
  • cap 是当前 slice 的容量

比如以下代码定义了一个 len 是 3,cap 是 5 的 slice。

	s := make([]int, 3, 5)
	s[0] = 1
	s[1] = 2
	s[2] = 3

以上 slice 在内存中的数据结构如下图所示:

slice-struct.png

同样为了分析 slice 的内存组成,可以编写以下代码 main.go ,然后通过 debug 工具 delve 分析 slice 的内存组成。

image-20220718235039554.png

首先通过 break main.go:8 打断点,然后 continue 执行程序,最后通过 examinemem -count 3 -size 8 -x &s 打印内存分布。

➜  test-slice dlv debug main.go 
Type 'help' for list of commands.
(dlv) break main.go:8
Breakpoint 1 set at 0x1058e9f for main.main() ./main.go:8
(dlv) continue 
> main.main() ./main.go:8 (hits goroutine(1):1 total:1) (PC: 0x1058e9f)
     3: func main() {
     4:         s := make([]int, 3, 5)
     5:         s[0] = 1
     6:         s[1] = 2
     7:         s[2] = 3
=>   8: }
(dlv) print s 
[]int len: 3, cap: 5, [1,2,3]
(dlv) examinemem -count 3 -size 8 -x &s
0xc00004a758:   0x000000c00004a730   0x0000000000000003   0x0000000000000005   
(dlv) examinemem -count 3 -size 8 0x000000c00004a730 
0xc00004a730:   0x0000000000000001   0x0000000000000002   0x0000000000000003   
(dlv) examinemem -count 5 -size 8 0x000000c00004a730 
0xc00004a730:   0x0000000000000001   0x0000000000000002   0x0000000000000003   0x0000000000000000   0x0000000000000000 

分析下打印结果:

  • 内存 s 中第一个值是 0x000000c00004a730,打印下这个地址,里面存储的是1,2,3;而这些则是 slice 的 pointer 指向的值,是一个数组。
  • 内存 s 中第二个值是 0x0000000000000003,这个值是 len。
  • 内存 s 中第三个值是 0x0000000000000005,这个值是 cap。

对比下 array 和 slice 的内存分布,可以得出结论: 在 slice 内部的确存在一个 pointer,而这个 pointer 则指向实际的 array。

认识到 slice 的内存分布,就可以回答上一 part 的问题了,如果 array 作为参数在函数调中使用,由于值传递,性能的确很浪费,但是如果把 slice 作为参数,copy 的就是 slice 内置的三个属性,内存占用率得到很大的降低。

slice 的截取

注: slice 有多种截取方式,但底层原理都是类似的,这里分析一种常见的方式 [i:j]

通过 [i:j] 对原 slice 进行截取,截取的原理是:初始化一个结构体,然后把 slice 中的 array 指针指向底层 array 的 i 元素,截取之后的新的 slice 的 len 和 cap 会发生变化。

  • len 的大小是: j - i,表示当前的 slice 的长度
  • cap 的大小是: 原来cap - i,表示当前 slice 的 cap

例如我们有以下代码: main.go,然后截取 s[2:4],这里 s 和 s1 在内存中的指针指向的是同一个 array。

image-20220719085650135.png

这两个 slice 在内存中的分布如下:

screenshot-20220719-091005.png

同样可以使用 delve 工具分析下内存

➜  test-slice dlv debug . 
Type 'help' for list of commands.
(dlv) break main.go:8
Breakpoint 1 set at 0x10abcb9 for main.main() ./main.go:8
(dlv) continue 
> main.main() ./main.go:8 (hits goroutine(1):1 total:1) (PC: 0x10abcb9)
     3: import "fmt"
     4: 
     5: func main() {
     6:         s := []int{1, 2, 3, 4, 5}
     7:         s1 := s[2:4]
=>   8:         fmt.Println(s, s1)
     9: }
(dlv) print s 
[]int len: 5, cap: 5, [1,2,3,4,5]
(dlv) print s1
[]int len: 2, cap: 3, [3,4]
(dlv) examinemem -count 3 -size 8 -x &s 
0xc000079f20:   0x000000c000020120   0x0000000000000005   0x0000000000000005   
(dlv) examinemem -count 3 -size 8 -x &s1
0xc000079f08:   0x000000c000020130   0x0000000000000002   0x0000000000000003    
(dlv) examinemem -count 5 -size 8 -x 0x000000c000020120 
0xc000020120:   0x0000000000000001   0x0000000000000002   0x0000000000000003   0x0000000000000004   0x0000000000000005   
(dlv) examinemem -count 3 -size 8 -x 0x000000c000020130 
0xc000020130:   0x0000000000000003   0x0000000000000004   0x0000000000000005

可以看到通过 examinemem -count 5 -size 8 -x 0x000000c000020120 examinemem -count 3 -size 8 -x 0x000000c000020130 获取的 array 就是一个。

slice 的扩容

首先要明确,只有 append 操作可以触发 slice 的扩容,但是为什么会有扩容这个动作呢?

原因是 slice 在初始化时只会申请有限的内存空间,而随着 append 元素的增多,当元素超过当前 slice 的 cap ,就会重新申请一段新内存,把原数据 copy 到这个新内存上,然后 slice 把内部的指针指向这段新内存。

slice 的扩容是一个很智能的动作,当发生扩容时, slice 会有两个动作:

  • 扩大 slice 内部的 cap 值(按照 2 倍或者 1.25 倍扩大)。
  • 重新申请一个 array,把原数据 copy 到这个新 array 上,然后把 slice 的 array 指针指向这个申请的 array。

以下是个会发生扩容的例子,比如我们有以下代码: main.go,声明一个 slice,len = 3, cap = 4,然后 append 两个元素。

image-20220720080529577.png

首先,可以先大胆假设下。当 1,2,3 赋值到 s(slice) 后, s 中的 len、cap 没发生变化: len = 3,cap = 4;当 append 5 时,由于 len < cap,所以还能放一个值,也就不会发生扩容,放入后 cap = len = 4;然而当 append 6 时,原底层 array 已经没有足够的空间了,这时会发生扩容。

代码执行后,s、s1、s2 在内存中的分布应该如下:

image-20220720084301096.png

我们再次小心求证下,同样使用 delve 工具分析下内存。

➜  test-slice dlv debug .
Type 'help' for list of commands.
(dlv) break main.go:12
Breakpoint 1 set at 0x10abd70 for main.main() ./main.go:12
(dlv) continue 
> main.main() ./main.go:12 (hits goroutine(1):1 total:1) (PC: 0x10abd70)
     7:         s[0] = 1
     8:         s[1] = 2
     9:         s[2] = 3
    10:         s1 := append(s, 5)
    11:         s2 := append(s1, 6)
=>  12:         fmt.Println(s, s1, s2)
    13: }
(dlv) examinemem -count 3 -size 8 -x &s
0xc00007bf10:   0x000000c00001a0e0   0x0000000000000003   0x0000000000000004   
(dlv) examinemem -count 3 -size 8 -x &s1
0xc00007bef8:   0x000000c00001a0e0   0x0000000000000004   0x0000000000000004   
(dlv) examinemem -count 3 -size 8 -x &s2
0xc00007bee0:   0x000000c00001c240   0x0000000000000005   0x0000000000000008   
(dlv) 

把上面的 debug 信息录入到以下表格中,可以发现:

内存值地址lenCap
s0x000000c00001a0e00x00000000000000030x0000000000000004
s10x000000c00001a0e00x00000000000000040x0000000000000004
s20x000000c00001c2400x00000000000000050x0000000000000008
  • s 和 s1 中的地址没有发生变化,说明底层指向的是同一个 array,而 s2 地址发生了变化,说明发生了扩容。
  • s 和 s1 的 cap 值是 4,当再次 append 一个元素时,当前 cap = 原 cap * 2,也能说明发生了扩容。

所以,小心验证的结论没问题,slice 可以很"智能"的进行扩容,每次扩容是原来 cap 的 2 倍或者 1.25 倍,网上已经有一些文章在讲述扩容的原理了,所以就不多赘述了。

使用 slice 遇到的"坑"

使用 slice 时,如果不清楚 slice 的底层原理,还是会遇到一些意想不到的问题,而有些问题有时会很隐蔽,这里总结下,避免踩坑。

slice 初始化的"坑"

初始化一个 slice 有两种方式:

  • 直接声明: 比如 var s []int
  • 使用 make 关键字,比如: s := make([]int, 0)

这两种方式有什么区别呢?在日常 coding 中应该如何选择,这是一个容易被忽视的问题。

首先,先说下区别:

  • 直接声明 slice 的方式内部是不申请内存空间的,slice 内部 array 指针指向 null。
  • 使用 make 关键字会申请包含 0 个元素的内存空间,底层 array 指针指向申请的内存。

两者的内存分布如下图所示:

image-20220720222805521.png

同样对以下代码使用 delve 工具分析下内存。

image-20220720223335070.png

分析结果如下: 直接声明的 array 指针指向 0x0000000000000000,也就是 null,而使用 make 方式则会指向一个确定的地址 0x0000000001180e80,但是这个地址是没有元素的。

➜  test-array-slice dlv debug . 
Type 'help' for list of commands.
(dlv) break main.go:9
Breakpoint 1 set at 0x10abd2b for main.main() ./main.go:9
(dlv) continue 
[] []
> main.main() ./main.go:9 (hits goroutine(1):1 total:1) (PC: 0x10abd2b)
     4: 
     5: func main() {
     6:         var a []int
     7:         b := make([]int, 0)
     8:         fmt.Println(a, b)
=>   9: }
(dlv) examinemem -count 3 -size 8 -x &a 
0xc00007bf20:   0x0000000000000000   0x0000000000000000   0x0000000000000000   
(dlv) examinemem -count 3 -size 8 -x &b 
0xc00007bf08:   0x0000000001180e80   0x0000000000000000   0x0000000000000000 

清楚两者的区别后,再说下遇到的"坑"。有次在开发服务接口声明 slice 之后,然后通过 json.Marshal 序列化 slice,但是序列化的结果是有区别的。

  • json.Marshal(直接声明): 返回 null
  • json.Marshal(make关键字初始化): 返回 []

本来和前端定义的的某个返回字段是 slice ,但是最后返回的是 null,感觉很奇怪,最后定位到是使用直接声明的方式初始化 slice 的,下面是出问题代码的最小 case。

package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	var a []int
	b := make([]int, 0)

	aResult, _ := json.Marshal(a)
	bResult, _ := json.Marshal(b)
	fmt.Println(string(aResult)) //null
	fmt.Println(string(bResult)) // []
}

slice 扩容的"坑"

这个坑在面试中经常会遇到,当 slice 作为函数参数时,如果在函数内部发生了扩容,这时再修改 slice 中的值是不起作用的,因为修改发生在新的 array 内存中,对老的 array 内存不起作用,以下是代码的最小 case。

package main

import "fmt"

func main() {
	var s = []int{1, 2, 3}
	modifySlice(s)
	fmt.Println(s) // 打印 [1 2 3]
}

func modifySlice(s []int) {
	s = append(s, 4)
	s[0] = 4
}

同样感兴趣的可以使用 delve 工具分析下内存,这样理解的更加清晰,这里不再赘述。

总结

本篇文章首先分析了 array,然后指出 array 的两个劣势,从而引出 slice,slice 通过优化内部的结构,解决了使用 array 的问题。但是也正是因为 slice 独特的内部结构,让我们在开发中可能会遇到一些"坑",所以分析了一些易出错的点。

最后,希望本篇文章真的能给你带来收获,感谢🙏。

参考

go.dev/blog/slices…

blog.devgenius.io/go-with-me-…

stackoverflow.com/questions/2…