Go 中的值、指针与引用语义

统一解释 Go 的按值传递:完整值、指针、以及 slice/map/chan/func 描述符如何在复制后仍指向共享底层数据,并给出变异对照表。

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go 中的值、指针与引用语义

这个概念为什么会出现

初学者常被两句互相打架的话困住:

  1. “Go 只有值传递。”
  2. “slice / map 是引用类型。”

两句各自抓到一半真相,合在一起却推不出可预测的行为:

  • 改函数参数里的 int,外面不变——像值传递
  • 改函数参数里的 map,外面变了——像引用传递
  • append 有时影响调用方,有时不影响——像玄学

真正需要的不是背“引用类型名单”,而是统一模型:

赋值、传参、范围变量、方法接收者:复制的都是“值”。
有的值本身携带指向共享内存的入口(指针、描述符、句柄)。

这篇笔记把指针、slice、map、chan、func 放进同一张表里,建立可迁移的判断力。细节实现仍以各专题笔记为准。

[!abstract] 一句话理解 Go 一律按值传递;差别只在于被复制的值是“完整载荷”还是“指向共享底层数据的小头(指针/描述符/句柄)”。

最小可运行示例

业务场景:购物车结算前的一次“处理函数”

假设结账前要做几件小事:

  1. 给优惠券计数 +1(普通 int)
  2. 通过指针改订单状态码
  3. 修改商品列表第一个元素的展示价(slice)
  4. 尝试往商品列表追加赠品(slice append)
  5. 往统计 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 结构体字段名。

类型复制时拷走什么共享什么
数值、boolstring 的 header+数据策略*值本身(string 是只读数据的描述)string 底层只读字节可共享,但内容不可变
array全部元素默认不共享
struct所有字段的值字段若含指针/描述符则间接共享
*T地址指向的同一块 T
[]T sliceptr, len, cap 描述符底层数组(在 cap 窗口内)
map[K]V运行时 map 句柄同一张哈希表
chan T通道描述符同一通道队列与状态
func函数值(代码+闭包环境入口)同一函数/闭包环境
interface类型信息 + 数据(具体表示是实现细节)动态值可能含指针从而共享

*字符串在语言层面不可变;复制 string 不会让你通过一个变量改另一个变量的字符内容。

专题展开:

变异对照表(Mutation tables)

表 A:函数内“改内容” vs “改绑定”

操作int*int[]intmap[K]Vchan
改本地绑定(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] = vi < 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 是“引用类型”可以当速记,但考试级准确说法应是:

  1. 语言只有值传递
  2. 这些类型的值是小型引用型描述符
  3. 描述符复制 ≠ 深拷贝底层数据

数组、结构体按值整体复制;大数组作参数通常应改传 slice 或指针,避免无谓拷贝。

接口值、方法接收者:同一 ban 律

  • 接口变量持有动态类型与动态值;赋值接口会复制“接口值”本身。
  • 值接收者方法拿到 T 副本;指针接收者拿到 *T 副本。
  • “能用值调用指针方法”是调用语法糖,不等于方法集规则改变——见 方法集与接收者

并发提醒

共享底层数据 并发安全。

[!warning] 共享不等于同步 两个 goroutine 同时写同一 map、同一 slice 未同步区域,或无同步读写指针指向的结构,都可能 data race。可见性与同步见 happens-before 与同步;检测见 race detector

边界与限制

  1. 没有用户自定义“引用参数”语法;要共享就传指针或描述符类型。
  2. 深拷贝不是语言默认;需要时对 slice 用 copy/append 克隆,对 struct 手写或序列化。
  3. string 不可变:不会出现“改 s1 字符影响 s2”的可变共享。
  4. nil 有多种形态nil slice / map / chan / func / pointer / interface 行为不同,见 nil 的多种形态
  5. range 中的元素是拷贝:改 range 变量不等于改集合元素(slice 元素是副本;改 map 值类型的局部变量同理),见 range 语义
  6. 逃逸分析 / 堆栈分配是实现细节:语义上你只负责“是否共享、是否并发安全”,不要用“一定在栈上”写正确性逻辑。

常见误区

[!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:函数里改了参数,外面一定变 / 一定不变 先问:改的是副本载荷,还是经入口触及的共享数据?

工程实践

  1. 先画“复制了什么”再查 bug:打印 len/cap、是否同一底层(可对 slice 做实验性写入探针)。
  2. API 边界明确所有权
    • 函数若保留 slice/map,文档写清是否会修改
    • 需要独占时在边界 copy
  3. 返回 slice 时注意别泄露内部大数组:用完整切片表达式截断 cap 或显式复制。
  4. map 不要跨 goroutine 无锁读写;需要并发 map 时用同步或 sync.Map(权衡后)。
  5. 优先值语义表达不变式;可变共享放在小范围并集中同步。
  6. 接收者选择:以是否需要突变、结构大小、nil 接收者语义为准,并与方法集一致(见方法集笔记)。
  7. 测试里用 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 会变吗?指针接收者呢?

答案
  1. 不会。m = nil 只改函数内句柄副本,调用方变量仍持有原 map。
  2. s[0]=1 写共享数组;append 赋给局部 s,调用方描述符的 len 不变(若扩容,新数组更与调用方无关)。
  3. 2b = a 复制描述符,共享底层数组)。
  4. 值接收者改的是副本,调用方不变;指针接收者经指针改原对象,会变(前提调用方传的是同一对象地址)。

依据与延伸

创建于 2026/6/20 更新于 2026/7/15