Skip to content

Go 数组、切片与 map:从容器语法到内存语义

面向有 Java 经验的开发者,基于 Go 1.26。

数组、切片和 map 的语法都不复杂,麻烦在于它们看起来相似,传递和修改时却遵循完全不同的规则:

  • 数组保存元素本身,赋值和传参都会复制整个数组;
  • 切片保存的是一段连续数组的描述信息,多个切片可能共享同一块存储;
  • map 是运行时管理的哈希表,变量本身不是全部数据,复制变量后仍会访问同一张表。

只记住“切片是动态数组”“map 是字典”,通常也能写出代码,但 append、子切片、函数参数、并发读写和 JSON 序列化很快就会暴露这个模型的不足。这一章从基本用法一直讲到所有权与内存语义,既可以顺着阅读,也适合按目录查具体边界。

目录

1. 先建立整体认识

先用一张表把三种类型放在一起:

类型示例长度是否属于类型零值能否直接比较常见用途
数组[4]int所有元素为零值元素可比较时可以固定尺寸数据、值对象、哈希键
切片[]intnil只能和 nil 比较绝大多数顺序集合
mapmap[string]int不适用nil只能和 nil 比较键值索引、集合、计数器

三者都不能直接套用 Java 集合类的理解。

Go 数组更接近固定尺寸的值;切片像一份“数组视图”,但还带有长度和容量,并能通过 append 增长;map 则是语言内建类型,并非带有一组方法的 HashMap 对象。三者的大部分操作由语法和内建函数完成:

go
len(v)
cap(s)
append(s, values...)
copy(dst, src)
delete(m, key)
clear(v)
make(T, ...)

appendmakedelete 等名字都是预声明的内建函数。它们不属于某个包,也不是容器的方法。

2. 数组的声明与初始化

2.1 声明数组

数组类型写成 [长度]元素类型

go
var numbers [3]int
var names [2]string
var flags [4]bool

只声明而不赋值时,数组本身及其每个元素都会初始化为零值:

go
var numbers [3]int
fmt.Printf("%#v\n", numbers) // [3]int{0, 0, 0}

var names [2]string
fmt.Printf("%#v\n", names) // [2]string{"", ""}

Go 没有 Java 局部变量那种“必须先赋值才能读取”的未初始化状态。变量声明完成后,总有一个确定的值。

2.2 数组字面量

最完整的写法:

go
numbers := [3]int{10, 20, 30}

类型已能从右侧推断时也可以用 var

go
var numbers = [3]int{10, 20, 30}

如果不想重复计算长度,可以让编译器数元素:

go
numbers := [...]int{10, 20, 30}

fmt.Printf("%T\n", numbers) // [3]int

[...] 只用于数组字面量,表示“长度由字面量推导”;它不是一种可以到处使用的“任意长度数组类型”。

下面不能编译:

go
// var numbers [...]int
// func handle(values [...]int) {}

2.3 部分初始化

没有显式给出的元素仍是零值:

go
numbers := [5]int{10, 20}
fmt.Println(numbers) // [10 20 0 0 0]

也可以按索引初始化:

go
numbers := [6]int{
    1: 10,
    4: 40,
}

fmt.Println(numbers) // [0 10 0 0 40 0]

索引可以和顺序元素混用,但可读性通常不如统一使用一种形式:

go
numbers := [...]int{
    2: 20,
    30, // 上一个索引是 2,所以这里位于索引 3
    5: 50,
}

fmt.Println(numbers) // [0 0 20 30 0 50]

按索引写字面量适合稀疏的固定表,也适合强调某个协议位置:

go
const (
    fieldID = iota
    fieldName
    fieldEmail
    fieldCount
)

fields := [fieldCount]string{
    fieldID:    "id",
    fieldName:  "name",
    fieldEmail: "email",
}

2.4 元素可以是任意类型

“数组中的元素必须是同一种类型”是对的,但这个类型不必是基本类型:

go
type User struct {
    ID   int64
    Name string
}

users := [2]User{
    {ID: 1, Name: "Alice"},
    {ID: 2, Name: "Bob"},
}

还可以是指针、函数、接口、切片或另一个数组:

go
var handlers [3]func() error
var buffers [4][]byte
var matrix [3][3]float64

这里的“同一种类型”指静态类型一致,并不要求所有值的内容相同。

3. 数组的类型、比较与复制

3.1 长度是数组类型的一部分

[3]int[4]int 是两个不同的类型:

go
var a [3]int
var b [4]int

// a = b // 编译错误:类型不同

这也是函数参数写成 [3]int 后,调用方只能传长度恰好为 3 的数组的原因:

go
func sum3(values [3]int) int {
    return values[0] + values[1] + values[2]
}

长度必须是编译期可确定、非负且能用 int 表示的常量:

go
const size = 4
var ok [size]int

n := 4
// var bad [n]int // 编译错误:n 不是常量

Go 不支持运行期长度的数组类型。长度运行时才知道时,用切片。

3.2 数组是值

数组赋值会复制全部元素:

go
a := [3]int{1, 2, 3}
b := a

b[0] = 100

fmt.Println(a) // [1 2 3]
fmt.Println(b) // [100 2 3]

传给函数时也是复制:

go
func change(a [3]int) {
    a[0] = 100
}

func main() {
    a := [3]int{1, 2, 3}
    change(a)
    fmt.Println(a) // [1 2 3]
}

需要修改原数组时可以传数组指针:

go
func change(a *[3]int) {
    a[0] = 100 // 等价于 (*a)[0],Go 自动解引用
}

func main() {
    a := [3]int{1, 2, 3}
    change(&a)
    fmt.Println(a) // [100 2 3]
}

不过业务 API 通常更适合接收切片:

go
func change(values []int) {
    values[0] = 100
}

a := [3]int{1, 2, 3}
change(a[:])

数组指针常见于“尺寸就是协议的一部分”的场景,例如固定 16 字节标识、32 字节摘要或固定尺寸矩阵。

3.3 数组可以比较

只要元素类型可比较,同类型数组就能使用 ==!=

go
a := [3]int{1, 2, 3}
b := [3]int{1, 2, 3}
c := [3]int{1, 2, 4}

fmt.Println(a == b) // true
fmt.Println(a == c) // false

数组会逐元素比较。元素不可比较时,数组也不可比较:

go
var a [2][]int
var b [2][]int

// fmt.Println(a == b) // 编译错误:[]int 不可比较

可比较数组还能作为 map 的键:

go
type IPv4 [4]byte

requests := map[IPv4]int{
    {127, 0, 0, 1}: 10,
}

这类用途是切片做不到的,因为切片不可比较。

3.4 数组与切片的显式转换

数组变成切片通常使用切片表达式,二者共享存储:

go
array := [3]int{1, 2, 3}
slice := array[:]

slice[0] = 100
fmt.Println(array) // [100 2 3]

从 Go 1.20 起,也可以把切片显式转换为数组值:

go
slice := []int{1, 2, 3, 4}
array := [3]int(slice)

array[0] = 100

fmt.Println(slice) // [1 2 3 4]
fmt.Println(array) // [100 2 3]

转换成数组值会复制前 3 个元素。切片长度小于目标数组长度时会在运行时 panic

go
slice := []int{1, 2}
// array := [3]int(slice) // panic

转换成数组指针则共享底层数组:

go
slice := []int{1, 2, 3, 4}
array := (*[3]int)(slice)

array[0] = 100
fmt.Println(slice) // [100 2 3 4]

同样要求切片长度至少达到目标数组长度。

这种转换适合调用固定尺寸协议、加密或系统接口。普通业务集合没必要为了“更严格”频繁来回转换;先验证长度,再明确需要复制还是共享。

4. 索引与遍历

4.1 索引从 0 开始

数组和切片都使用从 0 开始的整数索引:

go
values := []string{"a", "b", "c", "d"}

fmt.Println(values[0]) // a
fmt.Println(values[3]) // d

有效索引范围是:

text
0 <= i && i < len(values)

Go 不支持负索引。倒数第 n 个元素要换算:

go
last := values[len(values)-1]       // d
secondLast := values[len(values)-2] // c

原教程把 len(values)-2 的结果写成了 "a",那是笔误。长度为 4 时,4-2 等于 2,索引 2 对应 "c"

如果集合可能为空,读取最后一个元素前必须判断:

go
if len(values) > 0 {
    fmt.Println(values[len(values)-1])
}

越界不会返回 null 或零值,而是在运行时 panic

go
values := []int{10, 20}
fmt.Println(values[2]) // panic: index out of range

4.2 普通 for

需要精确控制索引时:

go
for i := 0; i < len(values); i++ {
    fmt.Println(i, values[i])
}

逆序遍历:

go
for i := len(values) - 1; i >= 0; i-- {
    fmt.Println(values[i])
}

这里索引用 int 很重要。如果写成无符号整数,减到 0 后还会下溢。

4.3 range

同时取得索引和值:

go
for i, value := range values {
    fmt.Println(i, value)
}

只要索引:

go
for i := range values {
    fmt.Println(i)
}

只要值:

go
for _, value := range values {
    fmt.Println(value)
}

4.4 range 中的 value 是副本

这是最常见的坑之一:

go
type User struct {
    Name string
}

users := []User{{Name: "Alice"}, {Name: "Bob"}}

for _, user := range users {
    user.Name = "changed"
}

fmt.Println(users) // [{Alice} {Bob}]

user 是本次元素值的副本,修改它不会修改切片中的结构体。按索引改:

go
for i := range users {
    users[i].Name = "changed"
}

如果元素本身是指针,复制的是指针值,指向的对象仍然相同:

go
users := []*User{{Name: "Alice"}, {Name: "Bob"}}

for _, user := range users {
    user.Name = "changed"
}

不要把“range 值是副本”简化成“range 里永远改不了原值”。要看复制后的值里面是否还引用同一份数据。

4.5 range 数组时还可能复制整个数组

数组是值。下面 range 的表达式会先得到数组值:

go
a := [3]int{1, 2, 3}

for i, value := range a {
    a[1] = 200
    fmt.Println(i, value)
}

循环读取的是开始迭代时得到的数组副本,修改 a 不会改变后续迭代值。大数组又可能产生不必要的复制。

只需要索引时,规范允许不对数组或切片做元素索引:

go
for i := range a {
    a[i] *= 2
}

也可以遍历数组指针:

go
for i, value := range &a {
    fmt.Println(i, value)
}

工程代码里如果数组很大,先确认是否真的需要数组,而不是切片。

5. 多维数组

Go 的多维数组是数组嵌套:

go
matrix := [2][3]int{
    {1, 2, 3},
    {4, 5, 6},
}

fmt.Println(matrix[1][2]) // 6

[2][3]int 的意思是“长度为 2 的数组,每个元素都是 [3]int”。它不是一个特殊的二维对象。

遍历:

go
for row := range matrix {
    for col := range matrix[row] {
        fmt.Printf("%d ", matrix[row][col])
    }
    fmt.Println()
}

因为内层数组长度也属于类型,下面两个类型不同:

go
var a [2][3]int
var b [3][2]int

// a = b // 编译错误

如果每行长度不同,使用切片的切片:

go
rows := [][]int{
    {1, 2},
    {3, 4, 5},
    {6},
}

注意,[][]int 的每个内层切片彼此独立,不保证所有数据处于一块连续内存。数值计算或协议布局如果要求连续存储,可以用一个一维切片手工换算:

go
width := 3
data := make([]float64, 2*width)

at := func(row, col int) int {
    return row*width + col
}

data[at(1, 2)] = 3.14

6. 切片到底是什么

切片不是“没有长度的数组”,也不是数组本身。它是对底层数组某个连续区间的描述。

从概念上看,一个切片值包含三项信息:

text
指向底层数组中某个元素的位置
长度 len
容量 cap

业务代码不应依赖运行时实现细节,但下面的简化模型很适合解释切片行为:

go
type conceptualSliceHeader struct {
    Data *T
    Len  int
    Cap  int
}

例如:

go
array := [5]int{10, 20, 30, 40, 50}
slice := array[1:4]

此时:

text
slice 的元素: [20 30 40]
len(slice):   3
cap(slice):   4

容量是 4,因为它从原数组索引 1 开始,底层数组到末尾还有 4 个位置。

切片索引仍从 0 开始:

go
fmt.Println(slice[0]) // 20,对应 array[1]

修改切片会修改共享的底层数组:

go
slice[1] = 300

fmt.Println(array) // [10 20 300 40 50]

把切片赋值给另一个变量,只复制这份描述信息,不复制元素:

go
a := []int{1, 2, 3}
b := a

b[0] = 100
fmt.Println(a) // [100 2 3]

这也是切片传给函数后,函数通常能改动已有元素的根本原因。

7. 创建切片:nil、字面量与 make

7.1 nil 切片

只声明不初始化:

go
var values []int

fmt.Println(values == nil) // true
fmt.Println(len(values))   // 0
fmt.Println(cap(values))   // 0

nil 切片是可用的零值。可以安全地:

  • 读取 lencap
  • 使用 range,循环零次;
  • 执行 append
  • 执行 copy
  • 再切成 values[:0]
go
var values []int
values = append(values, 10)
fmt.Println(values) // [10]

不能读取 values[0],原因是长度为 0,不是因为它“未初始化”。

7.2 空但非 nil 的切片

字面量:

go
values := []int{}

make

go
values := make([]int, 0)

它们的长度和容量通常都是 0,但不等于 nil

go
fmt.Println(values == nil) // false

在大多数集合操作里,nil 和空切片行为相同;在 JSON、反射、某些协议和测试断言里,它们可能不同,后面会单独讲。

7.3 切片字面量

go
names := []string{"Alice", "Bob"}

注意与数组的区别:

go
[3]int{1, 2, 3} // 数组
[...]int{1, 2, 3} // 数组
[]int{1, 2, 3} // 切片

也可以按索引初始化:

go
values := []int{
    2: 20,
    5: 50,
}

fmt.Println(values) // [0 0 20 0 0 50]

7.4 make 的长度与容量

完整形式:

go
make([]T, length, capacity)

容量省略时等于长度:

go
a := make([]int, 3)
fmt.Println(a, len(a), cap(a)) // [0 0 0] 3 3

显式指定容量:

go
b := make([]int, 0, 8)
fmt.Println(b, len(b), cap(b)) // [] 0 8

必须满足:

text
0 <= length <= capacity

lengthcapacity 分别回答两个问题:

  • 长度内的元素已经存在,可以用索引读写;
  • 容量只是底层数组预留空间,容量大于长度的部分还不能直接索引;
  • append 才会把新元素纳入长度。

错误示例:

go
values := make([]int, 0, 8)
// values[0] = 10 // panic:长度仍然是 0

正确做法:

go
values = append(values, 10)

如果最终会填满固定数量,也可以先创建长度,再按索引赋值:

go
values := make([]int, 3)
for i := range values {
    values[i] = i * 10
}

二者选择取决于代码表达的含义:

go
make([]T, n)    // 现在就有 n 个零值元素,随后覆盖
make([]T, 0, n) // 现在没有元素,预计最多追加 n 个

8. 切片表达式与容量边界

8.1 左闭右开

基本形式:

go
s[low:high]

包含 low,不包含 high

go
values := []string{"a", "b", "c", "d"}

fmt.Println(values[1:3]) // [b c]

结果长度是:

text
high - low

使用左闭右开区间有几个好处:长度直接相减、相邻区间不重叠、空区间可以写成 s[i:i]

8.2 省略边界

go
values[2:]  // values[2:len(values)]
values[:3]  // values[0:3]
values[:]   // values[0:len(values)]

values[:] 生成一个共享底层数组的新切片值,并不会复制元素。

8.3 对切片再次切片

go
base := []int{10, 20, 30, 40, 50}
a := base[1:4] // [20 30 40]
b := a[1:2]    // [30]

b[0] = 300

fmt.Println(base) // [10 20 300 40 50]
fmt.Println(a)    // [20 300 40]

baseab 都指向同一个底层数组的不同区间。

8.4 切片可以扩到 cap,而不只是 len

对于切片,重新切片的上界可以到容量:

go
values := make([]int, 2, 5)
values[0], values[1] = 10, 20

extended := values[:5]
fmt.Println(extended) // [10 20 0 0 0]

但直接读取 values[2] 仍然越界,因为 len(values) 是 2。

8.5 三索引切片

完整切片表达式:

go
s[low:high:max]

结果:

text
len = high - low
cap = max - low

例如:

go
base := []int{10, 20, 30, 40, 50}
part := base[1:3:3]

fmt.Println(part)      // [20 30]
fmt.Println(len(part)) // 2
fmt.Println(cap(part)) // 2

三索引形式最常见的用途,是限制子切片的容量,防止它的 append 覆盖父切片后面的元素:

go
base := []int{10, 20, 30, 40}

a := base[:2]   // len=2, cap=4
b := base[:2:2] // len=2, cap=2

a = append(a, 99) // 复用 base,覆盖 base[2]
fmt.Println(base)  // [10 20 99 40]

b = append(b, 88) // 容量不足,分配新数组
fmt.Println(base)  // [10 20 99 40]
fmt.Println(b)     // [10 20 88]

8.6 边界条件

简单切片对数组、字符串和切片都适用。对于切片,运行时边界需要满足:

text
0 <= low <= high <= cap(s)

三索引切片:

text
0 <= low <= high <= max <= cap(s)

越界会 panic。处理外部传入的分页、区间参数时,应先验证:

go
func sub[T any](values []T, start, end int) ([]T, error) {
    if start < 0 || end < start || end > len(values) {
        return nil, fmt.Errorf(
            "invalid range [%d:%d] for length %d",
            start,
            end,
            len(values),
        )
    }
    return values[start:end], nil
}

不要依靠 recover 把普通参数校验变成错误处理。

9. append、扩容与共享底层数组

9.1 append 返回新的切片值

追加一个元素:

go
values := []int{1, 2}
values = append(values, 3)

追加多个:

go
values = append(values, 4, 5, 6)

追加另一个切片要展开:

go
more := []int{7, 8}
values = append(values, more...)

append 必须接住返回值:

go
values = append(values, 3) // 正确

以下代码甚至不能编译,因为 Go 不允许无意义地丢弃 append 的结果:

go
// append(values, 3)

原因不是形式主义。追加后长度一定改变,容量不足时底层数组和起始地址也会改变,所以调用者必须取得新的切片描述。

9.2 容量足够时复用底层数组

go
base := make([]int, 2, 4)
base[0], base[1] = 10, 20

grown := append(base, 30)
grown[0] = 100

fmt.Println(base)  // [100 20]
fmt.Println(grown) // [100 20 30]

base 的长度仍是 2,所以打印时看不到第三个元素;但它和 grown 仍共享底层数组。

9.3 容量不足时分配新数组

go
base := []int{10, 20} // 通常 len=2, cap=2
grown := append(base, 30)

grown[0] = 100

fmt.Println(base)  // [10 20]
fmt.Println(grown) // [100 20 30]

这次 append 需要更大空间,运行时分配新数组并复制已有元素。之后两个切片不再共享存储。

9.4 不要依赖具体扩容倍率

运行时会选择新的容量,但具体增长策略是实现细节,不能把“每次翻倍”当语言保证:

go
values := make([]int, 0)
lastCap := cap(values)

for i := 0; i < 10_000; i++ {
    values = append(values, i)
    if cap(values) != lastCap {
        fmt.Println(len(values), cap(values))
        lastCap = cap(values)
    }
}

观察当前实现可以帮助分析性能,却不能作为正确性依据。语言层面可以依赖的只有:

  • 新长度足以容纳追加的元素;
  • 旧元素被保留;
  • 可能复用旧数组,也可能换新数组。

9.5 append 造成的“时好时坏”

下面的函数表面上只是追加,实际上是否影响调用方的底层数组取决于容量:

go
func addMarker(values []int) []int {
    return append(values, 999)
}

如果调用者仍然持有原切片,此时就会出现一组很容易误判的共享关系:

go
base := make([]int, 2, 4)
base[0], base[1] = 1, 2

result := addMarker(base)
result[0] = 100

fmt.Println(base) // [100 2]

设计 API 时必须说清楚:函数是否可以复用并修改输入存储。返回值如果要求完全独立,就先做克隆。

9.6 append 自己是合法的

可以把切片的一部分追加到自身:

go
values := []int{1, 2, 3}
values = append(values, values...)

fmt.Println(values) // [1 2 3 1 2 3]

append 已经保证结果正确,调用方不需要手工处理中间覆盖。

10. copy、克隆与深浅复制

10.1 copy 复制多少个元素

go
n := copy(dst, src)

复制数量是:

text
min(len(dst), len(src))

示例:

go
src := []int{10, 20, 30}
dst := make([]int, 2)

n := copy(dst, src)

fmt.Println(n)   // 2
fmt.Println(dst) // [10 20]

目标切片必须先有足够的长度,不是只有容量:

go
src := []int{10, 20, 30}
dst := make([]int, 0, 3)

n := copy(dst, src)
fmt.Println(n, dst) // 0 []

正确做法:

go
dst := make([]int, len(src))
copy(dst, src)

10.2 copy 支持重叠区域

copy 能正确处理源和目标共享底层数组的情况:

go
values := []int{1, 2, 3, 4, 5}
copy(values[1:], values[:4])

fmt.Println(values) // [1 1 2 3 4]

这类似于能处理重叠内存的 memmove,因此适合切片内移动。

10.3 常用克隆方式

使用标准库:

go
cloned := slices.Clone(values)

使用 copy

go
cloned := make([]int, len(values))
copy(cloned, values)

使用 append

go
cloned := append([]int(nil), values...)

新代码优先考虑 slices.Clone,意图最清楚,而且会保留 nil

go
var values []int
cloned := slices.Clone(values)

fmt.Println(cloned == nil) // true

10.4 元素复制仍可能是浅复制

克隆切片只复制每个元素的值。如果元素里有指针、切片、map 或其他引用共享数据的字段,内部对象没有递归复制:

go
type User struct {
    Name string
    Tags []string
}

original := []User{
    {Name: "Alice", Tags: []string{"go"}},
}

cloned := slices.Clone(original)
cloned[0].Name = "Bob"
cloned[0].Tags[0] = "java"

fmt.Println(original[0].Name)    // Alice
fmt.Println(original[0].Tags[0]) // java

Name 是字符串值,替换克隆元素的字段不会影响原结构体;Tags 是切片,两个字段仍指向同一个底层数组。

真正的深复制要按数据结构定义:

go
func cloneUsers(src []User) []User {
    dst := make([]User, len(src))
    for i, user := range src {
        dst[i] = user
        dst[i].Tags = slices.Clone(user.Tags)
    }
    return dst
}

“深复制”不是一个通用语法动作,因为遇到循环引用、只读共享资源、锁、文件句柄时,是否复制以及怎样复制都是业务语义。

11. 切片的增删改查

11.1 读取与修改

go
values := []int{10, 20, 30}

value := values[1]
values[1] = 200

fmt.Println(value)  // 20
fmt.Println(values) // [10 200 30]

需要先判断索引是否合法:

go
func at[T any](values []T, index int) (T, bool) {
    if index < 0 || index >= len(values) {
        var zero T
        return zero, false
    }
    return values[index], true
}

11.2 尾部添加和删除

尾部添加:

go
values = append(values, value)

读取并删除最后一个:

go
if len(values) == 0 {
    return errors.New("empty stack")
}

last := values[len(values)-1]
values[len(values)-1] = 0 // 元素含引用时有助于及时释放
values = values[:len(values)-1]

对于 []int,清零通常不是内存管理所必需;对于 []*LargeObject[]string[][]byte 等含指针数据的元素,清零可以避免底层数组继续持有已删除对象。

泛型代码可以使用零值:

go
var zero T
values[len(values)-1] = zero
values = values[:len(values)-1]

11.3 保持顺序地删除

传统写法:

go
values = append(values[:i], values[i+1:]...)

现在更推荐:

go
values = slices.Delete(values, i, i+1)

删除区间 [i:j)

go
values = slices.Delete(values, i, j)

slices.Delete 返回修改后的切片,必须接住返回值。它会移动后续元素,时间复杂度为 O(len(values)-i)

11.4 不保持顺序地删除

如果顺序不重要,用最后一个元素覆盖待删元素,通常是 O(1)

go
last := len(values) - 1
values[i] = values[last]
values[last] = 0
values = values[:last]

泛型版本:

go
func removeUnordered[S ~[]E, E any](values S, i int) S {
    if i < 0 || i >= len(values) {
        panic("index out of range")
    }

    last := len(values) - 1
    values[i] = values[last]

    var zero E
    values[last] = zero

    return values[:last]
}

选择删除算法时,先看是否需要保持顺序,不要盲目追求 O(1)

11.5 插入

在索引 i 前插入:

go
values = slices.Insert(values, i, 99)

插入多个:

go
values = slices.Insert(values, i, 90, 91, 92)

传统写法需要小心扩容和重叠:

go
values = append(values, 0)
copy(values[i+1:], values[i:])
values[i] = 99

标准库版本更清楚。

11.6 替换区间

go
values = slices.Replace(values, i, j, newValues...)

它把 [i:j) 替换成新元素,替换前后长度可以不同。

11.7 过滤

分配一个新切片,所有权最清楚:

go
result := make([]int, 0, len(values))
for _, value := range values {
    if value%2 == 0 {
        result = append(result, value)
    }
}

原地过滤,复用输入数组:

go
result := values[:0]
for _, value := range values {
    if value%2 == 0 {
        result = append(result, value)
    }
}

clear(values[len(result):])
values = result

原地版本分配更少,但会覆盖原切片内容。除非 API 契约允许修改输入,否则不要暗中采用。

11.8 去重

不要求排序、保留第一次出现顺序:

go
func unique[S ~[]E, E comparable](values S) S {
    seen := make(map[E]struct{}, len(values))
    result := make(S, 0, len(values))

    for _, value := range values {
        if _, exists := seen[value]; exists {
            continue
        }
        seen[value] = struct{}{}
        result = append(result, value)
    }

    return result
}

已经排序时,可原地压缩相邻重复项:

go
slices.Sort(values)
values = slices.Compact(values)

Compact 只移除相邻重复项,不会替你排序。

11.9 清空

保留容量、长度设为 0:

go
values = values[:0]

丢弃对底层数组的引用:

go
values = nil

把当前长度范围内的元素设为零值,但长度不变:

go
clear(values)

三种“清空”含义不同:

写法len是否保留底层数组元素是否清零
values = values[:0]0不保证
values = nil0不适用
clear(values)不变

如果打算复用缓冲区,通常用 [:0];如果数组很大且不再需要,设为 nil 让垃圾回收器有机会回收;如果仍保留切片但要清除引用,使用 clear

12. 内存滞留与容量控制

12.1 小切片可能留住大数组

一个很小的子切片仍可能引用整块大底层数组:

go
func firstKB(data []byte) []byte {
    return data[:1024]
}

如果 data 是 100 MB,返回值虽然长度只有 1 KB,仍可能让 100 MB 的底层数组保持可达。

需要独立保存小片段时复制:

go
func firstKB(data []byte) []byte {
    n := min(1024, len(data))
    return bytes.Clone(data[:n])
}

通用切片:

go
small := slices.Clone(large[:n])

是否复制是生命周期权衡:短暂处理时共享很高效;小结果要长期存活,而原数据很大时应考虑复制。

12.2 删除元素后仍可能持有对象

传统删除:

go
values = append(values[:i], values[i+1:]...)

缩短了长度,但底层数组尾部可能仍保留被移动或删除对象的引用。现代 slices.Delete 会把不再使用的尾部元素清零,减少这类意外滞留。

手写容器算法时,元素含指针就要考虑清零:

go
copy(values[i:], values[i+1:])
var zero T
values[len(values)-1] = zero
values = values[:len(values)-1]

12.3 slices.Clip

slices.Clip 把容量限制到长度:

go
values := make([]int, 3, 100)
values = slices.Clip(values)

fmt.Println(len(values)) // 3
fmt.Println(cap(values)) // 3

它等价于:

go
values = values[:len(values):len(values)]

注意,Clip 只是限制切片的容量,并不承诺把底层数组复制成更小数组,也不等于立即释放多余内存。

它常用于 API 边界,避免调用者追加时覆盖当前切片后面的共享数据:

go
func page(data []Item, start, end int) []Item {
    return slices.Clip(data[start:end])
}

如果还要切断内存共享,应使用 slices.Clone

12.4 slices.Grow

知道还要追加多少元素时:

go
values = slices.Grow(values, additional)

它保证之后至少还能追加 additional 个元素而无需再次分配;现有长度和值不变。

仍然必须接住返回值:

go
values = slices.Grow(values, 100)

对于新建切片,更直接的是:

go
values := make([]T, 0, expected)

Grow 更适合已有切片继续批量增长。

13. 函数参数、返回值与所有权

13.1 传切片仍然是值传递

Go 只有值传递。切片传参时,复制的是切片描述,不是底层数组:

go
func changeFirst(values []int) {
    values[0] = 100
}

func main() {
    values := []int{1, 2, 3}
    changeFirst(values)
    fmt.Println(values) // [100 2 3]
}

函数拿到的切片描述和调用方指向同一个底层数组,因此能修改已有元素。这不意味着参数本身按引用传递。

下面的重新赋值不会改变调用方切片变量:

go
func reset(values []int) {
    values = nil
}

func main() {
    values := []int{1, 2, 3}
    reset(values)
    fmt.Println(values) // [1 2 3]
}

函数只把自己的切片副本设成了 nil

13.2 append 后为什么要返回切片

go
func add(values []int, value int) {
    values = append(values, value)
}

即使底层数组恰好被复用,调用方的长度也不会更新;如果发生扩容,调用方连新数组都拿不到。

正确形式:

go
func add(values []int, value int) []int {
    return append(values, value)
}

values = add(values, 10)

如果确实要让函数替换调用方的切片变量,可以传 *[]T

go
func add(values *[]int, value int) {
    *values = append(*values, value)
}

但普通“构建并返回”场景通常直接返回切片更自然,也更容易组合。*[]T 应保留给“修改切片变量本身”确实属于 API 语义的场景。

13.3 接收参数时明确是否允许修改

类型签名 func process([]byte) 看不出函数是否会修改输入。Go 没有只读切片类型,所以要靠 API 契约、命名和实现约束。

只读处理:

go
func checksum(data []byte) uint32 {
    // 不改 data,也不把它保存到函数外
    return crc32.ChecksumIEEE(data)
}

原地修改应表达出来:

go
func normalizeInPlace(values []float64) {
    // ...
}

需要长期保存调用方数据时,决定是共享还是复制:

go
type Message struct {
    payload []byte
}

func NewMessage(payload []byte) *Message {
    return &Message{
        payload: bytes.Clone(payload),
    }
}

复制带来分配成本,但建立了清晰所有权。若为了性能保留输入切片,应在文档中写明调用后谁还能修改、数据必须存活多久。

13.4 返回内部切片会暴露可变状态

go
type Store struct {
    items []Item
}

func (s *Store) Items() []Item {
    return s.items
}

调用方可以修改 Store 的内部数据:

go
items := store.Items()
items[0] = Item{}

如果不希望暴露,返回克隆:

go
func (s *Store) Items() []Item {
    return slices.Clone(s.items)
}

或者提供迭代、查询方法,不返回全部内部存储。这里没有统一答案:高频只读路径可能接受共享以减少分配,但契约必须明确。

13.5 字节缓冲区尤其要讲所有权

网络和编解码代码大量复用 []byte。有些 API 返回的切片只在下一次调用前有效,有些 API 会保留传入切片,还有些会转移所有权。

看到类似接口时要主动确认:

go
Read() []byte
Write([]byte)
Decode([]byte) Value

仅凭类型无法知道生命周期。阅读文档与实现,不要默认数据已经复制。

13.6 可变参数本质上也是切片

go
func sum(values ...int) int {
    total := 0
    for _, value := range values {
        total += value
    }
    return total
}

函数内部 values 的类型是 []int。可以逐个传参:

go
sum(1, 2, 3)

也可以展开已有切片:

go
values := []int{1, 2, 3}
sum(values...)

传入 values... 时,函数收到的切片会引用相同的底层数组,因此函数修改现有元素可能影响调用方:

go
func changeFirst(values ...int) {
    values[0] = 100
}

values := []int{1, 2, 3}
changeFirst(values...)

fmt.Println(values) // [100 2 3]

是否允许这样修改仍属于 API 契约。大多数可变参数函数只读取参数。

14. 排序、查找与 slices 包

Go 1.21 起标准库提供泛型 slices 包。对新代码来说,它比早期只针对若干具体类型的 sort.Intssort.Strings 更统一。

14.1 基本排序

go
values := []int{4, 5, 3, 2, 7}
slices.Sort(values)

fmt.Println(values) // [2 3 4 5 7]

slices.Sort 原地修改切片,没有返回值。

降序:

go
slices.Sort(values)
slices.Reverse(values)

也可以自定义比较:

go
slices.SortFunc(values, func(a, b int) int {
    return cmp.Compare(b, a)
})

比较函数返回:

text
负数:a 排在 b 前
零:  a 与 b 在排序意义上相等
正数:a 排在 b 后

不要写成减法:

go
// return a - b

大整数可能溢出。使用 cmp.Compare(a, b)

14.2 结构体多字段排序

go
type User struct {
    Name string
    Age  int
}

users := []User{
    {Name: "Bob", Age: 30},
    {Name: "Alice", Age: 40},
    {Name: "Alice", Age: 20},
}

slices.SortFunc(users, func(a, b User) int {
    if n := strings.Compare(a.Name, b.Name); n != 0 {
        return n
    }
    return cmp.Compare(a.Age, b.Age)
})

先按姓名升序,姓名相同再按年龄升序。

14.3 稳定排序

普通 SortFunc 不保证相等元素保持原顺序。需要稳定性时:

go
slices.SortStableFunc(users, func(a, b User) int {
    return strings.Compare(a.Name, b.Name)
})

所谓稳定,是比较结果为 0 的元素保持排序前的相对顺序。

14.4 查找与判断包含

线性查找:

go
index := slices.Index(values, target)
if index == -1 {
    fmt.Println("not found")
}

是否包含:

go
if slices.Contains(values, target) {
    // ...
}

自定义条件:

go
index := slices.IndexFunc(users, func(user User) bool {
    return user.Name == "Alice"
})

这些操作都是线性扫描,时间复杂度为 O(n)

14.5 二分查找

切片已经按相同规则升序排列时:

go
values := []int{10, 20, 30, 40}

index, found := slices.BinarySearch(values, 30)
fmt.Println(index, found) // 2 true

没找到时,index 是目标按顺序应插入的位置:

go
index, found := slices.BinarySearch(values, 25)
fmt.Println(index, found) // 2 false

可以直接插入:

go
if !found {
    values = slices.Insert(values, index, 25)
}

未排序的切片上调用二分查找没有意义。排序规则和查找比较规则也必须一致。

14.6 比较切片

切片不能使用 == 比较内容。元素可比较时:

go
equal := slices.Equal(a, b)

自定义等价规则:

go
equal := slices.EqualFunc(a, b, func(x, y User) bool {
    return x.ID == y.ID
})

字典序比较:

go
order := slices.Compare(a, b)

返回负数、0 或正数。

14.7 常用 slices API

目的API是否原地影响数据是否要接返回值
克隆slices.Clone
判断包含slices.Contains
查找slices.Index
排序slices.Sort
反转slices.Reverse
删除区间slices.Delete
插入slices.Insert可能
替换slices.Replace可能
压缩相邻重复slices.Compact
预留容量slices.Grow可能
限制容量slices.Clip否,改描述
拼接多个切片slices.Concat

“可能”表示底层数组容量足够时可能原地复用,不足时会分配新数组。凡返回切片的 API,都应认真接住结果。

15. 迭代器与切片

Go 1.23 起,range 可以遍历特定签名的迭代器函数,slicesmaps 包也提供了与 iter.Seqiter.Seq2 配合的函数。

15.1 正向与反向迭代

普通正向遍历不必包装:

go
for i, value := range values {
    fmt.Println(i, value)
}

需要把“索引和值的序列”作为迭代器传递时:

go
seq := slices.All(values)

for i, value := range seq {
    fmt.Println(i, value)
}

反向:

go
for i, value := range slices.Backward(values) {
    fmt.Println(i, value)
}

15.2 分块处理

go
for batch := range slices.Chunk(values, 100) {
    if err := handleBatch(batch); err != nil {
        return err
    }
}

每块最多 100 个元素。Chunk 返回的子切片与原切片共享元素,但容量被限制为长度,向某一块 append 不会覆盖下一块。

这很适合批量数据库请求、批量 RPC 和分页写入。

15.3 迭代器转切片

go
values := slices.Collect(seq)

追加到已有切片:

go
values = slices.AppendSeq(values, seq)

排序一个序列:

go
values := slices.Sorted(seq)

15.4 不要为了新语法把简单循环复杂化

迭代器适合:

  • 惰性生成数据;
  • 组合标准库返回的序列;
  • 隐藏集合内部表示;
  • 避免为了遍历而先建立完整中间切片。

如果手里已经有切片,只做一次简单遍历,普通 range 最清楚。新特性不是必须套在每段代码上。

16. map 的类型与创建

16.1 map 类型

语法:

go
map[Key]Value

示例:

go
map[string]int
map[int64]User
map[UserID]*User
map[string][]string

键和值的类型在编译期确定,同一张 map 中不能临时混入不同类型的键值。

如果确实要保存不同动态类型,可以把值写成 any

go
metadata := map[string]any{
    "name":   "Alice",
    "age":    30,
    "active": true,
}

但读取后需要类型断言,静态类型保护也更弱。业务模型明确时优先定义结构体。

16.2 键的准确规则:必须可比较

“map 的 key 必须是基本数据类型”并不准确。规范要求键类型支持 ==!=

常见可用键:

go
map[string]int
map[int64]string
map[bool]string
map[*User]State
map[[16]byte]Record
map[Coordinate]Place

结构体所有字段都可比较时,结构体也可作为键:

go
type Coordinate struct {
    X int
    Y int
}

visited := map[Coordinate]bool{
    {X: 10, Y: 20}: true,
}

不可作为键:

go
// map[[]byte]int
// map[map[string]int]int
// map[func()]int

切片、map、函数不可比较。

浮点数虽然可比较,技术上能做键,但 NaN != NaN+0 == -0 等语义常令人意外。业务键通常不要选浮点数。

map 会复制键值。插入后修改原结构体变量,不会修改已经存入的键:

go
type Key struct {
    Tenant string
    ID     int64
}

m := make(map[Key]string)
key := Key{Tenant: "a", ID: 1}
m[key] = "value"

key.ID = 2

fmt.Println(m[key])                       // ""
fmt.Println(m[Key{Tenant: "a", ID: 1}]) // value

如果键是指针,比较和哈希依据是指针身份,不是指向对象的字段内容:

go
a := &User{ID: 1}
b := &User{ID: 1}

m := map[*User]string{a: "value"}

fmt.Println(m[a]) // value
fmt.Println(m[b]) // "",a 与 b 是不同指针

接口类型可以声明为键:

go
m := map[any]string{}

但放入的动态值必须可比较。下面会在运行时 panic

go
key := []int{1, 2, 3}
m[key] = "value" // panic: hash of unhashable type []int

编译器只能确认接口类型本身可比较,动态值是否合法要到运行时才知道。

16.3 nil map

go
var users map[int64]User

fmt.Println(users == nil) // true
fmt.Println(len(users))   // 0

nil map 可以读取、查询、遍历和删除:

go
fmt.Println(users[1]) // User 的零值

_, exists := users[1]
fmt.Println(exists) // false

delete(users, 1) // 安全,无事发生

for id, user := range users {
    fmt.Println(id, user) // 循环零次
}

不能写入:

go
users[1] = User{} // panic: assignment to entry in nil map

16.4 make 初始化

go
users := make(map[int64]User)

可以提供容量提示:

go
users := make(map[int64]User, expectedCount)

第二个参数只是初始空间提示,不是长度,不会预先放入零值元素:

go
users := make(map[int64]User, 100)
fmt.Println(len(users)) // 0

也没有 cap(users)map 的桶、增长策略和容量是运行时实现细节。

16.5 map 字面量

空 map:

go
users := map[int64]User{}

声明并填充:

go
statusCode := map[string]int{
    "ok":        200,
    "not_found": 404,
}

复合值可省略重复类型:

go
users := map[int64]User{
    1: {ID: 1, Name: "Alice"},
    2: {ID: 2, Name: "Bob"},
}

make(map[K]V)map[K]V{} 都会得到可写的空 map。前者适合容量提示,后者适合字面量表达。

16.6 new(map[K]V) 通常不是你想要的

go
p := new(map[string]int)

p 的类型是 *map[string]int,但它指向的 map 值仍是 nil

go
fmt.Println(*p == nil) // true

还要再初始化才能写:

go
*p = make(map[string]int)
(*p)["answer"] = 42

绝大多数场景直接 make。Go 的 new 只分配一个零值变量并返回指针,make 才负责初始化切片、map 和 channel 的运行时结构。

17. map 的读取、写入、删除与清空

17.1 写入和更新

go
scores := make(map[string]int)

scores["Alice"] = 90 // 新增
scores["Alice"] = 95 // 覆盖

写法相同,键不存在时新增,存在时替换。

17.2 读取

go
score := scores["Alice"]

键存在时返回值;不存在时返回值类型的零值:

go
scores := map[string]int{"Alice": 95}

fmt.Println(scores["Alice"]) // 95
fmt.Println(scores["Bob"])   // 0

这不代表 "Bob" 存在且分数为 0。需要区分时使用 comma-ok。

17.3 删除

go
delete(scores, "Alice")

删除不存在的键是安全的,不返回结果,也不报错:

go
delete(scores, "nobody")

如果需要知道是否曾经存在,先查询:

go
if _, exists := scores[name]; exists {
    delete(scores, name)
}

不要仅仅为了“安全删除”而先查一次,delete 本身已经安全。只有业务确实关心存在性时才查。

17.4 清空

Go 1.21 起可使用:

go
clear(scores)

它删除所有条目,scores 仍是可写的非 nil map:

go
fmt.Println(len(scores))   // 0
fmt.Println(scores == nil) // false

也可以丢弃整张 map:

go
scores = nil

之后读是安全的,写会 panic。如果还要继续写:

go
scores = make(map[string]int)

选择 clear(m) 还是重新 make,先看语义和对象生命周期。不要假设 clear 会立即归还所有内部内存,也不要依赖运行时桶实现。

17.5 len

go
count := len(scores)

返回当前键值对数量。nil map 的长度也是 0。

18. comma ok 与零值技巧

18.1 区分不存在和值为零

go
score, exists := scores["Bob"]
if !exists {
    fmt.Println("Bob 不存在")
}

变量名常用 okexists

go
value, ok := m[key]

只检查是否存在:

go
if _, ok := m[key]; ok {
    // key 存在
}

18.2 零值有时正好能简化代码

计数器:

go
counts := make(map[string]int)

for _, word := range words {
    counts[word]++
}

不存在的键读取为 0,所以无需先判断。

分组:

go
groups := make(map[string][]User)

for _, user := range users {
    groups[user.Department] = append(
        groups[user.Department],
        user,
    )
}

不存在的键读取为 nil 切片,而 append(nil, user) 合法。

集合:

go
set := make(map[string]struct{})

set["go"] = struct{}{}

struct{}{} 是零尺寸值,常用来表达“只关心键是否存在”:

go
if _, exists := set["go"]; exists {
    fmt.Println("contains go")
}

若用 map[string]bool,读取写起来更短:

go
if enabled["go"] {
    // ...
}

false 可能表示“明确关闭”,也可能表示“不存在”。需要区分三态时用 comma-ok,或使用 map[K]struct{} 表达纯集合。

18.3 map 中嵌套 map

内层 map 也要初始化:

go
matrix := make(map[string]map[string]int)

row := matrix["A"]
if row == nil {
    row = make(map[string]int)
    matrix["A"] = row
}
row["B"]++

封装辅助函数:

go
func increment(
    matrix map[string]map[string]int,
    rowKey string,
    colKey string,
) {
    row := matrix[rowKey]
    if row == nil {
        row = make(map[string]int)
        matrix[rowKey] = row
    }
    row[colKey]++
}

或者重新考虑模型:键是两个字段的结构体时,一张 map 可能更简单:

go
type Pair struct {
    Row string
    Col string
}

matrix := make(map[Pair]int)
matrix[Pair{Row: "A", Col: "B"}]++

18.4 获取或创建

Go 没有内建 computeIfAbsent。普通写法:

go
value, ok := cache[key]
if !ok {
    value = buildValue(key)
    cache[key] = value
}

并发环境不能把这段代码直接搬过去,因为“读取、计算、写入”不是原子操作,需要锁、sync.Map 或更高层的 singleflight 策略。

19. map 的遍历顺序

19.1 顺序没有规定

go
for key, value := range m {
    fmt.Println(key, value)
}

map 的遍历顺序未规定,也不保证两次遍历一致。不要把当前一次运行看到的顺序当成插入顺序、哈希顺序或稳定顺序。

这不是偶尔才会变化的边角行为。语言层面就没有顺序契约。

19.2 需要稳定输出时排序键

go
keys := make([]string, 0, len(m))
for key := range m {
    keys = append(keys, key)
}

slices.Sort(keys)

for _, key := range keys {
    fmt.Println(key, m[key])
}

Go 1.23 起也可收集 maps.Keys 返回的迭代器:

go
keys := slices.Sorted(maps.Keys(m))

for _, key := range keys {
    fmt.Println(key, m[key])
}

稳定顺序对以下场景尤其重要:

  • 生成文本、配置和 SQL;
  • 日志与快照测试;
  • 哈希或签名输入;
  • 需要可复现的测试失败信息;
  • 面向用户的输出。

19.3 遍历中删除

在同一个 goroutine 中遍历时删除尚未到达的条目是语言允许的;被删除条目不会再产生:

go
for key, value := range m {
    if shouldDelete(value) {
        delete(m, key)
    }
}

这是很实用的原地过滤方式。

遍历中新增条目时,新条目可能被遍历到,也可能不被遍历到。不要依赖某一种结果:

go
for key := range m {
    m[newKey(key)] = value // 新键是否在本轮出现,没有保证
}

这里说的是单 goroutine 内的语言语义。多个 goroutine 并发访问是另一回事。

20. map 的赋值语义与元素限制

20.1 复制 map 变量不会复制条目

go
a := map[string]int{"x": 1}
b := a

b["x"] = 100

fmt.Println(a["x"]) // 100

ab 访问同一张 map。可以把 map 值理解成由运行时管理的数据结构的描述符,但不要依赖其具体内部布局。

传参同理:

go
func update(m map[string]int) {
    m["x"] = 100
}

func main() {
    m := map[string]int{"x": 1}
    update(m)
    fmt.Println(m["x"]) // 100
}

仍然是值传递,只是复制后的 map 值访问同一份条目存储。

20.2 函数内重新赋值不会替换调用方变量

go
func reset(m map[string]int) {
    m = make(map[string]int)
    m["new"] = 1
}

func main() {
    m := map[string]int{"old": 1}
    reset(m)
    fmt.Println(m) // map[old:1]
}

函数替换的是自己的参数副本。若要清空原 map,使用 clear(m);若要让函数替换调用方 map 变量,返回新 map:

go
func reset() map[string]int {
    return map[string]int{"new": 1}
}

m = reset()

*map[K]V 通常没必要,因为 map 本身已经具备共享修改条目的语义。只有确实需要替换 map 变量且返回值不合适时再考虑指针。

20.3 map 元素不可取地址

下面不能编译:

go
m := map[string]int{"x": 1}
// p := &m["x"]

map 增长时元素可能迁移,语言不允许取得元素地址。

同样不能直接修改 map 中结构体值的字段:

go
type User struct {
    Name string
}

users := map[int64]User{
    1: {Name: "Alice"},
}

// users[1].Name = "Bob" // 编译错误

取出、修改、放回:

go
user := users[1]
user.Name = "Bob"
users[1] = user

或者存指针:

go
users := map[int64]*User{
    1: {Name: "Alice"},
}

users[1].Name = "Bob"

两种模型语义不同:

  • map[K]User 保存结构体值,取出会复制,所有权更清晰;
  • map[K]*User 保存指针,便于原地修改,但会引入别名、nil 和共享可变状态。

不要只为了少写两行代码就把所有 map value 改成指针。

20.4 map 本身不能比较

go
var a map[string]int
var b map[string]int

// fmt.Println(a == b) // 编译错误

只能与 nil 比较:

go
fmt.Println(a == nil)

内容比较使用 maps.Equal 或自定义逻辑。

21. map 的克隆、复制与比较

21.1 maps.Clone

go
cloned := maps.Clone(original)

这是浅克隆:

go
original := map[string][]int{
    "numbers": {1, 2, 3},
}

cloned := maps.Clone(original)
cloned["numbers"][0] = 100

fmt.Println(original["numbers"]) // [100 2 3]

map 的条目表独立了,但 value 是切片,复制后的两个切片仍共享底层数组。

深复制需要按 value 结构处理:

go
func cloneMapOfSlices[K comparable, V any](
    src map[K][]V,
) map[K][]V {
    if src == nil {
        return nil
    }

    dst := make(map[K][]V, len(src))
    for key, values := range src {
        dst[key] = slices.Clone(values)
    }
    return dst
}

21.2 maps.Copy

把源条目复制进目标:

go
maps.Copy(dst, src)

键冲突时,src 的值覆盖 dst

go
dst := map[string]int{"a": 1, "b": 2}
src := map[string]int{"b": 20, "c": 30}

maps.Copy(dst, src)

fmt.Println(dst) // 包含 a:1、b:20、c:30,打印顺序不定

dst 必须是可写的非 nil map:

go
var dst map[string]int
// maps.Copy(dst, src) // 写入 nil map,会 panic

21.3 maps.Equal

值可比较时:

go
equal := maps.Equal(a, b)

它比较键集合以及每个键对应的值,不关心遍历顺序。

自定义 value 比较:

go
equal := maps.EqualFunc(a, b, func(x, y User) bool {
    return x.ID == y.ID
})

21.4 DeleteFunc

按条件删除:

go
maps.DeleteFunc(m, func(key string, value int) bool {
    return value < 0
})

条件返回 true 的条目被删除。

21.5 Keys 与 Values

当前标准库中:

go
keySeq := maps.Keys(m)
valueSeq := maps.Values(m)

它们返回迭代器,不是切片。需要切片时收集:

go
keys := slices.Collect(maps.Keys(m))
values := slices.Collect(maps.Values(m))

返回顺序仍未规定。需要稳定键序:

go
keys := slices.Sorted(maps.Keys(m))

不要假设 KeysValues 分别收集后能按索引一一对应。两次遍历顺序都没有保证。

21.6 map 与键值迭代器互转

把键值迭代器收集为新 map:

go
cloned := maps.Collect(maps.All(original))

把序列条目插入已有 map:

go
maps.Insert(dst, maps.All(src))

键冲突时,后写入的值覆盖原值。这两个 API 在组合迭代器时很方便;单纯克隆和复制仍优先使用意图更直接的 maps.Clonemaps.Copy

22. 并发访问 map

22.1 普通 map 不是并发容器

多个 goroutine 访问同一张 map 时,只要存在写操作,就必须同步:

go
// 错误示意
go func() {
    m["x"] = 1
}()

go func() {
    fmt.Println(m["x"])
}()

并发读写或并发写可能导致数据竞争,运行时还可能报:

text
fatal error: concurrent map read and map write
fatal error: concurrent map writes

不要把运行时是否恰好报 fatal 当安全检测。没有同步的数据竞争本身就已经是错误。

多个 goroutine 只读同一张 map 是可以的,前提是 map 已完成构建,并且没有任何 goroutine 同时修改它或其共享可变 value。

22.2 使用互斥锁

常见封装:

go
type UserCache struct {
    mu    sync.RWMutex
    users map[int64]User
}

func NewUserCache() *UserCache {
    return &UserCache{
        users: make(map[int64]User),
    }
}

func (c *UserCache) Get(id int64) (User, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()

    user, ok := c.users[id]
    return user, ok
}

func (c *UserCache) Put(user User) {
    c.mu.Lock()
    defer c.mu.Unlock()

    c.users[user.ID] = user
}

锁应保护“状态”,不只是某一条 map 语句。复合操作必须在同一个临界区:

go
func (c *Counter) Increment(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()

    c.values[key]++
    return c.values[key]
}

不能把读锁和写锁拆开后期待“读—改—写”整体原子。

22.3 返回内部引用时锁可能提前失效

go
type Cache struct {
    mu   sync.RWMutex
    data map[string][]byte
}

func (c *Cache) Get(key string) ([]byte, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()

    value, ok := c.data[key]
    return value, ok
}

锁释放后,调用方拿到的 []byte 仍可能与缓存内部共享底层数组。其他 goroutine 修改它时仍会竞争。

如果 value 需要隔离,锁内克隆:

go
func (c *Cache) Get(key string) ([]byte, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()

    value, ok := c.data[key]
    return bytes.Clone(value), ok
}

锁保护 map 并不自动保护 map 中指向的所有对象,所有权设计仍然重要。

22.4 sync.Map

sync.Map 是专用并发 map,不是泛型 map[K]V 的默认替代品。它适合文档描述的两类典型负载:

  • 某个键通常只写一次、读很多次,例如只增长的缓存;
  • 不同 goroutine 操作彼此不相交的键集合。
go
var cache sync.Map

cache.Store("answer", 42)

value, ok := cache.Load("answer")
if ok {
    answer := value.(int)
    fmt.Println(answer)
}

它使用 any,需要类型断言。普通业务 map 加 MutexRWMutex 往往类型更安全,也更容易维护跨多个字段的不变量。

常用原子式操作:

go
actual, loaded := cache.LoadOrStore(key, value)
actual, loaded := cache.LoadAndDelete(key)
swapped := cache.CompareAndSwap(key, old, new)

即便使用 sync.Map,放进去的 value 如果是可变指针或切片,其内部并发安全仍要另行保证。

22.5 用 channel 限定所有权

另一种设计是只允许一个 goroutine 持有并修改 map,其他 goroutine 通过 channel 发送请求:

go
type request struct {
    key   string
    delta int
    reply chan int
}

func runCounter(requests <-chan request) {
    counts := make(map[string]int)

    for req := range requests {
        counts[req.key] += req.delta
        req.reply <- counts[req.key]
    }
}

这不是“channel 永远优于锁”。当状态机、顺序处理本身就是模型的一部分时很合适;简单共享缓存通常用锁更直接。

22.6 用 race detector 验证

测试:

bash
go test -race ./...

运行程序:

bash
go run -race .

构建:

bash
go build -race ./cmd/server

race detector 只能发现实际执行路径中发生的数据竞争,不是形式化证明。并发测试要覆盖真实负载和关键分支。

23. nil 与空集合

23.1 切片的三种常见状态

go
var nilSlice []int
emptyLiteral := []int{}
emptyMake := make([]int, 0)

它们都有:

go
len(s) == 0

但只有第一个等于 nil

go
fmt.Println(nilSlice == nil)    // true
fmt.Println(emptyLiteral == nil) // false
fmt.Println(emptyMake == nil)    // false

集合算法通常只应关心长度:

go
if len(values) == 0 {
    // nil 和空切片都进入这里
}

只有协议明确区分“缺失”和“已提供但为空”时,才检查 nil

23.2 map 也有 nil 和空

go
var nilMap map[string]int
emptyMap := map[string]int{}

两者长度均为 0。不同点是空 map 可以写,nil map 不可以写。

23.3 JSON 序列化不同

标准 encoding/json 默认会区分:

go
type Payload struct {
    Items []int          `json:"items"`
    Meta  map[string]int `json:"meta"`
}

字段为 nil

json
{
  "items": null,
  "meta": null
}

字段为空但非 nil

json
{
  "items": [],
  "meta": {}
}

对外 API 如果约定集合永远输出 []{},构造响应时就要初始化:

go
payload := Payload{
    Items: make([]int, 0),
    Meta:  make(map[string]int),
}

omitempty 时,长度为 0 的切片和 map 通常都会被省略:

go
Items []int `json:"items,omitempty"`

“缺字段”“null”“空数组”在一些客户端和补丁协议中含义不同,不能只看 Go 内部操作是否一样。

23.4 JSON 中 map 键的限制

Go map 可以使用很多可比较类型作为键,但 JSON 对象的键必须是字符串。encoding/json 支持的 map 键类型更窄,通常是:

  • 字符串类型;
  • 整数类型,编码成十进制字符串;
  • 实现 encoding.TextMarshaler 的类型。

结构体键虽然能用于 Go map,却不能直接作为普通 JSON 对象键:

go
type Coordinate struct {
    X int
    Y int
}

points := map[Coordinate]string{}

对外序列化时可以改成条目数组:

go
type Entry struct {
    Coordinate Coordinate `json:"coordinate"`
    Value      string     `json:"value"`
}

或者为键定义稳定、可逆的文本编码。

23.5 测试中的相等

reflect.DeepEqual 区分 nil 切片与空切片,也区分 nil map 与空 map:

go
fmt.Println(
    reflect.DeepEqual([]int(nil), []int{}),
) // false

slices.Equal 把两个长度为 0 的切片视为内容相等:

go
fmt.Println(
    slices.Equal([]int(nil), []int{}),
) // true

maps.Equal 也按条目内容比较,因此两个没有条目的 map 内容相等。

测试前先决定你要断言哪一种语义:

  • 要求协议状态完全一致:区分 nil 与空;
  • 只关心集合内容:把二者视为相等。

不要让断言库的默认行为替你做领域决策。

24. 泛型中的切片与 map

24.1 为什么约束常写成 ~[]E

一个只接受字面类型 []E 的函数:

go
func first[E any](values []E) (E, bool) {
    if len(values) == 0 {
        var zero E
        return zero, false
    }
    return values[0], true
}

读取普通切片完全够用。但如果函数返回同类型切片,并希望保留调用方的定义类型,常写:

go
func clone[S ~[]E, E any](values S) S {
    return slices.Clone(values)
}

~[]E 表示底层类型是 []E 的类型:

go
type UserList []User

users := UserList{{ID: 1}}
cloned := clone(users)

fmt.Printf("%T\n", cloned) // main.UserList

如果返回固定 []E,调用方的定义类型会丢失。

24.2 comparable 与 map 键

泛型 map 的键必须满足 comparable

go
func keys[K comparable, V any](m map[K]V) []K {
    result := make([]K, 0, len(m))
    for key := range m {
        result = append(result, key)
    }
    return result
}

这是语言类型规则的直接体现,不是随便选的约束。

保留定义 map 类型:

go
func cloneMap[M ~map[K]V, K comparable, V any](m M) M {
    return maps.Clone(m)
}

24.3 一个泛型集合

go
type Set[E comparable] map[E]struct{}

func NewSet[E comparable](values ...E) Set[E] {
    set := make(Set[E], len(values))
    for _, value := range values {
        set.Add(value)
    }
    return set
}

func (s Set[E]) Add(value E) {
    s[value] = struct{}{}
}

func (s Set[E]) Remove(value E) {
    delete(s, value)
}

func (s Set[E]) Contains(value E) bool {
    _, exists := s[value]
    return exists
}

func (s Set[E]) Len() int {
    return len(s)
}

使用:

go
languages := NewSet("Go", "Java")
languages.Add("Rust")

fmt.Println(languages.Contains("Go")) // true

这个类型的零值仍是 nil map:

go
var languages Set[string]
fmt.Println(languages.Contains("Go")) // false

// languages.Add("Go") // panic:nil map 不可写

如果希望“零值即可写”,仅靠 map 定义类型做不到;要么要求构造函数,要么用包含 map 字段的结构体并在写方法中延迟初始化:

go
type LazySet[E comparable] struct {
    values map[E]struct{}
}

func (s *LazySet[E]) Add(value E) {
    if s.values == nil {
        s.values = make(map[E]struct{})
    }
    s.values[value] = struct{}{}
}

24.4 泛型 Filter

返回同一种切片定义类型:

go
func Filter[S ~[]E, E any](
    values S,
    keep func(E) bool,
) S {
    result := make(S, 0, len(values))
    for _, value := range values {
        if keep(value) {
            result = append(result, value)
        }
    }
    return result
}

这里选择分配新切片,是为了不修改输入。也可以实现 FilterInPlace,但名称和文档要明确其破坏性。

24.5 不要为了泛型重新实现标准库

优先查看:

text
slices
maps
cmp
iter

标准库已经覆盖排序、查找、克隆、比较、增删、迭代等大量通用操作。自己写泛型函数的价值通常在领域语义,而不是把 slices.Contains 换个名字。

25. 性能与容量规划

25.1 切片预分配

已知最终长度,先分配长度再按索引写:

go
result := make([]Output, len(inputs))
for i, input := range inputs {
    result[i] = transform(input)
}

已知大致上限,但只有部分元素会保留:

go
result := make([]Output, 0, len(inputs))
for _, input := range inputs {
    if shouldKeep(input) {
        result = append(result, transform(input))
    }
}

这样通常能减少扩容和复制。不要为了省几次分配而把容量估计写得过大,长期高估也会浪费内存。

25.2 map 容量提示

go
index := make(map[int64]User, len(users))
for _, user := range users {
    index[user.ID] = user
}

容量参数是提示,不是硬上限:

go
m := make(map[string]int, 10)
// 仍然可以放入远超 10 个键

它也不是 Java HashMap 那套公开的 capacity/load factor API。不要依赖具体桶数量。

25.3 数组复制成本

数组按值传递会复制全部元素:

go
func process(data [1 << 20]byte) {}

这不一定都会落到堆上,编译器也可能优化,但类型语义确实是值复制。大数据通常传切片或数组指针:

go
func process(data []byte) {}
func processFixed(data *[1 << 20]byte) {}

不要凭直觉决定,先看语义,再用基准测试与逃逸分析验证性能。

25.4 value 还是 pointer

go
map[int64]User
map[int64]*User

选择时考虑:

  • User 是否很大;
  • 是否需要共享并原地修改;
  • 是否允许 nil
  • 所有权是否清楚;
  • 是否要减少复制;
  • 指针数量对垃圾回收的影响;
  • 并发访问内部对象如何同步。

小而稳定的值对象很适合直接存值。大型、需要共享身份的实体可能适合指针。不存在“map value 一律用指针”的规则。

25.5 [][]T 的分配

这样创建二维切片:

go
rows := make([][]int, rowCount)
for i := range rows {
    rows[i] = make([]int, colCount)
}

每行通常是一次独立分配。需要连续内存时:

go
data := make([]int, rowCount*colCount)
rows := make([][]int, rowCount)

for i := range rows {
    start := i * colCount
    rows[i] = data[start : start+colCount : start+colCount]
}

这种布局减少分配并改善局部性,但所有行共享 data,生命周期和扩容规则也要更谨慎。仅在性能数据证明值得时采用。

25.6 基准测试

go
func BenchmarkBuildWithoutCapacity(b *testing.B) {
    for range b.N {
        var values []int
        for i := 0; i < 10_000; i++ {
            values = append(values, i)
        }
    }
}

func BenchmarkBuildWithCapacity(b *testing.B) {
    for range b.N {
        values := make([]int, 0, 10_000)
        for i := 0; i < 10_000; i++ {
            values = append(values, i)
        }
    }
}

运行:

bash
go test -bench=. -benchmem

关注:

  • ns/op
  • B/op
  • allocs/op

容量优化只是容器性能的一部分。数据库、网络、锁竞争或算法复杂度占主导时,少一次切片扩容可能没有实际意义。

25.7 不要用 unsafe 猜内部结构

unsafereflect.SliceHeader 和运行时 map 实现适合少数底层库或调试分析,不适合作为普通业务优化手段。

尤其不要把临时构造的 SliceHeader.Data 当成稳定指针,或依赖 map 桶布局。能用普通切片操作、unsafe.Slice 的受控场景以及标准库完成的,不要手工伪造内部结构。

26. 与 Java 集合的差异

主题GoJava
固定数组[N]T,长度属于类型,数组是值T[],长度是对象运行期状态,变量保存引用
动态顺序集合[]T 切片,语言内建常用 ArrayList<T>
哈希映射map[K]V,语言内建HashMap<K,V> 等类
泛型元素直接保存具体类型泛型不能直接使用 primitive,需要装箱类型
越界panicIndexOutOfBoundsException
不存在的 map 键返回 value 零值,可用 comma-ok 区分get 通常返回 null,也有 containsKey
nil/null 键map 键类型决定;指针键可为 nilHashMap 允许一个 null key,部分实现不允许
map 遍历顺序未规定HashMap 不保证,LinkedHashMap 可保插入顺序
集合相等切片/map 不能直接 ==;用 slices/mapsList/Map 通常重写 equals
复制数组复制元素;切片/map 复制描述并共享数据集合变量赋值复制引用
线程安全普通 map 非并发安全HashMap 非线程安全,有 ConcurrentHashMap
只读视图语言没有只读切片/map 类型有 unmodifiable 集合包装
容量切片公开 cap;map 不公开ArrayList/HashMap 有各自容量概念

几个迁移时特别值得注意的点:

Java 数组变量是引用,Go 数组变量是值

Java:

java
int[] a = {1, 2, 3};
int[] b = a;
b[0] = 100; // a[0] 也变

Go 数组:

go
a := [3]int{1, 2, 3}
b := a
b[0] = 100 // a 不变

Go 切片才表现出共享底层存储:

go
a := []int{1, 2, 3}
b := a
b[0] = 100 // a[0] 也变

comma-ok 不等于只检查 null

go
value, ok := m[key]

ok 表示键是否存在,不是 value 是否为 nil。对于指针值:

go
m := map[string]*User{
    "known-empty": nil,
}

user, ok := m["known-empty"]
fmt.Println(user == nil, ok) // true true

另一个键可能得到:

go
user, ok := m["missing"]
fmt.Println(user == nil, ok) // true false

Go 没有 ArrayList 那套方法调用风格

不是:

go
// values.add(x)
// values.size()
// values.remove(i)

而是:

go
values = append(values, x)
len(values)
values = slices.Delete(values, i, i+1)

这不是少了面向对象封装,而是语言把高频容器作为内建类型,并由标准泛型函数补充通用算法。

27. 常见误区

数组

  1. [3]int[4]int 当成同类型。
    长度属于数组类型。

  2. 以为数组赋值后共享元素。
    数组赋值和传参复制整个数组。

  3. [...]int 当作可变长数组类型。
    它只在数组字面量中推导长度。

  4. 大数组无意识按值传递。
    先确认是否应改为切片或数组指针。

切片

  1. 把容量当长度。
    make([]T, 0, n) 不能直接写 s[0]

  2. 认为 s[:] 会复制。
    它仍共享底层数组。

  3. 忽略 append 的返回值。
    长度和底层数组都可能改变。

  4. 认为 append 一定扩容,或一定不扩容。
    是否共享取决于当前容量;正确性不能依赖猜测。

  5. 在 range 的 value 副本上修改结构体。
    要修改切片元素时按索引操作。

  6. 返回大缓冲区中的小子切片。
    可能长期保留整块底层数组。

  7. 以为 slices.Clone 是递归深复制。
    它只复制切片元素值。

  8. 删除含指针元素后不考虑清零。
    手写删除算法可能让底层数组继续持有对象。

  9. 认为 slices.Clip 一定释放多余内存。
    它只把容量限制为长度。

  10. 把 nil 和空切片在所有边界都视为完全相同。
    JSON、反射和协议可能区分。

map

  1. 认为 map key 只能是基本类型。
    准确规则是键必须可比较,数组和合适的结构体也可以。

  2. 向 nil map 写入。
    读取安全,写入会 panic

  3. 只读取一个返回值,却需要区分不存在和零值。
    使用 value, ok := m[key]

  4. 依赖 map 遍历顺序。
    需要稳定输出时提取并排序键。

  5. 直接修改 map 中结构体 value 的字段。
    取出、修改、放回,或有意识地存指针。

  6. 以为 maps.Clone 会深复制 value。
    value 含切片、map、指针时仍可能共享内部数据。

  7. 普通 map 一边读一边并发写。
    使用锁、sync.Map 或单 goroutine 所有权。

  8. 锁住 map,却返回内部可变切片或指针。
    锁释放后内部对象仍可能被并发修改。

  9. map[any]V 后放入切片键。
    编译能过,哈希动态值时会 panic

  10. 认为 make(map, n) 创建了 n 个元素。
    n 只是初始空间提示,长度仍是 0。

28. 速查表

数组

go
var a [3]int
b := [3]int{1, 2, 3}
c := [...]int{1, 2, 3}
d := [5]int{2: 20, 4: 40}

length := len(a)
same := b == c
slice := b[:]

创建切片

go
var a []int                // nil,len=0,cap=0
b := []int{}               // 非 nil 空切片
c := []int{1, 2, 3}        // 字面量
d := make([]int, 3)        // len=3,cap=3
e := make([]int, 0, 10)    // len=0,cap=10

切片范围

go
s[low:high]     // len=high-low
s[low:high:max] // len=high-low,cap=max-low
s[:n]
s[n:]
s[:]

追加与复制

go
s = append(s, value)
s = append(s, a, b, c)
s = append(s, other...)

dst := make([]T, len(src))
copy(dst, src)

cloned := slices.Clone(src)

增删改查

go
value := s[i]
s[i] = value

s = slices.Insert(s, i, values...)
s = slices.Delete(s, i, j)
s = slices.Replace(s, i, j, values...)

index := slices.Index(s, value)
exists := slices.Contains(s, value)

slices.Sort(s)
slices.Reverse(s)

清理切片

go
s = s[:0]   // 长度归零,保留底层数组
s = nil     // 丢弃切片引用
clear(s)    // 长度不变,所有元素设为零值

创建 map

go
var a map[string]int                 // nil
b := make(map[string]int)            // 可写空 map
c := make(map[string]int, expected)  // 带容量提示
d := map[string]int{}                // 可写空 map
e := map[string]int{"answer": 42}    // 字面量

map 操作

go
m[key] = value

value := m[key]
value, ok := m[key]

delete(m, key)
clear(m)
count := len(m)

map 工具

go
cloned := maps.Clone(m)
maps.Copy(dst, src)
equal := maps.Equal(a, b)
maps.DeleteFunc(m, predicate)

keys := slices.Collect(maps.Keys(m))
sortedKeys := slices.Sorted(maps.Keys(m))
values := slices.Collect(maps.Values(m))

集合

go
set := make(map[string]struct{})

set[value] = struct{}{}
_, exists := set[value]
delete(set, value)

并发检查

bash
go test -race ./...
go run -race .

29. 练习

这些题不追求炫技,重点是把共享、容量和存在性想清楚。

练习 1:解释输出

不运行,先写出输出:

go
a := []int{1, 2, 3}
b := a[:2]
b = append(b, 9)

fmt.Println(a)
fmt.Println(b)

再把第一行改成:

go
a := make([]int, 3, 3)
copy(a, []int{1, 2, 3})

结果是否变化?为什么?

练习 2:修复返回内部状态

下面的类型要求调用方不能修改内部数据:

go
type Registry struct {
    names []string
}

func (r *Registry) Names() []string {
    return r.names
}

修改实现并写一个测试,证明调用方修改返回值不会影响 Registry

练习 3:稳定词频

输入一段文本,统计每个单词出现次数,并按:

  1. 次数降序;
  2. 次数相同时按单词升序;

输出结果。思考为什么不能直接遍历 map 输出。

练习 4:LRU 之外先做有界缓存

实现一个线程安全缓存:

go
type Cache[K comparable, V any] struct {
    // ...
}

提供:

go
Get(K) (V, bool)
Put(K, V)
Delete(K)
Len() int

先用 map + sync.RWMutex,通过 go test -race。不要急着做 LRU,先把锁保护范围和 value 所有权写清楚。

练习 5:深复制

为下面的配置实现 Clone

go
type Config struct {
    Labels  map[string]string
    Routes  map[string][]string
    Backups []*Config
}

先定义:

  • Backups 是否递归复制;
  • 是否可能形成环;
  • nil 与空集合是否保留;
  • 修改克隆对象的任何字段是否允许影响原对象。

写完契约再写代码,会比直接上反射靠谱得多。

练习 6:批处理

使用 slices.Chunk 把 1050 条 ID 按每批最多 100 条处理。要求:

  • 最后一批是 50 条;
  • 处理函数不能通过 append 覆盖下一批;
  • 任意一批失败立即返回错误;
  • 写测试记录每批长度。

30. 可运行示例

集合类 bug 往往不是语法写错,而是误判了底层数据是否共享。下面三个程序都能直接运行,源码位于 examples/ch04

30.1 看见切片共享与扩容分离

切片共享关系会随着扩容改变。 为什么修改一个切片会影响另一个?为什么有时 append 会影响原切片,有时又不会?

go
package main

import "fmt"

func main() {
	base := []int{10, 20, 30, 40}
	view := base[1:3]

	// 切片赋值和切片表达式只复制“指针、长度、容量”这份描述,
	// view 与 base 此刻仍共享底层数组。
	view[0] = 99
	fmt.Printf("共享修改: base=%v view=%v\n", base, view)

	// 容量足够时 append 仍可能写入共享数组。
	view = append(view, 77)
	fmt.Printf("容量内追加: base=%v view=%v\n", base, view)

	// 把容量限制为长度后,下一次 append 必须分配新数组。
	isolatedGrowth := base[1:3:3]
	isolatedGrowth = append(isolatedGrowth, 88)
	isolatedGrowth[0] = 55
	fmt.Printf("扩容后分离: base=%v grown=%v\n", base, isolatedGrowth)
}

运行:

bash
go run ./examples/ch04/slice-sharing

预期输出:

text
共享修改: base=[10 99 30 40] view=[99 30]
容量内追加: base=[10 99 30 77] view=[99 30 77]
扩容后分离: base=[10 99 30 77] grown=[55 30 88]

三次操作对应三种存储关系。

  1. view := base[1:3] 没有复制元素,两个切片都描述同一个底层数组的不同区间。
  2. view 的容量延伸到 base 末尾,所以第一次 append 有空间原地写入,并覆盖 base[3]
  3. 三索引切片 base[1:3:3] 把容量限制为 2;下一次 append 必须另行分配数组。
  4. 分配发生后,isolatedGrowth[0] = 55 不再影响 base。不要依赖“append 大概会扩容”,需要隔离时应明确复制或限制容量。

把容量也打印出来。 打印每一步的 lencap;再把第一次 view 改成 base[1:3:3],观察 base[3] 是否仍被覆盖。

30.2 用 comma-ok 区分零值和不存在

零值不能证明键存在。 map 的 value 本来就可能是零值时,怎样判断键是否真的存在?

go
package main

import "fmt"

func main() {
	scores := map[string]int{
		"alice": 0, // 零分是一个真实存在的值。
	}

	// 单值读取无法区分“不存在”和“存在但值为零”。
	fmt.Printf("alice=%d bob=%d\n", scores["alice"], scores["bob"])

	aliceScore, aliceExists := scores["alice"]
	bobScore, bobExists := scores["bob"]
	fmt.Printf("alice: score=%d exists=%t\n", aliceScore, aliceExists)
	fmt.Printf("bob: score=%d exists=%t\n", bobScore, bobExists)
}

运行:

bash
go run ./examples/ch04/map-comma-ok

预期输出:

text
alice=0 bob=0
alice: score=0 exists=true
bob: score=0 exists=false

两次读取的差异只在 exists。

  1. 单值读取 scores[key] 在键不存在时返回 int 的零值 0。
  2. alice 的 0 是已存入的真实分数,bob 的 0 是缺失键产生的零值,单看 value 完全一样。
  3. 双值读取的第二个结果只说明键是否存在,与 value 是否为零无关。
  4. 如果业务中“缺失”和“零”语义不同,不能用 value == 0 代替 comma-ok。

换一种零值再确认。 把 value 类型换成 bool,存入 "enabled": false;再使用 delete(scores, "alice"),比较删除前后的 exists

30.3 在函数边界安全复制集合

配置副本必须切断可变引用。 返回或保存配置时,怎样避免调用方通过 map 或 slice 意外修改内部状态?

go
package main

import (
	"fmt"
	"maps"
	"slices"
)

type Config struct {
	Labels map[string]string
	Ports  []int
}

func cloneConfig(source Config) Config {
	// maps.Clone 与 slices.Clone 都复制一层容器。
	// 这里的元素是 string 和 int,没有更深的引用,所以一层复制已经足够隔离。
	return Config{
		Labels: maps.Clone(source.Labels),
		Ports:  slices.Clone(source.Ports),
	}
}

func main() {
	original := Config{
		Labels: map[string]string{"env": "prod"},
		Ports:  []int{80, 443},
	}
	copied := cloneConfig(original)

	copied.Labels["env"] = "test"
	copied.Ports[0] = 8080

	// 修改副本后,原对象保持不变,函数之间的所有权边界更清楚。
	fmt.Printf("original: env=%s ports=%v\n", original.Labels["env"], original.Ports)
	fmt.Printf("copied: env=%s ports=%v\n", copied.Labels["env"], copied.Ports)
}

运行:

bash
go run ./examples/ch04/safe-copy

预期输出:

text
original: env=prod ports=[80 443]
copied: env=test ports=[8080 443]

克隆过程分为外层与内层。

  1. 只写 copied := original 会复制结构体,但 map 和 slice 字段仍指向原有存储。
  2. maps.Clone 创建新 map,slices.Clone 创建包含相同元素的新切片,修改顶层元素不会串回原对象。
  3. 当前元素只有 string 和 int,本身不含引用,一层复制已经是完整隔离。
  4. 若字段改成 map[string][]int、指针或嵌套结构,一层 clone 仍会共享更深层数据,需要按明确的所有权契约递归复制。

先故意退回浅复制。 先把 cloneConfig 改成直接 return source,观察输出;再增加 Routes map[string][]string 字段,亲自验证为什么只 maps.Clone 还不够。

31. 官方资料

原教程适合作为语法入口,但有两点需要修正:

  1. sList[len(sList)-2]["a", "b", "c", "d"] 中得到 "c",不是 "a"
  2. map 键不要求是“基本数据类型”,而是要求键类型可比较。

读完这一章,最值得留下的不是某个删除切片的技巧,而是三句话:

数组赋值复制全部元素;切片复制描述但可能共享数组;map 复制变量后仍访问同一张表。

把这三种值语义分清,后面的函数设计、并发控制和性能优化才不会靠猜。

以 Go 官方规范与标准库文档为准,示例面向 Go 1.26。