先说结论Go 语言里没有“引用传递”函数参数全部都是值传递。很多人被“指针传递”这四个字带偏以为传了指针进去函数内部和外部就完全共享变量了。实际并不是。传指针进去传的也只是“指针变量本身”的副本。这篇文章围绕“函数里改了外面为什么没变”这个经典问题展开把int、struct、slice、map、指针参数全部拆开讲一遍。看完你就能理解什么时候函数内修改会生效什么时候不会以及遇到“改了没变”时应该怎么排查。内容很适合刚入门 Go 的读者也适合写过一段时间但被 slice、map 行为搞懵的开发者。文末有一份排查表格和工程最佳实践建议直接收藏备用。1. 核心结论Go 只有值传递没有引用传递先把这个结论钉死Go 语言没有引用传递。任何函数参数在进入函数时都会生成一份副本。哪怕你传的是一个 map、一个 slice、一个 channel语言层面同样是复制了一份参数变量本身。1.1 三种传递方式对比传递方式函数内是否改变外部变量本质值传递不会改的是副本复制变量内容指针传递可以通过地址访问原变量复制指针变量本身但指向同一块内存引用传递典型 C 引用可以且不需要取地址参数是外部变量的别名如果你学过 C对比一下 C 的int b a和 Go 的p : a。C 的引用在语法上没有“地址副本”的概念直接就是外部变量的别名而 Go 的指针是一个普通变量本质上也是按值复制一份进来的。Go 官方 FAQ 也明确说过函数参数是 pass by value。这个设计让 Go 的参数语义非常简单代价是新手需要额外理解“指针的值复制”这个概念。1.2 什么时候函数内能改外部变量判断标准只有一条函数内有没有拿到“外部变量的地址”。传变量本身函数内只拿到副本。传变量函数内通过地址定位到原变量。传 slice、map它们内部本来就封装了底层数据的指针所以修改底层数据会生效。但不要把这种语义误解为“引用传递”。接下来我们从内存模型开始逐步验证这个结论。2. 从内存模型看懂变量、地址和指针在 Go 里一个int变量在 64 位平台上占 8 字节。变量有一个名字比如a它在内存中有一个地址比如0xc0000b4000。变量名是给程序员看的内存地址才是 CPU 真正用来定位数据的。2.1 变量和地址package main import fmt func main() { a : 10 fmt.Println(a) // 10 fmt.Println(a) // 0xc0000b4000每次运行可能不同 }a返回变量 a 的内存地址这个地址就是“指针的值”。你可以把这个值保存到指针变量里。2.2 指针类型package main import fmt func main() { a : 10 p : a fmt.Println(p) // 0xc0000b4000 fmt.Println(*p) // 10解引用 }p的类型是*int意思是“指向 int 的指针”。*p是解引用表示“通过地址 p 找到对应存储位置的值”。package main import fmt func main() { a : 10 p : a *p 20 fmt.Println(a) // 20 }这里*p 20是往“a 的地址”这块内存写入 20所以 a 变成了 20。这是后面理解一切修改行为的基础想改变外部变量必须拿到它的地址并且通过解引用写入。3. 值传递函数里改了外面为什么没变先看最基础的场景传普通变量进函数函数内修改外部不受影响。3.1 int 值传递package main import fmt func change(x int) { x 100 fmt.Println(函数内部 x:, x) } func main() { a : 50 change(a) fmt.Println(外部 a:, a) }输出函数内部 x: 100 外部 a: 50原因调用change(a)时Go 把 a 的值 50 复制了一份给形参 x。函数内部x 100改的是副本函数结束后副本销毁a 的值不受影响。如果你在函数里打印a和x会发现它们的内存地址完全不同这进一步证明了复制行为。3.2 数组值传递package main import fmt func modify(arr [3]int) { arr[0] 99 } func main() { nums : [3]int{1, 2, 3} modify(nums) fmt.Println(nums) // [1 2 3] }数组在 Go 中是值类型传入函数时整个数组都会被复制。如果数组比较大这种复制会有额外的内存和性能开销。这也是为什么工程中很少直接用大数组作为参数而是用切片。3.3 结构体值传递package main import fmt type Student struct { Name string Age int } func updateName(s Student) { s.Name changed } func main() { stu : Student{Name: alice, Age: 18} updateName(stu) fmt.Println(stu.Name) // alice }结构体同样按值复制。函数内修改s.Name只影响副本外部stu不会被改变。这类类型int、数组、结构体是 Go 最常见的值类型特点是函数内改形参不影响实参。4. 指针传递怎么才能在函数里改掉外面的值如果想让函数修改外部变量必须把地址传进去。4.1 通过 int 指针修改外部值package main import fmt func change(p *int) { *p 200 } func main() { a : 1 change(a) fmt.Println(a) // 200 }这里函数参数类型是*int传入的是 a 的地址。函数内部执行*p 200通过地址直接写入 a 所在的内存区域所以 a 变成了 200。注意一个关键点这里同样发生了值传递。change(a)实际上是把“a 的地址”这个值复制了一份给 p。p 和a是两份不同的指针值但它们指向同一个内存地址。所以即使传的是指针也能通过解引用间接修改原变量。4.2 通过结构体指针修改字段package main import fmt type Counter struct { Count int } func (c *Counter) Add() { c.Count } func main() { counter : Counter{Count: 0} c : counter c.Add() fmt.Println(counter.Count) // 1 }这里Add方法使用指针接收者*Counter对计数器字段的修改会作用于原结构体。如果改成值接收者func (c Counter) Add()counter.Count就会一直是 0。这个细节在大型项目里非常容易踩坑。5. slice 的真相传递的是切片头不是底层数组slice 是 Go 里最容易踩坑的类型。首先要搞清楚 slice 的内部结构它不是一个简单的“动态数组引用”而是一个包含三个字段的结构体。5.1 slice header 三要素一个 slice 变量在内存中其实是一个 structtype sliceHeader struct { Data uintptr // 指向底层数组的指针 Len int Cap int }当s : []int{1, 2, 3}时s 变量存的是Data指向一块连续数组的起始地址。Len 3。Cap 3如果通过 append 扩容Cap 可能比 Len 大。把 s 传给函数时Go 复制的是这三个字段。函数内拿到的 slice header 和外面的 slice header 是独立的但它们的Data指向同一个底层数组。5.2 修改元素外部能看到package main import fmt func changeItem(s []int) { s[0] 100 } func main() { nums : []int{1, 2, 3} changeItem(nums) fmt.Println(nums) // [100 2 3] }为什么s[0]通过Data指向底层数组写入 100。这份 slice header 虽然被复制了但Data仍然指向原来的数组所以外部读取底层数组时发现 100。5.3 append外部长度没变package main import fmt func appendItem(s []int) { s append(s, 4) fmt.Println(函数内:, s) // [1 2 3 4] } func main() { nums : []int{1, 2, 3} appendItem(nums) fmt.Println(外部:, nums) // [1 2 3] }原因append 修改的是函数内这份 slice header 的Len。如果底层数组容量够用确实把 4 写进了底层数组但函数外 main 里的 slice header 的Len仍然是 3所以打印出来只有[1 2 3]。更危险的是底层数组的第 4 个位置可能已经被写入 4如果后面外层也对同一个底层数组做 append可能导致意外数据。如果 append 触发扩容函数内会分配一个新的底层数组函数外的Data还指向旧数组两边彻底分道扬镳。5.4 想让 append 结果反映到外部方案一使用返回值这是最推荐的方式。package main import fmt func appendItem(s []int) []int { return append(s, 4) } func main() { nums : []int{1, 2, 3} nums appendItem(nums) fmt.Println(nums) // [1 2 3 4] }方案二传切片指针。package main import fmt func appendItem(s *[]int) { *s append(*s, 4) } func main() { nums : []int{1, 2, 3} appendItem(nums) fmt.Println(nums) // [1 2 3 4] }推荐方案一返回值更直观几乎不会把函数签名搞复杂而且在多数场景下编译器能避免额外的拷贝。6. map 和 channel引用类型但参数也是值传递6.1 map 传参增删 key 外部能看到package main import fmt func addEntry(m map[string]int) { m[age] 18 } func main() { dict : make(map[string]int) addEntry(dict) fmt.Println(dict) // map[age:18] }函数外确实能看到新增的 key。于是很多人说“map 是引用传递”这个理解不准确。map 变量本身是一个指向hmap结构的指针封装。函数参数复制的是这个指针变量但它和外面的 map 变量指向同一个hmap。所以通过这个副本修改 map 数据时外部能看到。但如果你在函数里给 map 变量重新赋值package main import fmt func resetMap(m map[string]int) { m make(map[string]int) m[new] 1 } func main() { dict : map[string]int{old: 1} resetMap(dict) fmt.Println(dict) // map[old:1] }外部dict还是旧 map因为函数内m make(...)修改的是副本的指向函数外的 map 变量不受影响。6.2 channel 传参收发消息外部能看到package main import fmt func sendMsg(ch chan- string) { ch - hello } func main() { ch : make(chan string, 1) sendMsg(ch) fmt.Println(-ch) // hello }channel 同样是指针类型的封装。函数内可以往 channel 发送消息外部能收到。但如果在函数里把 ch 重新赋值成另一个 channel外部不会感知。6.3 引用类型不等于引用传递类型函数内修改内部数据函数内重新赋值slice修改元素生效外部 Length 不变map增删 key 生效外部 map 不变channel收发消息生效外部 channel 不变指针解引用修改生效外部指针不变核心规律是引用类型封装的“指针字段”会被复制但指向的底层数据是共享的。不要把这个行为直接等同于“引用传递”。7. 指针参数按值传递的坑p nil 不会影响外部指针作为参数时同样遵循值传递规则。最经典的坑就是在函数里把指针置为 nil外面毫无变化。7.1 修改指针本身无效package main import fmt func setNil(p *int) { p nil } func main() { x : 10 p : x setNil(p) fmt.Println(p nil) // false }解释p 本身是一个变量存储的值是 x 的地址。调用setNil(p)时地址值被复制一份给形参 p。函数内p nil只是让函数内的那个拷贝变成 nil外面 p 变量存储的地址没有任何变化。7.2 需要通过二级指针修改外部指针变量如果的确想在函数内把外部指针改成 nil需要传二级指针package main import fmt func setNil(p **int) { *p nil } func main() { x : 10 p : x setNil(p) fmt.Println(p nil) // true }不过二级指针在实际工程中并不多见。多数情况下用返回值表达“新指针”更清晰func clearPointer() *int { return nil }7.3 解引用修改才是有效的func setZero(p *int) { *p 0 }这种是修改 p 指向的内存单元不是修改 p 本身所以外部变量会被改成 0。这是 Go 新手最容易混淆的地方函数里写的是p ...还是*p ...两者效果完全不同。前者改的是地址副本后者改的是地址指向的实际数据。8. 实战场景结构体该传值还是传指针写 Go 代码时几乎每天都会面对一个问题函数参数到底传结构体本身还是传结构体指针8.1 选择标准场景推荐方式原因函数只读结构体较小传值简单避免意外修改函数需要修改结构体字段传指针修改才能落到外部结构体很大包含大数组字段传指针避免复制开销需要表达“可能为空”传指针nil 可以表示不存在并发共享一个对象传指针但要加锁直接操作同一对象8.2 示例批量修改结构体切片package main import fmt type Item struct { Name string Price float64 } func applyDiscount(items []Item) { for i : range items { items[i].Price * 0.8 } } func main() { items : []Item{ {Name: A, Price: 100}, {Name: B, Price: 200}, } applyDiscount(items) fmt.Println(items[0].Price) // 80 fmt.Println(items[1].Price) // 160 }这里items是[]Item切片items[i]是切片底层数组中的元素直接通过索引修改元素会生效。但如果你写成for _, item : range items { item.Price * 0.8 }改的是 item 的副本不会生效。这是很常见的坑必须记住遍历切片修改元素用索引不要用 range 值拷贝。8.3 传值时的深拷贝陷阱传值时函数内拿到副本修改副本不影响原结构体。对大部分小对象来说这是安全的。但注意如果结构体里包含 slice、map 等字段即使结构体本身是值传递修改这些引用类型字段的数据仍然可能影响外部。package main import fmt type Config struct { Values []int } func modifyConfig(c Config) { c.Values[0] 99 // 外部 Config 的 slice 底层数组也会被改 } func main() { cfg : Config{Values: []int{1, 2, 3}} modifyConfig(cfg) fmt.Println(cfg.Values) // [99 2 3] }如果不想让函数内部修改外部数据需要在传入前做深拷贝。否则就会出现“结构体是值传递但内部可变数据仍然被改了”的现象。9. 常见问题排查与工程最佳实践9.1 快速排查表问题现象可能原因排查方式解决方案函数里改了 int 参数外部没变值传递改的是副本打印函数内外变量地址改为传*int或返回值函数里 append(slice) 后外面长度没变slice header 按值传递Len 不共享打印函数内外len(s)和cap(s)用返回值或传*[]int函数里改 map外部却变了map 内部封装了指向 hmap 的指针确认是否希望共享如果需要隔离复制 map 或深拷贝遍历 slice 时所有元素地址相同Go 1.22 之前循环变量复用打印循环内v升级 Go 版本或循环内局部变量拷贝结构体字段明明改了外部没变使用了值接收者方法检查方法签名是否带*改为func (s *Struct) Update()传了指针函数里p xxx外部没变修改的是指针副本的指向打印函数内外 p 的值改为*p xxx或用返回值编译报“cannot take the address of”对不可寻址的值取地址检查是否对字面量或 map 元素取地址先存入变量再取地址9.2 for range 循环变量地址Go 1.22 之前range 循环变量是复用的package main import fmt func main() { nums : []int{1, 2, 3} var ptrs []*int for _, v : range nums { ptrs append(ptrs, v) } for _, p : range ptrs { fmt.Println(*p) } }在旧版本中会输出 3 3 3因为每次循环的v都是同一个变量的地址循环结束后 v 的值是最后一次迭代的值。Go 1.22 之后语义已经修复为正确输出 1 2 3。如果项目还在用旧 Go 版本要么升级要么循环内创建局部变量for _, v : range nums { v : v ptrs append(ptrs, v) }9.3 方法接收者误用值接收者package main import fmt type User struct { name string } func (u User) SetName(name string) { u.name name } func main() { u : User{name: old} u.SetName(new) fmt.Println(u.name) // old }方法接收者也是值传递。如果希望方法修改结构体必须使用指针接收者func (u *User) SetName(name string) { u.name name }9.4 工程最佳实践写代码时记住这几条准则可以少踩很多坑小对象优先传值。小结构体、基础类型直接传值代码简单不容易产生共享数据的副作用。需要修改就传指针。函数签名里带*本身就是一种“我会修改”的约定增加可读性。slice 修改元素用索引增加元素用返回值。不要指望 append 自动同步到外部。不需要在函数内修改外部 map 时保持只读操作。如果需要隔离复制一份 map 再处理。明确函数契约。传值表示“我不会改你的数据”传指针表示“我需要修改或者数据很大不想复制”。团队协作时这种语义要清晰。注意并发安全。传指针意味着多个 goroutine 可能访问同一块内存。如果需要并发修改必须加锁或使用 channel 同步。最后给你三个定位技巧。下次遇到“函数里改了外面为什么没变”先别急着怀疑编译器在函数入口和出口分别打印变量的值和地址确认是否指向同一块内存。检查你写的是p ...还是*p ...前者改的是副本后者改的是实际数据。如果是 slice打印len和cap的变化看是扩容还是误用了值拷贝。三分钟之后你会发现大部分问题就是值传递和指针传递这一层窗户纸。