Go 语言指针
基本概念
Go 语言中支持通过指针来对变量内存地址直接访问和控制。
指针定义
计算机内存以字节(Byte)作为数据储存单位,每个字节配有唯一内存地址(Memory Address),存放内存地址的数据类型叫指针(Pointer)。
在声明变量时,会在内存中给它分配一个位置,以便能储存、修改和获取变量值。所有变量在程序运行时都有指针,变量实际数据第一个字节地址值为变量的指针。通过变量指针可以绕过变量名,间接读取或更新变量值。
指针类型
Go 语言中指针类型 uintptr
是一个特殊的无符号整数,大小根据操作系统的架构而不同:
- 32 位架构:占用 4 个字节,取址范围
0
到2^32 - 1
(约 4 GB),这是 32 位系统内存 4 GB 限制的由来。 - 64 位架构:占用 8 个字节,取址范围
0
到2^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
逃逸到堆上。