在 Go 语言中,我们都知道函数传参采用的是值传递:传入的参数会被完整地拷贝一份。那么问题来了,既然切片是按值传递的,为什么我们在函数内部修改切片的元素,却能直接影响到调用方的原切片呢?

要解开这个疑惑,我们需要明白:在传参时,Go 究竟拷贝了切片的什么内容?答案是:它拷贝的是「切片头」(Slice Header),而不是底层的数组数据。

本质探究:拷贝的是切片头

回顾一下切片在底层的结构定义(详见 底层结构):

type slice struct {
    array unsafe.Pointer  // 指向底层数组
    len   int
    cap   int
}

当我们将一个切片作为参数传递给函数时,发生拷贝的仅仅是这个包含三个字段的切片头结构体(在 64 位机器上通常占用 24 字节)。而真正存储数据的底层数组,由于并不在切片头结构体内,因此不会被拷贝。

这就导致了一个有趣的现象:

  • 调用方持有一个切片头 s,函数形参也获得了一个全新的切片头副本 s
  • 尽管这两个切片头在内存中是独立的,但它们的 array 指针却指向了同一块底层数组
  • 因此,当你在函数内部执行 s[i] = x 时,你实际上是在修改那块被双方共享的底层数组。这就是为什么调用方能够“看”到这些修改的原因。

行为对比:修改元素 vs 修改切片本身

理解了切片头的拷贝机制后,我们就能清晰地分辨出不同操作带来的不同后果。请看下表:

操作是否影响调用方为什么?
s[i] = x修改的是共享的底层数组,双方均可见。
s = s[:0]仅仅修改了形参切片头的 len 字段,调用方的切片头毫发无损。
s = append(s, x) 且触发扩容扩容会导致形参切片头指向一块全新的底层数组,而调用方依然傻傻地指向旧数组。

简单验证

为了巩固这一认知,让我们通过一段代码来亲自验证:

package main
 
import "fmt"
 
func modifyElem(s []int) {
    s[0] = 999  // 修改元素 → 影响调用方
}
 
func modifySlice(s []int) {
    s = s[:0]   // 修改切片本身 → 不影响调用方
}
 
func main() {
    s := []int{1, 2, 3}
    modifyElem(s)
    fmt.Println(s)  // [999 2 3]
 
    s = []int{1, 2, 3}
    modifySlice(s)
    fmt.Println(s)  // [1 2 3],不变
}

通过输出结果,我们可以确信:只要不改变形参切片头中的 array 指针,对元素的修改就会反映到原切片上;而一旦对切片本身进行重新切片或扩容,它就会与原切片彻底分道扬镳。