记账单位到手,现在进第一格内存。后面每一篇都会出现 head、tail、cur、prev、tmp——名字换来换去,谜底只有一个:它们都是存着地址的变量。这一篇把这句话拆到不能再拆;拆完之后,链表、树、图的一切增删改,都只是「几个格子换了内容」。

一格内存能存什么.

内存是一排编了号的格子,编号就是地址。一格能存一个值——也能存另一个格子的编号。存编号的格子,就是指针:

内存表:x 在 0xA000 存着 100;p 在 0xA008,数据是 0xA000——x 的地址;橙色箭头从 p 的数据绕回 x 那一行 DATA STRUCTURES · 指针 地址就是数据. 地址 变量 数据 0xA000 x 100 0xA008 p 0xA000 p 的「数据」是 0xA000——正是 x 所在格子的编号。取这个编号的动作,就是 &x。
x := 100
p := &x         // p 的数据 = x 的编号
fmt.Println(p)  // 形如 0xc000014098——每次运行都不同

图里的地址是示意值——真实地址由运行时分配,每次都变,用 fmt.Println(&x) 可以亲测。不变的是关系:p 的数据栏里躺着 x 的编号。指针没有任何魔法,这一行看懂,全系列的箭头就都看懂了——图上每根箭头,都是某个格子里真实存着的一个编号。

三个符号.

围绕这一格,Go 只给了三个符号:

&x —— 取编号
问一个变量:你住哪格?返回它的地址。任何变量都有地址,因为任何变量都住在某格内存里。
*p —— 顺着编号走
两个身份:写在类型里(*int)表示「存 int 地址的格子」;写在变量前(*p)表示顺着 p 里的编号找到那格,去读或写它——解引用。
nil —— 还没存编号
指针的零值:格子里还没有任何编号。解引用 nil 是运行时崩溃,判空是每个链表操作的第一反应——它也是遍历的停机条件。
var p *int        // 能存 int 地址的格子,初值 nil
if p == nil {     // 还没指向任何地方
    fmt.Println("p is nil")
}
 
n := 11
p = &n            // 存入 n 的编号
*p = 12           // 顺着编号写:n 从此是 12

指针能用 ==!= 比较——比的是编号本身:两个指针相等,意味着它们指着同一格。后面每一篇的遍历都靠它收尾:cur == nil 是走到底。(同一招还能判链表成环——让快慢两个指针跑,slow == fast 就说明有环;本系列用不到,知道原理即可。)

结构体:连续的几格.

一个值一格,那一个「东西」呢?结构体的回答很直白:按声明顺序,占连续的几格

内存表:结构体 u 占 0xB000 和 0xB008 两个相邻格子,ID 是 7、Age 是 42;指针 p 存着基址 0xB000,橙色箭头指回 u 的第一格 DATA STRUCTURES · 结构体 结构体,就是连续的几格. 地址 变量 数据 0xB000 u.ID 7 0xB008 u.Age 42 0xB020 p 0xB000 u 字段是偏移:Age 住在基址 + 8。p 拿着基址,pu.Age 就是「顺编号走,再加 8」。
type User struct {
    ID  int64
    Age int64
}
 
u := User{ID: 7, Age: 42}
pu := &u
pu.Age = 43 // Go 帮你省了 (*pu).Age 的括号

Age 住在基址加 8——字段访问就是「基址 + 偏移」的算术。指向结构体的指针只需要记住基址,剩下的都是加法。系列的记录体记法就从这里来:链表节点 Node{Val, Next} 画成两格方框,因为它在内存里就是两格;下一篇把这套算术放大到 n 个元素,就是数组。

Go 只有值拷贝.

现在回收开篇埋的钥匙句。Go 的赋值和传参只有一种行为:把格子里的数据抄一份。抄的是什么,决定你改得到哪里——经典案例,交换两个数:

func swapByValue(a, b int) {
    a, b = b, a // 换的是副本
}
 
func swapByPointer(a, b *int) {
    *a, *b = *b, *a // 顺着地址,换本尊
}

传值:换的是副本

内存表:main 的 a、b 在 0xC000、0xC008 存着 10 和 20;swapByValue 的形参在 0xC100、0xC108 存着换过的 20 和 10——副本换了,本尊没动 DATA STRUCTURES · 值拷贝 换的是副本. 地址 变量 数据 0xC000 a 10 0xC008 b 20 main 0xC100 a 20 0xC108 b 10 形参(副本) 调用把 10、20 抄进 0xC100 那边;交换全程发生在副本上,main 毫发无损。

传指针:换的是本尊

内存表:swapByPointer 的形参存的是地址 0xC000 和 0xC008,箭头指回 main 的两格;main 的 a、b 已经互换成 20 和 10 DATA STRUCTURES · 指针拷贝 顺着地址换本尊. 地址 变量 数据 0xC000 a 20 0xC008 b 10 main 0xC100 a 0xC000 0xC108 b 0xC008 形参(地址) 形参拷的也是值——那个值是地址。*a 顺着 0xC000 写回去,main 的两格当场互换。

注意右图的形参拷的也是值——只不过那个值是地址。「传指针」不是第二种传参方式,是同一种:抄数据,数据恰好是编号。所以 cur := head 之后两个变量指着同一个节点,改 cur.Val 会影响 head.Val,但把 cur 挪去下一格不动 head 分毫——前者顺着编号写格子,后者只换了 cur 自己格子里的编号。这一个区分,就是后面所有遍历和增删改的地基。

指针的指针,一瞥.

地址的格子自己也有地址——所以指针可以套娃:

x := 42
p1 := &x   // p1 存 x 的编号
p2 := &p1  // p2 存 p1 的编号
**p2 = 100 // 顺两次编号:x 变成 100

这个系列用到一层就够。但现在你能读懂任何层:每个 * 就是「顺着编号再走一步」,没有别的含义。

格子、编号、三个符号、一条值拷贝——地基打完。下一篇把「基址 + 偏移」放大成整片连续内存:数组与切片——连续内存的红利