第 10/41 课 · 复合数据类型与面向对象 基础 m02-l01

指针与内存:&、*、逃逸分析初识

本课所属:第二模块:复合数据类型与面向对象 | 课时:m02-l01 | 难度:入门 学完本课你将能够:理解指针语义与内存模型,掌握 new 与 make 的区别,并能通过逃逸分析优化代码性能。

一、学习目标

  • 能正确使用 & 和 * 进行指针的取址与解引用。
  • 能准确区分 new 与 make 的适用场景及返回值差异。
  • 能解释值传递与指针传递在内存中的行为差异。
  • 能使用 -gcflags=-m 进行逃逸分析,理解栈与堆的分配逻辑。

二、知识讲解

2.1 指针语义:& 与 *

指针是一个变量的内存地址。在 Go 中,指针类型写作 *T。 - 取址符 &:获取变量的内存地址。例如 &x 返回 x 的地址,类型为 *T。 - 解引用符 *:通过地址访问或修改值。例如 *p 访问 p 指向的值。 - nil 指针:零值指针,不指向任何有效内存。对 nil 指针解引用会导致 panic。 - 指针大小:占用一个机器字,64 位平台为 8 字节,32 位平台为 4 字节。

2.2 new 与 make 的区别

Go 中只有两个内置函数用于分配内存,但用途截然不同: - new(T):分配零值内存,返回类型为 *T 的指针。它不初始化内部结构,仅置零。 - make(T, args):仅用于 slice、map、chan 三种内置引用类型。它分配内部数据结构并初始化,返回类型即为 T 本身(而非指针)。

重要事实:new 并非不能用于切片/map,而是不该:它只分配零值(得到 *[]int 指向 nil 切片、*map[K]V 指向 nil map),解引用后才能用;创建可用的引用类型应使用 make。

2.3 值传递 vs 指针传递

Go 函数参数默认是值传递。 - 值传递:拷贝变量的值。对于 int、struct 等,修改副本不影响原值。 - 指针传递:拷贝地址。修改 *p 指向的内容,会影响原变量。 - 切片是值类型:切片头(指针、长度、容量)是值传递,但底层数组共享。因此修改切片元素会影响原切片,但重新赋值(如 s = append(s, 1))不会改变原切片头。

2.4 栈与堆:逃逸分析

  • 栈(Stack):随函数调用自动分配与释放,速度快,空间有限。
  • 堆(Heap):由 GC 管理,生命周期长,空间大,分配与回收有成本。
  • 逃逸分析(Escape Analysis):编译器决定变量分配在栈还是堆的过程。如果变量在函数返回后仍被引用,则“逃逸”到堆。
  • 工具:使用 go build -gcflags=-m 或 go run -gcflags=-m 查看逃逸分析结果。

三、示例代码

示例 1:指针基础与 new/make 对比

演示指针取址、解引用,以及 new 与 make 创建切片和 map 的差异。

package main

import (
    "fmt"
)

func main() {
    // 1. 指针基础
    var x int = 10
    p := &x // 取址,p 类型为 *int
    fmt.Printf("x 的值: %d, 地址: %p\n", x, p)
    *p = 20 // 解引用修改
    fmt.Printf("修改后 x 的值: %d\n", x)

    // 2. new 的使用
    // new 返回指向零值类型的指针
    pInt := new(int)
    *pInt = 30
    fmt.Printf("new 分配的 int 值: %d\n", *pInt)

    // 3. make 的使用
    // make 用于 slice, map, chan,返回初始化后的实例
    s := make([]int, 0, 5) // 长度0,容量5
    s = append(s, 1)
    fmt.Printf("make 创建的切片: %v\n", s)

    m := make(map[string]int, 10) // hint 只是初始容量提示,map 无 cap 概念
    m["key"] = 100
    fmt.Printf("make 创建的 map: %v\n", m)
}

运行结果(预期输出):

x 的值: 10, 地址: 0xc000012110(每次运行不同)
修改后 x 的值: 20
new 分配的 int 值: 30
make 创建的切片: [1]
make 创建的 map: map[key:100]

示例 2:逃逸分析初识

演示变量何时会逃逸到堆,通过 -gcflags=-m 观察编译器行为。

package main

import "fmt"

// escapeExample 演示逃逸
func escapeExample() *int {
    x := 10
    // x 在函数返回后被返回,因此逃逸到堆
    return &x
}

func main() {
    p := escapeExample()
    fmt.Println(*p)
}

运行结果(预期输出):

10

(注:运行 go run -gcflags=-m example2.go 可查看逃逸信息。实测输出中会包含 ./example2.go:9:2: moved to heap: x,表明 x 逃逸到堆。同时会看到 a escapes to heap / b escapes to heap,那是 fmt.Printf 的 ...any 参数导致的装箱逃逸,与返回值逃逸是两回事。行号随文件内容变化、每次编译还可能多出 inlining 相关提示。)

四、常见错误与避坑指南

  1. 误用 new 创建切片/Map 并直接操作 - 错误写法:s := new([]int); s = append(s, 1) - 原因:new([]int) 返回 *[]int,它指向一个 nil 切片,本身合法;但 append 的第一个实参必须是切片,写 append(s, 1) 会编译失败(first argument to append must be a slice)。要用就得显式解引用:s := new([]int); *s = append(*s, 1)。工程上仍建议直接用 make([]int, 0) 或 var s []int。 - 正确做法:使用 make 创建切片或 map。

  2. 误以为传切片就是传副本 - 错误认知:func modifySlice(s []int) { s[0] = 99 } 调用后原切片不变。 - 原因:s[0] = 99 会修改底层数组,因此原切片可见(实测输出 [99 2 3]);传指针 *[]int 只在需要改切片头本身(长度/容量/重新赋值)时才有必要。切片是‘描述符按值传递 + 底层数组共享’。 - 正确做法:理解切片头的值传递特性与底层数组的共享特性。

  3. 对指针类型的 map 进行索引 - 错误写法:m := new(map[string]int); m["key"] = 1 - 原因:m 是 *map[string]int 类型,不能直接索引。编译器报错:invalid operation: cannot index m (variable of type *map[string]int)。 - 正确做法:*m["key"] = 1 或更简单地直接使用 make。

五、最佳实践

  1. 优先使用值传递:对于小结构体或基本类型,值传递更直观且避免指针空指针风险。
  2. 大结构体或需修改原值时使用指针:避免拷贝开销,或明确需要修改调用方数据。
  3. 在热路径上注意不必要的小对象逃逸:用 go build -gcflags=-m 核查;但构造并返回对象指针(如 func NewX() *X)是 Go 的惯用法,此时堆分配是设计使然,不必规避。
  4. 切片使用 make 初始化:除非明确需要 nil 切片,否则使用 make 确保切片可用。
  5. 不要依赖指针地址的稳定性:地址和相对偏移每次运行都可能不同,不要依赖。

六、练习题

  1. 概念题:解释 new([]int) 和 make([]int, 0) 在内存分配上的区别,以及它们返回值的类型差异。
  2. 编码题:编写一个函数 swap(a, b *int),交换两个整数的值。并在 main 中测试。
  3. 进阶/调试题:以下代码能正确修改切片元素吗?请说明切片作为参数传递时的内存语义,并判断这里使用 *[]int 是否必要。 go func modify(s *[]int) { (*s)[0] = 99 } func main() { s := []int{1, 2, 3} modify(&s) fmt.Println(s) }

七、练习参考答案

参考答案(点击展开 / 收起)

第 1 题

new([]int) 返回 *[]int,它在堆或栈上分配了一块内存用于存放 []int 的零值(即 nil 切片头),并返回指向该内存的指针。它不分配底层数组。 make([]int, 0) 返回 []int,它分配了切片头(指针、长度、容量)并初始化,对于长度为 0 的切片,通常不分配底层数组(共享 zerobase)。 区别在于:new 返回指针,需解引用;make 返回值本身,可直接使用。

第 2 题

package main

import "fmt"

func swap(a, b *int) {
    *a, *b = *b, *a
}

func main() {
    x, y := 10, 20
    swap(&x, &y)
    fmt.Printf("x: %d, y: %d\n", x, y)
}

说明:通过传入指针,函数内部解引用并交换值,从而修改原变量。

第 3 题

该代码在现行 Go 中完全合法(*[]int 是普通指针类型)。 分析: - 切片是值类型,但底层数组共享。 - modify 接收 *[]int,通过 (*s)[0] 访问底层数组元素并修改。 - 使用 *[]int 是必要的吗?如果只修改元素,传 []int 即可(因为底层数组共享)。但如果需要修改切片头(如 *s = append(*s, ...) 改变长度/容量),则必须传指针。 - 本题中仅修改元素,传 []int 更惯用,但传 *[]int 也完全正确且合法。

八、本课小结

  • 指针通过 & 取址,* 解引用,零值为 nil。
  • new 分配零值并返回指针,make 初始化引用类型并返回实例。
  • 切片是值类型,但底层数组共享,修改元素影响原切片。
  • 逃逸分析决定变量在栈还是堆,-gcflags=-m 是重要调试工具。
  • 下一课预告:结构体定义与字段访问。

问题反馈

本站是纯前端静态站,没有后端也没有账号系统,反馈走 GitHub Issues、讨论区或邮件。

粘贴到 issue 或邮件里能帮我更快定位问题,其中不含你输入的任何内容

其它:查看已有反馈 · 邮件反馈(无需 GitHub 账号):278975598@qq.com