Go 中的值、指针与引用语义
统一解释 Go 的按值传递:完整值、指针、以及 slice/map/chan/func 描述符如何在复制后仍指向共享底层数据,并给出变异对照表。
[!info] 关联笔记
Go 中的值、指针与引用语义
这个概念为什么会出现
初学者常被两句互相打架的话困住:
- “Go 只有值传递。”
- “slice / map 是引用类型。”
两句各自抓到一半真相,合在一起却推不出可预测的行为:
- 改函数参数里的
int,外面不变——像值传递 - 改函数参数里的
map,外面变了——像引用传递 append有时影响调用方,有时不影响——像玄学
真正需要的不是背“引用类型名单”,而是统一模型:
赋值、传参、范围变量、方法接收者:复制的都是“值”。
有的值本身携带指向共享内存的入口(指针、描述符、句柄)。
这篇笔记把指针、slice、map、chan、func 放进同一张表里,建立可迁移的判断力。细节实现仍以各专题笔记为准。
[!abstract] 一句话理解 Go 一律按值传递;差别只在于被复制的值是“完整载荷”还是“指向共享底层数据的小头(指针/描述符/句柄)”。
最小可运行示例
业务场景:购物车结算前的一次“处理函数”
假设结账前要做几件小事:
- 给优惠券计数 +1(普通 int)
- 通过指针改订单状态码
- 修改商品列表第一个元素的展示价(slice)
- 尝试往商品列表追加赠品(slice append)
- 往统计 map 里记一次命中
这些操作都“传给函数”,但外面看不看得到变化,取决于复制的是什么。
package main
import "fmt"
// 1) 传 int:复制的是完整数字
// 业务:函数里给“临时计数”+1,不影响订单上的原计数
func bumpCouponCount(n int) {
n++ // 只改副本
}
// 2) 传 *int:复制的是地址
// 业务:把订单状态改成 2(已锁定库存)
func lockOrderStatus(status *int) {
*status = 2 // 通过地址改到原来的变量
}
// 3) 传 slice:复制的是描述符(指针/len/cap),底层数组共享
// 业务:把购物车第一件商品的价格改成 99
func rewriteFirstPrice(cart []int) {
cart[0] = 99 // 改底层数组元素,调用方看得到
}
// 4) 传 slice 后 append:可能只改“函数自己的那份描述符”
// 业务:尝试追加赠品 ID=1,但没有 return 新 slice
func tryAppendGift(cart []int) {
cart = append(cart, 1) // 可能分配新数组,或只更新局部 len
// 没有 return cart,调用方的 len 不一定变
_ = cart
}
// 5) 传 map:复制的是指向同一哈希表的句柄
// 业务:统计接口命中次数
func hitCounter(m map[string]int) {
m["checkout"] = m["checkout"] + 1
}
func main() {
coupon := 1
bumpCouponCount(coupon)
fmt.Println("coupon still:", coupon) // 1,函数改不到外面
status := 0
lockOrderStatus(&status)
fmt.Println("status locked:", status) // 2
cart := []int{10, 20, 30} // 三件商品价格
rewriteFirstPrice(cart)
fmt.Println("cart prices:", cart) // [99 20 30]
tryAppendGift(cart)
fmt.Println("cart after append attempt:", cart, "len=", len(cart))
// 常见情况:内容前三个不变,len 仍是 3(赠品没挂回调用方)
stats := map[string]int{}
hitCounter(stats)
fmt.Println("stats:", stats) // map[checkout:1]
}
建议运行:
go run .
期望输出类似:
coupon still: 1
status locked: 2
cart prices: [99 20 30]
cart after append attempt: [99 20 30] len= 3
stats: map[checkout:1]
结合场景怎么判断“外面变不变”
| 操作 | 复制了什么 | 调用方看不看得到 |
|---|---|---|
改 int 参数 | 数字本身 | 否 |
通过 *int 写入 | 地址 | 是 |
改 slice 元素 | 描述符,共享数组 | 是 |
append 后不返回 | 可能是新描述符 | 常常否 |
写 map | 句柄 | 是 |
一句话口诀:先问“复制的袋子里有没有指向共享仓库的钥匙”。
核心概念与准确模型
命题 1:Everything is pass-by-value
官方 FAQ 明确:Go 的参数传递是 pass by value。
见 FAQ — pass by value。
含义:
- 调用
f(x)时,函数形参是x的副本 =赋值同样复制右值- 方法
func (t T) M()的接收者是T的副本;func (t *T) M()复制的是指针值
不存在 C++ 意义上的 void f(int& x) 那种语言级引用参数。
命题 2:被复制的“值”有多种形状
flowchart TB
V["被复制的值"]
V --> A["完整数据<br/>int / float / struct 字段 / array"]
V --> B["指针 *T<br/>地址字"]
V --> C["描述符 / 句柄<br/>slice / map / chan / func / interface"]
B --> S["共享:同一对象"]
C --> S
A --> I["独立:默认互不影响"]
各类型“头”里有什么(语义模型)
以下是使用语义模型,不是保证你在 debugger 里看到的固定 C 结构体字段名。
| 类型 | 复制时拷走什么 | 共享什么 |
|---|---|---|
数值、bool、string 的 header+数据策略* | 值本身(string 是只读数据的描述) | string 底层只读字节可共享,但内容不可变 |
array | 全部元素 | 默认不共享 |
struct | 所有字段的值 | 字段若含指针/描述符则间接共享 |
*T | 地址 | 指向的同一块 T |
[]T slice | ptr, len, cap 描述符 | 底层数组(在 cap 窗口内) |
map[K]V | 运行时 map 句柄 | 同一张哈希表 |
chan T | 通道描述符 | 同一通道队列与状态 |
func | 函数值(代码+闭包环境入口) | 同一函数/闭包环境 |
interface | 类型信息 + 数据(具体表示是实现细节) | 动态值可能含指针从而共享 |
*字符串在语言层面不可变;复制 string 不会让你通过一个变量改另一个变量的字符内容。
专题展开:
变异对照表(Mutation tables)
表 A:函数内“改内容” vs “改绑定”
| 操作 | int | *int | []int | map[K]V | chan |
|---|---|---|---|---|---|
改本地绑定(x = ...) | 不影响调用方 | 不影响调用方指针变量 | 不影响调用方描述符 | 不影响调用方变量 | 不影响调用方变量 |
| 经入口改共享数据 | 无入口 | *p = 影响对象 | s[i]= 影响数组 | m[k]= / delete 影响表 | send/recv 影响通道 |
| 扩展结构 | — | — | append 可能新数组 | 插入新键 | 关闭通道(全局可见) |
表 B:赋值后两边关系
a := x
// 问:改 a 会不会被 x 看见?改 * 或底层会不会?
x 的类型 | 改 a 的顶层字段/值 | 改共享底层 |
|---|---|---|
int | 否 | — |
struct{ n int } | 否 | — |
struct{ p *int } | 否(a.p 另指) | 改 *a.p 若仍同一指针则是 |
*T | 否(a 另指) | *a 是 |
[]T | 否(len/cap 独立) | 元素写入常常是 |
map | 否 | 是 |
chan | 否 | 是(同一 chan) |
表 C:slice 的“看起来像引用”细拆
| 动作 | 调用方是否可见 |
|---|---|
s[i] = v(i < len) | 是(共享数组) |
s = s[:n] 只在函数内 | 否(只改局部 len) |
s = append(s, x) 且 len+1 <= cap | 元素可能写入原数组;调用方 len 不变,除非返回新描述符 |
s = append(s, x) 触发扩容 | 新数组;调用方旧描述符仍看旧数组 |
因此工程规则是:
s = append(s, x) // 调用方必须接住
s = modifySlice(s) // API 返回新 slice 头
和“引用类型”说法如何相处
口语里说 slice/map/chan 是“引用类型”可以当速记,但考试级准确说法应是:
- 语言只有值传递
- 这些类型的值是小型引用型描述符
- 描述符复制 ≠ 深拷贝底层数据
数组、结构体按值整体复制;大数组作参数通常应改传 slice 或指针,避免无谓拷贝。
接口值、方法接收者:同一 ban 律
- 接口变量持有动态类型与动态值;赋值接口会复制“接口值”本身。
- 值接收者方法拿到
T副本;指针接收者拿到*T副本。 - “能用值调用指针方法”是调用语法糖,不等于方法集规则改变——见 方法集与接收者。
并发提醒
共享底层数据 ≠ 并发安全。
[!warning] 共享不等于同步 两个 goroutine 同时写同一 map、同一 slice 未同步区域,或无同步读写指针指向的结构,都可能 data race。可见性与同步见 happens-before 与同步;检测见 race detector。
边界与限制
- 没有用户自定义“引用参数”语法;要共享就传指针或描述符类型。
- 深拷贝不是语言默认;需要时对 slice 用
copy/append克隆,对 struct 手写或序列化。 string不可变:不会出现“改 s1 字符影响 s2”的可变共享。nil有多种形态:nilslice / map / chan / func / pointer / interface 行为不同,见 nil 的多种形态。range中的元素是拷贝:改 range 变量不等于改集合元素(slice 元素是副本;改 map 值类型的局部变量同理),见 range 语义。- 逃逸分析 / 堆栈分配是实现细节:语义上你只负责“是否共享、是否并发安全”,不要用“一定在栈上”写正确性逻辑。
常见误区
[!warning] 误区 1:Go 有引用传递 没有。只有值传递;有的值里带着引用。
[!warning] 误区 2:slice 是引用,所以 append 一定改外面 错。
append返回新描述符;是否新数组取决于容量。必须s = append(s, ...)。
[!warning] 误区 3:传 struct 就一定昂贵,必须全用指针 小 struct 值传递更清晰;大 struct 或需原地更新再用指针。过早全指针化增加
nil与生命周期心智负担。
[!warning] 误区 4:map 赋值是深拷贝
m2 := m1后两者操作同一 map。要独立副本需逐键复制或用其他策略。
[!warning] 误区 5:指针 = 引用类型全集 指针只是地址值。slice/map 不是“语法上的指针”,但语义上同样通过小头共享大数据。
[!warning] 误区 6:函数里改了参数,外面一定变 / 一定不变 先问:改的是副本载荷,还是经入口触及的共享数据?
工程实践
- 先画“复制了什么”再查 bug:打印
len/cap、是否同一底层(可对 slice 做实验性写入探针)。 - API 边界明确所有权:
- 函数若保留 slice/map,文档写清是否会修改
- 需要独占时在边界
copy
- 返回 slice 时注意别泄露内部大数组:用完整切片表达式截断 cap 或显式复制。
- map 不要跨 goroutine 无锁读写;需要并发 map 时用同步或
sync.Map(权衡后)。 - 优先值语义表达不变式;可变共享放在小范围并集中同步。
- 接收者选择:以是否需要突变、结构大小、nil 接收者语义为准,并与方法集一致(见方法集笔记)。
- 测试里用 race detector 验证“以为只读其实共享写”的假设。
实验
package main
import "fmt"
func main() {
// 1) 数组值复制 vs slice 共享
a := [3]int{1, 2, 3}
b := a
b[0] = 9
fmt.Println("array", a, b) // a 不变
s1 := a[:] // 从数组得到 slice
s2 := s1
s2[1] = 8
fmt.Println("slice share", s1, s2, a)
// 2) append 与 cap
s := make([]int, 1, 2)
s[0] = 1
t := s
t = append(t, 2) // 通常复用底层
t[0] = 7
fmt.Println("after append in cap", s, t, "len s", len(s))
u := append(t, 3, 4) // 更可能扩容
u[0] = 5
fmt.Println("after grow", t, u)
// 3) map 句柄
m1 := map[string]int{"a": 1}
m2 := m1
m2["a"] = 2
fmt.Println("map", m1, m2)
}
把输出和表 A/B/C 对照,直到能不看答案预测每一行。
总结
| 问题 | 答案 |
|---|---|
| Go 怎么传参? | 一律按值 |
| 为何 map 能改外面? | 复制的是句柄 |
| 为何 slice 有时像引用有时不像? | 元素共享 vs 描述符/append 换数组 |
| 指针是什么? | 可复制的地址值;解引用触及对象 |
| 正确提问方式? | 复制了什么?共享入口还在吗?有没有同步? |
一句话收束:
先谈复制,再谈共享,最后谈并发。
自测题
1. 解释:func f(m map[int]int) { m = nil } 会不会清空调用方 map?
2. 为什么 func f(s []int) { s[0]=1; s=append(s,2) } 可能只让调用方看到 s[0] 的变化?
3. var a, b []int; a = append(a, 1); b = a; b[0]=2 之后 a[0] 是什么?
4. 值接收者方法里修改字段,调用方 struct 会变吗?指针接收者呢?
答案
- 不会。
m = nil只改函数内句柄副本,调用方变量仍持有原 map。 s[0]=1写共享数组;append赋给局部s,调用方描述符的len不变(若扩容,新数组更与调用方无关)。2(b = a复制描述符,共享底层数组)。- 值接收者改的是副本,调用方不变;指针接收者经指针改原对象,会变(前提调用方传的是同一对象地址)。
依据与延伸
- FAQ — When are function parameters passed by value?
- FAQ — Why are maps, slices, and channels references while arrays are values?
- Go Spec — Assignments
- Go Spec — Calls
- Go Spec — Slice types
- Go Spec — Map types
- Go Spec — Channel types
- Go Spec — Pointer types
- Go Slices: usage and internals
- Go maps in action
- Effective Go — Slices
- Effective Go — Maps
- A Tour of Go — Pointers
- A Tour of Go — Slices