本节摘要:指针是"数据的地址"。本节讲清取地址与解引用、可寻址规则、函数传参的复制语义,并给出空指针的防御写法。学完本节,你拿到任何一段传参代码,都能准确指出哪里共享、哪里复制——这项能力是判断并发数据安全的第一依据。
档案卡与岗位说明书都有了,还差一个关键概念:怎么让别人"直接在你的档案上改",而不是改一份复印件。答案是地址。值班场景里,把工位地址告诉行政,行政就能直接把新工牌放到你桌上(共享原值);只把档案复印件交上去,改动就永远落在复印件上(值复制)。本节把这两种语义掰开,它直接决定第 3 章"数据在并发间怎么传才安全"。
package main import "fmt" func main() { jobs := 8 p := &jobs // 取地址:p 的类型是 *int,读作"指向 int 的指针" fmt.Println("地址上的值:", *p) // 解引用:顺着地址取值 *p = 12 // 通过地址写值,原变量随之改变 fmt.Println("原变量:", jobs) var q *int // 指针零值是 nil,"还没登记任何地址" fmt.Println("空指针:", q) } // 运行输出(地址内容每次运行可能不同,此处只展示值部分): // 地址上的值: 8 // 原变量: 12 // 空指针: nil
取地址用 &,解引用用 *,两者互为逆操作。指针的零值是 nil,表示"不指向任何东西"——对一个 nil 指针解引用会直接 panic,这是 Go 程序最常见的崩溃形态,防御方法见本节末尾。

可寻址性是个隐藏规则:变量、数组元素、结构体字段、切片元素都"有固定工位",能取地址;映射的元素、字符串的字节、字面量本身"没有固定工位",取地址编译不过:
package main import "fmt" type Conf struct{ Timeout int } func main() { m := map[string]Conf{"a": {Timeout: 3}} // p := &m["a"] // 编译错误:映射元素不可寻址 c := m["a"] // 取出副本,改副本 c.Timeout = 5 m["a"] = c // 再整体写回,这才是映射元素的正确更新姿势 fmt.Println(m["a"].Timeout) s := []Conf{{Timeout: 3}} s[0].Timeout = 5 // 切片元素可寻址,直接改,无需写回 fmt.Println(s[0].Timeout) } // 运行输出: // 5 // 5
映射元素不可寻址的根因是映射会因扩容搬迁元素,旧地址会失效,语言干脆禁止。切片元素可寻址则因为底层数组位置稳定。记住这条,"为什么 map 里改不了结构体字段"的报错就有了答案。
Go 的函数传参严格按值复制,没有引用传递。但"值"本身的构成决定了后果:传结构体复制全部字段;传切片只复制 24 字节头部(指针、长度、容量),底层数组仍然共享;传映射复制的是内部指针,函数内增删键调用方看得见;传指针复制地址,指向同一份数据。
package main import "fmt" func editSlice(s []int) { s[0] = 99 // 改底层数组,调用方可见 s = append(s, 1) // 可能触发扩容搬迁,此后 s 指向新数组,改动出不了函数 } func editMap(m map[string]int) { m["deep"] = 1 // 映射头部共享,增删对调用方可见 } func main() { nums := []int{1, 2, 3} editSlice(nums) fmt.Println(nums) conf := map[string]int{} editMap(conf) fmt.Println(conf) } // 运行输出: // [99 2 3] // map[deep:1]
这段代码是本节的实验台:切片首元素被改到是因为共享底层数组;append 之后的新元素没有出现在调用方,是因为扩容后函数内的切片头指向了新数组,长度变化也只留在函数内。要安全地把"追加"带出去,惯用写法是让函数返回新切片,由调用方接收。
背景:服务有一个全局配置结构,含十几个字段;热更新逻辑需要在函数里修正超时阈值。
操作:先写值传递版本,再改指针传递版本,对比行为与风险。
package main import "fmt" type Conf struct { Timeout int Retries int } func tuneBad(c Conf) { c.Timeout = 30 } // 值传递:改的是复印件 func tuneGood(c *Conf) { c.Timeout = 30 } // 指针传递:改原值 func main() { var conf Conf // 零值建档 tuneBad(conf) fmt.Println("值传递后:", conf.Timeout) tuneGood(&conf) fmt.Println("指针传递后:", conf.Timeout) // 防御:指针可能为 nil,解引用前先判空 var p *Conf if p != nil { tuneGood(p) } else { fmt.Println("配置未初始化,跳过调优") } } // 运行输出: // 值传递后: 0 // 指针传递后: 30 // 配置未初始化,跳过调优
结果:值传递版本改动丢失,指针传递版本生效,判空分支兜住了未初始化场景。
解读:tuneBad 的行为就是 2.1 节内存布局图的直接推论——字段被复制,修改留在副本上。tuneGood 生效的同时也引入了共享,如果这个配置对象将来被多个 goroutine 同时 tune,数据竞争就登场了,这正是 2.3 与第 4 章之间的逻辑桥。
变式:把 Conf 换成含一个 1000 元素数组的大结构体,对两个版本各做基准测试(第 5 章会教基准写法)。你会看到值传递的复制开销真实存在,但指针传递引入的逃逸与缓存不友好也可能反噬——性能决策永远靠测量,不靠直觉。
if p == nil。& 取地址、* 解引用,指针零值为 nil,解引用 nil 必崩。值与指针的分野已经清晰,下一节的泛型将处理另一个重复问题:逻辑相同、类型不同的代码,怎么只写一遍。