Go 语言指针

基本概念

Go 语言中支持通过指针来对变量内存地址直接访问和控制。

指针定义

计算机内存以字节(Byte)作为数据储存单位,每个字节配有唯一内存地址(Memory Address),存放内存地址的数据类型叫指针(Pointer)。

在声明变量时,会在内存中给它分配一个位置,以便能储存、修改和获取变量值。所有变量在程序运行时都有指针,变量实际数据第一个字节地址值为变量的指针。通过变量指针可以绕过变量名,间接读取或更新变量值。

指针类型

Go 语言中指针类型 uintptr 是一个特殊的无符号整数,大小根据操作系统的架构而不同:

  • 32 位架构:占用 4 个字节,取址范围 02^32 - 1(约 4 GB),这是 32 位系统内存 4 GB 限制的由来。
  • 64 位架构:占用 8 个字节,取址范围 02^64 - 1(约 16 EB,1EB = 1024 PB),近乎无上限。

指针限制

Go 语言指针有一系列设计层的限制:

  • 没有指针运算:在很多低级语言中,允许直接操作内存地址,例如通过加减指针值来访问数组元素(连续内存块)。Go 语言中禁止这类操作,避免因指针运算导致数组越界访问、野指针等错误。

  • 不能对常量或临时值取址:在 Go 语言中不能获取常量、字面量或表达式临时结果的地址。因为这些值没有固定地址,尝试取址将导致编译错误。

  • 无法直接操作内存: 直接操作内存允许软件与硬件直接交互,如在嵌入式系统开发中,直接向特定内存地址的硬件寄存器写值。Go 语言不能通过指针直接操作内存地址,给指针变量赋值。

要在 Go 语言在操作内存,只能通过 unsafe 包来绕过类型安全执行指针操作。

语法糖

数组和结构体通过指针访问元素时,不需要显式解引用。这是 Go 语言中提供的语法糖(Syntactic sugar),以取代其他语言中的 -> 运算符。语法糖仅作用于简化编写代码,实际运行时还是会自动解引用:

package main

import "fmt"

// Person 为结构体类型
type Person struct {
	Name string
	Age  int
}

func main() {
	// 数组示例
	pArr := &[3]int{10, 20, 30}
	// 修改数组元素时,无需先解引用,编译器会翻译为 (*pArr)[0] = 100
	pArr[0] = 100
	fmt.Println("通过指针访问数组元素:", pArr[0])

	// 结构体示例
	pBob := &Person{"Bob", 25}
	pBob.Age++ // 实际等同于 (*pBob).Age++
	fmt.Println("通过指针访问结构体字段:", pBob.Age)

	// 基本数据类型示例
	num := 100
	//pNum := &100 // 报错:无法提取常量地址
	pNum := &num
	// 修改指针指向值,必须显式解引用
	*pNum = 200
	// 必须解引用来访问值,否则打印指针地址
	fmt.Println("通过解引用指针访问值:", *pNum, "原变量:", num)
}

指针应用

由于 Go 语言中指针操作很安全,因此在程序中使用得非常广泛。

声明和初始化

指针类型使用 * 符号跟在其他类型之前来表示:

var PointerName *PointerType
  • PointerName:指针变量名称。一般前缀为「p」、「ptr」或添加后缀「Ptr」。

  • PointerType:指针指向的变量类型,前面加上 * 表示这是一个指向该类型的指针。

指针类型有 3 种常用声明和初始化方式:

package main

import "fmt"

func main() {
	// 在类型前加上星号,完整声明一个 float64 类型空指针
	var pn *float64 // 声明但未初始化
	fmt.Println(pn) // 打印指针默认值 <nil>

	// 使用 new 函数创建并初始化一个指针变量,值为类型零值
	pi := new(int)
	fmt.Printf("地址:%p 值:%v\n", pi, *pi)

	// 使用短变量声明数组和结构体类型指针
	pl := &[2]string{"a", "b"}
	fmt.Printf("地址:%p 值:%v\n", pl, *pl)
}

变量取址

使用 & 操作符可以获取一个变量内存地址,也就是指针:

package main

import "fmt"

func main() {
	s := "111"
	p := &s // 将指针赋给变量

	// 指向相同地址,用十六进制表示,如 0xc0420dc1c0
	fmt.Printf("变量值:%v,变量地址:%p\n", s, p)
	fmt.Printf("变量值:%v,变量地址:%p\n", s, &s)
}

结构体成员或数组元素都是变量,所以也能取址。

指针取值

对指针变量使用 * 操作符来解引用,获得指针对应的值内容:

package main

import "fmt"

func main() {
	s := "111"
	p := &s
    
	// 使用 * 符号对指针进行解引用,获取指针所指向值
	*p = "222" // 通过指针修改变量值
	fmt.Println("通过指针获取变量值:", *p, s)
}

传递指针

由于指针地址只占 4 或 8 个字节,因此用指针传递大体积数据时,会明显节省开销。像切片、映射、接口、通道这样的引用类型默认使用指针传递,结构体必要时也能用指针传递。

另外,传递指针也用于在函数内部修改外部变量的值:

package main

import "fmt"

func main() {
	var num int = 1
	fmt.Println("main 函数 num 初始值为:", num) // 打印原值:1
	fmt.Println("main 函数 num 地址为:", &num) // 打印变量地址

	incs(num) // 通过值传递,传入变量 num 副本
	fmt.Println("调用 incs 后,main 函数中 num 值不变:", num)

	incp(&num) // 通过指针传递,传入变量 num 地址
	fmt.Println("调用 incp 后,main 函数中 num 值变为:", num)
}

// incs 通过值传递接收一个 int 参数,在内部修改它不影响原始变量
func incs(num int) int {
	fmt.Println("incs 函数中,num 地址为:", &num) // 打印不同地址
	num++
	return num
}

// incp 通过指针传递接收一个指针,会修改原始变量值
func incp(numPtr *int) int {
	fmt.Println("incp 函数中,num 地址为:", numPtr) // 打印相同地址
	*numPtr++
	return *numPtr
}

在并发场景下,需要小心处理指针,避免竟态条件。

空指针

Go 语言中空指针值为 nil,也叫 nil 指针。空指针不指向任何有效地址,解引用会致使程序崩溃。因此在使用指针前需要检查是否为 nil

package main

import "fmt"

func main() {
	var p *int // 默认为 nil
	//fmt.Println("p 的地址", *p) // 试图打印空指针地址时报错:内存地址不正确

	// 为 p 分配一个值
	//*p = 5 // 试图解引用空指针将导致运行时错误:空指针解引用
	num := 5
	p = &num // p 现在指向 num

	// 检查指针是否为 nil,安全地使用指针
	if p == nil {
		fmt.Println("p 是空指针")
		return
	}
	fmt.Println(p, *p)
}

多级指针

多级指针是指向指针的指针,通过在指针类型前再加上 * 符号来表示:

package main

import "fmt"

// modify 函数接收多级指针,间接修改指针指向值
func modify(ptr **int) {
	**ptr = 100
}

func main() {
	a := 20
	pa := &a   // pa 是普通指针
	ppa := &pa // ppa 是二级指针

	// 都输出 20
	fmt.Println("a 值 =", a)
	fmt.Println("pa 值 =", *pa)
	fmt.Println("ppa 值 =", **ppa)

	// 调用函数,通过多级指针修改 a 值
	modify(ppa)

	// 都输出新值 100
	fmt.Println("修改后:\na 值 =", a)
	fmt.Println("pa 值 =", *pa)
	fmt.Println("ppa 值 =", **ppa)
}

在 Go 语言中多级指针比较少见,适用于构建自定义复杂数据结构,如链表或树。

逃逸分析

逃逸分析(Escape Analysis)是一种内存优化技术,指编译器对变量存放位置进行分析。函数内局部变量会在栈上分配,函数运行结束后自动清除。但如果局部变量逃到函数外部,生命周期不再和函数绑定,那这个变量就会分配到堆上,由垃圾回收器跟踪回收。

逃逸情形

常会引发逃逸的情况如下:

  • 返回局部变量指针:函数返回指向其局部变量指针,这个变量在函数执行完后,还能继续访问。
  • 发送指针到通道:通过通道发送指针或包含指针的结构体,接收者能在函数执行完毕后,继续访问这些数据。
  • 闭包引用局部变量:函数闭包中引用的局部变量可能会逃逸,闭包在函数返回后还能调用。

变量逃逸

下面通过函数返回局部变量指针,导致变量逃逸:

package main

import "fmt"

func main() {
	// 将函数返回指针赋值,打印出和函数内部一样地址
	p := getP()
	fmt.Printf("函数外地址:%p\n", p)
	fmt.Printf("函数外访问值:%d\n", *p)
}

// getP 创建一个整数变量,并返回变量指针
func getP() *int {
	n := 1
	fmt.Printf("函数内地址:%p\n", &n)
	return &n
}

堆上对象生命周期不由作用域控制,而是通过垃圾回收器管理。垃圾回收器定期运行,查找并清除不再被任何指针引用的对象。

逃逸检测

实际开发中要避免不必要的变量逃逸,减少额外内存分配和性能开销。编译时,可以在 go build 命令中添加 -gcflags="-m -l" 参数来检查逃逸分析结果。额外标志参数 -m 用于输出编译器优化细节,而 -l 会禁用函数内联优化:

D:\Software\Programming\Go\new>go build -gcflags="-m -l"
# new
./main.go:14:2: moved to heap: n
./main.go:15:12: ... argument does not escape
./main.go:8:12: ... argument does not escape
./main.go:9:12: ... argument does not escape
./main.go:9:42: *p escapes to heap

上面结果显示变量 n 逃逸到堆上。