Files
notes/resource/go/Go 语言的指针.md
2026-03-01 01:43:46 +08:00

14 KiB
Raw Permalink Blame History

1. 指针基础:地址与解引用

指针(Pointer)是一个存储了另一个变量内存地址的变量。通过指针,我们可以间接地读取或修改其所指向变量的值。这是 Go 语言中实现引用传递、优化性能和构建复杂数据结构的基础。

1.1 什么是地址与指针?

在 Go 中,每个变量都存储在内存的特定位置,这个位置就是它的内存地址指针就是专门用来存放这个地址的变量。

  • 取地址 (&):使用 & 操作符可以获取一个变量的内存地址。
  • 解引用 (*):使用 * 操作符可以获取指针所指向地址上存储的
package main

import "fmt"

func main() {
	// 声明一个整型变量 a
	var a int = 42

	// 1. 使用 & 操作符获取变量 a 的内存地址
	// p 是一个指针变量,它的类型是 *int (读作“int的指针”)
	// p 存储的是 a 的地址,所以我们说“p 指向 a”
	var p *int = &a

	// 2. 打印变量 a 的值和地址
	// %p 用于格式化输出内存地址
	fmt.Printf("变量 a 的值是: %d\n", a)
	fmt.Printf("变量 a 的内存地址是: %p\n", &a)

	// 3. 打印指针 p 自身的值(即 a 的地址)
	fmt.Printf("指针 p 存储的地址是: %p\n", p)

	// 4. 使用 * 操作符解引用,获取指针 p 指向地址上的值
	// *p 的意思是“访问 p 指向的那个地址上的值”
	fmt.Printf("通过指针 p 解引用得到的值是: %d\n", *p)

	// 5. 通过指针修改变量 a 的值
	fmt.Println("---通过指针修改值---")
	*p = 100 // 将 p 指向的地址(即 a 的地址)上的值修改为 100
	fmt.Printf("修改后,变量 a 的值变为: %d\n", a)
}

1.2 指针的声明与初始化

声明指针变量时,需要指定它将要指向的数据类型。Go 语言是类型安全的,一个 *int 类型的指针不能指向一个 string 类型的变量。

指针的零值为 nil。一个 nil 指针不指向任何内存地址。

package main

import "fmt"

func main() {
	// 准备一个变量 a 用于取地址
	var a int = 10

	// --- 三种常见的指针初始化方式 ---

	// 方式一:先声明,后赋值(推荐 &a 方式)
	// ptr1 是一个空指针,其值为 nil
	var ptr1 *int
	fmt.Printf("方式一(声明后):ptr1 的值是 %v\n", ptr1) // 输出: <nil>
	ptr1 = &a
	fmt.Printf("方式一(赋值后):ptr1 指向的值是 %d\n", *ptr1)

	// 方式二:使用 new() 函数
	// new(T) 会为类型 T 分配内存空间,并返回一个指向该空间的指针
	// 该空间的值会被初始化为对应类型的零值
	var ptr2 *int = new(int) // *ptr2 的初始值为 0
	fmt.Printf("方式二(new):ptr2 指向的地址是 %p,值是 %d\n", ptr2, *ptr2)
	*ptr2 = 20
	fmt.Printf("方式二(赋值后):ptr2 指向的值是 %d\n", *ptr2)

	// 方式三:在声明时直接使用 & 取地址(最常用)
	var ptr3 *int = &a
	fmt.Printf("方式三(取地址):ptr3 指向的值是 %d\n", *ptr3)

	// 类型必须严格匹配
	// var f float64 = 3.14
	// ptr3 = &f // 这行代码会编译错误:cannot use &f (value of type *float64) as *int in assignment
}

2. 指针的常见应用场景

2.1 在函数间共享与修改数据

这是指针最核心的用途。Go 函数的参数默认是值传递pass-by-value)。如果你想在函数内部修改外部变量的值,就需要传递这个变量的指针。

package main

import "fmt"

// 该函数接收一个 int 指针
// 通过指针,它可以修改函数外部的原始变量
func modifyValue(ptr *int) {
	fmt.Printf("  [函数内] 接收到的指针地址: %p\n", ptr)
	*ptr = 100 // 修改指针指向地址上的值
}

func main() {
	num := 10
	fmt.Printf("调用函数前, num 的值: %d, 地址: %p\n", num, &num)

	// 将 num 的地址传递给函数
	modifyValue(&num)

	fmt.Printf("调用函数后, num 的值: %d\n", num) // 值被成功修改
}

2.2 方法接收器 (Method Receiver)

在为结构体定义方法时,可以选择使用值接收器指针接收器

  • 值接收器 (func (p Person)):方法操作的是结构体的副本,不会影响原始结构体。
  • 指针接收器 (func (p *Person)):方法操作的是原始结构体的引用,可以修改原始结构体。

[补充知识点] 最佳实践

  1. 如果方法需要修改接收器的状态,必须使用指针接收器。
  2. 如果接收器是大型结构体,为了避免每次方法调用都进行昂贵的值拷贝,推荐使用指针接收器。
  3. 为了保持一致性,如果一个类型有一个指针接收器方法,那么其他方法也最好使用指针接收器。
package main

import "fmt"

type Person struct {
	name string
	age  int
}

// 值接收器: p 是 Person 的一个副本
func (p Person) updateAgeByValue(newAge int) {
	p.age = newAge
	fmt.Printf("  [值接收器内] p.age 被修改为 %d\n", p.age)
}

// 指针接收器: p 是指向原始 Person 实例的指针
func (p *Person) updateAgeByPointer(newAge int) {
	p.age = newAge // Go 会自动解引用,等价于 (*p).age = newAge
	fmt.Printf("  [指针接收器内] p.age 被修改为 %d\n", p.age)
}

func main() {
	// 创建一个 Person 实例
	user := Person{name: "Alice", age: 30}

	fmt.Printf("原始 user: %+v\n", user)
	fmt.Println("--- 调用值接收器方法 ---")
	user.updateAgeByValue(31)
	fmt.Printf("调用后,原始 user: %+v (未改变)\n\n", user)

	fmt.Println("--- 调用指针接收器方法 ---")
	// Go 语言很方便,可以直接用 user 调用指针方法,它会自动转换为 (&user)
	user.updateAgeByPointer(35)
	fmt.Printf("调用后,原始 user: %+v (已改变)\n", user)
}

2.3 构建复杂数据结构(如链表)

指针是构建链表、树、图等动态数据结构的核心。结构体可以包含指向同类型结构体的指针,从而将多个节点连接起来。

这个示例非常好,代码完整且清晰地展示了如何使用内嵌指针构建和操作一个简单的链表。我已按 gofmt 标准格式化。

package main

import "fmt"

// Node 定义了链表中的一个节点(比喻为火车车厢)
type Node struct {
	data int   // 车厢里的货物编号
	next *Node // 指向下一节车厢的指针
}

// printTrain 遍历并打印整个链表
func printTrain(head *Node) {
	current := head
	for current != nil {
		fmt.Printf("车厢[%d] -> ", current.data)
		current = current.next // 移动到下一个节点
	}
	fmt.Println("终点站")
}

func main() {
	// 创建一个3节车厢的火车(链表)

	// 1. 创建第一节车厢(火车头)
	head := &Node{data: 1}

	// 2. 连接第二节车厢
	head.next = &Node{data: 2}

	// 3. 连接第三节车厢
	head.next.next = &Node{data: 3}

	fmt.Println("初始火车:")
	printTrain(head) // 输出: 车厢[1] -> 车厢[2] -> 车厢[3] -> 终点站

	// 在火车头和第二节车厢之间插入一个新车厢
	fmt.Println("\n在车厢[1]和[2]之间添加新车厢[4]:")
	newNode := &Node{data: 4} // 准备新车厢
	newNode.next = head.next  // 新车厢连接到原来的第二节
	head.next = newNode       // 第一节连接到新车厢

	fmt.Println("改造后的火车:")
	printTrain(head) // 输出: 车厢[1] -> 车厢[4] -> 车厢[2] -> 车厢[3] -> 终点站
}

3. 指针的高级主题与辨析

3.1 多级指针

多级指针是指向指针的指针。例如,**int 是一个指向 *int 类型指针的指针。它在需要修改指针本身(而不是指针指向的值)的场景下非常有用。

package main

import "fmt"

// 这个函数接收一个二级指针,用来修改一级指针的指向
func reassignPointer(ptr **int, newValue *int) {
	*ptr = newValue
}

func main() {
	// 原始变量
	valueA := 10
	valueB := 99

	// 一级指针,指向 valueA
	ptr1 := &valueA
	fmt.Printf("初始时, ptr1 指向的地址是 %p, 值是 %d\n", ptr1, *ptr1)

	// 二级指针,指向 ptr1
	ptr2 := &ptr1
	fmt.Printf("ptr2 指向的地址是 %p (即 ptr1 的地址)\n", ptr2)
	fmt.Printf("通过 ptr2 两次解引用得到的值: %d\n\n", **ptr2)

	// 目标:让 ptr1 不再指向 valueA,而是指向 valueB
	// 我们需要修改 ptr1 本身的值,所以要传递 ptr1 的地址给函数
	fmt.Println("--- 调用函数修改 ptr1 的指向 ---")
	reassignPointer(&ptr1, &valueB) // 也可以写作 reassignPointer(ptr2, &valueB)

	fmt.Printf("修改后, ptr1 指向的地址是 %p, 值是 %d\n", ptr1, *ptr1)
}

3.2 指针数组 vs 数组指针

这是一个常见的易混淆点,需要明确区分:

  • 指针数组 [N]*T:一个数组,其元素都是 *T 类型的指针。
  • 数组指针 *[N]T:一个指针,它指向一个大小为 N 的数组 [N]T
package main

import "fmt"

func main() {
	a, b, c := 10, 20, 30

	// --- 1. 指针数组 ---
	// 定义一个长度为3的数组,每个元素都是 *int 类型
	// 它存储的是 a, b, c 各自的内存地址
	pointerArray := [3]*int{&a, &b, &c}
	fmt.Println("--- 指针数组 `[3]*int` ---")
	for i, ptr := range pointerArray {
		fmt.Printf("  索引 %d: 指针地址 %p, 指向的值 %d\n", i, ptr, *ptr)
	}
	// 修改指针数组中某个指针指向的值
	*pointerArray[0] = 100
	fmt.Printf("  修改后, 变量 a 的值变为: %d\n\n", a)

	// --- 2. 数组指针 ---
	// 定义一个普通的数组
	arr := [3]int{1, 2, 3}
	// 定义一个指针,它指向整个 arr 数组
	var arrayPointer *[3]int = &arr
	fmt.Println("--- 数组指针 `*[3]int` ---")
	fmt.Printf("  指针地址 %p, 指向的数组值 %v\n", arrayPointer, *arrayPointer)

	// 通过数组指针修改数组元素
	// Go 会自动解引用,arrayPointer[1] 等价于 (*arrayPointer)[1]
	arrayPointer[1] = 200
	fmt.Printf("  修改后, 原始数组 arr 的值变为: %v\n", arr)
}

3.3 切片(Slice)与指针:深入辨析

这是一个非常重要的知识点,常常引起混淆。

切片本身就是一个包含指向底层数组的指针、长度(len)和容量(cap)的结构体。因此,当你将切片作为参数传递给函数时,实际上是复制了这个结构体。

  • func(s []T)(传切片):可以修改切片底层数组的元素,但如果在函数内使用 append 导致切片扩容,外部的切片不会改变
  • func(s *[]T)(传切片指针):可以修改切片底层数组的元素,并且当 append 导致扩容时,可以改变外部切片本身(它的长度、容量和指向的底层数组)。

结论:只有当你需要在函数内部修改切片的长度或容量(比如使用 append)并希望这种改变反映到函数外部时,才需要使用指向切片的指针。

package main

import "fmt"

// 接收切片本身。可以修改元素,但 append 的结果不会影响外部。
func modifySlice(s []int) {
	s[0] = 99
	// append 可能会创建一个新的底层数组,并返回一个新的切片头
	s = append(s, 4)
	fmt.Printf("  [函数内 modifySlice] 切片修改为: %v, len=%d, cap=%d\n", s, len(s), cap(s))
}

// 接收指向切片的指针。可以修改原始切片头。
func modifySliceByPointer(s *[]int) {
	(*s)[0] = 999
	// 对指针解引用后进行 append,会修改原始的切片头
	*s = append(*s, 40)
	fmt.Printf("  [函数内 modifySliceByPointer] 切片修改为: %v, len=%d, cap=%d\n", *s, len(*s), cap(*s))
}

func main() {
	// --- 场景1: 直接传递切片 ---
	slice1 := []int{1, 2, 3}
	fmt.Printf("调用前 slice1: %v, len=%d, cap=%d\n", slice1, len(slice1), cap(slice1))
	modifySlice(slice1)
	// 元素修改成功,但 append 的效果丢失
	fmt.Printf("调用后 slice1: %v, len=%d, cap=%d  (append 无效)\n\n", slice1, len(slice1), cap(slice1))

	// --- 场景2: 传递切片的指针 ---
	slice2 := []int{10, 20, 30}
	fmt.Printf("调用前 slice2: %v, len=%d, cap=%d\n", slice2, len(slice2), cap(slice2))
	modifySliceByPointer(&slice2)
	// 元素修改和 append 都成功
	fmt.Printf("调用后 slice2: %v, len=%d, cap=%d (append 成功!)\n", slice2, len(slice2), cap(slice2))
}

4. 指针与内存管理

4.1 空指针(nil)的风险与防范

对一个 nil 指针进行解引用操作会导致程序 panic(运行时错误)。因此,在使用指针前进行 nil 检查是一个必须养成的良好习惯。

package main

import "fmt"

// 一个安全的函数,它在解引用前检查指针是否为 nil
func safeGetValue(ptr *int) {
	if ptr == nil {
		fmt.Println("警告: 接收到一个空指针(nil),无法获取值。")
		return
	}
	fmt.Printf("指针指向的值是: %d\n", *ptr)
}

func main() {
	var p1 *int // p1 是一个空指针,值为 nil
	var num int = 10
	p2 := &num // p2 指向 num

	fmt.Println("--- 尝试操作空指针 ---")
	safeGetValue(p1)

	fmt.Println("\n--- 尝试操作有效指针 ---")
	safeGetValue(p2)

	// 下面这行代码如果取消注释,将会导致 panic
	// fmt.Println(*p1) // panic: runtime error: invalid memory address or nil pointer dereference
}

4.2 内存逃逸分析

Go 编译器会自动决定变量是分配在栈(stack)上还是堆(heap)上。

  • 栈分配:速度快,由编译器自动分配和释放。函数调用结束时,其栈上的变量就被销毁。
  • 堆分配:速度相对慢,需要垃圾回收器(GC)来管理和释放。

逃逸分析(Escape Analysis 是编译器用来决定变量分配位置的过程。如果一个局部变量的生命周期超出了其所在的函数(例如,被函数作为指针返回),它就会“逃逸”到堆上。

你可以使用 go build -gcflags="-m" 命令来观察逃逸分析的结果。

package main

import "fmt"

// x 在函数返回后就不再被需要,因此分配在栈上
// 编译时会提示:stackAlloc() x does not escape
func stackAlloc() int {
	x := 10
	return x
}

// x 的内存地址被返回,在函数外部仍需访问
// 因此 x 必须“逃逸”到堆上,以保证在函数返回后依然有效
// 编译时会提示:heapAlloc() x escapes to heap
func heapAlloc() *int {
	x := 10
	return &x
}

func main() {
	// a 的值是 10,来自栈上的拷贝
	a := stackAlloc()
	fmt.Printf("来自栈分配的值: %d\n", a)

	// p 是一个指针,指向堆上的一块内存,其值为 10
	p := heapAlloc()
	fmt.Printf("来自堆分配的指针: %p, 指向的值: %d\n", p, *p)

	// 这块由 heapAlloc 分配的内存,将由 Go 的垃圾回收器在不再被使用时自动清理
}