Go 编程实战:指针 Pointer——理解地址、取址与解引用

74 阅读10分钟

上一篇我们学习了 Map,到这里已经接触了数组、Slice、Map 等常用数据结构。在继续学习函数之前,还有一个非常重要的基础概念必须掌握——指针 Pointer
很多刚接触 Go 的开发者看到指针会觉得复杂,其实 Go 的指针相比 C/C++ 简单很多。Go 不支持复杂的指针运算,日常开发主要掌握三个概念即可:变量、内存地址、通过地址访问变量
理解指针之后,后面学习函数参数、Struct、方法、接口以及并发编程都会更加容易。

一、什么是指针

先看一个普通变量:

age := 20

程序运行时,变量 age 的值需要存放在内存中的某个位置。
可以简单理解为:

内存地址        数据
0x10001   ->   20

age 保存的数据是 20,而 0x10001 可以理解为这块数据所在的内存地址。
所谓指针,就是保存某个变量内存地址的变量
例如:

age := 20
p := &age

这里:

age -> 20
p   -> age的内存地址

因此 p 就是一个指针。

二、使用 & 获取变量地址

Go 使用:

&

获取变量的地址。
例如:

package main
import "fmt"
func main() {
	age := 20
	fmt.Println(age)
	fmt.Println(&age)
}

输出类似:

20
0xc0000120a0

具体地址每次运行可能不同,因此不要依赖某个固定地址。
这里:

age

表示变量中的值,而:

&age

表示变量的内存地址。
因此:

p := &age

就是把 age 的地址保存到变量 p 中。

三、指针类型

普通整数变量:

var age int

对应的整数指针类型:

var p *int

这里的:

*int

表示“指向 int 类型数据的指针”。
例如:

age := 20
var p *int
p = &age

也可以简写:

age := 20
p := &age

Go 会自动推导 p 的类型为:

*int

字符串指针:

var p *string

布尔指针:

var p *bool

如果有一个自定义类型:

type User struct {
	Name string
}

对应的指针类型就是:

*User

四、使用 * 获取指针指向的数据

如果:

age := 20
p := &age

此时 p 保存的是地址。
如果想通过 p 获取这个地址中的数据,需要使用:

*p

例如:

package main
import "fmt"
func main() {
	age := 20
	p := &age
	fmt.Println(age)
	fmt.Println(p)
	fmt.Println(*p)
}

输出类似:

20
0xc0000120a0
20

这里:

p

得到地址。
而:

*p

得到地址对应的数据。
这个操作通常叫做解引用

五、& 和 * 的区别

这是学习指针最需要理解的两个符号。

&

表示取地址。

*

在表达式中表示通过指针访问它指向的数据。
例如:

age := 20
p := &age

可以理解为:

age = 20
&age = age的地址
p = age的地址
*p = 20

因此:

p := &age
fmt.Println(*p)

最终得到的仍然是 20

六、通过指针修改变量

指针真正重要的地方,不只是读取数据,而是可以通过地址修改原变量。
例如:

package main
import "fmt"
func main() {
	age := 20
	p := &age
	*p = 30
	fmt.Println(age)
}

输出:

30

虽然没有直接执行:

age = 30

但是:

*p = 30

修改了 p 指向的内存,因此 age 也变成了 30。
可以理解为:

age --------+
            |
            v
         [ 20 ]
            ^
            |
p ----------+

执行:

*p = 30

之后:

age --------+
            |
            v
         [ 30 ]
            ^
            |
p ----------+

变量和指针访问的是同一份数据。

七、为什么需要指针

假设定义一个函数:

func change(age int) {
	age = 30
}

然后:

func main() {
	age := 20
	change(age)
	fmt.Println(age)
}

结果仍然是:

20

因为 Go 的函数参数是值传递
调用:

change(age)

实际上会把 age 的值复制给函数参数。
可以理解为:

main中的age = 20
change中的age = 20

它们是两个不同的变量。
函数内部修改:

age = 30

只修改函数内部的副本。

八、使用指针修改函数外变量

如果希望函数修改原变量,可以传递地址:

package main
import "fmt"
func change(age *int) {
	*age = 30
}
func main() {
	age := 20
	change(&age)
	fmt.Println(age)
}

输出:

30

调用:

change(&age)

传入的是 age 的地址。
函数参数:

age *int

接收到这个地址。
然后:

*age = 30

通过地址修改原变量。
这是指针最典型的使用场景之一。

九、值传递和指针传递

普通参数:

func change(value int) {
	value = 100
}

调用:

num := 10
change(num)

可以理解为:

num -> 10
       ↓复制
value -> 10

修改 value 不影响 num
如果使用指针:

func change(value *int) {
	*value = 100
}

调用:

change(&num)

可以理解为:

num -> [10]
        ↑
        |
value --+

此时函数可以修改 num
严格来说,Go 中所有参数依然都是值传递。传递指针时,复制的是“地址值”,只是复制后的指针仍然指向同一份数据。

十、nil 指针

声明一个指针:

var p *int

此时没有指向任何有效变量,因此:

fmt.Println(p)

输出:

<nil>

可以判断:

if p == nil {
	fmt.Println("空指针")
}

需要特别注意,不能直接:

fmt.Println(*p)

因为 p 没有指向有效数据,对 nil 指针进行解引用会导致运行时 panic。
因此处理可能为 nil 的指针时应该先判断:

if p != nil {
	fmt.Println(*p)
}

十一、使用 new 创建指针

Go 提供内置函数:

new()

例如:

p := new(int)

它会为一个 int 类型的值分配空间,并返回指向该值的指针。
此时:

fmt.Println(*p)

输出:

0

因为 int 的零值是 0。
可以修改:

*p = 100
fmt.Println(*p)

输出:

100

因此:

p := new(int)

得到的 p 类型是:

*int

不过实际开发中,如果已经有变量,通常直接:

p := &value

更加常见。

十二、指针之间赋值

例如:

num := 10
p1 := &num
p2 := p1

此时 p1p2 都保存 num 的地址。
因此:

*p2 = 100

再执行:

fmt.Println(num)
fmt.Println(*p1)
fmt.Println(*p2)

结果都是:

100
100
100

因为三个操作最终访问的是同一份数据。

十三、指针可以比较

指针可以使用:

==
!=

进行比较。
例如:

num := 10
p1 := &num
p2 := &num
fmt.Println(p1 == p2)

输出:

true

因为两个指针指向同一个变量。
例如:

a := 10
b := 10
p1 := &a
p2 := &b
fmt.Println(p1 == p2)

通常得到:

false

虽然 ab 的值都是 10,但它们是不同变量,地址不同。
因此指针比较的是它们保存的地址是否相同,而不是指向的数据内容是否相等。

十四、Struct 与指针

指针在 Struct 中使用非常频繁。
例如:

type User struct {
	Name string
	Age  int
}

创建:

user := User{
	Name: "Tom",
	Age:  20,
}
p := &user

此时 p 的类型是:

*User

可以通过:

(*p).Name

访问字段。
不过 Go 为 Struct 指针提供了语法简化,可以直接:

p.Name

例如:

p.Age = 30
fmt.Println(user.Age)

输出:

30

因此实际 Go 项目中经常看到:

func updateUser(user *User) {
	user.Age = 30
}

而不是:

(*user).Age = 30

十五、为什么 Struct 经常使用指针

假设:

type User struct {
	Name string
	Age  int
}

如果函数参数:

func update(user User) {
	user.Age = 30
}

调用:

user := User{Name: "Tom", Age: 20}
update(user)

原来的 user 不会被修改。
如果:

func update(user *User) {
	user.Age = 30
}

调用:

update(&user)

就可以修改原对象。
另外,如果 Struct 非常大,直接传值需要复制整个 Struct,而传递指针只需要复制一个地址值。因此很多 Go 项目中的 Struct 方法会使用指针接收者:

func (u *User) UpdateName(name string) {
	u.Name = name
}

后面学习方法时会详细讲解。

十六、Slice 需要传指针吗

上一篇我们看到:

func change(nums []int) {
	nums[0] = 100
}

可以修改 Slice 中已有元素。
因此通常没有必要:

func change(nums *[]int)

原因是 Slice 本身包含了对底层数组的引用信息。
不过如果函数需要直接修改调用方的 Slice 描述本身,例如希望函数内部 append 后让调用方获得新的 Slice,更常见的设计是返回新的 Slice:

func add(nums []int, value int) []int {
	return append(nums, value)
}

调用:

nums = add(nums, 100)

这种写法通常更加清晰。

十七、Map 需要传指针吗

Map 作为参数时:

func change(m map[string]int) {
	m["Tom"] = 100
}

可以修改 Map 中的数据。
因此通常也没有必要写成:

func change(m *map[string]int)

直接:

func change(m map[string]int)

即可。
需要记住:Go 仍然是值传递,只不过 Map 的值内部包含运行时管理底层数据所需的信息,因此复制 Map 值后,仍然可以操作同一份底层 Map 数据。

十八、Go 不支持普通指针运算

在 C/C++ 中可能看到:

p++
p + 1
p - 1

使用指针直接计算内存地址。
普通 Go 指针不支持这种指针算术。
例如:

p := &num
p++

是不允许的。
这使 Go 指针更加简单,也避免了大量直接操作任意内存地址产生的问题。
日常 Go 开发主要掌握:

&value
*pointer

基本已经可以应对绝大多数普通业务场景。

十九、指针实战:交换两个变量

假设:

a := 10
b := 20

希望通过函数交换两个变量。
如果:

func swap(a int, b int) {
	a, b = b, a
}

调用后外部变量不会变化。
可以使用指针:

package main
import "fmt"
func swap(a *int, b *int) {
	*a, *b = *b, *a
}
func main() {
	a := 10
	b := 20
	swap(&a, &b)
	fmt.Println(a)
	fmt.Println(b)
}

输出:

20
10

这里:

*a
*b

直接操作两个原变量,因此可以完成交换。

二十、指针实战:修改用户信息

例如:

package main
import "fmt"
type User struct {
	Name string
	Age  int
}
func updateUser(user *User) {
	user.Name = "Jack"
	user.Age = 30
}
func main() {
	user := User{
		Name: "Tom",
		Age:  20,
	}
	updateUser(&user)
	fmt.Println(user)
}

输出:

{Jack 30}

这就是 Go 项目中非常典型的指针使用方式。

二十一、什么时候使用指针

常见场景主要有几个。
第一,需要修改原变量:

func update(value *int)

第二,需要修改 Struct:

func updateUser(user *User)

第三,Struct 较大,不希望频繁复制:

func process(data *BigStruct)

第四,需要使用 nil 表示“没有值”:

var user *User = nil

不过也不要为了使用指针而使用指针。对于 intbool 等简单数据,如果函数只需要读取:

func printAge(age int)

通常直接传值更加简单。

二十二、常见错误

第一个错误是混淆 &*

num := 10
p := &num
fmt.Println(*p)

记住:

& -> 获取地址
* -> 获取地址对应的数据

第二个错误是 nil 指针解引用:

var p *int
fmt.Println(*p)

会产生 panic。
第三个错误是认为传入指针就不是值传递。实际上:

func test(p *int)

仍然是值传递,只不过复制的是地址值。
第四个错误是所有参数都使用指针。指针应该根据是否需要共享或修改数据等实际需求选择,而不是机械使用。
第五个错误是试图进行普通指针运算:

p++

Go 普通指针不支持这种操作。

二十三、总结

定义普通变量:

num := 10

获取地址:

p := &num

定义指针:

var p *int

读取指针指向的数据:

fmt.Println(*p)

修改:

*p = 100

判断 nil:

if p != nil {
	fmt.Println(*p)
}

函数接收指针:

func change(num *int) {
	*num = 100
}

调用:

change(&num)

学习 Pointer 最重要的是理解:

1.变量的数据存储在内存中
2.& 用于获取变量地址
3.* 用于通过指针访问对应数据
4.指针可以修改它指向的原变量
5.nil 指针不能直接解引用
6.Go 的函数参数始终是值传递
7.传递指针实际上也是复制地址值
8.Go 普通指针不支持指针算术

理解指针之后,我们就可以更加深入地学习 Go 中最基础也最重要的代码组织方式之一——函数。前面的示例已经大量使用了 main()change()swap() 等函数,但函数还有参数、返回值、多返回值、可变参数、匿名函数、闭包等非常重要的知识点。
下一篇:函数 Function——参数、返回值与代码复用