在 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 指针,对元素的修改就会反映到原切片上;而一旦对切片本身进行重新切片或扩容,它就会与原切片彻底分道扬镳。