原创分享 切片真的是引用类型嘛

go-coder · 2020年09月14日 · 310 次阅读

来自公众号:新世界杂货铺

: 以下仅代表个人看法, 如果和大部分文章内容不一致,还请酌情参考

灵魂三问

Q1. 引用和指针的区别是什么?

答: 其实他们的区别我也说不清楚, 但是他们都有一个共同点, 那就是他们能够指向真实的值, 操作他们会改变真实的值

Q2. 在 go 中有指针了我们为什么还要有提出引用的概念?

答:很明显这个问题我答不上来, 说实话我也只是看很多文章里面这么说, 我自己没有去认真研究过

Q3. go 中真的有引用这个概念嘛?

答: 这个问题或许可以解答一下 Q2. 但是我也不敢作出肯定的答复. 我抱着这个问题去官方文档搜索了一通,发现并没有搜索到 “引用” 相关的单词, 也没有找到引用变量的定义

灵魂三问引发的思考

灵魂三问中核心就是引用,那么这个时候我就开始思考了,在 go 中哪些是引用类型呢。众所周知, 大部分文章里面都会提到切片是引用类型, 我曾经也一直坚信切片就是引用类型, 但是直到我遇到了下面的代码, 我开始怀疑自己的认知了

a := make([]int64, 0)
a = append(a, 10)

上述代码在平时的开发中出现的频率非常高,那么我的疑问就是既然切片是引用类型,那么为什么 append 之后还必须要赋值给它本身呢。

append 的分析

假如我们不赋值给它自己, 会发生什么呢? 我们来看看下面的代码

a := make([]int64, 0, 0)
_ = append(a, 10)
fmt.Println(a)
fmt.Printf("address: %p\n", a)
// 输出:
[]
address: 0x1195a98

这个结果和切片是引用类型明显是不相符合的。这中间究竟发生了什么, 我们又应该怎么做呢?接下来我们让代码说话, 此时上述简单的代码已经没有更多的信息了, 那我们尝试看看编译生成的汇编代码, 希望能够从汇编中找到蛛丝马迹。

# go代码转汇编
go tool compile -N -l -S test.go

摘抄其中部分关键汇编代码如下:

0x002f 00047 (test.go:6)    PCDATA  $0, $1
0x002f 00047 (test.go:6)    PCDATA  $1, $0
0x002f 00047 (test.go:6)    LEAQ    type.int64(SB), AX
0x0036 00054 (test.go:6)    PCDATA  $0, $0
0x0036 00054 (test.go:6)    MOVQ    AX, (SP)
0x003a 00058 (test.go:6)    XORPS   X0, X0
0x003d 00061 (test.go:6)    MOVUPS  X0, 8(SP)
0x0042 00066 (test.go:6)    CALL    runtime.makeslice(SB)
0x0047 00071 (test.go:6)    PCDATA  $0, $1
0x0047 00071 (test.go:6)    MOVQ    24(SP), AX
0x004c 00076 (test.go:6)    PCDATA  $1, $1
0x004c 00076 (test.go:6)    MOVQ    AX, "".a+112(SP)
0x0051 00081 (test.go:6)    XORPS   X0, X0
0x0054 00084 (test.go:6)    MOVUPS  X0, "".a+120(SP)
0x0059 00089 (test.go:7)    JMP 91
0x005b 00091 (test.go:7)    PCDATA  $0, $2
0x005b 00091 (test.go:7)    LEAQ    type.int64(SB), CX
0x0062 00098 (test.go:7)    PCDATA  $0, $1
0x0062 00098 (test.go:7)    MOVQ    CX, (SP)
0x0066 00102 (test.go:7)    PCDATA  $0, $0
0x0066 00102 (test.go:7)    MOVQ    AX, 8(SP)
0x006b 00107 (test.go:7)    XORPS   X0, X0
0x006e 00110 (test.go:7)    MOVUPS  X0, 16(SP)
0x0073 00115 (test.go:7)    MOVQ    $1, 32(SP)
0x007c 00124 (test.go:7)    CALL    runtime.growslice(SB)
0x0081 00129 (test.go:7)    PCDATA  $0, $1
0x0081 00129 (test.go:7)    MOVQ    40(SP), AX
0x0086 00134 (test.go:7)    JMP 136
0x0088 00136 (test.go:7)    PCDATA  $0, $0
0x0088 00136 (test.go:7)    MOVQ    $10, (AX)

go 的汇编是 Plan9 的汇编。而我几乎是看不懂的,但幸运的是我发现了其中的部分关键词。

0x0042 00066 (test.go:6)    CALL    runtime.makeslice(SB)
0x007c 00124 (test.go:7)    CALL    runtime.growslice(SB)

(test.go:6) CALL runtime.makeslice(SB)a := make([]int64, 0), (test.go:7) CALL runtime.growslice(SB)_ = append(a, 10) 都能够对应起来。 最后,我在 go 源码的 runtime 包中的slice.go文件中发现了这两个函数, 并发现了下面这个结构体

// 其中array 为一个指向数组的地址
// len 表示当前数组的长度
// cap 表示当前数组的真实容量
type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

其中调用 append 函数时, 汇编代码中也调用了runtime.growslice, growslice 函数签名如下

func growslice(et *_type, old slice, cap int) slice

我们注意看下面的 debug 调用栈 (debug runtime 的小技巧后面有机会再介绍):

由上图知, 在本列中,append 在 runtime 层调用了growslice并返回了一个新的 slice 结构体, 而我没有将返回的变量赋值给原有变量a, 所以导致打印的结果为空。 到这里感觉问题已经基本有点头绪了, 因为返回的是一个结构体, 所以必须要进行赋值, 否则原结构体变量是不会发生改变的。

那么新的问题来了, 如果我们 append 之后不赋值给原先的切片, 原先的切片是不是就没有任何变化呢?

从最开始的代码输出结果来看, 原先的切片确实没有任何变化, 但真实结果还是让我们用代码来说话

// 长度取1, 是为了保证能够正常取到底层数组的地址
// 容量取2,是为了保证数组容量足够, 而不用对数组进行扩容导致地址发生变化
a := make([]int64, 1, 2)
_ = append(a, 10)
fmt.Println(a)
// 强制访问元素a[2]
baseAddr := unsafe.Pointer(&a[0])
offset := unsafe.Sizeof(a[0])
fmt.Println(*(*int64)(unsafe.Pointer(uintptr(baseAddr) + offset)))
// 输出:
[0]
10

通过上面的代码知道, append 函数操作后,底层的数组实际已经发生了变化, 只是因为 append 后的结果未赋值给变量 a, 所以结构体中的 len 未发生变化, 导致无法正确打印切片的内容

结论

  1. go 中切片其实是一个 runtime 中的slice结构体
  2. append 操作 slice 底层的数组后, 改变了数组的长度或者容量, 所以需要重新赋值给原先的切片, 如果切片发生扩容原先的数组地址也要发生变化
  3. 根据以上我擅自得出结论: go 中不存在引用的概念, 主要是指针, 所以 go 中也只有值传递

补充

slice 是一个包含 data、cap 和 len 的私有结构体

map 是一个指向 runtime.hmap 结构体的指针

chan 是一个指向 runtime.hchan 结构体的指针

Q1: runtime.makeslice 返回一个 unsafe.Pointer?

这个 unsafe.Pointer 即为切片中数组第一个元素的地址

Q2: 有些汇编代码 append 没有调用 runtime.growslice?

go 编译器进行了部分优化, 如果切片的容量足够, 就不需要调用 runtime.growslice, 容量不足就会调用 runtime.growslice 进行扩容, 具体扩容逻辑请参见 runtime/slice.go

注: 写本文时, 笔者所用 go 版本为: go1.13.4

参考

https://draveness.me/golang/docs/part2-foundation/ch05-keyword/golang-make-and-new/

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册