指针:没有你想的那么可怕
提到"指针",很多人的第一反应是恐惧。这很正常——在 C/C++ 的世界里,指针确实是很多 bug 的根源:野指针、空指针、内存泄漏、缓冲区溢出……简直是一场噩梦。
但是,Go 语言的指针完全不一样。
Go 的设计者深知指针的复杂性和危险性,所以他们做了两件事:
- 保留了指针——因为指针确实很有用,不能因为危险就完全不用
- 去掉了危险的指针操作——Go 的指针不支持算术运算,不能像 C 那样随意偏移
结果是:Go 的指针非常简单和安全。如果你被 C 的指针吓过,放心,Go 的指针完全不是那回事。
今天,我会用最通俗的方式帮你理解指针。读完这篇文章,你会发现指针其实就是一个很自然的概念。
什么是指针?
让我们先用一个生活中的比喻来理解指针。
想象一下你和你的朋友住在同一栋楼里。你住在 301 房间,你的朋友住在 502 房间。你的房间号"301"就是一个"指针"——它不是一间实际的房间,而是指向一间实际房间的地址。
在计算机内存中,每个变量都存储在一个特定的位置上。这个位置有一个地址(就像房间号)。指针就是存储这个地址的变量。
内存示意图:
地址 变量名 值
0x1000 age 25 ← 这是一个普通的整数变量
0x1008 ptr 0x1000 ← 这是一个指针,存储着 age 的地址
指针 ptr 的值是 0x1000,它"指向"了变量 age 所在的位置。
& 和 * 操作符
Go 语言有两个和指针相关的操作符:
&(取地址操作符):获取变量的内存地址*(解引用操作符):通过指针访问指针指向的值
package main
import "fmt"
func main() {
age := 25
// & 获取地址
fmt.Printf("age 的值: %d\n", age)
fmt.Printf("age 的地址: %p\n", &age)
// 声明一个指针变量
var ptr *int // ptr 是一个指向 int 的指针
ptr = &age // ptr 存储 age 的地址
// * 解引用:通过指针获取值
fmt.Printf("ptr 的值(地址): %p\n", ptr)
fmt.Printf("ptr 指向的值: %d\n", *ptr)
// 通过指针修改值
*ptr = 30
fmt.Printf("修改后 age 的值: %d\n", age) // 30
}
运行结果:
age 的值: 25
age 的地址: 0xc000014098
ptr 的值(地址): 0xc000014098
ptr 指向的值: 25
修改后 age 的值: 30
注意:* 用在两个地方有不同的含义:
- 在类型声明中(
*int),*表示"这是一个指针" - 在表达式中(
*ptr),*表示"获取指针指向的值"
指针的类型
指针也是有类型的。*int 是一个"指向 int 的指针",*string 是一个"指向 string 的指针"。不同类型的指针不能互相赋值:
var intPtr *int
var strPtr *string
// intPtr = strPtr // 编译错误:类型不匹配
nil 指针
未初始化的指针的值是 nil:
var ptr *int
fmt.Println(ptr) // <nil>
fmt.Println(ptr == nil) // true
// 解引用 nil 指针会导致 panic
// fmt.Println(*ptr) // panic: runtime error: invalid memory address or nil pointer dereference
解引用 nil 指针是 Go 中最常见的 panic 原因之一。所以在解引用之前,最好先检查指针是否为 nil:
if ptr != nil {
fmt.Println(*ptr)
} else {
fmt.Println("指针为 nil")
}
值传递 vs 指针传递
这是理解指针最重要的一个概念。当你在 Go 中调用函数时,参数是怎么传递的?
Go 语言只有值传递(pass by value)——所有的参数传递都是复制的。但传递指针时,复制的是指针本身(地址),而不是指针指向的数据。
值传递(复制数据)
func doubleValue(n int) {
n = n * 2
fmt.Println("函数内部:", n) // 50
}
func main() {
x := 25
doubleValue(x)
fmt.Println("函数外部:", x) // 25 ← 没有变化!
}
传给函数的是 x 的副本,函数内部修改的是副本,不影响原来的 x。
指针传递(共享数据)
func doublePtr(n *int) {
*n = *n * 2
fmt.Println("函数内部:", *n) // 50
}
func main() {
x := 25
doublePtr(&x)
fmt.Println("函数外部:", x) // 50 ← 被修改了!
}
传给函数的是 x 的地址。函数通过这个地址直接修改了 x 的值。
用图来理解
值传递:
main() doubleValue()
┌─────┐ ┌─────┐
│ x=25│ ──复制──▶│ n=25│ → n=50
└─────┘ └─────┘
x=25 n=50
(互不影响)
指针传递:
main() doublePtr()
┌─────┐ ┌────────┐
│ x=25│◀──修改──│ n=&x │ → *n=50
└─────┘ └────────┘
x=50
(指向同一个内存位置)
什么时候用指针?
这是一个很实际的问题。在以下几种情况下,使用指针是合理的:
1. 需要在函数中修改参数
这是最常见的用途:
func swap(a, b *int) {
*a, *b = *b, *a
}
func main() {
x, y := 1, 2
swap(&x, &y)
fmt.Println(x, y) // 2 1
}
2. 避免复制大对象
如果你有一个很大的结构体,每次传参都复制整个结构体会很浪费内存和时间。传指针就高效多了:
type BigStruct struct {
data [10000]int
}
// 不好:每次调用都复制 10000 个整数
func process(s BigStruct) { ... }
// 好:只传一个指针(8 字节)
func process(s *BigStruct) { ... }
3. 表示"可选"或"不存在"的值
指针可以是 nil,所以可以用来表示"没有值"的情况:
type User struct {
Name string
Age *int // 年龄是可选的
}
func main() {
// 不提供年龄
user1 := User{Name: "张三", Age: nil}
// 提供年龄
age := 25
user2 := User{Name: "李四", Age: &age}
if user1.Age != nil {
fmt.Printf("%s 的年龄是 %d\n", user1.Name, *user1.Age)
} else {
fmt.Printf("%s 的年龄未知\n", user1.Name)
}
}
4. 方法接收者需要修改对象
在定义方法时,如果你想让方法能够修改接收者,接收者必须是指针类型(后面学结构体方法时会详细讲):
type Counter struct {
count int
}
func (c *Counter) Increment() {
c.count++
}
func main() {
counter := Counter{}
counter.Increment()
counter.Increment()
fmt.Println(counter.count) // 2
}
new 和 make 的区别
Go 有两个创建变量的内置函数:new 和 make。很多新手会搞混它们。
new
new(T) 分配一块内存,初始化为零值,返回指向这块内存的指针 *T:
p := new(int) // p 是 *int,指向一个值为 0 的 int
fmt.Println(*p) // 0
*p = 42
fmt.Println(*p) // 42
new 很少使用,因为通常你直接用 & 就够了:
// 这两种写法等价
p1 := new(int)
// 或者
v := 0
p2 := &v
make
make(T, args...) 只用于切片、map、channel 这三种引用类型的初始化。它不只是分配内存,还会初始化内部数据结构:
s := make([]int, 5) // 创建一个切片
m := make(map[string]int) // 创建一个 map
c := make(chan int) // 创建一个通道
对比总结
| 特性 | new | make |
|---|---|---|
| 适用类型 | 任意类型 | 只能用于 slice、map、channel |
| 返回值 | 指针 *T | 值 T(不是指针) |
| 初始化 | 只分配零值内存 | 初始化内部数据结构 |
// new:返回指针
p := new([]int) // p 是 *[]int
fmt.Println(p) // &[](指向一个空切片的指针)
// make:返回值(不是指针)
s := make([]int, 5) // s 是 []int
fmt.Println(s) // [0 0 0 0 0]
小贴士:在实际开发中,make 用得远比 new 多。当你需要创建切片、map 或 channel 时,用 make;其他情况下,直接用 & 取地址更直观。
指针的指针
Go 支持多级指针,但在实际开发中很少用到(超过两级的指针会让代码变得难以理解):
a := 42
p := &a // p 是 *int,指向 a
pp := &p // pp 是 **int,指向 p
fmt.Println(a) // 42
fmt.Println(*p) // 42
fmt.Println(**pp) // 42
**pp = 100
fmt.Println(a) // 100
Go 指针 vs C 指针
如果你熟悉 C 语言,这里列出一些 Go 指针和 C 指针的主要区别:
| 特性 | Go | C |
|---|---|---|
| 指针算术运算 | 不支持 | 支持 |
| 返回局部变量指针 | 安全(Go 会做逃逸分析) | 危险(悬垂指针) |
| 指针大小 | 统一(32位系统4字节,64位系统8字节) | 随类型可能不同 |
| void 指针 | 不支持 | 支持 |
| 强制转换 | 需要 unsafe 包 | 直接转换 |
Go 的指针不支持算术运算(比如 p++ 让指针移动到下一个元素),这让 Go 的指针更安全——你不会不小心访问到不属于你的内存。
逃逸分析
Go 有一个很聪明的机制叫逃逸分析(escape analysis)。编译器会自动判断一个变量是应该分配在栈上还是堆上。
在 C 语言中,如果你返回一个局部变量的指针,那个指针会变成"悬垂指针"——因为局部变量在函数返回后就被销毁了。但在 Go 中,编译器会检测到这个情况,自动把变量放到堆上,这样即使函数返回了,变量仍然存在:
func createInt() *int {
x := 42
return &x // 在 C 中这是 bug,在 Go 中完全安全
}
func main() {
p := createInt()
fmt.Println(*p) // 42 ← 正常工作
}
你可以用 go build -gcflags="-m" 来查看逃逸分析的结果:
$ go build -gcflags="-m" main.go
./main.go:5:2: moved to heap: x
这告诉你变量 x 被"逃逸"到了堆上。
逃逸分析的常见问题
为什么有的变量会逃逸到堆上?
- 返回局部变量的指针
- 被接口引用
- 被闭包捕获
- 大小超过栈限制
- 调用
fmt.Println等函数(参数是 interface{})
func stackVar() int {
x := 42
return x // x 在栈上,值被复制返回
}
func heapVar() *int {
x := 42
return &x // x 逃逸到堆上
}
指针与切片的奇妙关系
切片看起来像是引用类型,它内部其实是一个结构体,包含指针、长度和容量:
type SliceHeader struct {
Data uintptr // 指向底层数组的指针
Len int // 长度
Cap int // 容量
}
这意味着:
- 切片传参是值传递,但底层数组是共享的
- 修改切片元素会影响原切片
- 但
append可能导致底层数组重新分配
func modifySlice(s []int) {
s[0] = 100 // 修改会影响原切片
s = append(s, 4) // 如果容量不够,s 指向新数组
}
func main() {
s := []int{1, 2, 3}
modifySlice(s)
fmt.Println(s) // [100 2 3]
}
实战:链表的基本操作
让我们用指针实现一个简单的单向链表:
package main
import "fmt"
// Node 链表节点
type Node struct {
Value int
Next *Node // 指向下一个节点的指针
}
// LinkedList 链表
type LinkedList struct {
Head *Node
}
// NewLinkedList 创建空链表
func NewLinkedList() *LinkedList {
return &LinkedList{Head: nil}
}
// Append 在链表末尾添加节点
func (ll *LinkedList) Append(value int) {
newNode := &Node{Value: value, Next: nil}
if ll.Head == nil {
ll.Head = newNode
return
}
// 遍历到最后一个节点
current := ll.Head
for current.Next != nil {
current = current.Next
}
current.Next = newNode
}
// Prepend 在链表开头添加节点
func (ll *LinkedList) Prepend(value int) {
newNode := &Node{Value: value, Next: ll.Head}
ll.Head = newNode
}
// Print 打印链表
func (ll *LinkedList) Print() {
current := ll.Head
for current != nil {
fmt.Printf("%d -> ", current.Value)
current = current.Next
}
fmt.Println("nil")
}
// Length 链表长度
func (ll *LinkedList) Length() int {
count := 0
current := ll.Head
for current != nil {
count++
current = current.Next
}
return count
}
// Reverse 反转链表
func (ll *LinkedList) Reverse() {
var prev *Node
current := ll.Head
for current != nil {
next := current.Next // 保存下一个节点
current.Next = prev // 反转指针
prev = current // 前进
current = next
}
ll.Head = prev
}
func main() {
ll := NewLinkedList()
ll.Append(1)
ll.Append(2)
ll.Append(3)
ll.Append(4)
fmt.Println("原始链表:")
ll.Print() // 1 -> 2 -> 3 -> 4 -> nil
fmt.Println("长度:", ll.Length())
ll.Prepend(0)
fmt.Println("\n添加头部后:")
ll.Print() // 0 -> 1 -> 2 -> 3 -> 4 -> nil
ll.Reverse()
fmt.Println("\n反转后:")
ll.Print() // 4 -> 3 -> 2 -> 1 -> 0 -> nil
}
指针与接口
指针类型也可以实现接口:
package main
import "fmt"
// Mover 接口
type Mover interface {
Move(x, y float64)
Position() (float64, float64)
}
// Point 结构体
type Point struct {
X, Y float64
}
// 值接收者:不能修改原对象
func (p Point) Position() (float64, float64) {
return p.X, p.Y
}
// 指针接收者:可以修改原对象
func (p *Point) Move(x, y float64) {
p.X += x
p.Y += y
}
func main() {
// 指针实现接口
var m Mover = &Point{X: 0, Y: 0}
m.Move(10, 20)
x, y := m.Position()
fmt.Printf("位置: (%.1f, %.1f)\n", x, y)
}
重要:接口存储的是 (值, 类型) 对。如果指针是 nil,接口本身不是 nil:
func main() {
var p *Point = nil
var m Mover = p // m 不是 nil!
fmt.Println(m == nil) // false!
// m.Move(1, 2) // 这会 panic!
}
这是 Go 的一个常见陷阱。要判断接口内部是否为 nil,需要用反射或设计上的回避。
指针作为 map 的键
Go 允许指针作为 map 的键,因为指针是可比较的类型:
package main
import "fmt"
type Node struct {
ID int
}
func main() {
// 使用指针作为键
nodeMap := make(map[*Node]string)
n1 := &Node{ID: 1}
n2 := &Node{ID: 2}
nodeMap[n1] = "第一个节点"
nodeMap[n2] = "第二个节点"
// 通过指针查找
fmt.Println(nodeMap[n1]) // 第一个节点
fmt.Println(nodeMap[n2]) // 第二个节点
// 注意:两个内容相同但地址不同的指针是不同的键
n3 := &Node{ID: 1}
fmt.Println(nodeMap[n3]) // ""(空字符串)- n3 和 n1 地址不同!
}
应用场景:当你需要在 map 中关联对象本身(而非对象的副本)时,用指针作为键很有用。例如在图算法中,将节点指针映射到距离值。
指针相关的常见 panic
1. nil 指针解引用
var p *int
fmt.Println(*p) // panic: runtime error: invalid memory address or nil pointer dereference
预防:在使用指针前检查是否为 nil。
2. 向 nil map 写入
var m map[string]int
m["key"] = 1 // panic: assignment to entry in nil map
预防:用 make 初始化 map。
3. 切片越界
s := make([]int, 3)
p := &s[3] // panic: runtime error: index out of range
4. 接口内的 nil 指针
type Reader interface {
Read([]byte) (int, error)
}
func main() {
var p *bytes.Buffer = nil
var r Reader = p
r.Read(nil) // panic: 接口不是 nil,但内部指针是 nil
}
调试技巧:当遇到 nil pointer dereference 时:
- 查看堆栈跟踪定位哪一行
- 检查该行涉及的所有指针变量
- 追溯指针的来源,看看哪里忘记初始化
unsafe 包与指针转换
Go 提供了 unsafe 包用于底层操作,但使用时要非常小心:
package main
import (
"fmt"
"unsafe"
)
func main() {
// 获取变量的大小
var x int64
fmt.Println(unsafe.Sizeof(x)) // 8
// 指针类型转换
var i int32 = 42
p := unsafe.Pointer(&i)
// 将 *int32 转换为 *int64(危险!)
// q := (*int64)(p)
// fmt.Println(*q) // 可能读到未定义的内存!
// 正确的用途:计算结构体字段偏移
type Person struct {
Name string
Age int
}
p2 := &Person{Name: "Alice", Age: 30}
agePtr := (*int)(unsafe.Pointer(
uintptr(unsafe.Pointer(p2)) + unsafe.Offsetof(p2.Age),
))
fmt.Println(*agePtr) // 30
}
警告:unsafe 包打破了 Go 的类型安全保证。使用 unsafe 的代码可能因编译器升级而失效,且在 Go 1 兼容承诺之外。
指针的最佳实践
- 优先使用值传递:小对象传值更简单、更安全
- 需要修改时用指针:函数需要修改外部变量时
- 大对象用指针:避免不必要的复制开销
- 慎用多级指针:
**int等让代码难以阅读 - 检查 nil:解引用前确保指针不为 nil
- 文档说明:如果函数参数是指针,文档中说明是否允许 nil
// GetUser 根据 ID 获取用户信息。
// 如果用户不存在,返回 nil 和错误。
func GetUser(id int64) (*User, error) {
// 实现...
}
指针调试技巧
当程序出现 panic(如 nil pointer dereference)时,你可以:
- 查看堆栈跟踪:Go 的 panic 信息会显示完整的调用栈
- 使用
fmt.Printf("%p", ptr):打印指针地址,确认是否为 nil - 使用
reflect.TypeOf(ptr):确认指针类型 - 使用 debugger(delve):
# 安装 delve
go install github.com/go-delve/delve/cmd/dlv@latest
# 调试程序
dlv debug main.go
(dlv) b main.go:25 # 在 25 行设置断点
(dlv) c # 继续运行
(dlv) p ptr # 打印 ptr 的值
(dlv) p *ptr # 打印 ptr 指向的值
指针与垃圾回收
指针是 GC 追踪对象可达性的基础。如果一个对象被指针引用,GC 就不会回收它:
package main
import (
"fmt"
"runtime"
)
type BigData struct {
data [1024 * 1024]int64 // 8MB
}
func main() {
var ptr *BigData
{
data := &BigData{}
ptr = data // ptr 引用了 data
}
// 虽然 data 离开了作用域,但 ptr 仍然引用它
runtime.GC() // 强制 GC
fmt.Println(ptr.data[0]) // 数据仍然可用
ptr = nil // 解除引用
runtime.GC() // 现在 GC 可以回收了
}
建议:及时将不需要的指针设为 nil,帮助 GC 更快回收内存。但要权衡代码可读性。
常见问题(FAQ)
Q: Go 有引用传递吗?
A: Go 只有值传递。传指针时,复制的是指针的值(地址),不是变量本身。
Q: 指针能参与算术运算吗?
A: 不能。这是 Go 和 C 最大的区别之一,也是 Go 指针安全的原因。
Q: 返回局部变量的指针安全吗?
A: 安全。Go 编译器会做逃逸分析,自动将相关变量放在堆上。
Q: 什么时候应该用指针接收者?
A: 当方法需要修改接收者的状态、接收者很大需要避免复制、或者需要表示"可选"时。
Q: * 在类型和表达式中的区别是什么?
A: *int 是"指向 int 的指针"类型;*ptr 是解引用操作,获取指针指向的值。
Q: 为什么 map 和 channel 必须 make 才能用?
A: 因为它们内部有复杂的数据结构需要初始化(哈希桶、环形缓冲区等),不是简单的零值就能工作的。
Q: 指针和引用类型(slice、map、channel)有什么区别?
A: 指针是对某个值的引用。slice、map、channel 内部本来就包含指针,所以它们天然是"引用类型"——传递时不需要显式取地址就能共享数据。
Q: Go 有引用类型吗?
A: Go 的 slice、map、channel 是"引用类型"——它们内部包含指针,传递时共享底层数据。但这和 C++ 的引用(reference)不同。Go 的参数传递始终是按值传递的。
Q: 空指针和 nil 接口有什么区别?
A: nil 指针是值为 nil 的指针变量。nil 接口是类型和值都为 nil 的接口变量。一个接口持有 nil 指针时,接口本身不是 nil,调用方法会 panic。这是 Go 中非常著名的接口陷阱。
Q: uintptr 和 unsafe.Pointer 有什么区别?
A: unsafe.Pointer 是指针类型,GC 会追踪它指向的对象。uintptr 只是整数,不持有引用,GC 不会追踪。将 unsafe.Pointer 转为 uintptr 后,GC 可能回收原对象,所以这种转换要非常小心。
小结
今天我们学习了 Go 语言的指针:
- 指针是什么:存储变量内存地址的变量
- 两个操作符:
&取地址,*解引用 - nil 指针:未初始化的指针,解引用会 panic
- 值传递 vs 指针传递:Go 只有值传递,传指针可以共享数据
- 何时使用指针:修改参数、避免大对象复制、表示可选值、方法接收者
- new vs make:
new返回指针,make初始化引用类型 - 逃逸分析:Go 自动处理局部变量的生命周期
- 指针与切片:切片底层使用指针,传参时需注意
Go 的指针设计哲学是:够用就好,安全第一。它去掉了 C 指针中那些危险的特性(算术运算、void 指针等),保留了有用的部分(传地址、避免复制)。
如果你还是觉得指针有点绕,不要担心。多写几次代码,多练习一下,很快你就会发现指针其实是很自然的。
练习时间
- 基础操作:声明一个 int 变量,创建一个指针指向它,通过指针修改变量的值
- 指针传递:写一个函数
increment(p *int),让指针指向的值加 1 - 交换函数:用指针实现一个
swap函数,交换两个变量的值 - 结构体指针:定义一个
Point结构体(有 X、Y 字段),写一个函数通过指针修改它的坐标 - 思考题:为什么切片和 map 不需要显式传指针就能在函数中修改?(提示:想想它们的底层结构)
下一篇预告
下一篇文章,我们将学习 Go 语言的结构体和方法。这是 Go 语言迈向"面向对象"的一步——虽然 Go 没有类和继承,但通过结构体和方法,你仍然可以写出组织良好的面向对象代码。我们会讨论:
- 结构体的定义和使用
- 匿名字段和嵌入
- 方法的定义
- 值接收者 vs 指针接收者
- 用组合代替继承
我们下篇见!👋
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。