引言

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

指针的定义与基本使用

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

var p *int // p 是 *int 类型,默认零值为 nil

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

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

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

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

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

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

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 的拷贝,修改只是改了拷贝。

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

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) 使用 & 对已声明变量取地址

x := 0
p := &x // p 非 nil,指向 x

2) 使用 new(T) 创建并返回 *T

p := new(int) // p 是 *int,指向一个被零值初始化的 int(值为 0)
*p = 5

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

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

p := &Person{name: "gopher"} // 推荐:直接得到 *Person

4) 零值指针(nil)

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

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

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

new 与 make 的差异(非常重要)

很容易混淆 new 和 make。概念上:

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

示例对比:

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)。
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 初始化
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 源)。

var p *int = nil
var i interface{} = p
fmt.Println(i == nil) // false  (interface holds (*int, nil))

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

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

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

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

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

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

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

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

示例:

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

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

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

指针的工程建议与常见坑

建议

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

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

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

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

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

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

常见坑

  • 把 range 中的迭代变量地址存入切片/闭包会被复用(经典问题):

nums := []int{1,2,3}
ptrs := []*int{}
for _, v := range nums {
    ptrs = append(ptrs, &v) // 错误:&v 每次相同地址
}

正确写法:

for i := range nums {
    ptrs = append(ptrs, &nums[i])
}

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

总结

  • Go 的指针语义简单:*T 指向类型 T& 取地址,* 解引用。
  • Go 按值传递(函数参数会被拷贝),传入指针可以让函数直接修改原值或避免大对象拷贝。
  • new(T) 分配零值并返回 *Tmake 为 slice/map/chan 初始化内部数据结构并返回该类型(不是指针)。
  • nil 在不同类型上有不同行为:nil 切片能 append,nil map 写入会 panic,nil chan 在操作上会阻塞。接口的 nil 判断要注意“类型为 nil vs 接口为 nil”的区别。
  • swap(&a, &b) 演示了通过指针交换外部变量的值:函数内修改 *a/*b 会影响调用者。
  • 了解逃逸分析可以解释 &local 为何安全:编译器会把需要逃逸的局部变量放到堆上。