Go 指针详解:定义、初始化、nil 语义与用例(含 swap 示例与原理分析)

引言

指针在 Go 中并不复杂,但想把它用好、用稳,需要弄清楚几个核心概念:Go 是按值传递指针保存变量地址newmake 的差别 、以及 nil 在不同类型上的行为差异。本文把这些知识点串联起来,边写代码边解释原理与工程实践建议。

指针的定义与基本使用

在 Go 中,指针类型写做 *T,表示指向 T 类型值的地址。指针可用于获取或修改其它变量的值而不进行拷贝。

Go 复制代码
var p *int // p 是 *int 类型,默认零值为 nil

取地址(得到指针)用 &,解引用(访问指针指向的值)用 *

Go 复制代码
x := 42
p := &x   // p 指向 x 的地址,类型 *int
fmt.Println(*p) // 输出 42,解引用得到 x 的值
*p = 100        // 通过指针修改 x
fmt.Println(x)  // 输出 100

注意:*p 是解引用表达式,不是类型声明。

为什么用指针(示例:修改结构体字段)

Go 的函数参数是 按值传递(pass-by-value)。把结构体作为参数传递时,会拷贝整个结构体。如果要在函数内部修改调用者的字段,必须传入指针。

示例:按值传递不能改变调用者的字段

Go 复制代码
package main

import "fmt"

type Person struct {
	name string
}

func changeName(p Person) {
	p.name = "go"
}

func main() {
	p := Person{name: "java"}
	changeName(p)
	fmt.Println(p.name) // 仍然 "java"
}

changeName 收到的是 p 的拷贝,修改只是改了拷贝。

改成 传指针,可以直接修改原始对象:

Go 复制代码
package main

import "fmt"

type Person struct {
	name string
}

func changeName(p *Person) {
	p.name = "go"
}

func main() {
	p := Person{name: "java"}
	changeName(&p)      // 传入 p 的地址
	fmt.Println(p.name) // 输出 "go"
}

为什么可以?

传递 &p*Person)到 changeName 后,函数里 p.name 实际编译器会把 p.name 视为 (*p).name,这是对调用者内存的直接修改(没有拷贝整个结构体)。

指针的初始化:new& 与零值(nil)

指针要使用之前必须"有东西指向",否则 nil 解引用会导致运行时 panic。

几种常见的创建指针方式:

1) 使用 & 对已声明变量取地址

Go 复制代码
x := 0
p := &x // p 非 nil,指向 x
  1. 使用 new(T) 创建并返回 *T
Go 复制代码
p := new(int) // p 是 *int,指向一个被零值初始化的 int(值为 0)
*p = 5

new 的作用是:分配一块内存、把它置为类型零值,并返回指向它的指针。new 返回的是 *T,而非 T

3) 使用复合字面量取地址(常用)

Go 复制代码
p := &Person{name: "gopher"} // 推荐:直接得到 *Person

4) 零值指针(nil)

如果你声明 var p *int,此时 p == nil,解引用 *p 会 panic:

Go 复制代码
var p *int
fmt.Println(p == nil) // true
// fmt.Println(*p) // panic: runtime error: invalid memory address or nil pointer dereference

结论:指针必须初始化(指向真实内存)后才能解引用。

newmake 的差异(非常重要)

很容易混淆 newmake。概念上:

  • new(T):分配并返回 *T,主要用于任何类型 T,返回指向 T 的指针,内存已清零(零值)。
  • make(T, args...):只用于内建引用类型:slice, map, chan。返回的是初始化后的 T(不是 *T),例如 make([]int, 0) 返回 []int

示例对比:

Go 复制代码
p := new(int)    // p 的类型 *int,*p == 0
s := make([]int, 0) // s 是 []int,已初始化,可用 append
m := make(map[string]int) // m 是 map[string]int,已初始化
c := make(chan int) // c 是 chan int,已初始化

为什么 make 需要单独存在?因为切片、映射、通道都是"内建的复杂数据结构",需要初始化内部结构(比如 slice 的 header、map 的哈希表、channel 的队列)才能使用。new 只是分配零值内存,不会为这些引用类型构建内部数据结构。

nil 的细节(指针 / slice / map / chan / interface)

不同类型的零值 nil 行为不同,理解这些差异很关键。

指针 (*T)

  • 默认值是 nil,解引用 nil 会 panic。

切片 ([]T)

  • 零值是 nil 切片:var s []int -> s == nillen(s) == 0cap(s) == 0
  • 你可以对 nil 切片 append,这是安全的(append 会自动分配底层数组)。
  • 但是不能索引 s[0](会 panic)。
Go 复制代码
var s []int
s = append(s, 1) // OK even if s is nil

映射 (map[K]V)

  • 零值为 nil map:var m map[string]int
  • 对 nil map 做读取(m["a"])返回零值,不会 panic
  • 对 nil map 做写入(m["a"] = 1)会 panic ------ 写之前必须用 make 初始化
Go 复制代码
var m map[string]int
fmt.Println(m["x"]) // 0
m["x"] = 1          // panic: assignment to entry in nil map

通道 (chan T)

  • 零值 nil chan:读或写会阻塞(如果没有超时/选择器),向 nil 通道发送或接收会导致永久阻塞(除非在 select 中),close(nil) 会 panic。

接口 (interface{})

  • 接口的零值是 nil。但有一个常见的陷阱:带有类型信息但值为 nil 的接口 != nil

    例如,一个 var p *int = nil,把它赋给 var i interface{} = p,此时 i != nil,因为接口内部记录了动态类型 *int 和动态值 nil。这个差异会导致 if i == nil 检查失效(常见 panic 源)。

Go 复制代码
var p *int = nil
var i interface{} = p
fmt.Println(i == nil) // false  (interface holds (*int, nil))

swap 示例:用指针交换变量的值并逐行解析

把两个整数交换,如果按值传递,交换仅限于函数内部拷贝;用指针可以交换实际变量。

Go 复制代码
package main

import "fmt"

func swap(a, b *int) {
    t := *a   // 取 a 指针指向的值到 t
    *a = *b   // 把 b 指向的值写到 a 指向的地址
    *b = t    // 把 t 写回 b 指向的地址
}

func main() {
    a, b := 1, 2
    swap(&a, &b) // 传入 a, b 的地址
    fmt.Println(a, b) // 输出: 2 1
}

逐行解释:

  • swap(&a, &b):传入的是 ab 的地址(类型 *int),函数参数 ab 本身是指针(传值拷贝指针,即拷贝地址,但地址仍然指向原变量)。
  • t := *a:解引用 a(取 a 指向的值),保存为临时 t
  • *a = *b:把 b 指向的值写入 a 指向的位置,直接改变主函数变量 a 的值。
  • *b = t:把临时值写回 b 指向的位置,完成交换。

注意:传递的是指针的复制(函数内的 ab 是指针拷贝),但它们都指向调用者的内存,所以对 *a / *b 的修改反映到调用者变量上。

逃逸分析 & 指向局部变量的指针(&local 安全性)

你可能会问:函数内的局部变量的地址传给外部会不会不安全?Go 的实现通过逃逸分析解决:

  • 编译器会判断某个变量是否会逃逸到函数外(比如取其地址并返回或存入堆上可访问的结构)。

  • 如果变量逃逸,编译器将该变量分配到堆上;否则分配到栈上以提高效率。

示例:

Go 复制代码
func f() *int {
    x := 100
    return &x // x 逃逸到堆上
}

f() 返回的指针是安全的,x 会被分配到堆上。你可以在编译时用 go build -gcflags '-m' 查看逃逸分析结果。

因此 &pmain 中传给函数是安全的;即使你把 &pLocal 返回或保存,编译器会自动把该局部变量放到堆上。

指针的工程建议与常见坑

建议

  • 首选值语义 :如果类型很小(例如 int、小结构体),优先用值传递,避免过早使用指针。

  • 对大对象使用指针以免复制开销:结构体较大(几百字节)或包含互斥锁等不能拷贝的字段,应使用指针接收者或指针参数。

  • 方法接收者一致性:对于一个类型,尽量保持方法接收者要么全部为值,要么全部为指针,混用会困惑(尤其涉及接口实现)。

  • 在并发场景慎用可变共享指针 :多 goroutine 同时访问同一内存时必须同步(sync.Mutex, atomic)。

  • make map 之后再写入:不要对 nil map 写入。

  • 使用 &T{}new(T) 初始化指针 :语义清晰。&T{} 更常用于自定义结构体初始化。

常见坑

  • range 中的迭代变量地址存入切片/闭包会被复用(经典问题):
Go 复制代码
nums := []int{1,2,3}
ptrs := []*int{}
for _, v := range nums {
    ptrs = append(ptrs, &v) // 错误:&v 每次相同地址
}

正确写法:

Go 复制代码
for i := range nums {
    ptrs = append(ptrs, &nums[i])
}

接口持有 类型为指针但值为 nil 导致 if iface == nil 判断失败(参见上文 nil 细节)。

总结

  • Go 的指针语义简单:*T 指向类型 T& 取地址,* 解引用。
  • Go 按值传递(函数参数会被拷贝),传入指针可以让函数直接修改原值或避免大对象拷贝。
  • new(T) 分配零值并返回 *Tmakeslice/map/chan 初始化内部数据结构并返回该类型(不是指针)。
  • nil 在不同类型上有不同行为:nil 切片能 append,nil map 写入会 panic,nil chan 在操作上会阻塞。接口的 nil 判断要注意"类型为 nil vs 接口为 nil"的区别。
  • swap(&a, &b) 演示了通过指针交换外部变量的值:函数内修改 *a/*b 会影响调用者。
  • 了解逃逸分析可以解释 &local 为何安全:编译器会把需要逃逸的局部变量放到堆上。
相关推荐
旧梦吟2 小时前
脚本 生成图片水印
前端·数据库·算法·golang·html5
ldmd2843 小时前
Go语言实战:应用篇-1:项目基础架构介绍
开发语言·后端·golang
周杰伦_Jay3 小时前
【Golang 核心特点与语法】简洁高效+并发原生
开发语言·后端·golang
ChineHe3 小时前
Golang并发编程篇_context包详解
开发语言·后端·golang
卜锦元7 小时前
Golang项目开发过程中好用的包整理归纳(附带不同包仓库地址)
开发语言·后端·golang
Tony Bai12 小时前
“我曾想付钱给 Google 去工作”—— Russ Cox 深度访谈:Go 的诞生、演进与未来
开发语言·后端·golang
海上彼尚14 小时前
Go之路 - 6.go的指针
开发语言·后端·golang
卜锦元20 小时前
Golang中make()和new()的区别与作用?
开发语言·后端·golang
海上彼尚21 小时前
Go之路 - 3.go的数据类型与转换
开发语言·后端·golang