核心认知: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 被改了

三个符号记牢:

符号

名字

作用

例子

*T

指针类型

声明一个存 T 地址的变量

var p *int

&x

取地址

拿到变量 x 的地址

p := &a

*p

解引用

顺着地址去读写那个值

*p = 100

零值是 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)规则

接口赋值时的硬规则:

类型

方法集包含

T

值接收者定义的方法

*T

值接收者 + 指针接收者定义的所有方法

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 什么时候必须用指针接收者

三条军规:

  1. 需要修改接收者 → 必须指针接收者

  2. 结构体很大 → 建议指针接收者(避免每次调用拷贝)

  3. 包含同步原语(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

new(T)

make(T, ...)

适用类型

任何类型

仅 slice / map / chan

返回值

*T(指针)

T(值,不是指针)

初始化

零值

初始化内部结构

底层

分配内存并清零

分配 + 初始化 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).Name

Go 对 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))
}

四条铁律:

  1. *T → unsafe.Pointer → *T 合法

  2. unsafe.Pointer → uintptr → unsafe.Pointer 合法,但中间不能持有 uintptr 变量跨语句

  3. unsafe.Pointer 可以转成 uintptr 做运算,但结果必须立刻转回 unsafe.Pointer

  4. unsafe.Pointer 是普通指针,会被 GC 追踪

建议: 除非在写 reflect、sync 这类底层库,或做 CGO 内存共享,否则别碰。用了就要写清楚注释,做好测试。

九、实战速查表

场景

推荐做法

函数要修改参数

传指针 *T

大结构体传参

传指针 *T

小结构体(<64B)、只读

值传递 T

表示"可选/无值"

指针 *T,nil 表示无

方法要修改接收者

指针接收者 func (t *T)

结构体含 sync.Mutex

必须指针接收者

接口实现的方法

保持接收者一致,全值或全指针

查询结果出参

返回 *T,出错返回 nil

循环里起 goroutine

显式 item := item,不依赖 Go 版本

slice 要改 len/cap

传 *[]T 或返回新 slice