指针:没有你想的那么可怕
提到"指针",很多人的第一反应是恐惧。这很正常——在 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 被"逃逸"到了堆上。
实战:链表的基本操作
让我们用指针实现一个简单的单向链表:
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
}
小结
今天我们学习了 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 指针接收者
- 用组合代替继承
我们下篇见!👋
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。