Interview Lab / Go / new 与 make

Go new 与 make:别只背“一个返回指针,一个负责初始化”

new 和 make 都是 Go 的内置函数,也就是不用导入包就能直接调用的语言工具。经典写法 new(T) 为类型 T 准备一个零值变量(没有显式赋值时采用的默认值),并返回指针 *T(保存该变量地址的值);从 Go 1.26 起,new(x) 也能把表达式 x 的值作为初始值。make(T, ...) 只创建 Slice(切片,可变长度的数据序列)、Map(映射,按键查值的数据结构)和 Channel(通道,让并发任务交换数据的工具),并直接返回可使用的 T。

这篇不从定义硬背,而是先看“只拿到一块空内存为什么还不够”,再推导两者的返回值、三类特殊数据的初始化、内存位置,以及面试中最容易答错的边界。

new:得到指针make:得到 TGo 1.26:new(x)Slice / Map / Channel逃逸分析
版本边界:Go 1.25 及更早版本只支持 new(T),参数必须是类型;Go 1.26 新增 new(x),允许用表达式的值直接初始化新变量。本文会把两种语法明确分开。“最后放在栈还是堆”由编译器的 escape analysis(逃逸分析,用来判断变量能否安全留在当前函数内存中)决定,不属于 new 或 make 的固定承诺。
一、问题从哪里来:一块“全是零”的内存不一定能直接工作

假设要创建一个计数器。只要有一个整数变量,并把初始值设为 0,它就能工作:

count := new(int)
fmt.Println(*count) // 0
*count = 1

但 Map 不只是“若干个零值字段”。它还需要运行时建立可写的数据结构;Channel 也需要通信队列和同步状态。只给它们一个 nil(空引用,表示还没有连接到实际对象)的零值,读取或比较也许可以,完整功能却没有准备好。

只需要一个零值变量

整数、布尔值、结构体、数组等类型,拿到零值后就能正常赋值。此时 new 可以直接满足需求。

还需要特定内部状态

当 Slice 要指定长度或容量、Map 要立即可写、Channel 要参与通信时,需要按各自规则建立底层状态。make 负责这类初始化。

先建立直觉:经典的 new(T) 像“给你一个清零的新房间和门牌地址”;Go 1.26+ 的 new(x) 还能先把指定物品 x 放进房间;make 则像“把特定设施装好后,直接把可用设施交给你”。类比只帮助入门,下面会落回准确的类型和代码行为。
二、new 做什么:创建变量并返回指针

T 代表某个具体类型,例如 int 或 User。*T 叫作“指向 T 的指针”,它保存的不是 T 的内容本身,而是这个变量所在的地址。

new(T) → 准备一个值为零值的 T → 返回它的地址,类型是 *T

零值是一个类型在没有显式赋值时的默认值:整数是 0,布尔值是 false,字符串是空字符串,结构体的每个字段也各自取零值。

type User struct {
    Name   string
    Active bool
}

p := new(User)
fmt.Printf("%T %#v\n", p, *p)
// *main.User main.User{Name:"", Active:false}

new(User) 的结果是 *User,所以变量 p 保存地址。*p 是解引用,也就是沿着地址取出真正的 User 值。

Go 1.26+:new 也能接收表达式

表达式是能够计算出一个值的代码,例如 18、user.Name 或函数调用 yearsSince(born)。从 Go 1.26 起,new(x) 会先计算 x,再创建同类型变量,把 x 的值放进去,最后返回指针。

zero := new(int) // *int,指向 0;所有 Go 版本都支持
age  := new(18)  // *int,指向 18;需要 Go 1.26+
name := new("Ada") // *string,指向 "Ada";需要 Go 1.26+
Go 1.26+:new(x) → 计算表达式 x → 创建同类型变量并写入 x → 返回该变量的指针

无类型常量会先转成默认类型,例如 new(18) 得到 *int。new(nil) 不合法,因为单独的 nil 没有可确定的具体类型。

p := new(User) 的两步结果 变量 p类型:*User保存内容:右侧变量的地址 指向 一个新的 User 变量 Name"" Activefalse 左边拿到地址,右边保存真正的数据;右边所有字段先采用各自的零值。
最应记住:new(User) 返回的是地址 *User,不是 User 值;被指向的 User 已经完成零值初始化。

new 不是构造函数

构造函数通常会检查参数、设置业务默认值或维护约束。new(T) 只采用 T 的零值,new(x) 只采用表达式 x 算出的值;它们都不会自动调用名为 NewUser 的函数,也不会理解“新用户默认应该启用”这样的业务规则。

func NewUser(name string) *User {
    return &User{Name: name, Active: true}
}
避免误解:new(User) 是语言内置的零值分配;NewUser(...) 只是项目自己命名的普通函数,两者没有自动联系。
三、make 做什么:只初始化 Slice、Map、Channel,返回 T

make 的第一个参数始终是类型,而且只允许前文介绍的 Slice、Map 和 Channel。Channel 让 goroutine(Go 运行时调度的轻量并发任务)之间传递数据。make 返回的类型与传入类型相同,不额外加星号。

代码返回类型make 额外准备了什么
make([]int, 3, 5)[]int长度 3、容量 5 的 Slice,并准备可存元素的底层数组
make(map[string]int, 8)map[string]int可写 Map;8 是预计元素数量的提示,不是固定上限
make(chan int, 2)chan int可通信 Channel;2 是缓冲区可暂存的元素数量
scores := make(map[string]int)
scores["Go"] = 100 // 可以直接写入

jobs := make(chan string, 1)
jobs <- "build"       // 缓冲区已准备好

items := make([]int, 0, 8)
items = append(items, 10)
一句话:make 不返回“内部对象的指针”,而是返回 Go 程序平时直接使用的 Slice、Map 或 Channel 值;运行时需要的内部状态已经建立。
四、核心对比:限制、返回值和使用目的完全不同
比较维度new(T) / Go 1.26+ 的 new(x)make(T, ...)
参数允许什么类型 T;Go 1.26+ 也允许表达式 x类型只能是 Slice、Map、Channel,后面可跟类型专属整数参数
返回类型new(T) 返回 *T;new(x) 返回指向 x 类型的指针T,与传入类型相同
得到的初始状态new(T) 使用零值;new(x) 使用 x 的值按参数初始化后的可用值
主要解决的问题需要一个地址,指向新建并已初始化的变量三种特殊类型需要准备内部运行状态
是否保证在堆上不保证,由逃逸分析决定不保证,由具体对象与逃逸分析决定
经典对照:同样接收“类型”,走向两种不同结果 输入一个类型 T new(T)任何 T 都允许创建 T 的零值变量返回 *T make(T, ...)T 只能是 Slice、Map、Channel建立内部状态返回 T 判断时先问“我要地址,还是要可用的三类容器/通道”,不要先问谁更高级。
最应记住:无论参数是类型还是 Go 1.26+ 的表达式,new 都返回指针;make 只服务三种类型,并直接返回初始化后的类型值。
new:类型或 Go 1.26+ 的表达式 → 指针 | make:仅 Slice / Map / Channel 类型 → T
五、为什么 make 只支持 Slice、Map、Channel

这三种类型的变量都像一张“小说明书”或一个入口,完整工作还依赖运行时维护的其他数据。只把变量本身清零,会得到 nil;nil 值仍有各自有限的合法行为,但 make 能根据参数继续建立指定长度或容量、可写映射和可通信通道等底层状态。Slice 与 Map 也能通过字面量等方式创建,并不是只能使用 make。

make 为三种类型补齐“能工作”的内部状态 make(T, 参数) Slice可变长度的数据序列长度 / 容量信息以及所需底层数组可索引、可 append Map按键查值的数据结构可写的映射状态容量参数只是提示可以写入键值 Channelgoroutine 间传递数据发送接收等待状态以及可选缓冲区可以参与通信 蓝色表示 make 建立的主流程;绿色文字表示初始化后能够继续执行的操作。
最应记住:make 的价值不是“也申请内存”,而是根据三种类型各自的规则,建立可用的底层状态。

三个参数不要混成一种含义

类型参数含义最容易误解的点
Slice第二个参数是长度,第三个可选参数是容量make([]int, 5) 已经有 5 个零值元素,不是“空但预留 5 个”
Map第二个参数是预计初始元素数量不是容量上限,超过后仍能继续写入
Channel第二个参数是缓冲容量省略或写 0 都是无缓冲 Channel,不代表 nil

如果想进一步理解底层行为,可继续阅读 Slice 扩容专题、当前 Map 实现专题和 Channel 缓冲与关闭专题。

六、最小对照:new 出来的 nil 与 make 出来的可用值

下面只改变创建方式,观察同一种类型的结果。nil 不等于“对象内容为空”,它表示当前变量没有连接到实际对象。

Slice:nil Slice 仍然可以 append,但多了一层指针

new([]int)

p := new([]int)
fmt.Println(*p == nil) // true
*p = append(*p, 1)

返回 *[]int,指向一个 nil Slice。nil Slice 允许 append,所以并非完全不能用,只是通常没必要拿 Slice 指针。

make([]int, 0)

s := make([]int, 0)
fmt.Println(s == nil) // false
s = append(s, 1)

返回 []int,直接按 Slice 值使用。长度是 0;是否预分配底层数组取决于容量参数和实现优化。

Map:nil Map 可读,但不能写

这里的 panic 是运行时错误:当前正常执行流程会被中断,并开始执行延迟清理与错误传播。

p := new(map[string]int)
fmt.Println((*p)["Go"]) // 0:读取 nil Map 安全
(*p)["Go"] = 100       // panic:assignment to entry in nil map

要让它可写,必须再赋入一个 make 的结果:*p = make(map[string]int)。这也说明直接写 m := make(map[string]int) 更自然。

Channel:nil Channel 不会自动变成无缓冲 Channel

p := new(chan int)
*p <- 1 // 永久阻塞;若主程序因此无法继续,运行时可能报告死锁

ch := make(chan int)
go func() { ch <- 1 }()
fmt.Println(<-ch) // 1:有发送方和接收方配对
危险边界:向 nil Channel 发送或从它接收会一直阻塞;关闭 nil Channel 会 panic。make(chan T) 得到的是可配对通信的无缓冲 Channel,它与 nil Channel 不是一回事。
七、底层内存:new 不等于“强制上堆”,make 也不等于“只在堆上”

Heap(堆)是适合保存生命周期跨越当前函数的数据区域;Stack(栈)是函数调用期间常用、退出时容易回收的数据区域。初学资料常把 new 说成“在堆上分配”,这不是 Go 语言层面的保证。

编译器会做 escape analysis:如果一个变量的地址没有逃出当前函数,并且大小等条件允许,它可能留在栈上;如果地址需要在函数返回后继续使用,它通常需要逃逸到堆上。代码仍然只应依赖语言语义,不应依赖这次编译恰好选择的位置。

func local() int {
    p := new(int)
    *p = 7
    return *p // p 没有返回,编译器可能把对象放在栈上或直接优化掉
}

func escape() *int {
    p := new(int)
    *p = 7
    return p // 地址离开函数,通常会逃逸
}

可用 go build -gcflags=-m 查看当前编译器的逃逸判断;输出属于特定 Go 版本和具体代码的优化结果,不是语言永久规则。

面试纠错:new 的正式语义是“创建已初始化的变量并返回指针”:new(T) 用零值初始化,Go 1.26+ 的 new(x) 用 x 的值初始化;它不是“调用后必定放到堆上”。make 创建的底层数据也可能受到编译器优化和对象大小影响。
八、实际写代码怎么选:new、&T{}、字面量与 make

下面会用到复合字面量:它是在代码中直接写出结构体、数组、Slice 或 Map 初始内容的语法,例如 User{Name: "Ada"}。

需求更直接的写法原因
要一个基础类型的零值指针p := new(int)简短,并能直接得到 *int
要一个基础类型的指定值指针(Go 1.26+)p := new(18)直接得到指向 18 的 *int,适合可选字段
要一个结构体指针,并设置字段u := &User{Name: "Ada"}字段和值一眼可见,比先 new 再逐个赋值紧凑
要一个结构体零值指针new(User) 或 &User{}语义基本等价,按可读性和团队习惯选
要可写 Mapmake(map[K]V) 或 map[K]V{}两者都能得到非 nil、可写的 Map
要指定长度或容量的 Slicemake([]T, len, cap)能明确控制初始长度与容量
要 Channelmake(chan T, capacity)Channel 没有可替代 make 的复合字面量

new(T) 与 &T{} 的最小对照

a := new(User) // *User,字段全是零值
b := &User{}    // *User,字段全是零值
c := &User{Name: "Ada"} // *User,同时设置字段

&T{} 叫复合字面量取地址:先用字面量描述一个 T 值,再用 & 取得它的地址。它只适用于能写复合字面量的类型;new(int) 则能自然表达“需要一个 int 指针”。

选择顺序:先看目标类型是不是 Slice、Map、Channel,需要建立内部状态就用 make;否则只有在确实需要指针时才考虑 new 或取地址。普通局部值通常直接声明即可。
九、面试追问:怎么答才完整
Q1:new 和 make 的本质区别是什么?
new(T) 为任意类型 T 创建零值变量并返回 *T;Go 1.26+ 的 new(x) 还能用表达式值初始化变量,但仍返回指针。make 只用于 Slice、Map、Channel,按照类型规则初始化内部状态并返回 T。前者核心是“新变量的地址”,后者核心是“三种类型的可用值”。
Q2:new([]int) 和 make([]int, 0) 一样吗?
不一样。前者类型是 *[]int,指向一个 nil Slice;后者类型是 []int,是非 nil 的空 Slice。nil Slice 本身允许 append,所以差异不能粗暴说成“一个能用、一个不能用”,还包括返回类型、nil 状态和是否多一层解引用。
Q3:make(map[K]V, 100) 会限制最多放 100 个元素吗?
不会。100 是容量提示,运行时可据此减少早期扩容成本,但 Map 仍能继续增长。它也不代表已经存在 100 个键值对,初始长度仍是 0。
Q4:new 一定在堆上申请内存吗?
不一定。语言保证的是分配存储、零值初始化和返回指针;具体放栈、放堆或被优化掉,由编译器的逃逸分析和优化决定。
Q5:为什么没有 make(int)?
int 的零值 0 已经能直接使用,不需要建立额外运行状态。可以写 var n int 得到 0,或在需要指针时写 new(int);make 专门解决 Slice、Map、Channel 的初始化需求。
Q6:new(map[K]V) 为什么编译通过,却不推荐?
因为 new 接受任意类型,所以语法合法;但它返回指向 nil Map 的指针,仍不能直接写入。通常直接 make 得到可写 Map 更简单。只有当函数签名明确需要 *map[K]V 这种少见类型时,才有理由保留额外指针层。
Q7:new(18) 为什么有些环境编译不过?
因为表达式参数是 Go 1.26 才加入的语法。Go 1.25 及更早版本只把 new 的参数识别为类型,所以 new(18) 会报错;跨旧版本的代码仍可先声明变量再取地址。
十、代码坑点:真正容易写错的四个边界

1. 把 make 的 Slice 长度误当成预留容量

s := make([]int, 3)
s = append(s, 9)
fmt.Println(s) // [0 0 0 9],不是 [9]

若要“当前为空,但提前预留 3 个位置”,应写 make([]int, 0, 3)。

2. new Map 后直接写入

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

直接使用 m := make(map[string]int);若确实需要指针,则先执行 *p = make(map[string]int)。

3. 把 nil Channel 当成无缓冲 Channel

var ch chan int 的 ch 是 nil,收发都会一直阻塞;make(chan int) 才是已初始化的无缓冲 Channel,它可以在发送方与接收方同时就绪时完成数据交接。

4. 为了“性能”到处使用 new

指针不天然更快。它可能增加间接访问、别名关系和堆分配压力,也会让所有权更难读。先按语义选择值或指针,再用基准测试与编译器报告验证热点,不要把 new 当作性能开关。

十一、可运行示例:一次看清类型、nil 与写入行为

这段程序刻意避开会 panic 或永久阻塞的操作,只打印关键差异;复制到 main.go 后执行 go run main.go 即可验证。为了也能在 Go 1.25 及更早环境运行,示例主体使用 new(T);前文的 new(18) 需要 Go 1.26+。

main.go
package main

import "fmt"

type User struct {
    Name string
}

func main() {
    p := new(User)
    fmt.Printf("new(User): type=%T value=%#v\n", p, *p)

    slicePointer := new([]int)
    readySlice := make([]int, 0, 3)
    fmt.Printf("new slice: type=%T nil=%v\n", slicePointer, *slicePointer == nil)
    fmt.Printf("make slice: type=%T nil=%v len=%d cap=%d\n",
        readySlice, readySlice == nil, len(readySlice), cap(readySlice))

    mapPointer := new(map[string]int)
    readyMap := make(map[string]int)
    readyMap["Go"] = 100
    fmt.Printf("new map: type=%T nil=%v\n", mapPointer, *mapPointer == nil)
    fmt.Printf("make map: type=%T value=%v\n", readyMap, readyMap)

    readyChannel := make(chan int, 1)
    readyChannel <- 7
    fmt.Printf("make channel: type=%T value=%d\n", readyChannel, <-readyChannel)
}

预期输出中的地址格式可能不同,但类型关系稳定:new(User) 是 *main.User,new([]int) 是 *[]int,而三次 make 都直接返回原类型。

十二、30 秒总结话术
new 创建变量并返回指针;make 只初始化 Slice、Map、Channel 并返回 T。

面试时可以这样答:

“new(T) 适用于任意类型,它创建 T 的零值变量并返回 *T;Go 1.26 起也可写 new(x),用表达式值初始化变量,但结果仍是指针。make 只用于 Slice、Map 和 Channel,负责按各自规则建立可用的内部状态,返回值仍是 T。比如 new(map[string]int) 得到的是指向 nil Map 的指针,不能直接写;make(map[string]int) 得到的是可写 Map。另外,new 不代表一定在堆上,实际内存位置由编译器逃逸分析决定。”

十三、应用场景:什么时候用,什么时候不要硬用

表单或协议需要基础类型指针

什么时候用:需要区分“没有填写”和“填写了零值”时,可用 *int、*bool;new(int) 产生零值指针,Go 1.26+ 可用 new(18) 直接产生指定值指针。注意:业务语义要清晰,不要仅为少复制几个字节而改成指针。

批量收集结果

什么时候用:已知大致数量时用 make([]Item, 0, n),保持长度为 0 并预留容量。为什么适合:可减少 append 过程中扩容与复制。

建立查找表

什么时候用:需要持续写入键值时用 make(map[K]V, n)。注意:n 只是容量提示;若只是固定少量初始数据,Map 字面量通常更清楚。

组织并发任务

什么时候用:goroutine 之间需要交接任务或结果时用 make(chan T, n)。注意:缓冲大小会改变阻塞时机,应由并发协议决定,不能只为“更快”随意调大。

业务化表达:“我不会把 new 和 make 当成两种等价的内存申请方式。普通结构体需要地址时用 new 或取地址;集合和并发通道则根据长度、预计数据量与阻塞策略,用 make 明确初始化参数。”
十四、资料依据