一道很常见的 Go 面试题是:把切片传给函数后,在函数里执行 append,调用方看到的数据到底会不会变?这个问题容易被一句“切片是引用类型”或“Go 只有值传递”带偏,真正决定结果的其实是切片头的复制方式,以及 append 是否复用了原底层数组。把这两个点理顺后,你不仅能把题答完整,也更容易在参数拼装、权限列表、查询条件这些场景里提前避坑。
先把结论说清楚:append 不会自动把新长度带回调用方
先看最基础的一段代码:
func addTag(tags []string) {
tags = append(tags, "vip")
}
func main() {
tags := []string{"new"}
addTag(tags)
fmt.Println(tags) // [new]
}
这里调用方看不到 "vip"。原因不是 append 没生效,而是函数拿到的是一份切片描述信息的副本。append 之后得到的新切片,只重新赋给了函数内部的 tags,外层变量的长度仍然还是 1。
所以,只要函数里可能执行 append,最稳妥的接口写法就是返回新切片,并让调用方接住它:
func addTag(tags []string) []string {
return append(tags, "vip")
}
func main() {
tags := []string{"new"}
tags = addTag(tags)
fmt.Println(tags) // [new vip]
}
这也是实际开发里最值得记住的一条规则:函数内只要可能追加元素,就把结果返回出去,不要指望外层变量自动“长出来”。
为什么外层长度没变,底层数据却可能已经变了
切片本身可以理解为三项信息:底层数组的位置、当前长度 len、容量 cap。函数传参时,复制的是这三项信息,而不是整份元素内容。

这就带来一个容易忽略的现象:即使调用方的长度没变,只要底层数组仍被复用,函数里的 append 仍可能把数据写进原来的那块内存。
看下面这段代码:
func addHidden(nums []int) {
nums = append(nums, 99)
}
func main() {
nums := make([]int, 1, 3)
nums[0] = 10
addHidden(nums)
fmt.Println(nums, nums[:2]) // [10] [10 99]
}
输出里,nums 还是 [10],因为它的长度没有变;但一旦重新切到 nums[:2],99 就出现了。这说明函数里的追加操作并没有创建新数组,而是直接复用了原来的底层数组。
换句话说,调用方虽然“看不到”新长度,却已经承受了底层数据被写入的副作用。这也是很多线上问题难排查的原因:表面上切片没变,实际上共享数组早就被改了。
真正决定结果的,是容量 cap 是否足够
Go 的规则并不复杂:如果容量足够,append 会优先复用原底层数组;如果容量不够,就会分配新数组,再把原数据复制过去。于是,同样的函数,结果可能完全不同,差别只在调用方给出的容量。
把上面的例子稍微改一下:
func addHidden(nums []int) {
nums = append(nums, 99)
}
func main() {
nums := []int{10}
addHidden(nums)
fmt.Println(nums, cap(nums)) // [10] 1
}
这里 nums 的容量只有 1,追加 99 时已经放不下,函数内部只能申请新数组。于是这次不仅外层长度没变,原底层数组也不会被追加操作改写。
也正因为如此,面试里“append 后会不会影响原切片”不能只答会或者不会。更准确的说法是:
items[0] = x这类原位修改,调用方通常可见。append后,调用方的len不会自动增加。append是否写入原数组,要看容量cap是否足够。- 追加后的结果如果要给外层使用,必须通过返回值交还调用方。
这几条合在一起,才是这个问题的完整答案。
业务里最隐蔽的坑:多个分支继续 append 同一个基础切片
真正麻烦的地方,不在面试题本身,而在业务代码里它很容易制造出“看起来独立,实际上共用数据”的 Bug。查询过滤条件、权限列表、标签集合,都是高发区域。

下面这段代码就很典型:
func filters(base []string, v string) []string { return append(base, v) }
func main() {
base := make([]string, 1, 4)
base[0] = "enabled=1"
admin := filters(base, "role=admin")
_ = filters(base, "role=guest")
fmt.Println(admin) // [enabled=1 role=guest]
}
乍看之下,admin 和访客条件像是两次独立构造;但因为它们都从同一个 base 继续追加,而且底层数组容量充足,第二次追加把第一次写入的位置覆盖了。结果就是管理员条件被访客条件顶掉,最后打印出的是 [enabled=1 role=guest]。
这类问题特别容易出现在“从一个基础切片派生多个结果”的代码里。只要多个分支都基于同一个切片继续 append,就必须先想清楚:这些结果是否允许共享底层数据?如果不允许,就不能直接拿原切片往下传。
需要独立结果时,先复制再追加
如果函数语义是“基于输入生成一份新的列表”,那就应该主动复制数据,再进行后续追加。这样既避免共享底层数组带来的覆盖问题,也能让函数意图更清晰。
在 Go 1.21 及以上版本中,可以直接使用标准库里的 slices.Clone:
import "slices"
func adminFilters(base []string) []string {
out := slices.Clone(base)
return append(out, "role=admin")
}
这里的 out 拥有独立的数据副本,后续即使继续追加,也不会把原始输入或其他分支的结果一并改掉。对于代码评审来说,这样的写法还有一个额外好处:别人一眼就能看出这个函数的语义是“生成新结果”,而不是“顺手改输入”。
如果项目还没到 Go 1.21,也可以用传统方式处理:先用 make 创建同长度切片,再通过 copy 复制元素,之后再 append。核心思路不变,都是先把共享关系断开。
代码评审可以直接套用的检查清单
以后看到“函数接收切片参数”的代码,可以按下面几个问题快速过一遍:

- 函数内部是否调用了
append?如果调用了,返回值有没有被调用方接住? - 这个输入切片会不会被多个业务分支继续追加?如果会,是否应该先复制?
- 函数语义到底是修改输入,还是生成新结果?如果是生成新结果,就应该显式复制数据。
把这三点养成习惯后,很多“偶发覆盖”“条件串线”“权限错乱”问题,在提交代码前就能看出来。
最后把全文收成两条简单规则:需要追加结果时,返回切片并接住返回值;需要派生独立列表时,先 slices.Clone 再处理。这样既能把这道 Go 面试题答完整,也能把切片共享底层数组带来的实际风险控制住。









