1.Go 指针

背景与动机#

Go 里有指针,但 Go 并没有把指针设计成“底层内存自由操作工具”,而是把它收敛成一种更安全、更偏工程语义的能力:你可以通过指针共享对象、避免大对象拷贝、修改原值,但不能像 C 那样拿指针做任意算术运算。

学习这一节,重点不是记住 *& 两个符号,而是理解 4 个问题:

  1. 指针类型到底表示什么,零值是什么。
  2. & 和 分别发生在什么阶段,语义有什么不同。
  3. Go 的指针和 C 的指针最本质的差异是什么。
  4. newmake 都像“分配”,但为什么适用对象完全不同。

以下说明以 Go 1.22+ 语义为准,核心规则可对照 Go 语言规范Effective GoGo for C++ Programmers

核心原理拆解#

1. 指针类型是什么:它保存“某个变量的地址”#

Go 的指针类型写作:

*T

意思是:“指向一个 T 类型变量的指针”。

例如:

var p *int

这里 p 不是 int,而是“指向 int 的指针”。如果它还没指向任何变量,那么它的零值就是:

nil

这点很重要:Go 的未初始化指针不是野指针语义,而是明确的 nil

2. & 是取地址, 是解引用#

&x:取变量地址#

对于一个可取地址的值 x&x 会得到一个 *T 类型指针。

x := 10
p := &x

此时:

  • x 的类型是 int
  • p 的类型是 int
  • p 保存的是 x 的地址

Go 规范要求 & 的操作数通常必须是“可寻址的”对象,比如:

  • 变量
  • 可寻址结构体字段
  • 可寻址数组元素
  • 切片元素

此外,复合字面量是个特例,也可以直接取地址:

u := &User{Name: "Tom"}

p:通过指针访问或修改它指向的值#

x := 10
p := &x
fmt.Println(*p) // 10
*p = 20
fmt.Println(x) // 20

这里 *p 的含义不是“指针类型”,而是“取出指针指向的那个变量”。

也就是说:

  • 在类型位置,T 表示“指向 T 的指针类型”
  • 在表达式位置,p 表示“解引用指针 p”

3. Go 指针的核心价值:共享与修改,而不是手工走内存#

image.png
image.png

Go 里使用指针,通常是为了这几件事:

  • 让函数修改调用方对象
  • 避免大结构体频繁拷贝
  • 表达“可选值 / 可能不存在”
  • 让方法作用于同一个对象实例

例如:

type User struct {
Age int
}
func grow(u *User) {
u.Age++
}

这里传 *User 的意义,不是“更底层”,而是“修改原对象”。

4. Go 和 C 的关键区别:有指针,但没有指针算术#

这是最重要的边界。

在 C 里,指针常常可以:

  • p + 1
  • p - 1
  • 指针间求差
  • 直接按内存步进访问连续区域

而在 Go 里,这些都不允许。Go 官方文档对 C/C++ 程序员的说明很明确:Go has pointers but not pointer arithmetic。

也就是说,下面这种写法在 Go 里不成立:

p = p + 1

原因不是“语法还没支持”,而是语言设计上刻意不支持。Go 的取舍是:

  • 让切片承担绝大多数“连续数据遍历”需求
  • 让编译器和运行时更容易保证内存安全
  • 避免把业务代码写成地址跳跃和偏移计算

这和 C 的差异不只是一条语法限制#

它反映的是两种语言对指针定位完全不同:

  • C:指针是底层内存操作核心工具
  • Go:指针是受限引用机制,默认安全优先

顺带一提,Go 也不需要你手动 malloc/freenew/delete 配对管理生命周期,内存回收由垃圾回收器负责。这也是 Go 指针“能引用但不鼓励裸奔操作”的背景。

5. new:分配一个零值变量,并返回它的指针#

new 的规则很简单:

p := new(T)

含义是:

  • 分配一个类型为 T 的零值变量
  • 返回它的地址
  • 结果类型是 T

例如:

p := new(int)
fmt.Println(*p) // 0
*p = 42

这里的 new(int) 等价于“拿到一个指向零值 int 的指针”。

new 适合什么场景#

new 适合:

  • 你明确需要一个 T
  • 想获得某个零值对象的指针
  • 配合结构体指针使用

例如:

type Config struct {
Port int
}
c := new(Config)

这时 c 的类型是 *Config,且内部字段都为零值。

new 不是构造器#

new 不会执行“初始化逻辑”,只会给你一个零值对象指针。它不是 Java/C++ 意义上的构造函数。

6. make:初始化 slice、map、channel 这三种内建引用型结构#

makenew 最大区别不是“一个返回指针,一个不返回”,而是它们作用对象不同。

make 只用于:

  • slice
  • map
  • chan

例如:

s := make([]int, 3, 5)
m := make(map[string]int)
ch := make(chan int, 2)

make 返回的不是指针,而是“已经初始化好、可以直接使用的值”。

为什么这三种类型必须用 make#

因为它们不是“一个纯值盒子”那么简单,它们背后都关联运行时内部数据结构:

  • slice 需要底层数组与长度/容量信息
  • map 需要哈希表结构
  • channel 需要通信队列与同步结构

所以:

  • new([]int) 得到的是 []int,指向一个零值切片,里面还是 nil
  • make([]int, 3) 得到的是可直接用的切片值

这两者语义完全不同。

image.png
image.png

7. newmake 的使用边界#

new 常见理解方式#

new(T) 可以先粗暴记成:

  • “我要一个 T
  • “这个 T 先是零值”

make 常见理解方式#

make(T, ...) 可以记成:

  • “我要一个可直接用的 slice/map/channel 值”
  • “这类值的内部结构要先建好”

对 slice 的区别尤其容易混淆#

p := new([]int)
s := make([]int, 3)

这里:

  • p 的类型是 []int
  • p 的零值是 nil slice
  • s 的类型是 []int
  • s 已经可以直接按索引访问 0..2

这正是面试和实践里的高频考点。

8. 指针不是“万能优化”,切片/map/channel 也不是“都用指针包一下”#

很多初学者会误以为:

  • “用了指针就一定更高效”
  • “所有大对象都该到处传指针”
  • “slice/map/channel 再套一层指针会更底层”

这些都不准确。

需要注意:

  • slice、map、channel 本身已经带引用语义特征,很多场景没必要再传 []Tmap[K]V
  • 指针会引入共享可变状态,代码理解成本会增加
  • 传指针是为了语义正确或避免不必要拷贝,不是默认选项

最小可运行代码示例#

package main
import "fmt"
type User struct {
Name string
Age int
}
func grow(u *User) {
u.Age++
}
func main() {
x := 10
p := &x
fmt.Println(x, *p)
*p = 20
fmt.Println(x, *p)
n := new(int)
*n = 42
fmt.Println(*n)
s := make([]int, 3)
s[0] = 1
fmt.Println(s)
m := make(map[string]int)
m["go"] = 1
fmt.Println(m)
u := &User{Name: "Tom", Age: 18}
grow(u)
fmt.Println(u.Age)
}

这个例子同时覆盖了:

  • & 取地址
  • 解引用与修改原值
  • new 返回零值对象指针
  • make 初始化 slice、map
  • 指针参数修改原结构体

常见陷阱与错误示例#

1. 对 nil 指针直接解引用#

错误示例:

var p *int
fmt.Println(*p)

这会触发运行时 panic。Go 指针零值是 nil,不是可直接访问的有效对象。

2. 把 new(map[string]int) 当成“可直接写入的 map”#

错误示例:

p := new(map[string]int)
(*p)["x"] = 1

这里 *p 仍然是 nil map,直接写会 panic。真正要可写,应使用:

m := make(map[string]int)

3. 把 new([]int) 当成“长度已经初始化好的切片”#

错误示例:

p := new([]int)
fmt.Println(len(*p)) // 0
(*p)[0] = 1 // panic

new([]int) 只是得到一个指向零值切片的指针,不会创建底层数组。

4. 误以为 Go 指针可以像 C 一样做算术#

错误理解:

p = p + 1

Go 不支持这类操作。遍历连续数据应使用:

  • 数组索引
  • 切片索引
  • range

5. 给 slice/map/channel 再套一层指针当默认风格#

例如随手写:

func f(s *[]int)
func g(m *map[string]int)

多数场景并不需要。因为它们本身已经足够表达共享底层状态,除非你明确需要“修改切片头本身”或“重置调用方变量绑定”。

性能影响或设计取舍#

1. Go 保留指针,但禁止指针算术,是安全性和可维护性的取舍#

好处:

  • 减少越界和非法地址操作
  • 降低代码审查难度
  • 让切片成为更高层的顺序数据抽象

代价:

  • 不能像 C 那样直接写极底层偏移运算
  • 某些底层场景要借助 unsafe

2. new 非常轻语义,但不等于“更高级的初始化”#

它适合拿到零值对象指针,优点是简单直接;代价是如果你真正需要的是可用的 slice/map/channel,new 反而会把事情搞复杂。

3. make 体现的是“初始化内部结构”,不是“返回地址”#

这让 slice/map/channel 的使用更自然,但也要求你先理解它们不是普通纯值,否则容易把 newmake 混用。

4. 指针能减少大对象拷贝,但会引入共享可变状态#

优点:

  • 可能减少复制成本
  • 可以原地修改对象

代价:

  • 谁改了对象变得不那么直观
  • 并发场景更容易引入数据竞争
  • API 语义更重

面试高频问题#

1. Go 的 & 和 分别是什么#

&x 是取地址,生成 *T 类型指针;*p 是解引用,访问或修改指针 p 指向的那个值。* 在类型位置和表达式位置含义不同,但都和“指针”有关。

2. Go 的指针和 C 的指针最大区别是什么#

Go 有指针,但不支持指针算术,不能像 C 一样通过 p+1p-1 这类方式遍历内存。Go 更强调安全引用,连续数据遍历通常交给切片和索引完成。

3. newmake 的区别是什么#

new(T) 分配一个 T 的零值对象并返回 *Tmake 只用于 slice、map、channel,负责初始化其内部结构,返回的是可直接使用的值,不是指针。

4. 为什么 new(map[string]int) 不能直接写入#

因为 new(map[string]int) 返回的是 *map[string]int,而其中的 map 零值仍是 nil,没有初始化底层哈希表。真正可写的 map 要靠 make(map[string]int)

5. new([]int)make([]int, 10) 有什么本质区别#

前者得到的是 *[]int,指向一个零值切片;后者得到的是长度为 10、可直接使用的 []int。前者没有初始化底层数组,后者已经初始化好了。

6. 什么时候应该使用指针#

当你需要修改原对象、避免大对象复制、表达可选值,或方法需要绑定到同一实例状态时使用指针。不是所有类型、所有参数都默认该用指针。

一句话总结#

Go 的指针可以概括为:& 负责取地址,* 负责通过地址访问原值,指针主要用来共享和修改对象;它和 C 最大的区别是 Go 明确禁止指针算术,而 new 用来拿到某个零值对象的指针,make 则专门初始化 slice、map、channel 这三类带内部运行时结构的值。

文章目录

文章目录