Go 指针

Go 指针保存地址以间接读写对象:&、*、nil 解引用 panic、new 与取址、可寻址性、以及指针接收者与方法集的入口;堆栈分配仅作逃逸实现提示。

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

[!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

结合场景再看三个关注点

  1. 指针让函数能改调用方的对象
    bumpQuota 改的是 remaining 本身,不是副本。

  2. new(T) = 分配零值 T 并返回 *T
    适合“需要指针但还没有变量可取址”的情况;struct 更常用 &T{...}

  3. nil 必须先判断再 *p
    对应“可选配额未初始化”;硬解引用会直接打崩 goroutine。

核心概念与准确模型

类型与操作

对任意类型 T

语法含义
*T指向 T 的指针类型
&xx 的地址,得 *Tx 须可寻址)
*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指向字面量或变量
newp := 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

规则:

  1. 指针零值是 nil
  2. nil 解引用 → panic
  3. 比较两个指针:同指一对象或都为 nil 则相等(指向的是同一变量地址)
  4. 跨包导出的 nil 指针与接口组合时还有“接口非 nil 但动态值是 nil”陷阱,见 接口值与 nilnil 的多种形态

指针接收者 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 编译器做逃逸分析:若指针在函数返回后仍被使用,对象可能分配在堆上;否则可能留在栈上。

正确性层面请遵守:

  1. 不要假设“局部变量一定在栈上”
  2. 不要假设“new 一定在堆上”
  3. 语义只保证:只要有指针可达,对象不会被回收

性能调优时再看逃逸与分配(go build -gcflags=-m 等),那是优化问题,不是语言入门的正确性前提。GC 细节见 垃圾回收(若已建立笔记)。

指针与 slice/map 描述符

用户级 *T 与 slice/map 内部指针不同层:

  • 你显式声明的 *User 是类型系统中的指针
  • slice 头里的 array 指针由运行时管理,不支持算术,也不直接暴露为可移植 API

不要用 C 的指针算术直觉去 +1 移动 Go 指针——语言不允许。

边界与限制

  1. 无指针算术p+1 对指针不合法)。
  2. -> 运算符:用 p.Field,编译器自动解引用。
  3. 无手动 free:生命周期由 GC 与可达性决定。
  4. *T 不是可选类型的完整代数数据类型:可用,但要约定 nil 含义并防 panic。
  5. 指针不自动保证并发安全
  6. 指向短生命周期对象的指针若逃逸到堆,可能延长对象寿命(实现层面的内存占用问题)。
  7. 不能取 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。

工程实践

  1. 需要突变或避免大结构复制时用指针;只读小结构优先值。
  2. API 用 *T 表示可选时,文档写清 nil 含义;入口校验。
  3. 结构体方法接收者保持一致:多数可变类型统一 *T,避免混用造成方法集意外。
  4. 不要暴露内部可变 slice 却又当不可变用:指针/描述符别名是 bug 温床。
  5. 打印调试%p 看地址,%v/%+v 看值;确认是否同一实例。
  6. 并发下保护指针指向的数据(mutex 或所有权转移),而不是假设指针本身有魔法。
  7. 优先让零值可用:很多类型通过值接收者 + 零值比“必须 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 中的结构体字段?

答案
  1. 能。返回局部变量地址合法,编译器可将其分配到堆上。
  2. 前者 pnil,解引用 panic;new(int) 返回指向零值 int 的合法指针。
  3. 不能(方法集:T 不含指针接收者方法)。应使用 *T。详见方法集笔记。
  4. 不能,map 元素不可寻址。应读出值、改字段、写回:v := m[k]; v.Field = x; m[k] = v;若值是指针类型则另当别论。

依据与延伸

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