引言
指针在 Go 中并不复杂,但想把它用好、用稳,需要弄清楚几个核心概念:Go 是按值传递 、指针保存变量地址 、
new与make的差别 、以及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
- 使用
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
结论:指针必须初始化(指向真实内存)后才能解引用。
new 与 make 的差异(非常重要)
很容易混淆 new 和 make。概念上:
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 == nil、len(s) == 0、cap(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)
- 零值为
nilmap: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)
- 零值
nilchan:读或写会阻塞(如果没有超时/选择器),向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):传入的是a和b的地址(类型*int),函数参数a、b本身是指针(传值拷贝指针,即拷贝地址,但地址仍然指向原变量)。t := *a:解引用a(取a指向的值),保存为临时t。*a = *b:把b指向的值写入a指向的位置,直接改变主函数变量a的值。*b = t:把临时值写回b指向的位置,完成交换。
注意:传递的是指针的复制(函数内的 a 与 b 是指针拷贝),但它们都指向调用者的内存,所以对 *a / *b 的修改反映到调用者变量上。
逃逸分析 & 指向局部变量的指针(&local 安全性)
你可能会问:函数内的局部变量的地址传给外部会不会不安全?Go 的实现通过逃逸分析解决:
-
编译器会判断某个变量是否会逃逸到函数外(比如取其地址并返回或存入堆上可访问的结构)。
-
如果变量逃逸,编译器将该变量分配到堆上;否则分配到栈上以提高效率。
示例:
Go
func f() *int {
x := 100
return &x // x 逃逸到堆上
}
f() 返回的指针是安全的,x 会被分配到堆上。你可以在编译时用 go build -gcflags '-m' 查看逃逸分析结果。
因此 &p 在 main 中传给函数是安全的;即使你把 &pLocal 返回或保存,编译器会自动把该局部变量放到堆上。
指针的工程建议与常见坑
建议
-
首选值语义 :如果类型很小(例如
int、小结构体),优先用值传递,避免过早使用指针。 -
对大对象使用指针以免复制开销:结构体较大(几百字节)或包含互斥锁等不能拷贝的字段,应使用指针接收者或指针参数。
-
方法接收者一致性:对于一个类型,尽量保持方法接收者要么全部为值,要么全部为指针,混用会困惑(尤其涉及接口实现)。
-
在并发场景慎用可变共享指针 :多 goroutine 同时访问同一内存时必须同步(
sync.Mutex,atomic)。 -
makemap 之后再写入:不要对 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)分配零值并返回*T,make为slice/map/chan初始化内部数据结构并返回该类型(不是指针)。nil在不同类型上有不同行为:nil切片能 append,nilmap 写入会 panic,nilchan 在操作上会阻塞。接口的nil判断要注意"类型为 nil vs 接口为 nil"的区别。swap(&a, &b)演示了通过指针交换外部变量的值:函数内修改*a/*b会影响调用者。- 了解逃逸分析可以解释
&local为何安全:编译器会把需要逃逸的局部变量放到堆上。