对话:指针的本质 - 不过是一个存地址的变量


前置知识: [01-virtual-memory.md, 02-memory-layout.md] 难度: ⭐⭐ 预计时间: 35分钟 关键词: 指针, 地址, 解引用, 值传递, 引用传递, unsafe.Pointer

引入场景

很多人觉得指针难理解、容易出错。但指针的本质其实极其简单——它就是一个存储内存地址的变量。学完虚拟内存和内存布局后,指针就不再神秘了。

对话探索

问题1:指针到底是什么?它和普通变量有什么区别?

💭 思考方向:

  • var x int = 42 在内存中是什么样子?
  • var p *int = &x 在内存中又是什么样子?
  • p本身也占内存空间,它存的是什么?

📖 参考答案:

内存(虚拟地址空间)
地址         内容
─────────────────────
0xC0000A0    42            ← x 变量,存的是值
...
0xC0000B0    0xC0000A0     ← p 变量,存的是 x 的地址

指针就是一个变量,它的值是另一个变量的地址。

var x int = 42     // x在地址0xC0000A0,内容是42
var p *int = &x    // p在地址0xC0000B0,内容是0xC0000A0
fmt.Println(x)     // 42(直接读取x的值)
fmt.Println(p)     // 0xC0000A0(p存的是地址)
fmt.Println(*p)    // 42(解引用:去p存的地址处读取值)

三个关键操作:

操作语法含义
取地址&x获取变量x的内存地址
声明指针var p *int声明一个存储int地址的变量
解引用*p去p存的地址处读/写值

问题2:Go是”值传递”还是”引用传递”?函数内修改参数会影响外面吗?

💭 思考方向:

  • 函数参数是复制一份还是传地址?
  • 传指针和传值有什么区别?
  • slice、map呢?它们好像能在函数内修改?

📖 参考答案: Go一切都是值传递——但传递的”值”可以是一个指针(地址)。

// 传值:函数内修改不影响外面
func changeValue(x int) {
    x = 100  // 修改的是x的副本
}
 
// 传指针:可以修改原始值
func changePointer(p *int) {
    *p = 100  // 通过地址修改原始值
}
 
func main() {
    a := 42
    changeValue(a)
    fmt.Println(a)  // 42(未改变)
    
    changePointer(&a)
    fmt.Println(a)  // 100(改变了)
}
传值时:       传指针时:
main栈帧      main栈帧
┌────────┐    ┌────────┐
│ a = 42 │    │ a = 42 │ ← *p 通过地址直接修改这里
└────────┘    └────────┘
                   ↑
changeValue栈帧    changePointer栈帧
┌────────┐    ┌─────────────┐
│ x = 42 │    │ p = &a(地址) │
│→x = 100│    │ *p = 100    │
└────────┘    └─────────────┘
 x是副本,       p是指针的副本,
 修改不影响a     但指向同一个a

slice、map的”特殊”行为:

func modify(s []int) {
    s[0] = 100  // 能修改!但不是因为"引用传递"
}

slice的底层结构是:

type slice struct {
    ptr  *array  // 指向底层数组的指针
    len  int
    cap  int
}

传递slice时,复制了这个结构体(值传递)。但 ptr 字段的副本仍然指向同一个底层数组——所以修改元素会影响原始slice。


问题3:new 和 make 有什么区别?什么时候用哪个?

💭 思考方向:

  • new(int) 返回什么?
  • make([]int, 10) 返回什么?
  • 为什么slice不能用new?

📖 参考答案:

new(T)make(T, ...)
返回值*T(指针)T(值)
初始化零值完整初始化
适用类型所有类型仅 slice、map、channel
做了什么分配内存,填零值分配+初始化内部结构
// new:分配零值,返回指针
p := new(int)       // p 是 *int,*p == 0
s := new([]int)     // s 是 *[]int,*s == nil(没有底层数组!)
 
// make:分配并初始化内部结构
s2 := make([]int, 10)    // s2 是 []int,len=10,底层数组已分配
m := make(map[string]int) // m 是 map,内部哈希表已初始化
ch := make(chan int, 5)    // ch 是 channel,缓冲区已分配

为什么slice/map/channel需要make?因为它们内部有复杂结构(指针+长度+容量),仅分配零值是不够的。


问题4:Go有 unsafe.Pointer,它和普通指针有什么不同?为什么叫”unsafe”?

💭 思考方向:

  • 普通指针有类型约束(*int 只能指向int)
  • 如果想把 *int 转成 *float64 呢?
  • C语言的 void* 是什么?

📖 参考答案:

// 普通指针:类型安全
var x int = 42
var p *int = &x
// var q *float64 = p  ← 编译错误!类型不匹配
 
// unsafe.Pointer:绕过类型系统
var q *float64 = (*float64)(unsafe.Pointer(&x))
fmt.Println(*q)  // 输出 x 的位模式按 float64 解释的值

unsafe.Pointer 的转换规则:

*T ←→ unsafe.Pointer ←→ *U    (任意指针类型互转)
unsafe.Pointer ←→ uintptr       (指针和整数互转)

为什么叫unsafe?

  1. 绕过类型检查:编译器不再保证内存安全
  2. 可能读到垃圾数据:把int的位模式当float64读,结果毫无意义
  3. GC不感知:uintptr只是整数,GC不知道它是指针,可能移动了对象但不更新uintptr
  4. 破坏可移植性:依赖内存布局,不同架构可能不同

何时使用:

  • 几乎不需要。标准库内部用(如reflect、sync.Pool)
  • 性能极度敏感的场景(避免接口转换开销)
  • 和C代码交互(CGO)

问题5:nil指针是什么?解引用nil指针为什么会panic?

💭 思考方向:

  • nil在内存中是什么值?
  • 地址0处有什么?
  • 操作系统如何防止访问地址0?

📖 参考答案:

var p *int = nil  // p 的值是 0(地址0)
fmt.Println(*p)   // panic: runtime error: invalid memory address

nil指针的值就是0——它指向虚拟地址空间的最底部。

操作系统故意将地址0附近的页设为”不可访问”(保护页)。当程序试图访问这些地址时:

  1. CPU发出内存访问请求
  2. MMU查页表 → 该页是不可访问的
  3. CPU触发段错误(Segmentation Fault)
  4. Go运行时捕获信号,转为panic
虚拟地址空间
0x00000000 ┌───────────┐
           │ 保护页     │ ← nil指针指向这里
           │ (不可访问) │    访问就触发段错误
0x00001000 ├───────────┤
           │ .text     │ ← 程序代码从这里开始
           │ ...       │

这也是为什么Go中要养成检查nil的习惯:

func process(user *User) {
    if user == nil {
        return  // 防御性编程
    }
    fmt.Println(user.Name)
}

Go语言实践

package main
 
import (
    "fmt"
    "unsafe"
)
 
type Point struct {
    X int
    Y int
}
 
func main() {
    // 基本指针操作
    x := 42
    p := &x
    fmt.Printf("x的值:   %d\n", x)
    fmt.Printf("x的地址: %p\n", &x)
    fmt.Printf("p的值:   %p(就是x的地址)\n", p)
    fmt.Printf("p的地址: %p(p自己也在内存中)\n", &p)
    fmt.Printf("*p的值:  %d(解引用,读取x的值)\n", *p)
 
    // 指针大小
    fmt.Printf("\n指针大小: %d字节(64位系统=8字节)\n", unsafe.Sizeof(p))
 
    // 结构体指针
    pt := &Point{X: 10, Y: 20}
    fmt.Printf("\nPoint地址: %p\n", pt)
    fmt.Printf("Point.X:   %d\n", pt.X) // Go自动解引用,不需要写(*pt).X
 
    // slice的共享底层数组
    a := []int{1, 2, 3}
    b := a  // b和a共享底层数组
    b[0] = 100
    fmt.Printf("\na[0] = %d(被b修改了!共享底层数组)\n", a[0])
}

小结

⭐ 核心要点:

  1. 指针 = 存地址的变量,取地址(&)、解引用(*)是两个基本操作
  2. Go一切值传递,传指针可以修改原始值;slice/map看起来像引用是因为内部有指针
  3. nil = 地址0,操作系统设置了保护页,访问地址0会触发段错误→panic

关联阅读

  • 前置:虚拟内存(指针存的是虚拟地址)
  • 前置:内存布局(指针指向不同的内存段)
  • 应用:运行时(指针在函数调用栈中的角色)