Go 指针
Go 指针保存地址以间接读写对象:&、*、nil 解引用 panic、new 与取址、可寻址性、以及指针接收者与方法集的入口;堆栈分配仅作逃逸实现提示。
[!info] 关联笔记
Go 指针
这个概念为什么会出现
值语义让数据流清晰:函数拿到副本,默认不改调用方。但真实系统需要:
- 在函数内原地更新大对象
- 多个结构共享同一配置/连接
- 表达“可选的
T”(用*T/nil) - 方法更新接收者状态
指针提供显式的共享与突变入口。Go 有意克制指针能力:
- 有
*与& - 没有指针算术
- 垃圾回收管理生命周期,不鼓励手动释放
目标不是把 Go 写成 C,而是在值语义为主的语言里保留可控的间接层。
[!abstract] 一句话理解 指针是可复制的地址值:
&x取址、*p解引用;复制指针不复制对象,对nil解引用会 panic。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:库存服务里“就地扣减剩余次数”
优惠券、验证码、连接池配额,常有一个可被多处更新的计数。
若函数按值接收 int,改的是副本,调用方看不到变化。
传 *int(或更常见的 *Counter 结构体)才能在函数内原地改调用方的数据。
同时要处理“计数器可选/未初始化”:指针为 nil 时不能解引用。
package main
import "fmt"
// bumpQuota 模拟“把剩余配额 +1 / 恢复一次额度”。
//
// 业务意图:
// - 调用方持有配额变量;
// - 本函数就地修改,不返回新值;
// - 若调用方还没准备好配额(nil),安静跳过,避免 panic。
//
// 教学点:
// - 参数 n 是指针的副本,但副本仍指向同一 int;
// - *n 读写的是目标对象,不是指针变量自己;
// - 解引用前必须防 nil。
func bumpQuota(n *int) {
if n == nil {
// 业务上:没有配额槽位就不操作。
// 语言上:避免 nil pointer dereference panic。
return
}
*n = *n + 1
}
func main() {
// --- 已有配额变量:取址后交给函数就地改 ---
remaining := 1
p := &remaining // p 的类型是 *int
bumpQuota(p)
// remaining 被改成 2;若 bumpQuota 收的是 int,这里仍会是 1
fmt.Println("remaining:", remaining) // 2
// --- new:要一个“指向零值的 *int”,还没有现成变量时 ---
// 业务上像“新建一个从 0 起算的配额槽”。
q := new(int) // *q == 0
bumpQuota(q)
fmt.Println("fresh quota:", *q) // 1
// --- nil:可选字段尚未设置 ---
var r *int
// *r = 1 // 会 panic:nil pointer dereference
bumpQuota(r) // 函数内已防护,安全
fmt.Println("unset is nil?", r == nil)
}
建议运行:
go run .
期望输出:
remaining: 2
fresh quota: 1
unset is nil? true
结合场景再看三个关注点
-
指针让函数能改调用方的对象
bumpQuota改的是remaining本身,不是副本。 -
new(T)= 分配零值T并返回*T
适合“需要指针但还没有变量可取址”的情况;struct 更常用&T{...}。 -
nil必须先判断再*p
对应“可选配额未初始化”;硬解引用会直接打崩 goroutine。
核心概念与准确模型
类型与操作
对任意类型 T:
| 语法 | 含义 |
|---|---|
*T | 指向 T 的指针类型 |
&x | 取 x 的地址,得 *T(x 须可寻址) |
*p | 解引用,得 T 值;也可出现在赋值左边写回 |
nil | 指针零值,不指向任何对象 |
var p *int // nil
x := 3
p = &x
fmt.Println(*p) // 3
*p = 4
fmt.Println(x) // 4
指针本身也是值
p1 := &x
p2 := p1 // 复制地址
p2 = nil // 只改 p2,p1 仍指向 x
这与 值、指针与引用语义 完全一致:传 *T 是传地址副本。
new vs &
| 方式 | 写法 | 结果 |
|---|---|---|
| 取址 | p := &T{...} 或 p := &x | 指向字面量或变量 |
new | p := new(T) | 分配一个零值 T,返回 *T |
type User struct{ Name string }
a := &User{Name: "ada"} // 字面量取址,常见
b := new(User) // b.Name == ""
工程上 struct 更常用 &T{...};new 在需要“指针到零值”且无字面量字段时可用。二者没有所有权语义差异,都受 GC 管理。
可寻址性(addressability)
不是所有 T 表达式都能 &:
通常可取址:
- 变量:
&x - 可寻址 struct 的字段:
&u.Name - 可寻址数组的元素:
&a[i] - 可寻址 slice 的元素:
&s[i](底层数组元素)
通常不可取址:
- 映射中的元素:不能
&m[key] - 函数返回值临时值:不能
&f() - 字面量多数情况需先赋给变量(复合字面量取址
&T{}是特判合法)
m := map[string]int{"a": 1}
// p := &m["a"] // 编译错误
v := m["a"]
p := &v // 合法,但 p 指向的是副本 v,不是 map 槽位
可寻址性也影响“值能否取址以调用指针方法”,与方法集规则交织——见下文 teaser。
nil 与解引用 panic
var p *int
fmt.Println(p == nil) // true
// fmt.Println(*p) // runtime panic
规则:
- 指针零值是
nil - 对
nil解引用 → panic - 比较两个指针:同指一对象或都为
nil则相等(指向的是同一变量地址) - 跨包导出的
nil指针与接口组合时还有“接口非 nil 但动态值是 nil”陷阱,见 接口值与 nil、nil 的多种形态
指针接收者 teaser(方法集入口)
type Counter struct{ N int }
func (c Counter) Value() int { return c.N } // 值接收者
func (c *Counter) Inc() { c.N++ } // 指针接收者
调用层:
- 变量
c常可调用Inc(编译器自动&c,因c可寻址) - 但 方法集规定:类型
Counter的方法集不含指针接收者方法;*Counter才包含
因此:
var _ interface{ Inc() } = &Counter{} // ok
// var _ interface{ Inc() } = Counter{} // 不满足接口
完整规则与口诀见 方法集与接收者。本笔记只需记住:
选指针接收者 = 允许突变 + 影响接口满足面。
栈 / 堆:仅作实现提示
Go 编译器做逃逸分析:若指针在函数返回后仍被使用,对象可能分配在堆上;否则可能留在栈上。
正确性层面请遵守:
- 不要假设“局部变量一定在栈上”
- 不要假设“
new一定在堆上” - 语义只保证:只要有指针可达,对象不会被回收
性能调优时再看逃逸与分配(go build -gcflags=-m 等),那是优化问题,不是语言入门的正确性前提。GC 细节见 垃圾回收(若已建立笔记)。
指针与 slice/map 描述符
用户级 *T 与 slice/map 内部指针不同层:
- 你显式声明的
*User是类型系统中的指针 - slice 头里的 array 指针由运行时管理,不支持算术,也不直接暴露为可移植 API
不要用 C 的指针算术直觉去 +1 移动 Go 指针——语言不允许。
边界与限制
- 无指针算术(
p+1对指针不合法)。 - 无
->运算符:用p.Field,编译器自动解引用。 - 无手动
free:生命周期由 GC 与可达性决定。 *T不是可选类型的完整代数数据类型:可用,但要约定nil含义并防 panic。- 指针不自动保证并发安全。
- 指向短生命周期对象的指针若逃逸到堆,可能延长对象寿命(实现层面的内存占用问题)。
- 不能取 map 元素地址;要改 map 值请整体写回
m[k]=v。
常见误区
[!warning] 误区 1:有指针就是引用传递 传递的仍是指针值副本。见 FAQ pass-by-value。
[!warning] 误区 2:到处用指针更快更专业 小对象值传递往往更清晰;指针引入
nil、别名与逃逸。先满足突变与共享需求。
[!warning] 误区 3:
&m[k]理所应当 map 元素不可寻址,这是语言规则。
[!warning] 误区 4:返回局部变量指针一定错误 在 Go 中合法;编译器会处理逃逸。C 里的“返回栈地址必炸”直觉不直接适用。
[!warning] 误区 5:值能调用指针方法 ⇒ 值类型实现了该接口 调用规则 ≠ 方法集。接口检查看方法集。
[!warning] 误区 6:
*p前从不检查 nil 库边界、可选指针字段、失败路径上nil很常见;解引用即 panic。
工程实践
- 需要突变或避免大结构复制时用指针;只读小结构优先值。
- API 用
*T表示可选时,文档写清nil含义;入口校验。 - 结构体方法接收者保持一致:多数可变类型统一
*T,避免混用造成方法集意外。 - 不要暴露内部可变 slice 却又当不可变用:指针/描述符别名是 bug 温床。
- 打印调试:
%p看地址,%v/%+v看值;确认是否同一实例。 - 并发下保护指针指向的数据(mutex 或所有权转移),而不是假设指针本身有魔法。
- 优先让零值可用:很多类型通过值接收者 + 零值比“必须 new 的指针”更 Go。
实验
package main
import "fmt"
type Point struct{ X, Y int }
func moveVal(p Point) { p.X++ }
func movePtr(p *Point) { p.X++ }
func main() {
// 1) 别名
a := Point{1, 2}
p1 := &a
p2 := p1
p2.X = 9
fmt.Println(a, p1, p2)
// 2) 值 vs 指针参数
b := Point{0, 0}
moveVal(b)
fmt.Println("after val", b)
movePtr(&b)
fmt.Println("after ptr", b)
// 3) new 与字面量
p := new(Point)
q := &Point{}
fmt.Println(*p, *q, p != q)
// 4) 字段取址
u := Point{3, 4}
px := &u.X
*px = 10
fmt.Println(u)
// 5) 返回局部指针(合法)
makePoint := func(x int) *Point {
pt := Point{X: x}
return &pt // 逃逸到堆(实现细节)
}
fmt.Println(makePoint(7))
}
把输出与“复制的是地址还是对象”对照。可选:go build -gcflags="-m" . 观察 pt 是否逃逸——仅作实现观察,不写进业务正确性假设。
总结
| 主题 | 记住 |
|---|---|
| 本质 | 指针是地址值,按值复制 |
& / * | 取址 / 解引用 |
nil | 零值;解引用 panic |
new / &T{} | 都得 *T;常用复合字面量取址 |
| 可寻址性 | map 元素、临时值等不可 & |
| 接收者 | 指针接收者服务突变与方法集 |
| 堆栈 | 实现可逃逸;正确性靠可达性而非“我声明在栈上” |
自测题
1. 下面会不会编译通过?为什么?
func f() *int {
x := 1
return &x
}
2. 为什么 var p *int; *p = 1 会 panic,而 p = new(int); *p = 1 不会?
3. func (t *T) M() 时,类型 T 能否赋值给 interface{ M() }?
4. 能否写下 p := &m["k"](m 为 map)?若不能,如何修改 map 中的结构体字段?
答案
- 能。返回局部变量地址合法,编译器可将其分配到堆上。
- 前者
p是nil,解引用 panic;new(int)返回指向零值int的合法指针。 - 不能(方法集:
T不含指针接收者方法)。应使用*T。详见方法集笔记。 - 不能,map 元素不可寻址。应读出值、改字段、写回:
v := m[k]; v.Field = x; m[k] = v;若值是指针类型则另当别论。
依据与延伸
- Go Spec — Pointer types
- Go Spec — Address operators
- Go Spec — Method sets
- FAQ — Why pass by value?
- FAQ — When should I use a pointer to an interface?(相关边界)
- Effective Go — Pointers vs. Values
- A Tour of Go — Pointers
- A Tour of Go — Struct Pointers
- A Tour of Go — The new function
- Go Blog — Go Slices: usage and internals(对比内部指针与用户指针心智)