第十八章 指针(Pointer)

这次,我们将讨论编程初学者的“鬼门”,指针。我认为比起 C 语言,觉得 Go 指针难的人并不多。但仍然会有不少人害怕它,因此我会仔细地解说。

指针

指针/pointer 是一个用来**“指出某物位置”**的词,储存变量等的“位置”。用法如下所示。

package main

import "fmt"

func main() {
	x := 42
	p := &x         // xのアドレスをpに代入
	fmt.Println(p)  // 0xから始まる謎の数字
	fmt.Println(*p) // 42
	*p = 99         // pが指し示す場所に99を代入
	fmt.Println(*p) // 99
	fmt.Println(x)  // 99
}
$ go run .
0x1400009a020
42
99
99

新出现的 &* 是进行指针相关操作的符号。可能会让人感到困惑,我们来一步步看看。

计算机的内部结构

计算机的核心是进行计算的 **CPU(英特尔整天做广告的那玩意)**和用于存储数据的内存(8GB 或 16GB 等)。它们通常被比作人类的大脑和桌面上的工作区域。大脑越聪明,计算越快;桌面越大,工作空间越宽裕。

这张桌子上的所有数据都井然有序地排列着,每个数据都有一个表示位置的数字。这个数字就是地址/address。最初是表示“住所”的词,有时也被翻译为“番地”。

下图是这样的内存中变量 x y vy 被保存的样子。

!954、958、974 等地址仅仅是为了说明“存在地址这个数字”而临时指定的值,因此与实际地址的分配方式有很大不同。请注意。

メモリの内部 内存内部的图像

变量存储在内存中,内存上的数据分配了地址,如果掌握了这些,指针就可以轻松应对了。

& 和 *

在 Go 中,如下图所示,可以通过 & 获取分配给变量的地址,通过 * 获取地址所指向的内容。

变量的指向关系

这样一来,开头的程序也一定能读懂了。

再贴一遍

package main

import "fmt"

func main() {
	x := 42
	p := &x         // xのアドレスをpに代入
	fmt.Println(p)  // 0xから始まる謎の数字の正体は、xのアドレス!
	fmt.Println(*p) // 42 (指し示す先はxなので)
	*p = 99         // pが指し示すxに99を代入
	fmt.Println(*p) // 99
	fmt.Println(x)  // 99
}

なお, fmt.Println(p) で显示的 0x ,这是 16 进制的意思。平常使用的 10 进制是用 0 到 9 的数字表示一位,而 16 进制则是用 0 到 9 和 A 到 F 共 16 种表示一位。在 Go 中,16 进制的前面加上 0x 以区分于 10 进制。

总之只是表示法不同而已,实际上只是整数,所以完全不用担心。例如 0x1400009a020 转换为十进制是 1374390165536 。地址使用十六进制只是一个习惯,所以不必太在意。

指针类型

下图中还有一个地方没有解释,那就是 p *int 的部分。

变量的指向关系

在 Go 中,可以将地址赋值给指针类型。指针类型以 *int 的形式写作 *型名

麻烦的是, & 获取的地址结果的类型不是 &int 而是 *int 。不过这是历史原因造成的,只能记住。个人来说,我希望指针类型叫 &int

零值,nil

指针类型的零值是 **nil(空)**这个特殊值。nil 表示不指向任何地方的无效值。 nil 指向的地方在 * 中使用时会崩溃,所以请小心使用。

func main() {
	var p *int
	*p = 42 // 崩溃
	fmt.Println(*p)
}

利用 nil 的含义,也可以用来表示有无效和有效时的变量。

func main() {
	p := 某个函数() // 返り値が無効な時と有効な時がある
	if p != nil {       // 有効な (nilではない) 時だけ処理を行う
		fmt.Println(*p)
	}
}

nil 不仅用于纯粹的指针类型,还用于 Go 提供的几种类型,表示空值。关于这一点我们会稍后再提。

结构体和指针

为了结合结构体和指针,提供了一些特殊的便利功能。

首先,在合成字面量前加上 & ,可以直接创建该结构体的指针,而无需通过变量。这是相当常用的。

pp := &position{10, 20}
// ↑と↓は同じ意味
p := position{10, 20}
pp := &p

另一个,当从结构体指针使用字段时,可以像普通结构体一样写 pp.x

pp := &position{10, 20}
pp.x = 30
fmt.Println(pp.x) // 30

如果没有这个功能,需要通过指针指向的结构体,那么在考虑 *. 的优先级时,就必须写成 (*pp).x ,这真是一个非常感谢的功能。对于有 C 系语言经验的人来说,听到可以不必区分 .-> ,应该就能明白了。

指针的使用场合

指针大致有三个使用场景。

  • 避免大数据的复制
  • 中身被改写
  • 无效值用 nil 表示

最后一个已经提到过了,接下来我将对前两个进行解说。

使用场景 1:避免大量数据的复制

当将参数传递给函数时,该参数会被复制。如果该参数非常庞大,复制将耗费时间(复制庞大数据对计算机来说是重负担)。

指针只是一个数字,因此只需复制指针,就可以相对快速地在函数中通过指针使用该数据。

大量数据与指针

使用场景 2:要求更改原始内容

这也是由于参数被复制而引起的事情,但在函数内部对参数这个副本进行的任何修改都不会影响原始变量。

使用指针时,复制的指针也会指向原始变量,因此即使在函数内部也可以修改原始变量。

func f(x int) {
	x = 99
}

func g(p *int) {
	*p = 99 // 指针指向的地方 = 原始值被覆盖
}

func main() {
	x := 42
	f(x)
	fmt.Println(x) // 42 x没有被更改
	g(&x)
	fmt.Println(x) // 99 x被更改了
}

猜数字游戏中出现的 fmt.Scanln 函数取参数为指针也是为了这个原因。

x := 0
fmt.Scanln(&x) // 此处的 x 被修改

其他用途

基本是以上所述,但在实践中正确区分所有指针,即使是熟练的程序员也非常困难。因此,这次我将传授一个大致可以说是 OK 的粗略指导。虽然提到了尚未涉及的 Go 功能,但请您谅解,并将其放在脑海的一角。

应使用指针的情况:

  • 值写入函数
  • 结构体型的参数
  • 切片元素的类型

不需要使用指针的情况:

  • 数值、真值型的参数(没有将其指针化的好处)
  • 文字串、切片、映射、通道、闭包、接口类型的参数(实质上是指针类型)

重构

那么照例,我们来试试在重构中使用指针的感觉。根据上述指导方针,这次关注的是“切片元素的类型”和“结构体类型的参数”。在 var wallsdrawWalls 附近似乎可以使用指针。

var walls = []*wall{}
func drawWalls(w *wall) {

那么我们来将其应用于整体。

package main

import (
	"embed"
	"math/rand/v2"

	"github.com/eihigh/miniten"
)

//go:embed *.png
var fsys embed.FS

type wall struct {
	wallX int
	holeY int
}

var (
	x    = 200.0
	y    = 150.0
	vy   = 0.0  // Y方向速度(Velocity of y)的缩写
	g    = 0.1  // 重力加速度(Gravity) 的缩写
	jump = -4.0 // 跳跃力

	frames     = 0         // 经过的帧总数
	interval   = 120       // 土管追加间隔
	wallStartX = 640       // 土管的初始x坐标
	walls      = []*wall{} // 土管的X坐标与空洞的Y坐标
	wallWidth  = 20        // 土管的宽度
	wallHeight = 360       // 土管的高度
	holeYMax   = 150       // 空洞的Y坐标的最大值
	holeHeight = 170       // 空洞的大小(高度)

	gopherWidth  = 60
	gopherHeight = 75

	scene         = "title"
	score         = 0     // 分数,全局变量。
	isPrevClicked = false // 前一帧按钮没按下
	isJustClicked = false // 这一帧按钮被按下
)

func main() {
	miniten.Run(draw)
}

func draw() {
	// ...省略...
}

func drawTitle() {
	// ...省略...
}

func drawGame() {
	miniten.DrawImageFS(fsys, "sky.png", 0, 0)
	for i, wall := range walls {
		if wall.wallX < int(x) {
			score = i + 1
		}
	}
	miniten.Println("Score", score)
	if miniten.IsClicked() {
		vy = jump
	}
	vy += g // 新的当前速度 = 当前速度+加速度
	y += vy // 新的当前位置 = 当前位置+速度
	miniten.DrawImageFS(fsys, "gopher.png", int(x), int(y))

	// 从这里开始,写追加土管的代码
	frames += 1
	if frames%interval == 0 {
		wall := &wall{wallStartX, rand.N(holeYMax)}
		walls = append(walls, wall)
	}
	// 追加土管的代码,到这里就结束了

	for i := range walls {
		walls[i].wallX -= 2 // 往左动
	}
	for _, wall := range walls {
		wall.wallX -= 2 // 往左动
	}
	for _, wall := range walls {
		drawWalls(wall)

		// ...省略...
	}

	if y < 0 {
		scene = "gameover"
	}
	if 360 < y {
		scene = "gameover"
	}
}

func drawGameover() {
	// 将背景、地鼠、土管的drawGame函数粘贴到这里
	miniten.DrawImageFS(fsys, "sky.png", 0, 0)
	miniten.DrawImageFS(fsys, "gopher.png", int(x), int(y))
	for _, wall := range walls {
		drawWalls(wall)
	}

	miniten.Println("Game Over")
	miniten.Println("Score", score)
	if isJustClicked {
		scene = "title"

		x = 200.0
		y = 150.0
		vy = 0.0
		frames = 0
		walls = []*wall{}
		score = 0
	}
}

func drawWalls(w *wall) {
	// 描绘上面的土管
	miniten.DrawImageFS(fsys, "wall.png", w.wallX, w.holeY-wallHeight)

	// 描绘下面的土管
	miniten.DrawImageFS(fsys, "wall.png", w.wallX, w.holeY+holeHeight)
}

func hitTestRects(aLeft, aTop, aRight, aBottom, bLeft, bTop, bRight, bBottom int) bool {
	return aLeft < bRight &&
		bLeft < aRight &&
		aTop < bBottom &&
		bTop < aBottom
}

最初提到的 walls 变量声明和 drawWalls 函数声明以外,

  • 壁的附加处理( &wall{...}
  • 壁的移动处理( wall.wallX -= 2
  • 壁的重置处理( walls = []*wall{}

对全局影响不大,意外地容易适用。

有趣的是墙壁的移动处理。 for range 文中提取的元素类型变成了指针,因此可以通过复制的 wall 指针来修改原始值,结果不再需要使用循环次数 i 。实际上,这种新的写法在 Go 中是常见的写法。

// before
	for i := range walls {
		walls[i].wallX -= 2 // 往左移动
	}
// after
	for _, wall := range walls {
		wall.wallX -= 2 // 往左移动
	}

本章总结

指针表示变量的位置。在 Go 中,使用 & 获取地址,使用 * 获取地址所指向的内容。指针用于避免大数据的复制或在函数中修改变量。

计算机的核心与其深度相关,想要正确使用即使是熟练者也会感到困难,但如果掌握基本方针,大多数情况下应该能够编写出合格的程序。