2.3 指针:工位地址与代签


2.3 指针:工位地址与代签

本节摘要:指针是"数据的地址"。本节讲清取地址与解引用、可寻址规则、函数传参的复制语义,并给出空指针的防御写法。学完本节,你拿到任何一段传参代码,都能准确指出哪里共享、哪里复制——这项能力是判断并发数据安全的第一依据。

档案卡与岗位说明书都有了,还差一个关键概念:怎么让别人"直接在你的档案上改",而不是改一份复印件。答案是地址。值班场景里,把工位地址告诉行政,行政就能直接把新工牌放到你桌上(共享原值);只把档案复印件交上去,改动就永远落在复印件上(值复制)。本节把这两种语义掰开,它直接决定第 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 程序最常见的崩溃形态,防御方法见本节末尾。

图 2-3:值传递与指针传递的差别

图 2-3:值传递与指针传递的差别

什么能取地址,什么不能

可寻址性是个隐藏规则:变量、数组元素、结构体字段、切片元素都"有固定工位",能取地址;映射的元素、字符串的字节、字面量本身"没有固定工位",取地址编译不过:

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 章会教基准写法)。你会看到值传递的复制开销真实存在,但指针传递引入的逃逸与缓存不友好也可能反噬——性能决策永远靠测量,不靠直觉。

空指针防御三招

  • 判空再解引用:跨包返回的指针、可能失败后为 nil 的指针,使用前一律 if p == nil
  • 构造函数保证非 nil:提供 New 开头的构造函数,返回保证可用的指针,调用方免判空,这是标准库的主流风格。
  • 用零值结构体替代 nil:能定义合理零值的类型(如 bytes.Buffer),声明即可用,从根上消灭空指针。

收班要点

  • & 取地址、* 解引用,指针零值为 nil,解引用 nil 必崩。
  • 映射元素不可寻址,更新要取副本改完写回;切片元素可直改。
  • 传参永远复制,区别在复制的是整个值、还是头部与地址。
  • 切片的 append 在扩容后另起炉灶,跨函数追加要靠返回值。
  • 防空指针三招:先判空、构造函数、零值可用。

值与指针的分野已经清晰,下一节的泛型将处理另一个重复问题:逻辑相同、类型不同的代码,怎么只写一遍。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U