bigywater.tech
Go · 19

切片共享底层数组:append 之后谁会被改掉

切片头是 {指针, 长度, 容量} 三个字,拷贝切片只拷贝这三个字,底层数组是共享的。这篇用真实输出验证几个最容易出事的场景:子切片被写、容量够时 append 悄悄改原切片、函数里 append、复用缓冲区。

cap 4
a[1:3] 的容量:append 在容量内会直接覆盖 a[3]
a[1:3:3]
三下标切片把 cap 限制成 2,append 必然换新数组
"gamma"、"gamm"
复用 buf 并存 buf[:n] 的结果,三个词都被改写

子切片与 append

实测 · 本机执行输出
  a = [1 2 3 4 5](len 5 cap 5);b := a[1:3] = [2 3](len 2 cap 4);b 的数据地址 - a 的数据地址 = 8 字节
  b[0] = 99 之后:a = [1 99 3 4 5](改到了 a)
  b = append(b, 100)(容量够,原地写):b = [99 3 100],a = [1 99 3 100 5](a[3] 被悄悄改成 100);b 仍与 a 共用数组?true
  再追加 3 个(超过 cap 4,换了新数组):b = [99 3 100 200 300 400];共用数组?false;a 仍是 [1 99 3 100 5]

a[1:3] 的数据指针指向 a 的第 2 个元素,容量一直延伸到 a 的末尾,所以对它 append 时,只要不超过容量,就会写进 a 后面的位置。这是 Go 里最典型的“别名”意外:代码看起来只改了 b,a 却变了。超过容量时运行时换一个新数组,别名关系才断开,而从外面看不出哪次 append 会换数组。

怎么切断共享

实测 · 本机执行输出
  c := a[1:3:3] = [2 3](len 2 cap 2);c2 := append(c, 100) → c2 = [2 3 100],a 仍是 [1 2 3 4 5];c2 的数据地址 == c 的数据地址?false
  d := slices.Clone(a[1:3]):d[0] = -7 之后 d = [-7 3],a = [1 2 3 4 5];d 的 cap = 2
  e := make([]int, 2); copy(e, a[1:3]) 拷贝了 2 个:e[1] = -8 之后 e = [2 -8],a = [1 2 3 4 5]

三下标切片 a[low:high:max] 把容量限制在 max - low,所以之后的 append 一定换新数组,原切片不会被写。要独立的副本就用 slices.Clone 或 copy 到新切片。两者都拷贝了元素;如果元素本身是指针、切片或 map,拷贝的只是它们的头,里面的内容仍是共享的(本文没有展示这一点)。

函数参数

实测 · 本机执行输出
  fill(x) 里写 s[0] = -1:调用者的 x = [-1 2 3](元素被改)
  appendInside(x) 里 s = append(s, 9)(cap 10,容量够):调用者的 x = [-1 2 3](len 仍是 3);但 x[:4] = [-1 2 3 9](底层数组第 4 个位置已经被写成 9)
  同样的调用,y 的 cap 只有 3(要换数组):y[:3] = [0 0 0],y 的 cap 仍为 3,数组没有被写

切片按值传递,所以函数里 s = append(s, x) 不会改变调用者的长度。但如果容量够,元素已经写进了共享的底层数组:调用者的 x 看不到(长度没变),x[:cap(x)] 能看到。这是“append 不能直接用返回值以外的方式”这条规则背后的原因:要把新切片返回或通过指针传回。

复用缓冲区

实测 · 本机执行输出
  读了 alpha、beta、gamma 三个词,每次都读进同一个 buf:
    saved[0] = "gamma"(直接存 buf[:n]);savedOK[0] = "alpha"(存 slices.Clone)
    saved[1] = "gamm"(直接存 buf[:n]);savedOK[1] = "beta"(存 slices.Clone)
    saved[2] = "gamma"(直接存 buf[:n]);savedOK[2] = "gamma"(存 slices.Clone)
  saved[0] 与 saved[2] 的数据地址相同?true

读取循环里复用同一块缓冲区是常见的性能优化,但要保存数据就必须拷贝。标准库里返回内部缓冲区切片的方法在文档里都有提醒:go doc bufio.Scanner.Bytes 写的是“The underlying array may point to data that will be overwritten by a subsequent call to Scan”,bufio.Reader.Peek 写的是“The bytes stop being valid at the next read call”(这两段是我用 go doc 读到的,没有运行这两个方法)。这里用一个手写的 buf 重现同样的后果:三个存下来的切片指向同一个数组。

分步演示

切片共享底层数组
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。用 unsafe.SliceData 比较切片的数据地址(只比较相等与偏移);实验里所有切片都是 []int(第四节是 []byte)
    • 没有验证:bufio.Scanner.Bytes 与 bufio.Reader.Peek 的实际行为(只引用了 go doc 的说法,没有运行)、切片元素是指针时拷贝的深浅、并发读写同一个底层数组的数据竞争(见竞态检测一篇)、go vet 或其它工具能否发现这类别名问题
    京ICP备2026067502号-1京公网安备11010502063487号