一道常见的 Go 面试题是:把切片传给函数,在函数里执行 append,调用方的数据会不会改变?

回答“切片是引用类型,所以会变”,或者“Go 只有值传递,所以不会变”,都不完整。这个细节在参数拼装、权限列表、查询过滤条件中经常造成偶发覆盖。通过几段短代码,就能把它变成开发中真正有用的小技巧。

先回答面试题

先看一个最简单的函数,它试图给列表追加一个元素:

func addTag(tags []string) {
    tags = append(tags, "vip")
}

func main() {
    tags := []string{"new"}
    addTag(tags)
    fmt.Println(tags) // [new]
}

调用方没有看到 "vip"。函数接收到的是一份切片描述信息的副本append 返回的新长度只赋给了函数内的 tags,外层变量的长度仍然是 1

因此,想让调用方拿到追加后的列表,最稳妥的接口写法是返回新切片:

func addTag(tags []string) []string {
    return append(tags, "vip")
}

func main() {
    tags := []string{"new"}
    tags = addTag(tags)
    fmt.Println(tags) // [new vip]
}

实用规则很直接:函数内可能执行 append 时,应返回结果,并让调用方接住返回值。 但即使外层长度没有变化,底层数据也可能已被修改。

切片里到底装了什么

切片可以理解为三项信息:底层数组位置、当前长度 len、容量 cap。函数参数复制的是这三项信息,而不是整份元素数据。下面的调用方预留了额外容量:

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],但重新切到长度 2 后,99 出现了:函数里的追加操作复用了原底层数组。调用方看不到新长度,却可能承受数组被写入的副作用。

容量决定是否共用数组

按照 Go 语言规范,如果容量足够,append 就复用底层数组;如果容量不足,则分配新数组并复制原数据。只改一下创建方式,结果就不同:

func addHidden(nums []int) {
    nums = append(nums, 99)
}

func main() {
    nums := []int{10}
    addHidden(nums)
    fmt.Println(nums, cap(nums)) // [10] 1
}

此时容量只有 1,追加元素已经装不下,函数内会使用新数组。这就是相同函数“有时影响输入、有时不影响”的原因:差异可能只来自调用方的容量。

面试时可以这样总结:

  • items[0] = x 这样的原位修改,调用方通常可见。
  • append 后,调用方的 len 不会自动增加。
  • 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]
}

两次追加写进了同一个数组位置,访客条件覆盖了管理员条件。两个返回值看似独立,实际可能共享数据;只要多条业务分支从同一个切片继续追加,就需要留意这个问题。

需要独立数据时主动复制

如果函数的语义是“基于输入生成新列表”,就应该先复制再追加。Go 1.21 及以上版本可以直接使用标准库 slices.Clone

import "slices"

func adminFilters(base []string) []string {
    out := slices.Clone(base)
    return append(out, "role=admin")
}

out 拥有独立数据,后续追加不会覆盖已有结果,也表达了“不修改输入”的意图。较早版本的 Go 也可以用 make 创建同长度切片,通过 copy 复制元素后再追加,效果相同。

代码评审时记住这份清单

遇到接收切片参数的函数,可以快速检查:

  • 函数是否执行了 append?如果执行了,是否返回并使用了新切片?
  • 输入参数是否可能被多个业务分支继续追加?如果会,是否应该先复制?
  • 函数语义是修改输入还是生成新结果?生成新结果时应复制数据。

写在最后

切片传参后的 append 并不神秘:函数拿到的是切片头副本,却可能仍与调用方共享底层数组。长度不会自动传回外层,新增元素是否写入原数组,则由容量决定。

实际开发中记住两条规则即可:需要追加结果时,返回切片并接住返回值;需要派生独立列表时,先 slices.Clone 再处理。这个小技巧既能回答面试题,也能避开查询条件和权限列表被意外覆盖的问题。