for range遍历slice时先复制快照(array指针、len、cap),循环次数和元素值均由快照决定,append扩容不影响遍历行为。

range 遍历时的 slice 拷贝行为是关键
Go 的 for range 遍历 slice 时,**不是直接遍历原 slice,而是先复制一份快照**。这个快照包含原 slice 的 array 指针、len 和 cap —— 三者在循环开始前就固定了。
这意味着:即使你在循环体内用 append 扩容、甚至让 slice 指向新底层数组,也不会改变当前循环的迭代次数或访问的元素值。
- 循环次数由快照的
len决定,与后续append无关 - 循环中读取的
v是元素副本,修改它不影响原 slice - 若扩容导致底层数组迁移(如从原数组拷贝到新数组),快照中的
array指针仍指向旧地址,但已不影响遍历逻辑——因为索引只用到快照长度范围内的旧数据
append 扩容后原 slice 可能“断连”,但 range 不受影响
当 append 触发扩容并分配新底层数组时,原 slice 结构体的 array 字段会更新为新地址。但 for range 用的是循环开始前的快照,其 array 仍指旧内存 —— 这正是为什么你在循环里 append 多少次,都不会让 range 多跑一轮。
典型陷阱场景:
-
s := []int{1,2,3}; for _, v := range s { s = append(s, v) }→ 输出[1 2 3 1 2 3],不会无限循环 - 若原 slice 容量足够(如
make([]int, 3, 10)),append不扩容,range快照和原 slice 共享同一底层数组,但遍历仍只看初始len
扩容策略变化影响的是性能,不是 range 行为
Go 1.18 起扩容阈值从 1024 改为 256,增长公式也更平滑(如容量 > 256 时按 (oldCap + 3*256)/4 增加)。但这些变化**只影响 append 分配多少内存、是否触发拷贝,对 for range 的语义毫无干扰**。
真正要注意的是:频繁扩容会放大以下问题:
- 每次扩容都可能触发底层数组拷贝,而
range快照还在读旧内存(虽安全,但浪费) - 若误以为
range能感知扩容后的长度,可能写出逻辑错误(比如想边遍历边收集新元素到同一 slice) - 大 slice 扩容成本高,而
range本身不感知,容易掩盖性能瓶颈
nil slice 和零长 slice 的 range 行为一致
nil slice(var s []int)和显式空切片(s := []int{})的 len 都是 0,所以 for range s 直接跳过,不执行循环体。这和扩容完全无关,但常被混淆。
容易踩的坑:
- 函数参数接收
[]T,调用方传nil,若函数内没做len(s) == 0判断就直接append,可能意外扩容出新底层数组,而调用方仍持有旧nil值 - 把
range当作“实时视图”使用,比如在 goroutine 中并发修改 slice 并期望另一 goroutine 的range看到变化 —— 实际上它只看到启动时的快照
最易被忽略的一点:range 的“不可变快照”特性看似安全,但它掩盖了底层数据是否真实共享。当你在循环中既读又写同一个 slice,必须明确区分「读的是哪块内存」「写到了哪块内存」,否则扩容带来的底层数组切换会让行为变得难以预测。











