Go · Slice 切片底层

slice 是"数组的三元视图" — ptr/len/cap 三元组, append 扩容搬家, 共享底层数组的暗雷

两个 header 看同一段底层数组 — 改 s2[0] 等于改 s[1], 彼此可见 s := arr[2:6] — 主视图 SliceHeader: 三个字段, 24 字节 ptr → &arr[2] len = 4 cap = 7 s2 := s[1:3] — 子视图 不拷贝数据, 只换三元组 ptr → &arr[3] len = 2 cap = 6 backing array 15 8 42 7 99 13 · · · · 0 1 2 3 4 5 6 7 8 9 (cap 外) len(s)=4 — s 的读写地盘 cap(s)=7 — append 的预留地, 第 9 格在 cap 外 危险区 — append 双面性 append — cap 还够: 原地写 s3 := append(s, 88) 写进 B[6], len 4→5, cap 不变 零分配零拷贝, 最快路径 但 s2 的 cap 也罩着 B[6] → 兄弟数据被踩! append — cap 不够: 搬家 s2 塞满 cap 后再 append 新数组 cap≈2× 起步 旧元素逐格拷入新家 旧数组有人引用就不释放 返回值必须赋回 s2 = 旧 header 不接 = 白搬 三索引 s[a:b:c] — 把 cap 钉死, 隔离兄弟 s2 := s[1:3:3] ptr=&arr[3] len=2 cap=2 cap 被钉死, 再 append 必搬家 不踩任何兄弟 — 隔离标准姿势 复用 s[:0] header 归零但底层数组还在 零分配循环利用缓冲 含指针元素记得置 nil copy 才是真拷贝 b := a 只拷 24B header copy(b, a) 逐元素复制 返回 n = 实际拷贝条数 Legend header / len 区 cap 区 (append 落点) 视野外 原地写 搬家 / 隔离

本质: 数组的三元视图

  • • header {ptr, len, cap}, 赋值/传参只拷这三个字段
  • • 数据原地不动, 便宜但共享
  • • nil slice 与空 slice 是两个东西
  • • 数组 [n]T 是值类型, 切片 []T 是引用视图

append 扩容双面性

  • • cap 够: 原地写, len+1, 零分配
  • • 不够: 新数组搬家 (1.18+: 256 前翻倍后渐进)
  • • 返回值必须赋回, 否则白搬
  • • 预知规模就 make([]T, 0, n) 一步到位

共享底层数组暗雷

  • • 子切片与本体同看一段格子, 改动互见
  • • 兄弟 append 互相覆盖经典事故
  • • 三索引 s[a:b:c] 钉死 cap 做隔离
  • • 大切片切小片 = 内存滞留隐患

💡 一句话理解

slice 不是"动态数组", 是数组上的一张三元视图: (ptr, len, cap)。赋值和传参拷的只是这三个字段, 数据原地不动 —— 这是它便宜的原因, 也是一切暗雷的来源: 两个切片可以看同一段底层数组, 一方的写入另一方立刻可见。

append 有双重性格: cap 还够就原地写 (可能踩到隔壁切片的地盘), 不够就整段搬家到新数组 (旧数组留给还引用它的人)。所以"s = append(s, x) 必须赋回"和"函数里改元素外部看得见"这对看似矛盾的现象, 都是同一张 header 图画出来的。

🧠 必知必会 必考 & 必会

SliceHeader
{ptr, len, cap} 三个字段: ptr 指向底层数组某元素, len 是当前视野, cap 是到数组末尾的余量。赋值/传参/比较都是拷这 24 字节, 不碰数据。
s := make([]int, 3, 5)
fmt.Println(len(s), cap(s))       // → 3 5
b := s                            // 关键: 只拷 24B header, 数据不动
fmt.Println(len(b), cap(b))       // → 3 5, 与 s 同看一段格子
nil slice vs 空 slice
var s []int 是 nil (header 全零); s := []int{} 非 nil, ptr 指向 zerobase。两者 len==0 行为一致, 但 json 序列化一个是 null 一个是 []。
var a []int              // nil: header 全零
b := []int{}             // 非 nil, len=0
fmt.Println(a == nil, b == nil)  // → true false
// json: a → null, b → []
扩容策略
Go 1.18+: cap < 256 时翻倍, 之后按约 1.25 倍渐进增长, 最后还要对齐到 size class —— 所以实际新 cap 常比公式略大。预知规模用 make([]T, 0, n) 免掉全部中间拷贝。
s := make([]int, 0)
s = append(s, 1, 2, 3)
fmt.Println(len(s), cap(s))       // → 3 3 (按需+对齐 size class)
s = append(s, 4, 5, 6, 7)
fmt.Println(len(s), cap(s))       // → 7 8 关键: 搬家即拷贝
子切片共享
s2 := s[1:3] 不拷数据, 两个 header 指向同一段格子; 改 s2[0] 等于改 s[1]。读多写少的分页/视图场景是福利, 乱写就是数据污染。
s := []int{1, 2, 3, 4}
s2 := s[1:3]                  // 不拷数据, 同看一段格子
s2[0] = 99                  // 改 s2[0] 等于改 s[1]
fmt.Println(s[1])               // → 99
copy 与赋值差异
b := a 拷 header; copy(b, a) 才逐元素复制数据, 返回实际拷贝条数 (受 min(len) 限制) —— 想要独立副本必须先 make 再 copy。
a := []int{1, 2, 3}
b := a                          // 只拷 header
b[0] = 99                     // a[0] 也变 99
c := make([]int, len(a))
n := copy(c, a)                 // 关键: 逐元素复制, n=3
三索引切片
s[a:b:c] 把 cap 钉死为 c-a, append 一撞墙就搬家 —— 与兄弟切片隔离的标准姿势, 标准库接口返回内部缓冲时常用。
s := []int{1, 2, 3}
s2 := s[0:2:2]                // 关键: cap 钉死为 2
_ = append(s2, 99)            // 撞墙搬家, 写进新数组
fmt.Println(s)                  // → [1 2 3], 本体没被踩
make 预分配
已知元素规模时 make([]T, 0, n): 一次分配到位; make([]T, n) 则是 n 个零值起步 —— 长度和容量语义别混。
a := make([]int, 3)         // len=3: 3 个零值起步
a = append(a, 1)              // → [0 0 0 1]
b := make([]int, 0, 3)        // 关键: 空但备好空间
b = append(b, 1)              // → [1], 0 次再分配
切片传参
参数是 header 的值拷贝: 函数内改元素外部可见 (共享数组), append 扩容后外部不可见 (header 分叉) —— 想把追加传出去就返回新切片。
func touch(s []int) {
    s[0] = 99                  // 外部可见: 共享数组
    s = append(s, 7)           // 外部不可见: header 分叉
}
数组 vs 切片
[5]int 是值类型: 赋值/传参全量拷贝, 可 ==; []int 是 header: 拷贝极廉价, 不可 ==。数组是切片的"地基", 业务代码几乎永远用切片。
a := [3]int{1, 2, 3}
b := a                          // 数组: 全量拷贝
b[0] = 99
fmt.Println(a[0])               // → 1 不受影响
s1 := []int{1, 2, 3}; s2 := s1  // 切片: 只拷 header
s2[0] = 99                     // s1[0] → 99
切片不可比较
切片之间 == 直接编译错 (引用语义无法定义相等), 只能与 nil 比; 内容比较用 bytes.Equal / slices.Equal 或自写循环。
_ = s1 == s2                   // 编译错: slice can only be compared to nil
fmt.Println(slices.Equal(
    []int{1}, []int{1}))      // → true, 内容比较
越界三处
索引 s[i]: 0≤i<len; 切片表达式 s[a:b]: 0≤a≤b≤cap(s); 三索引 s[a:b:c]: 还要求 c≤cap(s)。越界都是运行时 panic: index out of range / slice bounds out of range。
x := make([]int, 3, 8)
_ = x[3]          // panic: index out of range [3] with length 3
_ = x[0:9]        // panic: slice bounds out of range [:9] with capacity 8
_ = x[0:2:9]      // panic: [::9] with capacity 8, c 不得超 cap
s[:0] 复用
清空 header 但底层数组不释放: 元素类型含指针时旧对象仍被引用 (内存滞留), 复用前把槽位置 nil。
s = s[:0]              // 错: 槽位仍引用旧对象, GC 回收不了
for i := range s { s[i] = nil }  // 对: 复用前清槽
s = s[:0]              // 再复用, 零分配
大切片小视图滞留
keep := big[:10] 让 100MB 底层数组活到 keep 死 —— GC 只看引用不看 len。想只留小片: copy 出去再弃大数组。
big := make([]byte, 100<<20)
keep := big[:1024]           // 错: 100MB 活到 keep 死
own := make([]byte, 1024)
copy(own, big[:1024])           // 对: copy 出去再弃大数组
string 与 []byte
互转默认全量拷贝 (安全, 因为 string 不可变); unsafe 零拷贝必须保证"不再修改 + 生命周期", 生产要 -race 验证 + 评审背书。
s := string(b)                // 全量拷贝 (安全)
b2 := []byte(s)               // 也是拷贝, 改 b2 不影响 s
// unsafe 零拷贝: 不再修改 + 生命周期可控才可用

🏭 生产实战 real world

场景 1 · 高并发路径预分配: allocs/op 从 11 降到 1

热路径上切片从空长起, 每次翻倍都是"分配+拷贝+旧数组进 GC", 三重开销:

func withoutPrealloc(n int) []int64 {
    s := make([]int64, 0)                     // 起点 cap=0: 之后 log2(n) 轮搬家拷贝
    for i := 0; i < n; i++ {
        s = append(s, int64(i))
    }
    return s
}
func withPrealloc(n int) []int64 {
    s := make([]int64, 0, n)                    // 已知规模: 一次到位, 0 次再分配
    for i := 0; i < n; i++ {
        s = append(s, int64(i))
    }
    return s
}
// go test -bench=Pre -benchmem
// BenchmarkWithout-8   5841722   205.1 ns/op   16376 B/op  11 allocs/op
// BenchmarkWith-8      7628761    78.4 ns/op    8192 B/op   1 allocs/op

场景 2 · 百万行报表分页: s[start:end] 视图, 0 拷贝

每页 copy 一份 80MB 数据, P99 直接劣化; 返回视图只花 24 字节:

// 100 万行的导出报表: 每次翻页拷贝 = 80MB/页, 网关内存被打爆
func (q *PageQuery) View(all []Row) []Row {
    start := (q.page - 1) * q.size
    if start >= len(all) {
        return nil                             // 越界页: 返回空由上层决定 404
    }
    end := start + q.size
    if end > len(all) {
        end = len(all)                          // 末页不满一页: clamp 到边界
    }
    return all[start:end]                        // 视图: 只拷 header, 数据不动
}
// 契约注释必写: 返回值与 all 共享底层, 调用方只读不可改
// 一旦有人改元素, 本体数据就被污染 (共享数组暗雷)

收益: 翻页内存恒定, GC 停顿从 40ms 级降到不可测。

场景 3 · io.ReadAll 换上限读: 800MB 响应不再 OOM

抓取第三方接口, 响应体大小不可控, io.ReadAll 一路翻倍峰值≈2×终值:

// 事故: 256MB 容器抓到 800MB 响应, 直接 OOMKilled
const maxBody = 64 << 20                          // 64MB: 按业务最大合法响应定
var buf bytes.Buffer
buf.Grow(1 << 20)                                   // 预热 1MB, 减少翻倍次数
n, _ := io.CopyN(&buf, resp.Body, maxBody+1)       // 多读 1 字节探测超限
if n > maxBody {
    return nil, fmt.Errorf("body exceeds %d bytes", maxBody) // 熔断
}
// 未超限: buf.Bytes() 就是完整数据, 峰值内存 = 实际大小 + 1MB

场景 4 · 批量 insert 分批: copy 出独立切片防复用改写

rows 来自 sync.Pool 缓冲, 异步写库还没落, 缓冲已被下轮复用填充 —— 数据悄悄串单:

func insertAll(ctx context.Context, rows []Row) error {
    const batch = 500
    for start := 0; start < len(rows); start += batch {
        end := min(start+batch, len(rows))
        chunk := rows[start:end]
        own := make([]Row, len(chunk))              // 拷独立副本: 切断与池化缓冲的共享
        copy(own, chunk)                                // 池子复用会改写底层, 异步侧还拿着旧引用
        if err := db.InsertBatch(ctx, own); err != nil {
            return fmt.Errorf("batch %d-%d: %w", start, end, err)
        }
    }
    return nil
}

场景 5 · []byte→string 零拷贝: unsafe 的生产使用守则

高频路径上 strconv/日志拼接 12% CPU 花在无谓拷贝; unsafe 能救但要立规矩:

// 高频日志路径: []byte(string) 每次全量拷贝, profile 里 12% 在转换
func unsafeString(b []byte) string {
    return *(*string)(unsafe.Pointer(&b))       // 零拷贝: string 头直接盖在 slice 头上
}
// 铁律 1: 返回值生命周期内 b 不得被修改 (string 不可变契约被打破 = 数据竞争)
// 铁律 2: b 之后被 append 搬家没关系, 但底层数组不能被复用写入
// 生产流程: go test -race 全绿 + 压测对比 + 注释写明约束与评审人
// 不确定就别用: strings.Builder / bytes.Buffer 池化足够覆盖 90% 场景

场景 6 · DB 行集投影 DTO: 一次遍历 + 上限预分配

从 []Row 投影 []DTO, 往往比数据本身还高频; 遍历次数和分配次数都要抠:

func toDTOs(rows []Row) []DTO {
    out := make([]DTO, 0, len(rows))                // 上限预分配: 一次到位 0 扩容
    for _, r := range rows {
        out = append(out, DTO{
            ID:    r.ID,
            Name:  r.Name,
            State: stateName(r.State),                // 枚举码翻译在投影层一次做完
        })
    }
    return out                                      // rows 为空时返回 [] 而非 null, 前端少判空
}

场景 7 · 固定窗口环形缓冲: 满后原地覆盖零分配

监控滑窗只要最近 512 个点, append 版每轮都白扔旧数据给 GC:

type Window struct {
    buf  []float64                                // 固定容量 512: 满后环形覆盖
    head int                                      // 下一个写入位
}
func NewWindow(n int) *Window {
    return &Window{buf: make([]float64, 0, n)}   // len 从 0 长到 cap
}
func (w *Window) Push(v float64) {
    if len(w.buf) < cap(w.buf) {
        w.buf = append(w.buf, v)                 // 未满: 正常增长
        return
    }
    w.buf[w.head] = v                                  // 已满: 原地覆盖, 永零分配
    w.head = (w.head + 1) % cap(w.buf)              // 环形推进
}

场景 8 · 流式聚合: append(data, chunk...) 预估容量

分片下载聚合, 盲 append 会经历 log2(总大小) 次搬家; 按元数据预估一步到位:

func collect(ch <-chan Chunk) []byte {
    out := make([]byte, 0, estimate())               // 按文件大小预估, 偏差 20% 内都赚
    for c := range ch {
        out = append(out, c...)                     // cap 够就纯 memcpy; 不够一次搬家
    }
    return out                                       // 预估失败也只是多一次拷贝, 不是错误
}
func estimate() int {
    if fi, err := os.Stat(path); err == nil {
        return int(fi.Size()) + 4096                // 文件大小 + 头部余量
    }
    return 1 << 20                                    // 未知: 1MB 起步
}

场景 9 · 去重保序: map 判重 + 切片保序

map 无序、set 不存在; 要"去重且保持首次出现顺序"就得双结构配合:

func dedupKeepOrder(in []string) []string {
    seen := make(map[string]struct{}, len(in)) // 上限预分配: map 也不扩容
    out := make([]string, 0, len(in))                // 最坏全保留: 按 n 预留
    for _, v := range in {
        if _, ok := seen[v]; ok {
            continue                                    // map 负责判重
        }
        seen[v] = struct{}{}
        out = append(out, v)                       // 切片负责保序 — 双结构配合
    }
    return out
}

场景 10 · 容器 OOMKilled 排查: 大切片小视图滞留 + GOMEMLIMIT

heap profile 只有 30MB, 容器却涨到 512Mi 被 OOMKill —— 滞留的引用不在 heap 常驻统计里显眼:

 # 事故: 容器 limit 512Mi, RSS 缓涨到 OOMKilled; heap profile 却只有 30MB
 # 根因: 100MB 大切片切出 1KB 视图长期持有, GC 无法回收底层数组
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: api
          resources:
            requests: { memory: "384Mi" }
            limits:   { memory: "512Mi" }
          env:
            - name: GOMEMLIMIT    # Go 1.19+: 让 GC 看见容器边界
              value: "450MiB"
            - name: GOGC
              value: "100"
 # 代码侧正解: 视图要独立存活时, copy 出小片再弃大数组
 # view := make([]byte, 1024); copy(view, big[:1024]); big = nil

改完 RSS 稳在 60Mi; GOMEMLIMIT 只是止血带, 根治还得砍掉滞留引用。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · append 兄弟切片互相覆盖 — s2 := s[1:5] 后 append s 或 s2, 另一方数据"莫名"被改. 原因: 两者 cap 罩着同一段格子, 原地写互相踩. 正解: 要隔离就 s2 := s[1:5:5] 或 copy 出独立副本。
s := []int{1, 2, 3, 4}
s2 := s[1:3]                // 错: cap 直通到数组末尾
s2 = append(s2, 99)           // 原地写: s → [1 2 3 99]
s3 := s[1:3:3]              // 对: 钉死 cap, append 必搬家
坑 2 · 函数内 append 想影响外部, 没返回新切片 — 调用后外部 len 没变. 原因: 扩容后函数里是新 header, 外部还持旧 header. 正解: return append(s, x) 或传 *[]T。
func add1(s []int, x int) {
    s = append(s, x)          // 错: 新 header 留在函数里
}
func add2(s []int, x int) []int {  // 对: 返回新切片
    return append(s, x)
}
坑 3 · 传参改元素可见, append 不可见 — 同一函数里"改 s[0] 生效、append 不生效", 行为时灵时不灵. 原因: 元素写进共享数组, len 变化留在函数内. 正解: 契约写清楚谁拥有切片, 追加必须返回。
func f(s []int) {
    s[0] = 99                   // 生效: 元素写进共享数组
    s = append(s, 7)            // 错: 不生效, header 分叉
}
// 对: 契约写明谁拥有切片, 追加必须返回
坑 4 · s = s[:0] 复用但旧对象滞留 — 内存只降不归零, heap 里一堆"已删"对象. 原因: 底层数组槽位仍引用旧元素. 正解: 复用前 s[i] = nil 清引用, 或池化结构明确生命周期。
s = s[:0]                 // 错: 槽位仍引用旧元素, GC 不收
for i := range s { s[i] = nil }   // 对: 复用前清引用
s = s[:0]                 // 再复用, 零分配
坑 5 · copy 返回值被忽略, 静默截断 — copy 只拷 min(len(dst), len(src)) 条, dst 太短就悄悄丢数据. 正解: if n := copy(dst, src); n != len(src) { ... } 校验。
copy(dst, src)                    // 错: dst 短则静默截断
if n := copy(dst, src); n != len(src) {  // 对: 校验条数
    return fmt.Errorf("copied %d/%d", n, len(src))
}
坑 6 · 以为加了三索引, 其实没写 cap — s[1:3] 的 cap 直通到数组末尾, append 照样踩兄弟. 正解: s[1:3:3], 第三个数字才是隔离钉子。
s2 := s[1:3]               // 错: cap 直通到数组末尾
s2 = append(s2, 7)            // 照样踩兄弟切片
s3 := s[1:3:3]             // 对: 第三个数字才是隔离钉子
坑 7 · nil slice 与空 slice 的 json 差异 — var 声明的 nil slice 序列化成 null, 前端 if (arr.length) 直接 TypeError. 正解: 出参统一 make([]T, 0) 或 []T{} 起步。
var a []string          // json → null
b := make([]string, 0) // json → []
// 错: 前端 arr.length 对 null 报 TypeError
// 对: 出参统一 []T{} 起步
坑 8 · range 取元素地址 (Go 1.22 前) — ptrs = append(ptrs, &v) 存的全是同一个地址, 最后全指向最后一轮的值. 正解: 升 1.22+ (每轮新变量) 或循环内 v := v / &s[i]。
for _, v := range items {
    ptrs = append(ptrs, &v)     // 错(1.22前): 全是同一个地址
    v := v                        // 对: 老版本定格副本
    ptrs = append(ptrs, &v)     // 或直接 &items[i]
}
坑 9 · 多维切片内层未初始化就索引 — var m [][]int; m[0] = append(m[0], 1) 直接 index out of range. 原因: 外层 len 为 0. 正解: 先 append(m, make(...)) 或 make 外层长度。
var m [][]int
m[0] = append(m[0], 1)        // 错: panic: index out of range [0]
m = append(m, []int{1})      // 对: 先长外层
m2 := make([][]int, 3)      // 对: 或 make 外层长度
坑 10 · make 长度当容量用 — make([]T, n) 出发即 n 个零值, 再 append 从第 n+1 位接上, 遍历时前面带一截零值. 正解: 要"空但备好空间"是 make([]T, 0, n)。
s := make([]int, 3)          // 错: 想要空间却得到 3 个零值
s = append(s, 1)             // → [0 0 0 1]
s2 := make([]int, 0, 3)       // 对: 空但备好空间
坑 11 · 大切片切小片, 内存滞留 — 100MB 数组切出 1KB 视图长期持有, RSS 下不来, 容器 OOM. 原因: GC 只看底层数组引用不看 len. 正解: keep := make([]T, k); copy(keep, big[:k]) 再弃 big。
keep := big[:1024]                 // 错: 100MB 底层数组活着
own := make([]T, 1024)             // 对: copy 出小片
copy(own, big[:1024])
big = nil                          // 再弃大数组
坑 12 · sort.Slice 不稳定 — 相等 key 的元素顺序会乱, 分页翻页时同 key 行跳来跳去. 正解: slices.SortStableFunc, 或比较器补唯一键做全序。
sort.Slice(rows, func(i, j int) bool {
    return rows[i].key < rows[j].key   // 错: 相等 key 顺序乱
})
slices.SortStableFunc(rows, cmp)         // 对: 稳定排序
坑 13 · append 参数顺序写反 — 想合并却写成 dst = append(src, dst...), 结果是 src 前缀 + dst 尾巴, 数据错乱. 正解: dst = append(dst, src...), 永远"往谁里面加"在前。
dst = append(src, dst...)   // 错: src 前缀 + dst 尾巴
dst = append(dst, src...)   // 对: "往谁里面加"在前
坑 14 · 三处越界 panic 分不清 — s[i] 超 len、s[a:b] 的 b 超 cap、三索引 c 超 cap, 报错都是 runtime panic. 正解: 外部输入先 clamp (min/max) 再切片。
_ = s[5]          // 错: index out of range [5] with length 3
_ = s[0:9]        // 错: slice bounds out of range [:9] with capacity 8
end := min(end, len(s))   // 对: 外部输入先 clamp 再切
坑 15 · string 转 []byte 后改的是副本 — b := []byte(s); b[0]='X' 以为 s 也变了, 其实互不影响. 原因: 转换是全量拷贝. 正解: 要共享必须 unsafe 且遵守不可变约束 (见场景 5)。
b := []byte(s)
b[0] = 'X'                 // 错: 以为 s 也变了, 其实是副本
fmt.Println(s)          // → 原 string 不变
// 对: 要共享必须 unsafe 且守不可变约束
坑 16 · range 中边删元素边前进 — for i := range s { if bad(s[i]) { s = append(s[:i], s[i+1:]...) } } 删除后下一轮 i 照加, 跳过紧邻的元素. 正解: 倒序删, 或写过滤循环重建切片。
for i := range s {
    if bad(s[i]) {
        s = append(s[:i], s[i+1:]...)  // 错: 跳过紧邻元素
    }
}
for i := len(s) - 1; i >= 0; i-- {     // 对: 倒序删
    if bad(s[i]) { s = append(s[:i], s[i+1:]...) }
}
坑 17 · 用 == 比较切片内容, 编译错 — "slices can only be compared to nil". 正解: 字节串用 bytes.Equal, 通用用 slices.Equal, 结构复杂自写循环。
_ = s1 == s2              // 错: slice can only be compared to nil
fmt.Println(bytes.Equal(a, b))      // 对: 字节串
fmt.Println(slices.Equal(s1, s2))   // 对: 通用内容比较
坑 18 · copy 时 dst 长度为 0, 拷了 0 个 — dst := make([]byte, 0, n) 只给了容量, copy 受 min(len(dst), len(src)) 限制返回 0, 数据全丢. 正解: make([]T, len(src)) 按"长度"分配, 或 copy 后 dst = dst[:n]。
dst := make([]byte, 0, n)    // 错: len=0, copy 返回 0
copy(dst, src)
dst := make([]byte, len(src)) // 对: 按"长度"分配
copy(dst, src)
坑 19 · 多 goroutine 并发 append 同一切片 — 数据随机丢失/覆盖, -race 直接报 DATA RACE. 原因: header 各自拷贝, len/cap 更新互相踩. 正解: mutex 保护, 或各建局部结果最后合并, 或 chan 收集。
// 错: 多 goroutine 并发 append 同一切片
//   go test -race → WARNING: DATA RACE, 数据随机丢失
mu.Lock()                       // 对: mutex 保护
out = append(out, x)
mu.Unlock()
坑 20 · append 返回值没赋回 — append(s, x) 单独一行, 编译器不报错, 扩容后数据全在"没人引用的新家"里丢了. 正解: s = append(s, x) 永远赋回; 评审时专门盯这个模式。
append(s, x)          // 错: 扩容后数据在"没人引用的新家"
s = append(s, x)        // 对: 永远赋回
// 评审时专门盯这个模式