指针:没有你想的那么可怕

用最通俗的方式讲清楚 Go 语言的指针,让你不再谈'指针'色变

指针:没有你想的那么可怕

提到"指针",很多人的第一反应是恐惧。这很正常——在 C/C++ 的世界里,指针确实是很多 bug 的根源:野指针、空指针、内存泄漏、缓冲区溢出……简直是一场噩梦。

但是,Go 语言的指针完全不一样

Go 的设计者深知指针的复杂性和危险性,所以他们做了两件事:

  1. 保留了指针——因为指针确实很有用,不能因为危险就完全不用
  2. 去掉了危险的指针操作——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 有两个创建变量的内置函数:newmake。很多新手会搞混它们。

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)           // 创建一个通道

对比总结

特性newmake
适用类型任意类型只能用于 slice、map、channel
返回值指针 *TT(不是指针)
初始化只分配零值内存初始化内部数据结构
// 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 指针的主要区别:

特性GoC
指针算术运算❌ 不支持✅ 支持
返回局部变量指针✅ 安全(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 语言的指针:

  1. 指针是什么:存储变量内存地址的变量
  2. 两个操作符& 取地址,* 解引用
  3. nil 指针:未初始化的指针,解引用会 panic
  4. 值传递 vs 指针传递:Go 只有值传递,传指针可以共享数据
  5. 何时使用指针:修改参数、避免大对象复制、表示可选值、方法接收者
  6. new vs makenew 返回指针,make 初始化引用类型
  7. 逃逸分析:Go 自动处理局部变量的生命周期

Go 的指针设计哲学是:够用就好,安全第一。它去掉了 C 指针中那些危险的特性(算术运算、void 指针等),保留了有用的部分(传地址、避免复制)。

如果你还是觉得指针有点绕,不要担心。多写几次代码,多练习一下,很快你就会发现指针其实是很自然的。

练习时间

  1. 基础操作:声明一个 int 变量,创建一个指针指向它,通过指针修改变量的值
  2. 指针传递:写一个函数 increment(p *int),让指针指向的值加 1
  3. 交换函数:用指针实现一个 swap 函数,交换两个变量的值
  4. 结构体指针:定义一个 Point 结构体(有 X、Y 字段),写一个函数通过指针修改它的坐标
  5. 思考题:为什么切片和 map 不需要显式传指针就能在函数中修改?(提示:想想它们的底层结构)

下一篇预告

下一篇文章,我们将学习 Go 语言的结构体和方法。这是 Go 语言迈向"面向对象"的一步——虽然 Go 没有类和继承,但通过结构体和方法,你仍然可以写出组织良好的面向对象代码。我们会讨论:

  • 结构体的定义和使用
  • 匿名字段和嵌入
  • 方法的定义
  • 值接收者 vs 指针接收者
  • 用组合代替继承

我们下篇见!👋


参考资料:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页