核心认知:Go 只有值传递,指针是拷贝地址来共享数据的唯一手段。Go 的指针不支持运算(
p++、p+1都不行),这是为了安全。
一、指针基础
每个变量在内存里有两个属性:地址(住在哪)和值(住的是什么)。指针就是专门存地址的变量。
a := 42
p := &a // p 是 *int 类型,存了 a 的地址
fmt.Println(a) // 42
fmt.Println(p) // 0xc000014098(a 的地址)
fmt.Println(*p) // 42(解引用:顺着地址去取值)
*p = 100 // 通过指针改值
fmt.Println(a) // 100 —— a 被改了三个符号记牢:
符号 | 名字 | 作用 | 例子 |
|---|---|---|---|
| 指针类型 | 声明一个存 |
|
| 取地址 | 拿到变量 x 的地址 |
|
| 解引用 | 顺着地址去读写那个值 |
|
零值是 nil,解引用 nil 会直接 panic:
var p *int
fmt.Println(*p) // 💥 panic: invalid memory address or nil pointer dereference二、值传递 vs 指针传递
Go 没有引用传递。函数参数永远是拷贝。
func modify(a int) { a = 100 }
x := 42
modify(x)
fmt.Println(x) // 还是 42 —— 改的是副本"引用传递"的效果靠传指针实现:
func modify(p *int) { *p = 100 }
x := 42
modify(&x)
fmt.Println(x) // 100注意:传的 p 本身也是拷贝(拷贝了地址这个 8 字节的值),但因为地址指向同一块内存,所以能改到原数据。
实战意义:大对象千万别值传递。
type Order struct {
ID int64
UserID int64
Items []Item
// ... 几十个字段,几百字节
}
// ❌ 每次调用都拷贝整个 Order
func validate(o Order) error { ... }
// ✅ 只传 8 字节地址
func validate(o *Order) error { ... }💡 flow-core 场景:
internal/core/agent里如果有大的AgentState或TurnContext,在回合循环里层层传递时用*AgentState,否则每轮循环都在拷贝,浪费 CPU 和 GC。
三、指针的四大用途
用途 1:修改调用方的数据
func (s *Server) LoadConfig(path string) error {
cfg, err := os.ReadFile(path)
if err != nil { return err }
return json.Unmarshal(cfg, s.config) // 直接写进 s.config
}用途 2:避免大对象拷贝
经验法则:结构体超过 ~64 字节,或者不确定多大,就用指针。
用途 3:表达"可选 / 无值"(nil 语义)
Go 没有 Optional<T>,nil 就是"没有"。
type UpdateUserReq struct {
Name *string // nil 表示"不修改",非 nil 表示"改成这个值"
Email *string
Age *int
}
req := UpdateUserReq{
Name: ptr("新名字"), // 只改名字
// Email 是 nil,不动
}对比值类型:Name string 的空字符串是"改成空"还是"不修改"?分不清。
💡 flow-core 场景:审批 Park 里,一个 Turn 可能"有待审批"也可能"没有",用
*Approval比Approval + bool优雅:
type Turn struct {
ID string
Approval *Approval // nil = 不需要审批,非 nil = 卡在这里等审批
}用途 4:构建数据结构
链表、树、图的节点几乎必须用指针,需要共享和修改节点关系:
type TreeNode struct {
Val int
Children []*TreeNode // 指向子节点,不是拷贝
}四、值接收者 vs 指针接收者 ⭐ 最重点
先看现象:
type Counter struct{ n int }
func (c Counter) IncValue() { c.n++ } // 值接收者
func (c *Counter) IncPointer() { c.n++ } // 指针接收者
c := Counter{}
c.IncValue() // c.n 还是 0 —— 改的是副本
c.IncPointer() // c.n 变成 1 —— 改的是原数据4.1 方法集(Method Set)规则
接口赋值时的硬规则:
类型 | 方法集包含 |
|---|---|
| 值接收者定义的方法 |
| 值接收者 + 指针接收者定义的所有方法 |
type Speaker interface { Speak() }
type Dog struct{}
func (d Dog) Speak() {} // 值接收者
func (d *Dog) Walk() {} // 指针接收者
var s1 Speaker = Dog{} // ✅ Dog 的方法集有 Speak
var s2 Speaker = &Dog{} // ✅ *Dog 的方法集也有 Speak
type Cat struct{}
func (c *Cat) Speak() {} // 指针接收者
var s3 Speaker = Cat{} // ❌ 编译错误!Cat 的方法集里没有 Speak
var s4 Speaker = &Cat{} // ✅ 只有指针能赋值常见坑: 给某个类型加了指针接收者方法,它突然不能赋值给接口了,报 does not implement (method has pointer receiver)。
4.2 什么时候必须用指针接收者
三条军规:
需要修改接收者 → 必须指针接收者
结构体很大 → 建议指针接收者(避免每次调用拷贝)
包含同步原语(
sync.Mutex等)→ 必须指针接收者(拷贝锁是严重 bug)
type SafeCache struct {
mu sync.Mutex // ❗ 含锁
items map[string]any
}
// ❌ 值接收者会拷贝 Mutex,锁形同虚设,还触发 go vet 警告
func (c SafeCache) Get(k string) any { ... }
// ✅ 必须指针
func (c *SafeCache) Get(k string) any {
c.mu.Lock()
defer c.mu.Unlock()
return c.items[k]
}4.3 一致性原则
一旦某个类型有一个指针接收者,就全部用指针接收者。 混用会让方法集难以推理,也会让接口赋值坑随机出现。
4.4 实战:GORM 为什么是指针
var user User
db.First(&user) // 传 &user,因为要写进去
db.Save(&user)
db.Create(&user)GORM 内部用反射,要求传指针,否则改不到你的变量。
💡 flow-core 场景:Repository 层查询结果一律用指针出参:
func (r *MessageRepo) FindByID(ctx context.Context, id int64) (*Message, error) {
var msg Message
if err := r.db.WithContext(ctx).First(&msg, id).Error; err != nil {
return nil, err // 返回 nil 表示"没找到/出错"
}
return &msg, nil
}返回 *Message 而不是 Message,"没找到"可以返回 nil,调用方 if msg == nil 一目了然。
五、new 和 make
|
| |
|---|---|---|
适用类型 | 任何类型 | 仅 slice / map / chan |
返回值 |
|
|
初始化 | 零值 | 初始化内部结构 |
底层 | 分配内存并清零 | 分配 + 初始化 header/hashtable/buffer |
p := new(int) // p 是 *int,*p == 0
s := make([]int, 3) // s 是 []int,len=3 cap=3,元素都是 0
m := make(map[string]int) // m 是 map,已初始化,可直接赋值
ch := make(chan int, 10) // ch 是 chan,带缓冲常见误解: make 返回的不是指针。make([]int) 返回的是 slice 值(虽然 slice 内部含指向数组的指针,但它本身是值类型)。
什么时候用 new? 日常很少用,&T{} 更直观:
p1 := new(User) // *User,字段全零值
p2 := &User{} // 等价,但更常见
p3 := &User{Name: "wangjun"} // 顺便初始化,new 做不到六、指针与复合类型
6.1 结构体指针(最常用)
u := &User{Name: "wangjun"} // *User
u.Name = "新名字" // 自动解引用,不用 (*u).NameGo 对 p.Field 做了语法糖,自动帮你 (*p).Field。
6.2 指针数组 vs 数组指针
arr := [3]int{1, 2, 3}
pArr := &arr // *[3]int,指向数组的指针
fmt.Println(pArr[0]) // ✅ 自动解引用,等价于 (*pArr)[0]
ptrs := [3]*int{&arr[0], &arr[1], &arr[2]} // [3]*int,指针的数组区分口诀:看 * 贴着谁。*[3]int 贴着 [3]int,是"数组的指针";[3]*int 贴着 int,是"指针的数组"。
6.3 指针的指针 **T
当需要在一个函数里修改一个指针变量本身(让它指向别处):
func allocate(p **int) {
x := 42
*p = &x // 修改调用方的指针,让它指向 x
}
var myPtr *int
allocate(&myPtr) // 传指针的地址
fmt.Println(*myPtr) // 42日常业务极少用,但写通用库、链表头插法时会遇到。
6.4 指向 slice / map 的指针(少见,但有坑)
slice 和 map 本身就是"引用语义"(内部含指针),很少需要指向它们的指针。但有一个经典坑:
func appendItem(s []int) {
s = append(s, 4) // ❌ 外面的 s 感知不到
}
func appendItemPtr(s *[]int) {
*s = append(*s, 4) // ✅ 通过指针把新 slice 头写回去
}
s := []int{1, 2, 3}
appendItem(s)
fmt.Println(s) // [1 2 3] —— 没变!
appendItemPtr(&s)
fmt.Println(s) // [1 2 3 4]为什么? slice 传参时,头(ptr/len/cap)是拷贝的。append 如果触发扩容,会分配新数组,函数内的 s 指向新数组,但外面的 s 还指向旧数组。要传出去,必须传 *[]T 或返回新 slice。
map 没这个问题,因为 map 内部就是指针(*hmap),传参时拷贝的是指针,改内容外面能看到。但给 map 重新赋值(m = make(...))外面看不到。
七、常见陷阱 ⭐⭐ 重点
陷阱 1:nil 解引用
var u *User
fmt.Println(u.Name) // 💥 panic防御式编程:
func (s *Service) GetUser(id int64) (*User, error) {
u, err := s.repo.FindByID(id)
if err != nil {
return nil, err // 出错返回 nil
}
return u, nil
}
// 调用方
u, err := svc.GetUser(1)
if err != nil { ... }
if u == nil { ... } // 或者依赖 err 判断,别直接用 u
fmt.Println(u.Name)原则:返回指针时,出错返回 nil;调用方先判 err,再判 nil。
陷阱 2:循环变量取地址(Go 1.22 分水岭)
Go 最著名的坑之一,1.22 版本改了行为:
// Go 1.22 之前:所有迭代共享同一个 i
funcs := []func(){}
for i := 0; i < 3; i++ {
funcs = append(funcs, func() { fmt.Println(&i, i) })
}
for _, f := range funcs {
f() // 全部打印同一个地址,值都是 3
}
// Go 1.22+:每次迭代创建新变量
// 打印三个不同地址,值分别是 0 1 2实战影响: 循环里起 goroutine 或用指针存循环变量,必须确认 go.mod 里的 Go 版本:
// go.mod
module flow-core
go 1.21 // ⚠️ 如果是 1.21 及以下,下面这个代码有 bug
for _, item := range items {
go process(&item) // ❌ 1.21 及以下:所有 goroutine 共享同一个 item
}
// 1.21 及以下的正确写法:显式拷贝
for _, item := range items {
item := item // 遮蔽,创建新变量
go process(&item)
}💡 flow-core 注意:如果是 1.22+ 就安全,但为了可移植性和明确性,循环里起 goroutine 建议还是显式
item := item,不依赖语言版本行为。
陷阱 3:slice/map 传参的"假象"
func modifySlice(s []int) {
s[0] = 100 // ✅ 外面能看到!因为底层数组共享
}
func modifySliceLen(s []int) {
s = append(s, 4) // ❌ 外面看不到 len 变化
}
func modifyMap(m map[string]int) {
m["key"] = 100 // ✅ 外面能看到
m = make(map[string]int) // ❌ 外面看不到重新赋值
}记忆法: slice 和 map 传参,改内容外面看得到,改头(len/cap/重新赋值)外面看不到。
陷阱 4:逃逸分析(栈 vs 堆)
Go 自动决定变量放栈还是堆。返回局部变量的地址是安全的,Go 会把它逃逸到堆:
func newUser() *User {
u := User{Name: "wangjun"}
return &u // ✅ 安全!u 逃逸到堆,函数返回后依然有效
}但逃逸有代价(GC 压力)。可以用 go build -gcflags=-m 看逃逸情况:
go build -gcflags=-m ./... 2>&1 | grep "escapes to heap"实战: 热点路径上如果发现大对象逃逸,考虑用 sync.Pool 或改成值传递+返回值。
陷阱 5:指针接收者的并发陷阱
结构体含 sync.Mutex 时,必须用指针接收者,否则每次方法调用都拷贝锁,锁形同虚设(详见 4.2)。
八、unsafe.Pointer(进阶,了解即可)
日常业务基本用不到,CGO_ENABLED=0 的项目大概率接触不到。只留概念:
import "unsafe"
// *T1 → unsafe.Pointer → *T2
func float64bits(f float64) uint64 {
return *(*uint64)(unsafe.Pointer(&f))
}四条铁律:
*T→unsafe.Pointer→*T合法unsafe.Pointer→uintptr→unsafe.Pointer合法,但中间不能持有uintptr变量跨语句unsafe.Pointer可以转成uintptr做运算,但结果必须立刻转回unsafe.Pointerunsafe.Pointer是普通指针,会被 GC 追踪
建议: 除非在写 reflect、sync 这类底层库,或做 CGO 内存共享,否则别碰。用了就要写清楚注释,做好测试。
九、实战速查表
场景 | 推荐做法 |
|---|---|
函数要修改参数 | 传指针 |
大结构体传参 | 传指针 |
小结构体(<64B)、只读 | 值传递 |
表示"可选/无值" | 指针 |
方法要修改接收者 | 指针接收者 |
结构体含 | 必须指针接收者 |
接口实现的方法 | 保持接收者一致,全值或全指针 |
查询结果出参 | 返回 |
循环里起 goroutine | 显式 |
slice 要改 len/cap | 传 |