☰
GO [ 指针 ]
2026/9/25 19:51:22 网站建设 项目流程

前面我们已经学习了 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) // panic

nil 指针不是“指向 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 100

new(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实现 Runner
  • Task(值类型)的方法集合:空❌ →Task没有实现 Runner

注意:之前那个counter.Increment()的语法糖只在调用方法时生效,接口赋值不适用!

👉 重点区分两个场景,很多人在这里踩坑:

  1. 方法调用:counter.Increment(),counter 是可寻址变量,编译器自动转(&counter).Increment()(语法糖)
  2. 接口变量赋值: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)

运行结果:

10

second是**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]

这个示例说明:

  1. 指针接收者可以修改结构体;
  2. 可寻址变量调用指针方法时,编译器会自动取地址;
  3. 切片指针可以修改切片描述符;
  4. 普通情况下,返回新切片往往比传递*[]T更清晰。

总结

本章我们学习了 Go 指针的完整基础:

  1. 指针保存变量地址,类型写成*T;
  2. &用于取地址,*用于解引用;
  3. 未初始化指针的零值是nil;
  4. 解引用 nil 指针会触发运行时 panic;
  5. 指针参数可以让函数修改调用者的变量;
  6. Go 允许安全返回局部变量指针;
  7. 结构体经常使用指针接收者实现可修改方法;
  8. new(T)返回指向零值变量的*T,make用于初始化切片、map 和 channel;
  9. 切片和 map 已经拥有引用语义,不应该无条件再套一层指针;
  10. Go 使用垃圾回收,不需要手动释放普通指针指向的内存;
  11. unsafe.Pointer只适合严格受控的底层场景;
  12. 指针可以实现链表、树和其他节点型数据结构。

真正理解 Go 指针之后,就不会把它简单看成“内存地址变量”。更准确的理解是:指针是一种带类型的间接访问关系;它让函数可以修改外部变量,让方法可以修改接收者,也让链表、树等节点型数据结构能够通过地址建立连接。

官方资料:

  • Go 语言规范:Pointer types
  • Go 语言规范:Address operators
  • Go 官方文档:Effective Go - Allocation with new
  • Go 官方文档:Effective Go - Pointers vs. Values
  • Go 官方文档:unsafe.Pointer

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询