Java 里对象变量天然是引用,改对象字段随手就生效。Go 里参数、赋值、range 循环默认全是复制,想改调用方的变量必须显式传指针。这条差异带来两个方向的误差:该传指针时传了值,改动悄悄丢失;不该传指针时传了指针,代码变难推理。这篇给出一份判据清单:什么时候必须用指针、什么时候不该用、方法集怎么决定接口能否满足、nil 指针哪些操作合法、逃逸分析怎么看。
1. 值传递是默认行为
Go 只有值传递。没有引用传递这个概念——传指针也是把指针的值复制一份。
package main
import "fmt"
type User struct {
Name string
Age int
}
func modifyValue(u User) { u.Name = "值接收者改不动" }
func modifyPointer(u *User) { u.Name = "指针能改" }
func main() {
u := User{Name: "原始", Age: 20}
modifyValue(u)
fmt.Println(u.Name) // 原始
modifyPointer(&u)
fmt.Println(u.Name) // 指针能改
}
| 操作 | 复制的内容 | 能否影响调用方 |
|---|---|---|
| 传结构体值 | 整个结构体(含全部字段) | 不能 |
| 传指针 | 一个机器字(地址) | 能 |
| 传 slice | 描述符(指针 + len + cap) | 改元素能,重新 append 不能 |
| 传 map | hmap 指针 |
能(增删改都生效) |
| 传 chan | hchan 指针 |
能 |
range 遍历 |
每个元素复制一份 | 对副本取址无效 |
range 的复制是最容易被忽略的一条,for _, u := range users { u.Age++ } 完全改不动原切片。
2. 取址与解引用
n := 42
p := &n // 取址,p 类型 *int
*p = 100 // 解引用赋值
fmt.Println(n) // 100
fmt.Println(p == nil) // false
var q *int
fmt.Println(*q) // panic: nil pointer dereference
& 只能作用于可寻址的值:变量、字段、数组元素、切片元素、指针解引用。不能取址的有:字面量、常量、函数返回值、map 元素。
x := &User{} // OK,复合字面量可取址
// y := &42 // 编译错误
// z := &m["k"] // 编译错误:map 元素不可寻址
map 元素不可寻址是 Go 的刻意设计:map 扩容会搬动桶,地址不稳定。这也是第 8 节那个经典坑的根源。
3. 什么时候必须用指针
四条硬判据,任何一条命中就该用指针。
| 需求 | 为什么必须指针 | 判据 |
|---|---|---|
| 修改调用方变量 | 值传递复制副本 | 函数要写回结果,且不想用返回值 |
| 结构体较大 | 复制成本随字段线性增长 | 经验阈值:字段多或含大数组 |
| 表达"可为空" | 值类型没有 nil 状态 |
语义上区分"未设置"与"零值" |
| 满足接口方法集 | 指针接收者方法只属于 *T |
见第 4 节 |
// 1. 修改调用方
func grow(s *[]int, n int) { *s = append(*s, n) }
// 3. 表达可为空
type Patch struct {
Name *string // nil = 不修改;"" = 改成空串
}
第三条是最有价值的一条。*string 能区分三种状态,string 只能区分两种。API 里的 PATCH 语义、数据库的 NULL 列、配置项的"未显式设置",都该用指针表达。
3.1 结构体多大的分界
没有绝对数字。判据是复制是否出现在热路径:
| 场景 | 建议 | 理由 |
|---|---|---|
| 每请求调用一次的小结构体(< 4 字段) | 传值 | 复制成本低于解引用开销,且利于逃逸分析 |
| 热循环里百万次调用 | 传指针 | 复制累积明显 |
含 [1024]byte 这类大数组字段 |
传指针 | 一次复制就是 KB 级 |
| 需要保持不可变语义 | 传值 | 副本天然隔离 |
别凭感觉猜:go build -gcflags=-m 会显示变量是否逃逸,testing.B 加 -benchmem 能看到 allocs/op。
4. 方法集与接收者:接口能否满足的分水岭
这是 Go 最反直觉的规则之一,只有一条:
| 接收者类型 | 方法属于 | T 能满足接口 |
*T 能满足接口 |
|---|---|---|---|
值接收者 func (t T) M() |
T 和 *T |
能 | 能 |
指针接收者 func (t *T) M() |
仅 *T |
不能 | 能 |
type Speaker interface{ Speak() }
type Dog struct{ Name string }
func (d Dog) Speak() { fmt.Println(d.Name) } // 值接收者
type Cat struct{ Name string }
func (c *Cat) Speak() { fmt.Println(c.Name) } // 指针接收者
func main() {
var s Speaker = Dog{"wang"} // OK
s.Speak()
var s2 Speaker = &Cat{"miao"} // OK,必须取址
s2.Speak()
// var s3 Speaker = Cat{"miao"} // 编译错误:Cat does not implement Speaker
}
反过来,调用时 Go 会自动取址或解引用,所以 d.Speak() 和 (&d).Speak() 都能写——自动转换只发生在调用点,不发生在接口赋值点。
选接收者的实用规则:
| 情况 | 选值接收者 | 选指针接收者 |
|---|---|---|
| 方法要修改接收者 | ✓ | |
| 结构体大 | ✓ | |
含 sync.Mutex 等不可复制字段 |
✓(必须) | |
| 纯计算,无状态 | ✓ | |
| 同类型已有指针接收者方法 | ✓(保持一致) |
最后一条是工程纪律:同一类型的方法集要统一。混用会让"这个类型到底能不能赋给接口"变成需要记忆的问题。
5. nil 指针:哪些合法,哪些必炸
| 操作 | nil 指针 |
说明 |
|---|---|---|
判空 p == nil |
合法 | 最常见的用法 |
传给接受 *T 的函数 |
合法 | 由被调方判空 |
取字段 p.Field |
panic | 等价于解引用 |
| 调值接收者方法 | panic | Go 会先解引用 |
| 调指针接收者方法(方法内不解引用) | 合法 | 见下方示例 |
写 *p = x |
panic |
指针接收者方法对 nil 接收者是安全的,只要方法体内不解引用:
type Node struct {
Next *Node
Val int
}
func (n *Node) Len() int {
if n == nil { // 关键:先判空
return 0
}
return 1 + n.Next.Len()
}
这给了链表、树一个优雅的递归终止写法:nil 节点自己回答"长度为 0",调用方不用到处判空。
5.1 nil map 读、nil slice 遍历
var m map[string]int
fmt.Println(m["missing"]) // 0,安全
_, ok := m["missing"] // ok = false,用二值形式区分"不存在"
var s []int
for _, v := range s { // 0 次迭代,安全
_ = v
}
nil map 的读是安全的,只有写会 panic。nil slice 的 range 安全,因为描述符的 len 是 0。
6. 切片与映射的特殊性
切片不是引用类型,它是含指针字段的值类型。这个区分很关键:
func appendInside(s []int) []int {
s = append(s, 99)
return s
}
func main() {
s := make([]int, 0, 4)
s = append(s, 1, 2)
s2 := appendInside(s)
fmt.Println(len(s), len(s2)) // 2 3
}
append 若在 cap 内,会写同一个底层数组,但新长度只存在于被调方的描述符里,调用方的 len 不变。所以"传 slice 能改元素但不能改长度",除非返回新 slice。
| 类型 | 本质 | 作为参数时的行为 |
|---|---|---|
数组 [3]int |
纯值 | 完全复制,改不动 |
切片 []int |
描述符(值) | 改元素生效,改长度不生效 |
| map | 指向 hmap 的指针 |
增删改都生效 |
| 结构体含 slice 字段 | 值 + 描述符 | 改元素生效,换底层数组不生效 |
7. 逃逸分析:分配在栈还是堆
$ go build -gcflags=-m ./...
./main.go:12:2: moved to heap: u
./main.go:15:9: &User{...} escapes to heap
$ go build -gcflags='-m -m' ./... # 更详细的理由
| 输出 | 含义 |
|---|---|
escapes to heap |
该值分配到堆,受 GC 管理 |
moved to heap |
变量被提升到堆 |
does not escape |
留在栈上,函数返回即回收,零 GC 成本 |
三条常见逃逸原因:
- 返回局部变量的地址——合法且安全,Go 自动把该变量放堆上;
- 值被存入接口——装箱,通常逃逸;
- 值被闭包捕获且闭包逃出函数——随闭包一起进堆。
func newUser() *User {
u := User{Name: "x"} // 局部变量
return &u // 安全:编译器把 u 移到堆
}
"返回局部变量地址"在 Go 里完全安全,和 C 是两回事。这是 Go 相比 C 最重要的内存安全保证之一。
8. 排错:五个高频坑
| 现象 | 原因 | 处理 |
|---|---|---|
range 里 &v 拿到的地址全一样 |
v 是复用的循环变量副本 |
Go 1.22 起每次迭代新建变量;旧版本用 i := i 或索引取址 |
| 改 map 里结构体的字段不生效 | map 元素不可寻址 | 取出→改→写回,或用 map[K]*V |
cannot use t (type T) as type I |
接口方法是指针接收者 | 传 &t,或改值接收者 |
| 函数里改了参数但外面没变 | 传的是结构体值 | 改 *T,或用返回值 |
append 后调用方长度不变 |
append 只返回新描述符 |
返回新 slice 或传 *[]T |
8.1 map 里存结构体的正确写法
type Counter struct{ Hits int }
m := map[string]Counter{"a": {Hits: 1}}
// m["a"].Hits++ // 编译错误:cannot assign to struct field in map
c := m["a"] // 取出
c.Hits++ // 改副本
m["a"] = c // 写回
// 或者直接用指针,避免取出写回
mp := map[string]*Counter{"a": {Hits: 1}}
mp["a"].Hits++ // OK
第二种写法省一次复制,但引入指针别名,多处持有同一对象时改动会互相影响。数据量小、逻辑简单时第一种更安全。
9. 边界:这些场景本就该用别的方案
| 场景 | 用指针 | 更合适的选择 |
|---|---|---|
| 需要"可选字段" | *string |
sql.NullString / Optional[T] 泛型包装(语义更清楚) |
| 需要共享可变状态 | 到处传 *T |
加锁的结构体或 atomic 类型 |
| 需要函数式更新 | 传指针改入参 | 返回值 + 纯函数,可测性更好 |
| 需要避免复制大结构体 | *Big |
改成传接口,按需读取字段 |
| 需要表达"未初始化" | *T 判空 |
ok 布尔返回值,调用点更显式 |
一句话判据:指针用来表达"共享与可变",不是用来"省复制"的万能工具。当只读数据被传成指针时,读者无法从签名判断它会不会被改,这才是真正的维护成本。
10. 自测
- 传
[]int给函数并在里面append,调用方的len会变吗?为什么? - 一个类型只有指针接收者方法,它的值能赋给接口变量吗?
map[string]Counter里怎么写m["a"].Hits++?两种写法各自代价是什么?- 返回局部变量的地址在 Go 里安全吗?编译器做了什么?
nil指针调用指针接收者方法一定 panic 吗?什么条件下不 panic?go build -gcflags=-m输出里does not escape意味着什么?