前面我们已经学习了 Go 的变量、常量、数据类型、输入输出、条件控制、切片、字符串和映射表。接下来开始学习 Go 语言中连接变量和存储位置的重要概念:指针。
按照 Go 官方语言规范 的定义,指针类型表示指向某种基础类型变量的所有指针。未初始化指针的零值是nil。
简单理解:普通变量保存值,指针变量保存另一个变量的地址;通过指针可以间接读取或修改那个变量。
Go 的指针和 C 语言有明显区别:
- 普通指针带有明确的基础类型;
- 不能进行普通指针加减;
- 不需要手动
free; - 由垃圾回收器管理对象生命周期;
- 可以安全返回局部变量的地址;
unsafe.Pointer只适合严格受控的底层代码。
本章按照“官方定义 → 实际代码 → 运行结果 → 数据结构实现 → 避坑总结”的顺序展开。
取地址和解引用
使用&获取变量地址,使用*访问指针指向的值。
package main import "fmt" func main() { value := 10 pointer := &value fmt.Println("value:", value) fmt.Println("pointer:", pointer) fmt.Println("*pointer:", *pointer) *pointer = 20 fmt.Println("修改后:", value) }运行结果:
value: 10 pointer: 0x14000122018 *pointer: 10 修改后: 20地址部分会因运行环境不同而变化。这里的重点是:
value类型是int;&value类型是*int;pointer保存value的地址;*pointer表示访问地址对应的变量。
通过*pointer = 20修改的是原变量,而不是创建一个新的整数副本。
指针类型
指针类型使用*T表示:
var intPointer *int var stringPointer *string type User struct { Name string } var userPointer *User不同基础类型的指针不能直接混用:
number := 10 pointer := &number // var p *string = pointer // 编译错误Go 要求普通指针的基础类型明确,这可以让编译器阻止很多错误的内存访问。
nil 指针
指针的零值是nil:
var pointer *int fmt.Println(pointer == nil)运行结果:
true解引用前应该判断 nil:
var pointer *int if pointer != nil { fmt.Println(*pointer) } else { fmt.Println("指针为空") }直接解引用 nil 指针会产生运行时 panic:
var pointer *int // fmt.Println(*pointer) // panicnil 指针不是“指向 0 的指针”,而是没有指向有效变量。
指针作为函数参数
Go 的普通参数是值传递:
func change(value int) { value = 100 } number := 10 change(number) fmt.Println(number) // 10如果想让函数修改调用者的变量,可以传入指针:
package main import "fmt" func change(value *int) { *value = 100 } func main() { number := 10 change(&number) fmt.Println(number) }运行结果:
100函数接收的是地址,解引用后修改的就是调用者原变量。
指针参数的 nil 检查
func reset(value *int) { if value == nil { return } *value = 0 }如果 API 允许 nil,应当明确处理;如果不允许,应在文档和调用约定中写清楚。
返回指针
Go 允许返回局部变量地址:
C++ 这么写返回局部变量的栈地址是 UB(未定义行为);但 Go 这段代码是完全合法、安全的。
func createValue() *int { value := 42 return &value } func main() { pointer := createValue() fmt.Println(*pointer) }运行结果:
42编译器会根据逃逸分析决定变量存放位置,保证返回后的指针仍然有效。开发者不需要手动释放这块内存。
指针和结构体
指针经常用于修改结构体:
package main import "fmt" type User struct { Name string Score int } func updateScore(user *User, score int) { user.Score = score } func main() { user := User{Name: "rose"} updateScore(&user, 95) fmt.Printf("%+v\n", user) }运行结果:
{Name:rose Score:95}访问指针指向的结构体字段时,Go 允许省略显式解引用:
userPointer := &User{Name: "rose"} userPointer.Score = 95它等价于:
(*userPointer).Score = 95指针和数组
数组是值类型,传参会复制整个数组:
func changeArray(array [3]int) { array[0] = 100 } numbers := [3]int{1, 2, 3} changeArray(numbers) fmt.Println(numbers) // [1 2 3]传入数组指针则可以修改原数组:
func changeArray(array *[3]int) { array[0] = 100 } numbers := [3]int{1, 2, 3} changeArray(&numbers) fmt.Println(numbers) // [100 2 3]如果数据需要动态长度,普通业务代码通常优先使用切片;数组指针更适合固定大小且需要避免复制的场景。
指针和切片
切片本身已经包含底层数组引用,修改切片元素通常不需要传递*[]T:
func changeFirst(numbers []int) { numbers[0] = 100 } numbers := []int{1, 2, 3} changeFirst(numbers) fmt.Println(numbers) // [100 2 3]但函数如果需要改变切片长度,应返回新的切片:
func addValue(numbers []int, value int) []int { return append(numbers, value) } numbers = addValue(numbers, 4)也可以使用切片指针:
func addValue(numbers *[]int, value int) { *numbers = append(*numbers, value) }在普通 Go 代码中,返回新切片通常更清晰。指针不是越多越好,切片和 map 已经具有引用语义。
new和make
内置函数new(T)会创建一个T类型的零值变量,并返回*T:
package main import "fmt" func main() { pointer := new(int) fmt.Println(*pointer) *pointer = 100 fmt.Println(*pointer) }运行结果:
0 100new(int)的类型是*int,不是int。
| 函数 | 适用类型 | 返回值 |
|---|---|---|
new(T) | 任意类型 | 指向零值的*T |
make(T) | slice、map、channel | 已初始化的T |
pointer := new([]int) // *[]int,指向 nil 切片 numbers := make([]int, 0) // []int,可直接 append scores := make(map[string]int) messages := make(chan string)不要为了“申请内存”而机械使用new。很多场景直接使用结构体零值、复合字面量或make更符合 Go 风格。
指针接收者
方法可以使用值接收者或指针接收者。
type Counter struct { value int } func (c Counter) Value() int { return c.value } func (c *Counter) Increment() { c.value++ }完整测试:
package main import "fmt" type Counter struct { value int } func (c Counter) Value() int { return c.value } func (c *Counter) Increment() { c.value++ } func main() { counter := Counter{} counter.Increment() counter.Increment() fmt.Println(counter.Value()) }运行结果:
2当变量可寻址时,counter.Increment()会被编译器自动视为(&counter).Increment()。但临时值通常不能自动取地址:
// Counter{}.Increment() // 编译错误什么时候使用指针接收者
通常有以下情况:
- 方法需要修改接收者;
- 结构体较大,需要避免复制;
- 类型的方法集合需要统一;
- 接收者包含不应该复制的状态。
如果方法不修改对象、类型很小且值语义更清晰,可以使用值接收者。
指针和接口
如果方法使用指针接收者,通常只有*T实现接口,T不一定实现:
type Runner interface { Run() } type Task struct{} func (t *Task) Run() {} var task Task var runner Runner = &task // var runner Runner = task // 编译错误可以使用编译期断言:
var _ Runner = (*Task)(nil)这不会创建实际业务对象,只是让编译器检查接口实现。
接口实现规则:
类型 X 实现接口,当且仅当 X 的方法集合包含接口里所有方法。
type Runner interface { Run() } type Task struct{} // 指针接收者 func (t *Task) Run() {}*Task的方法集合:Run()✅ →*Task实现 RunnerTask(值类型)的方法集合:空❌ →Task没有实现 Runner
注意:之前那个
counter.Increment()的语法糖只在调用方法时生效,接口赋值不适用!👉 重点区分两个场景,很多人在这里踩坑:
- 方法调用:
counter.Increment(),counter 是可寻址变量,编译器自动转(&counter).Increment()(语法糖)- 接口变量赋值:
var r Runner = counter,语法糖不会触发!这里看的是类型本身有没有实现接口。所以:
var task Task task.Run() // ✅ 可以调用!方法调用语法糖:自动转 (&task).Run() var r Runner = task // ❌ 编译报错!接口赋值不做这个自动取地址这是最容易混淆的点!
task.Run()能跑,只是方法调用的语法糖- 不代表
Task类型实现了 Runner!只是临时帮你取地址调用方法而已。
那这句断言代码是什么?
var _ Runner = (*Task)(nil)拆解:
_:空标识符,这个变量不会被使用,不会分配内存(*Task)(nil):nil 的 *Task 指针,没有创建 Task 结构体对象,nil 指针而已- 整行含义:尝试把
*Task类型的值赋值给Runner接口变量
运行时完全没有开销,这行代码编译完成后直接被丢弃,不会执行。它唯一作用:编译阶段强制编译器检查:*Task是否实现 Runner 接口。
如果后续改代码,不小心把Run()删掉或者改了签名:
// 删掉 Run() type Task struct{} // func (t *Task) Run() {}那var _ Runner = (*Task)(nil)这一行编译直接报错,提前发现接口没实现,而不是等到业务代码使用的时候才报错。
对比:如果不写这行断言,编译器不会主动检查接口实现。只有当我们真正写
var r Runner = &task的时候,才会报错。 很多项目写这个断言放在包里面,用来显式声明:我们打算让 *Task 实现 Runner,强制编译期校验。
指针链
指针可以指向另一个指针:
value := 10 first := &value second := &first fmt.Println(**second)运行结果:
10second是**int,解引用两次才能得到最终的 int。多级指针适合底层场景,业务代码中应尽量保持层级简单。
垃圾回收和逃逸分析
Go 使用垃圾回收器。只要对象仍然可以从活动引用链访问,就不会被回收;当对象不再可达,运行时会在合适时间回收。
因此:
- 不需要手动
free; - 返回局部变量地址是安全的;
- 指针只是生命周期关系的一部分;
- 应避免无意义地长期保存指针。
可以使用编译器参数查看逃逸分析:
go build -gcflags="-m" main.go例如:
func create() *int { value := 10 return &value }编译器可能报告value escapes to heap。这不是错误,而是为了保证返回后的指针仍然有效。
不要为了避免“堆分配”而拒绝使用指针。应该先写清晰正确的代码,再通过基准测试和 profile 判断是否真的存在性能问题。
unsafe.Pointer的边界
unsafe.Pointer可以在不同指针类型之间转换,绕过部分类型检查:
package main import ( "fmt" "unsafe" ) func main() { value := int64(10) pointer := (*uint64)(unsafe.Pointer(&value)) fmt.Println(*pointer) }这类代码非常底层,可能影响类型安全和垃圾回收。官方文档明确要求unsafe.Pointer只能使用规定的安全转换模式,并建议运行go vet。
普通业务代码不应该使用unsafe.Pointer替代正常类型转换,也不应该把指针随意转换成uintptr后长期保存。
使用指针实现单链表
指针最经典的数据结构应用之一是链表:
head │ ▼ [value: 10] -> [value: 20] -> [value: 30] -> nil定义节点和链表
type node[T any] struct { value T next *node[T] } type LinkedList[T any] struct { head *node[T] size int }head指向第一个节点,每个节点的next指向下一个节点,最后一个节点的next是 nil。
头部插入
func (list *LinkedList[T]) PushFront(value T) { list.head = &node[T]{ value: value, next: list.head, } list.size++ }新节点先指向旧 head,再把 head 更新为新节点。
尾部追加
func (list *LinkedList[T]) PushBack(value T) { newNode := &node[T]{value: value} if list.head == nil { list.head = newNode list.size++ return } current := list.head for current.next != nil { current = current.next } current.next = newNode list.size++ }这种实现每次追加都要从头走到尾,时间复杂度是 O(n)。如果额外保存tail指针,可以把尾部追加优化到 O(1)。
删除第一个匹配值
泛型T不一定支持==比较,因此让调用者传入比较函数:
func (list *LinkedList[T]) RemoveFirst(equal func(T) bool) bool { var previous *node[T] current := list.head for current != nil { if equal(current.value) { if previous == nil { list.head = current.next } else { previous.next = current.next } var zero T current.value = zero current.next = nil list.size-- return true } previous = current current = current.next } return false }删除头节点时修改list.head;删除中间节点时,让前一个节点跳过当前节点,直接连接下一个节点。
完整实现
linked_list.go
package main import "fmt" type node[T any] struct { value T next *node[T] } type LinkedList[T any] struct { head *node[T] size int } func (list *LinkedList[T]) PushFront(value T) { list.head = &node[T]{value: value, next: list.head} list.size++ } func (list *LinkedList[T]) PushBack(value T) { newNode := &node[T]{value: value} if list.head == nil { list.head = newNode list.size++ return } current := list.head for current.next != nil { current = current.next } current.next = newNode list.size++ } func (list *LinkedList[T]) RemoveFirst(equal func(T) bool) bool { var previous *node[T] current := list.head for current != nil { if equal(current.value) { if previous == nil { list.head = current.next } else { previous.next = current.next } var zero T current.value = zero current.next = nil list.size-- return true } previous = current current = current.next } return false } func (list *LinkedList[T]) Values() []T { values := make([]T, 0, list.size) for current := list.head; current != nil; current = current.next { values = append(values, current.value) } return values } func (list *LinkedList[T]) Len() int { return list.size } func main() { var list LinkedList[int] list.PushBack(10) list.PushBack(20) list.PushFront(5) fmt.Println("初始链表:", list.Values()) fmt.Println("删除 10:", list.RemoveFirst(func(value int) bool { return value == 10 })) fmt.Println("删除后:", list.Values()) fmt.Println("长度:", list.Len()) }运行结果:
初始链表: [5 10 20] 删除 10: true 删除后: [5 20] 长度: 2链表和切片的选择
| 操作场景 | 更适合的结构 |
|---|---|
| 按下标随机读取 | 切片 |
| 连续存储和缓存友好 | 切片 |
| 尾部追加 | 切片或带 tail 的链表 |
| 频繁在头部插入删除 | 链表或双端队列 |
| 需要保存节点引用 | 链表 |
| 简单、高效、易维护 | 切片 |
链表不天然比切片快。链表节点分散在内存中,遍历缓存局部性较差,每个节点还需要额外指针。只有在插入删除位置已经通过节点定位、且不需要随机访问时,链表才更有优势。
常见错误和避坑提醒
误区一:解引用 nil 指针
var pointer *int // fmt.Println(*pointer) // panic使用前判断 nil,或者明确函数契约不接收 nil。
误区二:以为普通指针可以做地址加减
Go 普通指针不能进行指针算术。底层转换需要unsafe.Pointer,并且必须严格遵守官方规则。
误区三:以为指针需要手动释放
Go 使用垃圾回收,不需要手动free。重点是避免无意义地长期保存引用。
误区四:传递指针却修改了错误层级
**int解引用一次得到*int,解引用两次才得到int。遇到多级指针,应先明确每一层保存的是什么。
误区五:所有切片都传指针
切片已经包含底层数组引用。修改元素通常不需要*[]T;需要改变长度时,优先返回新切片。
误区六:混淆指针接收者和接口实现
指针接收者方法通常只属于*T的方法集,赋值给接口前要确认具体类型。
误区七:认为局部变量地址不能返回
Go 的逃逸分析会保证返回指针有效。不要把 C 语言中关于栈变量地址的规则直接套用到 Go。
一份可以直接复制的综合示例
pointer_demo.go
package main import "fmt" type Counter struct { value int } func (c *Counter) Increment() { c.value++ } func (c Counter) Value() int { return c.value } func add(numbers *[]int, value int) { *numbers = append(*numbers, value) } func main() { counter := Counter{} counter.Increment() counter.Increment() numbers := []int{1, 2} add(&numbers, 3) fmt.Println("counter:", counter.Value()) fmt.Println("numbers:", numbers) }运行结果:
counter: 2 numbers: [1 2 3]这个示例说明:
- 指针接收者可以修改结构体;
- 可寻址变量调用指针方法时,编译器会自动取地址;
- 切片指针可以修改切片描述符;
- 普通情况下,返回新切片往往比传递
*[]T更清晰。
总结
本章我们学习了 Go 指针的完整基础:
- 指针保存变量地址,类型写成
*T; &用于取地址,*用于解引用;- 未初始化指针的零值是
nil; - 解引用 nil 指针会触发运行时 panic;
- 指针参数可以让函数修改调用者的变量;
- Go 允许安全返回局部变量指针;
- 结构体经常使用指针接收者实现可修改方法;
new(T)返回指向零值变量的*T,make用于初始化切片、map 和 channel;- 切片和 map 已经拥有引用语义,不应该无条件再套一层指针;
- Go 使用垃圾回收,不需要手动释放普通指针指向的内存;
unsafe.Pointer只适合严格受控的底层场景;- 指针可以实现链表、树和其他节点型数据结构。
真正理解 Go 指针之后,就不会把它简单看成“内存地址变量”。更准确的理解是:指针是一种带类型的间接访问关系;它让函数可以修改外部变量,让方法可以修改接收者,也让链表、树等节点型数据结构能够通过地址建立连接。
官方资料:
- Go 语言规范:Pointer types
- Go 语言规范:Address operators
- Go 官方文档:Effective Go - Allocation with new
- Go 官方文档:Effective Go - Pointers vs. Values
- Go 官方文档:unsafe.Pointer