跳到主要内容

指针

问题

Go 的指针与 C 指针有什么区别?unsafe.Pointeruintptr 分别是什么?

答案

指针基础

Go 有指针但不支持指针运算(不能 p++),这是与 C 最大的区别:

x := 42
p := &x // p 是 *int 类型,指向 x
fmt.Println(*p) // 42(解引用)

*p = 100 // 通过指针修改值
fmt.Println(x) // 100

// Go 不支持指针运算
// p++ // 编译错误!
// p + 1 // 编译错误!

零值:指针的零值是 nil,解引用 nil 指针会 panic:

var p *int       // nil
// *p = 1 // panic: runtime error: invalid memory address

值传递 vs 指针传递

Go 只有值传递,传指针的效果等同于"引用传递":

// 值传递:函数内修改的是副本
func tryModify(x int) {
x = 100 // 修改副本,不影响原值
}

// 指针传递:函数内修改的是原值
func modify(p *int) {
*p = 100 // 通过指针修改原值
}

x := 42
tryModify(x)
fmt.Println(x) // 42(不变)

modify(&x)
fmt.Println(x) // 100(被修改)

什么时候用指针?

场景用指针 *T用值 T
需要修改原值
大结构体传参✅(避免拷贝)❌(拷贝开销大)
小结构体(< 64字节)可选✅(逃逸分析可能栈分配更快)
方法接收者✅(需修改或结构体大)✅(不修改且结构体小)
表示"可能为空"✅(nil 表示无值)
并发场景⚠️ 需要同步✅(拷贝天然安全)
性能并不总是指针更好

指针可能导致堆分配(逃逸分析),而值传递的小结构体可以留在栈上。栈分配比堆分配快得多,且不需要 GC。所以小结构体传值反而可能更快。

new 和 & 取地址

// & 取地址(更常用)
x := 42
p := &x // p 指向 x

// new 分配零值并返回指针
p2 := new(int) // *int,指向 0
*p2 = 42

// 结构体:两种方式等价
p3 := &User{Name: "Alice", Age: 30} // 更常用
p4 := new(User) // 零值 User 的指针

unsafe.Pointer

unsafe.Pointer 是 Go 的通用指针类型,可以在任意指针类型之间转换:

import "unsafe"

// 任意指针 ↔ unsafe.Pointer
x := 42
p := unsafe.Pointer(&x) // *int → unsafe.Pointer
q := (*float64)(p) // unsafe.Pointer → *float64(危险!)

// 计算结构体字段偏移
type User struct {
Name string
Age int
}
u := User{Name: "Alice", Age: 30}
// 获取 Age 字段的指针(绕过导出限制)
agePtr := (*int)(unsafe.Pointer(
uintptr(unsafe.Pointer(&u)) + unsafe.Offsetof(u.Age),
))
fmt.Println(*agePtr) // 30
unsafe.Pointer 的使用规则

Go 官方定义了 6 种合法的 unsafe.Pointer 使用模式,其他用法可能导致未定义行为:

  1. *T1unsafe.Pointer*T2(类型转换)
  2. unsafe.Pointeruintptr(仅用于打印,不能转回来)
  3. unsafe.Pointeruintptr + 算术运算 → unsafe.Pointer(指针运算,必须在同一表达式中)
  4. syscall.Syscall 参数
  5. reflect.Value.Pointer / reflect.Value.UnsafeAddr
  6. reflect.SliceHeader / reflect.StringHeader

最重要的规则uintptr 转回 unsafe.Pointer 必须在同一个表达式中完成,不能把 uintptr 存到变量中再转回来——因为 GC 可能在中间移动对象。

uintptr

uintptr 是一个整数类型,大小足以存放指针值。它与 unsafe.Pointer 的关键区别:

维度unsafe.Pointeruintptr
本质指针类型整数类型
GC 是否追踪✅ GC 知道这是指针❌ GC 不追踪
指针运算❌ 不支持✅ 支持加减
安全性相对安全⚠️ 可能导致悬垂指针
// ❌ 危险:uintptr 不被 GC 追踪
p := uintptr(unsafe.Pointer(&x))
// GC 可能移动 x 的内存位置,p 变成野指针
_ = (*int)(unsafe.Pointer(p)) // 可能指向错误的地址

// ✅ 正确:在同一表达式中完成
p := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&u)) + offset))

指针与逃逸分析

Go 编译器通过逃逸分析(Escape Analysis) 决定变量分配在栈还是堆上。返回局部变量的指针会导致逃逸到堆:

// x 逃逸到堆(函数返回后仍被引用)
func newInt() *int {
x := 42
return &x // Go 允许返回局部变量的指针(C 不允许!)
}

// x 留在栈上(不逃逸)
func noEscape() int {
x := 42
return x
}

查看逃逸分析结果:

go build -gcflags="-m" main.go
# ./main.go:3:2: moved to heap: x

常见面试问题

Q1: Go 可以返回局部变量的指针吗?跟 C 有什么区别?

答案

可以。Go 和 C 在这点上截然不同:

  • C:局部变量在栈上,函数返回后栈帧销毁,返回的指针变成悬垂指针(dangling pointer)
  • Go:编译器通过逃逸分析发现局部变量被返回指针引用后,会自动将其分配到堆上,由 GC 管理,不会出现悬垂指针
func newUser() *User {
u := User{Name: "Alice"} // 逃逸到堆
return &u // 安全!
}

Q2: Go 的指针不能运算,那如何实现类似 C 的指针偏移?

答案

通过 unsafe.Pointer + uintptr 实现指针运算。常见场景:

  1. 访问结构体的未导出字段
  2. 实现高性能数据结构(如 Go runtime 内部)
  3. 与 C 代码互操作(CGO)
// 通过偏移访问结构体字段
type User struct {
name string // 未导出
age int // 未导出
}

u := User{"Alice", 30}
namePtr := (*string)(unsafe.Pointer(&u))
fmt.Println(*namePtr) // "Alice"

agePtr := (*int)(unsafe.Pointer(
uintptr(unsafe.Pointer(&u)) + unsafe.Sizeof(u.name),
))
fmt.Println(*agePtr) // 30

在日常开发中应避免使用 unsafe 包,它会绕过 Go 的类型安全检查。

Q3: 什么是逃逸分析?什么情况下变量会逃逸?

答案

逃逸分析是编译器在编译时判断变量应该分配在栈还是堆上的过程。常见的逃逸场景

  1. 函数返回局部变量的指针
  2. 闭包引用局部变量
  3. interface 类型赋值(不确定具体类型大小)
  4. 切片/map 存储指针
  5. 发送到 channel 的数据
  6. 变量大小在编译时未知(如 make([]int, n) 中 n 是变量)

通过 go build -gcflags="-m" 查看逃逸分析结果。减少堆分配(即减少逃逸)是 Go 性能优化的重要手段。

Q4: nil 指针和空指针有区别吗?

答案

Go 中 nil 指针就是空指针,是同一个概念。指针的零值是 nil,表示"不指向任何对象"。

var p *int // nil

// 判断
if p == nil { // true
}

// 解引用 nil 指针会 panic
// *p = 1 // panic: runtime error: invalid memory address or nil pointer dereference

在接口中,nil 有更复杂的语义:一个接口值为 nil 仅当类型和值都是 nil。这是常见的面试陷阱。

相关链接