Go 数组、切片与 map:从容器语法到内存语义
面向有 Java 经验的开发者,基于 Go 1.26。
数组、切片和 map 的语法都不复杂,麻烦在于它们看起来相似,传递和修改时却遵循完全不同的规则:
- 数组保存元素本身,赋值和传参都会复制整个数组;
- 切片保存的是一段连续数组的描述信息,多个切片可能共享同一块存储;
map是运行时管理的哈希表,变量本身不是全部数据,复制变量后仍会访问同一张表。
只记住“切片是动态数组”“map 是字典”,通常也能写出代码,但 append、子切片、函数参数、并发读写和 JSON 序列化很快就会暴露这个模型的不足。这一章从基本用法一直讲到所有权与内存语义,既可以顺着阅读,也适合按目录查具体边界。
目录
1. 先建立整体认识
先用一张表把三种类型放在一起:
| 类型 | 示例 | 长度是否属于类型 | 零值 | 能否直接比较 | 常见用途 |
|---|---|---|---|---|---|
| 数组 | [4]int | 是 | 所有元素为零值 | 元素可比较时可以 | 固定尺寸数据、值对象、哈希键 |
| 切片 | []int | 否 | nil | 只能和 nil 比较 | 绝大多数顺序集合 |
| map | map[string]int | 不适用 | nil | 只能和 nil 比较 | 键值索引、集合、计数器 |
三者都不能直接套用 Java 集合类的理解。
Go 数组更接近固定尺寸的值;切片像一份“数组视图”,但还带有长度和容量,并能通过 append 增长;map 则是语言内建类型,并非带有一组方法的 HashMap 对象。三者的大部分操作由语法和内建函数完成:
len(v)
cap(s)
append(s, values...)
copy(dst, src)
delete(m, key)
clear(v)
make(T, ...)append、make、delete 等名字都是预声明的内建函数。它们不属于某个包,也不是容器的方法。
2. 数组的声明与初始化
2.1 声明数组
数组类型写成 [长度]元素类型:
var numbers [3]int
var names [2]string
var flags [4]bool只声明而不赋值时,数组本身及其每个元素都会初始化为零值:
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 数组字面量
最完整的写法:
numbers := [3]int{10, 20, 30}类型已能从右侧推断时也可以用 var:
var numbers = [3]int{10, 20, 30}如果不想重复计算长度,可以让编译器数元素:
numbers := [...]int{10, 20, 30}
fmt.Printf("%T\n", numbers) // [3]int[...] 只用于数组字面量,表示“长度由字面量推导”;它不是一种可以到处使用的“任意长度数组类型”。
下面不能编译:
// var numbers [...]int
// func handle(values [...]int) {}2.3 部分初始化
没有显式给出的元素仍是零值:
numbers := [5]int{10, 20}
fmt.Println(numbers) // [10 20 0 0 0]也可以按索引初始化:
numbers := [6]int{
1: 10,
4: 40,
}
fmt.Println(numbers) // [0 10 0 0 40 0]索引可以和顺序元素混用,但可读性通常不如统一使用一种形式:
numbers := [...]int{
2: 20,
30, // 上一个索引是 2,所以这里位于索引 3
5: 50,
}
fmt.Println(numbers) // [0 0 20 30 0 50]按索引写字面量适合稀疏的固定表,也适合强调某个协议位置:
const (
fieldID = iota
fieldName
fieldEmail
fieldCount
)
fields := [fieldCount]string{
fieldID: "id",
fieldName: "name",
fieldEmail: "email",
}2.4 元素可以是任意类型
“数组中的元素必须是同一种类型”是对的,但这个类型不必是基本类型:
type User struct {
ID int64
Name string
}
users := [2]User{
{ID: 1, Name: "Alice"},
{ID: 2, Name: "Bob"},
}还可以是指针、函数、接口、切片或另一个数组:
var handlers [3]func() error
var buffers [4][]byte
var matrix [3][3]float64这里的“同一种类型”指静态类型一致,并不要求所有值的内容相同。
3. 数组的类型、比较与复制
3.1 长度是数组类型的一部分
[3]int 和 [4]int 是两个不同的类型:
var a [3]int
var b [4]int
// a = b // 编译错误:类型不同这也是函数参数写成 [3]int 后,调用方只能传长度恰好为 3 的数组的原因:
func sum3(values [3]int) int {
return values[0] + values[1] + values[2]
}长度必须是编译期可确定、非负且能用 int 表示的常量:
const size = 4
var ok [size]int
n := 4
// var bad [n]int // 编译错误:n 不是常量Go 不支持运行期长度的数组类型。长度运行时才知道时,用切片。
3.2 数组是值
数组赋值会复制全部元素:
a := [3]int{1, 2, 3}
b := a
b[0] = 100
fmt.Println(a) // [1 2 3]
fmt.Println(b) // [100 2 3]传给函数时也是复制:
func change(a [3]int) {
a[0] = 100
}
func main() {
a := [3]int{1, 2, 3}
change(a)
fmt.Println(a) // [1 2 3]
}需要修改原数组时可以传数组指针:
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 通常更适合接收切片:
func change(values []int) {
values[0] = 100
}
a := [3]int{1, 2, 3}
change(a[:])数组指针常见于“尺寸就是协议的一部分”的场景,例如固定 16 字节标识、32 字节摘要或固定尺寸矩阵。
3.3 数组可以比较
只要元素类型可比较,同类型数组就能使用 == 和 !=:
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数组会逐元素比较。元素不可比较时,数组也不可比较:
var a [2][]int
var b [2][]int
// fmt.Println(a == b) // 编译错误:[]int 不可比较可比较数组还能作为 map 的键:
type IPv4 [4]byte
requests := map[IPv4]int{
{127, 0, 0, 1}: 10,
}这类用途是切片做不到的,因为切片不可比较。
3.4 数组与切片的显式转换
数组变成切片通常使用切片表达式,二者共享存储:
array := [3]int{1, 2, 3}
slice := array[:]
slice[0] = 100
fmt.Println(array) // [100 2 3]从 Go 1.20 起,也可以把切片显式转换为数组值:
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:
slice := []int{1, 2}
// array := [3]int(slice) // panic转换成数组指针则共享底层数组:
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 开始的整数索引:
values := []string{"a", "b", "c", "d"}
fmt.Println(values[0]) // a
fmt.Println(values[3]) // d有效索引范围是:
0 <= i && i < len(values)Go 不支持负索引。倒数第 n 个元素要换算:
last := values[len(values)-1] // d
secondLast := values[len(values)-2] // c原教程把 len(values)-2 的结果写成了 "a",那是笔误。长度为 4 时,4-2 等于 2,索引 2 对应 "c"。
如果集合可能为空,读取最后一个元素前必须判断:
if len(values) > 0 {
fmt.Println(values[len(values)-1])
}越界不会返回 null 或零值,而是在运行时 panic:
values := []int{10, 20}
fmt.Println(values[2]) // panic: index out of range4.2 普通 for
需要精确控制索引时:
for i := 0; i < len(values); i++ {
fmt.Println(i, values[i])
}逆序遍历:
for i := len(values) - 1; i >= 0; i-- {
fmt.Println(values[i])
}这里索引用 int 很重要。如果写成无符号整数,减到 0 后还会下溢。
4.3 range
同时取得索引和值:
for i, value := range values {
fmt.Println(i, value)
}只要索引:
for i := range values {
fmt.Println(i)
}只要值:
for _, value := range values {
fmt.Println(value)
}4.4 range 中的 value 是副本
这是最常见的坑之一:
type User struct {
Name string
}
users := []User{{Name: "Alice"}, {Name: "Bob"}}
for _, user := range users {
user.Name = "changed"
}
fmt.Println(users) // [{Alice} {Bob}]user 是本次元素值的副本,修改它不会修改切片中的结构体。按索引改:
for i := range users {
users[i].Name = "changed"
}如果元素本身是指针,复制的是指针值,指向的对象仍然相同:
users := []*User{{Name: "Alice"}, {Name: "Bob"}}
for _, user := range users {
user.Name = "changed"
}不要把“range 值是副本”简化成“range 里永远改不了原值”。要看复制后的值里面是否还引用同一份数据。
4.5 range 数组时还可能复制整个数组
数组是值。下面 range 的表达式会先得到数组值:
a := [3]int{1, 2, 3}
for i, value := range a {
a[1] = 200
fmt.Println(i, value)
}循环读取的是开始迭代时得到的数组副本,修改 a 不会改变后续迭代值。大数组又可能产生不必要的复制。
只需要索引时,规范允许不对数组或切片做元素索引:
for i := range a {
a[i] *= 2
}也可以遍历数组指针:
for i, value := range &a {
fmt.Println(i, value)
}工程代码里如果数组很大,先确认是否真的需要数组,而不是切片。
5. 多维数组
Go 的多维数组是数组嵌套:
matrix := [2][3]int{
{1, 2, 3},
{4, 5, 6},
}
fmt.Println(matrix[1][2]) // 6[2][3]int 的意思是“长度为 2 的数组,每个元素都是 [3]int”。它不是一个特殊的二维对象。
遍历:
for row := range matrix {
for col := range matrix[row] {
fmt.Printf("%d ", matrix[row][col])
}
fmt.Println()
}因为内层数组长度也属于类型,下面两个类型不同:
var a [2][3]int
var b [3][2]int
// a = b // 编译错误如果每行长度不同,使用切片的切片:
rows := [][]int{
{1, 2},
{3, 4, 5},
{6},
}注意,[][]int 的每个内层切片彼此独立,不保证所有数据处于一块连续内存。数值计算或协议布局如果要求连续存储,可以用一个一维切片手工换算:
width := 3
data := make([]float64, 2*width)
at := func(row, col int) int {
return row*width + col
}
data[at(1, 2)] = 3.146. 切片到底是什么
切片不是“没有长度的数组”,也不是数组本身。它是对底层数组某个连续区间的描述。
从概念上看,一个切片值包含三项信息:
指向底层数组中某个元素的位置
长度 len
容量 cap业务代码不应依赖运行时实现细节,但下面的简化模型很适合解释切片行为:
type conceptualSliceHeader struct {
Data *T
Len int
Cap int
}例如:
array := [5]int{10, 20, 30, 40, 50}
slice := array[1:4]此时:
slice 的元素: [20 30 40]
len(slice): 3
cap(slice): 4容量是 4,因为它从原数组索引 1 开始,底层数组到末尾还有 4 个位置。
切片索引仍从 0 开始:
fmt.Println(slice[0]) // 20,对应 array[1]修改切片会修改共享的底层数组:
slice[1] = 300
fmt.Println(array) // [10 20 300 40 50]把切片赋值给另一个变量,只复制这份描述信息,不复制元素:
a := []int{1, 2, 3}
b := a
b[0] = 100
fmt.Println(a) // [100 2 3]这也是切片传给函数后,函数通常能改动已有元素的根本原因。
7. 创建切片:nil、字面量与 make
7.1 nil 切片
只声明不初始化:
var values []int
fmt.Println(values == nil) // true
fmt.Println(len(values)) // 0
fmt.Println(cap(values)) // 0nil 切片是可用的零值。可以安全地:
- 读取
len和cap; - 使用
range,循环零次; - 执行
append; - 执行
copy; - 再切成
values[:0]。
var values []int
values = append(values, 10)
fmt.Println(values) // [10]不能读取 values[0],原因是长度为 0,不是因为它“未初始化”。
7.2 空但非 nil 的切片
字面量:
values := []int{}或 make:
values := make([]int, 0)它们的长度和容量通常都是 0,但不等于 nil:
fmt.Println(values == nil) // false在大多数集合操作里,nil 和空切片行为相同;在 JSON、反射、某些协议和测试断言里,它们可能不同,后面会单独讲。
7.3 切片字面量
names := []string{"Alice", "Bob"}注意与数组的区别:
[3]int{1, 2, 3} // 数组
[...]int{1, 2, 3} // 数组
[]int{1, 2, 3} // 切片也可以按索引初始化:
values := []int{
2: 20,
5: 50,
}
fmt.Println(values) // [0 0 20 0 0 50]7.4 make 的长度与容量
完整形式:
make([]T, length, capacity)容量省略时等于长度:
a := make([]int, 3)
fmt.Println(a, len(a), cap(a)) // [0 0 0] 3 3显式指定容量:
b := make([]int, 0, 8)
fmt.Println(b, len(b), cap(b)) // [] 0 8必须满足:
0 <= length <= capacitylength 和 capacity 分别回答两个问题:
- 长度内的元素已经存在,可以用索引读写;
- 容量只是底层数组预留空间,容量大于长度的部分还不能直接索引;
append才会把新元素纳入长度。
错误示例:
values := make([]int, 0, 8)
// values[0] = 10 // panic:长度仍然是 0正确做法:
values = append(values, 10)如果最终会填满固定数量,也可以先创建长度,再按索引赋值:
values := make([]int, 3)
for i := range values {
values[i] = i * 10
}二者选择取决于代码表达的含义:
make([]T, n) // 现在就有 n 个零值元素,随后覆盖
make([]T, 0, n) // 现在没有元素,预计最多追加 n 个8. 切片表达式与容量边界
8.1 左闭右开
基本形式:
s[low:high]包含 low,不包含 high:
values := []string{"a", "b", "c", "d"}
fmt.Println(values[1:3]) // [b c]结果长度是:
high - low使用左闭右开区间有几个好处:长度直接相减、相邻区间不重叠、空区间可以写成 s[i:i]。
8.2 省略边界
values[2:] // values[2:len(values)]
values[:3] // values[0:3]
values[:] // values[0:len(values)]values[:] 生成一个共享底层数组的新切片值,并不会复制元素。
8.3 对切片再次切片
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]base、a、b 都指向同一个底层数组的不同区间。
8.4 切片可以扩到 cap,而不只是 len
对于切片,重新切片的上界可以到容量:
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 三索引切片
完整切片表达式:
s[low:high:max]结果:
len = high - low
cap = max - low例如:
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 覆盖父切片后面的元素:
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 边界条件
简单切片对数组、字符串和切片都适用。对于切片,运行时边界需要满足:
0 <= low <= high <= cap(s)三索引切片:
0 <= low <= high <= max <= cap(s)越界会 panic。处理外部传入的分页、区间参数时,应先验证:
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 返回新的切片值
追加一个元素:
values := []int{1, 2}
values = append(values, 3)追加多个:
values = append(values, 4, 5, 6)追加另一个切片要展开:
more := []int{7, 8}
values = append(values, more...)append 必须接住返回值:
values = append(values, 3) // 正确以下代码甚至不能编译,因为 Go 不允许无意义地丢弃 append 的结果:
// append(values, 3)原因不是形式主义。追加后长度一定改变,容量不足时底层数组和起始地址也会改变,所以调用者必须取得新的切片描述。
9.2 容量足够时复用底层数组
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 容量不足时分配新数组
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 不要依赖具体扩容倍率
运行时会选择新的容量,但具体增长策略是实现细节,不能把“每次翻倍”当语言保证:
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 造成的“时好时坏”
下面的函数表面上只是追加,实际上是否影响调用方的底层数组取决于容量:
func addMarker(values []int) []int {
return append(values, 999)
}如果调用者仍然持有原切片,此时就会出现一组很容易误判的共享关系:
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 自己是合法的
可以把切片的一部分追加到自身:
values := []int{1, 2, 3}
values = append(values, values...)
fmt.Println(values) // [1 2 3 1 2 3]append 已经保证结果正确,调用方不需要手工处理中间覆盖。
10. copy、克隆与深浅复制
10.1 copy 复制多少个元素
n := copy(dst, src)复制数量是:
min(len(dst), len(src))示例:
src := []int{10, 20, 30}
dst := make([]int, 2)
n := copy(dst, src)
fmt.Println(n) // 2
fmt.Println(dst) // [10 20]目标切片必须先有足够的长度,不是只有容量:
src := []int{10, 20, 30}
dst := make([]int, 0, 3)
n := copy(dst, src)
fmt.Println(n, dst) // 0 []正确做法:
dst := make([]int, len(src))
copy(dst, src)10.2 copy 支持重叠区域
copy 能正确处理源和目标共享底层数组的情况:
values := []int{1, 2, 3, 4, 5}
copy(values[1:], values[:4])
fmt.Println(values) // [1 1 2 3 4]这类似于能处理重叠内存的 memmove,因此适合切片内移动。
10.3 常用克隆方式
使用标准库:
cloned := slices.Clone(values)使用 copy:
cloned := make([]int, len(values))
copy(cloned, values)使用 append:
cloned := append([]int(nil), values...)新代码优先考虑 slices.Clone,意图最清楚,而且会保留 nil:
var values []int
cloned := slices.Clone(values)
fmt.Println(cloned == nil) // true10.4 元素复制仍可能是浅复制
克隆切片只复制每个元素的值。如果元素里有指针、切片、map 或其他引用共享数据的字段,内部对象没有递归复制:
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]) // javaName 是字符串值,替换克隆元素的字段不会影响原结构体;Tags 是切片,两个字段仍指向同一个底层数组。
真正的深复制要按数据结构定义:
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 读取与修改
values := []int{10, 20, 30}
value := values[1]
values[1] = 200
fmt.Println(value) // 20
fmt.Println(values) // [10 200 30]需要先判断索引是否合法:
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 尾部添加和删除
尾部添加:
values = append(values, value)读取并删除最后一个:
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 等含指针数据的元素,清零可以避免底层数组继续持有已删除对象。
泛型代码可以使用零值:
var zero T
values[len(values)-1] = zero
values = values[:len(values)-1]11.3 保持顺序地删除
传统写法:
values = append(values[:i], values[i+1:]...)现在更推荐:
values = slices.Delete(values, i, i+1)删除区间 [i:j):
values = slices.Delete(values, i, j)slices.Delete 返回修改后的切片,必须接住返回值。它会移动后续元素,时间复杂度为 O(len(values)-i)。
11.4 不保持顺序地删除
如果顺序不重要,用最后一个元素覆盖待删元素,通常是 O(1):
last := len(values) - 1
values[i] = values[last]
values[last] = 0
values = values[:last]泛型版本:
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 前插入:
values = slices.Insert(values, i, 99)插入多个:
values = slices.Insert(values, i, 90, 91, 92)传统写法需要小心扩容和重叠:
values = append(values, 0)
copy(values[i+1:], values[i:])
values[i] = 99标准库版本更清楚。
11.6 替换区间
values = slices.Replace(values, i, j, newValues...)它把 [i:j) 替换成新元素,替换前后长度可以不同。
11.7 过滤
分配一个新切片,所有权最清楚:
result := make([]int, 0, len(values))
for _, value := range values {
if value%2 == 0 {
result = append(result, value)
}
}原地过滤,复用输入数组:
result := values[:0]
for _, value := range values {
if value%2 == 0 {
result = append(result, value)
}
}
clear(values[len(result):])
values = result原地版本分配更少,但会覆盖原切片内容。除非 API 契约允许修改输入,否则不要暗中采用。
11.8 去重
不要求排序、保留第一次出现顺序:
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
}已经排序时,可原地压缩相邻重复项:
slices.Sort(values)
values = slices.Compact(values)Compact 只移除相邻重复项,不会替你排序。
11.9 清空
保留容量、长度设为 0:
values = values[:0]丢弃对底层数组的引用:
values = nil把当前长度范围内的元素设为零值,但长度不变:
clear(values)三种“清空”含义不同:
| 写法 | len | 是否保留底层数组 | 元素是否清零 |
|---|---|---|---|
values = values[:0] | 0 | 是 | 不保证 |
values = nil | 0 | 否 | 不适用 |
clear(values) | 不变 | 是 | 是 |
如果打算复用缓冲区,通常用 [:0];如果数组很大且不再需要,设为 nil 让垃圾回收器有机会回收;如果仍保留切片但要清除引用,使用 clear。
12. 内存滞留与容量控制
12.1 小切片可能留住大数组
一个很小的子切片仍可能引用整块大底层数组:
func firstKB(data []byte) []byte {
return data[:1024]
}如果 data 是 100 MB,返回值虽然长度只有 1 KB,仍可能让 100 MB 的底层数组保持可达。
需要独立保存小片段时复制:
func firstKB(data []byte) []byte {
n := min(1024, len(data))
return bytes.Clone(data[:n])
}通用切片:
small := slices.Clone(large[:n])是否复制是生命周期权衡:短暂处理时共享很高效;小结果要长期存活,而原数据很大时应考虑复制。
12.2 删除元素后仍可能持有对象
传统删除:
values = append(values[:i], values[i+1:]...)缩短了长度,但底层数组尾部可能仍保留被移动或删除对象的引用。现代 slices.Delete 会把不再使用的尾部元素清零,减少这类意外滞留。
手写容器算法时,元素含指针就要考虑清零:
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 把容量限制到长度:
values := make([]int, 3, 100)
values = slices.Clip(values)
fmt.Println(len(values)) // 3
fmt.Println(cap(values)) // 3它等价于:
values = values[:len(values):len(values)]注意,Clip 只是限制切片的容量,并不承诺把底层数组复制成更小数组,也不等于立即释放多余内存。
它常用于 API 边界,避免调用者追加时覆盖当前切片后面的共享数据:
func page(data []Item, start, end int) []Item {
return slices.Clip(data[start:end])
}如果还要切断内存共享,应使用 slices.Clone。
12.4 slices.Grow
知道还要追加多少元素时:
values = slices.Grow(values, additional)它保证之后至少还能追加 additional 个元素而无需再次分配;现有长度和值不变。
仍然必须接住返回值:
values = slices.Grow(values, 100)对于新建切片,更直接的是:
values := make([]T, 0, expected)Grow 更适合已有切片继续批量增长。
13. 函数参数、返回值与所有权
13.1 传切片仍然是值传递
Go 只有值传递。切片传参时,复制的是切片描述,不是底层数组:
func changeFirst(values []int) {
values[0] = 100
}
func main() {
values := []int{1, 2, 3}
changeFirst(values)
fmt.Println(values) // [100 2 3]
}函数拿到的切片描述和调用方指向同一个底层数组,因此能修改已有元素。这不意味着参数本身按引用传递。
下面的重新赋值不会改变调用方切片变量:
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 后为什么要返回切片
func add(values []int, value int) {
values = append(values, value)
}即使底层数组恰好被复用,调用方的长度也不会更新;如果发生扩容,调用方连新数组都拿不到。
正确形式:
func add(values []int, value int) []int {
return append(values, value)
}
values = add(values, 10)如果确实要让函数替换调用方的切片变量,可以传 *[]T:
func add(values *[]int, value int) {
*values = append(*values, value)
}但普通“构建并返回”场景通常直接返回切片更自然,也更容易组合。*[]T 应保留给“修改切片变量本身”确实属于 API 语义的场景。
13.3 接收参数时明确是否允许修改
类型签名 func process([]byte) 看不出函数是否会修改输入。Go 没有只读切片类型,所以要靠 API 契约、命名和实现约束。
只读处理:
func checksum(data []byte) uint32 {
// 不改 data,也不把它保存到函数外
return crc32.ChecksumIEEE(data)
}原地修改应表达出来:
func normalizeInPlace(values []float64) {
// ...
}需要长期保存调用方数据时,决定是共享还是复制:
type Message struct {
payload []byte
}
func NewMessage(payload []byte) *Message {
return &Message{
payload: bytes.Clone(payload),
}
}复制带来分配成本,但建立了清晰所有权。若为了性能保留输入切片,应在文档中写明调用后谁还能修改、数据必须存活多久。
13.4 返回内部切片会暴露可变状态
type Store struct {
items []Item
}
func (s *Store) Items() []Item {
return s.items
}调用方可以修改 Store 的内部数据:
items := store.Items()
items[0] = Item{}如果不希望暴露,返回克隆:
func (s *Store) Items() []Item {
return slices.Clone(s.items)
}或者提供迭代、查询方法,不返回全部内部存储。这里没有统一答案:高频只读路径可能接受共享以减少分配,但契约必须明确。
13.5 字节缓冲区尤其要讲所有权
网络和编解码代码大量复用 []byte。有些 API 返回的切片只在下一次调用前有效,有些 API 会保留传入切片,还有些会转移所有权。
看到类似接口时要主动确认:
Read() []byte
Write([]byte)
Decode([]byte) Value仅凭类型无法知道生命周期。阅读文档与实现,不要默认数据已经复制。
13.6 可变参数本质上也是切片
func sum(values ...int) int {
total := 0
for _, value := range values {
total += value
}
return total
}函数内部 values 的类型是 []int。可以逐个传参:
sum(1, 2, 3)也可以展开已有切片:
values := []int{1, 2, 3}
sum(values...)传入 values... 时,函数收到的切片会引用相同的底层数组,因此函数修改现有元素可能影响调用方:
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.Ints、sort.Strings 更统一。
14.1 基本排序
values := []int{4, 5, 3, 2, 7}
slices.Sort(values)
fmt.Println(values) // [2 3 4 5 7]slices.Sort 原地修改切片,没有返回值。
降序:
slices.Sort(values)
slices.Reverse(values)也可以自定义比较:
slices.SortFunc(values, func(a, b int) int {
return cmp.Compare(b, a)
})比较函数返回:
负数:a 排在 b 前
零: a 与 b 在排序意义上相等
正数:a 排在 b 后不要写成减法:
// return a - b大整数可能溢出。使用 cmp.Compare(a, b)。
14.2 结构体多字段排序
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 不保证相等元素保持原顺序。需要稳定性时:
slices.SortStableFunc(users, func(a, b User) int {
return strings.Compare(a.Name, b.Name)
})所谓稳定,是比较结果为 0 的元素保持排序前的相对顺序。
14.4 查找与判断包含
线性查找:
index := slices.Index(values, target)
if index == -1 {
fmt.Println("not found")
}是否包含:
if slices.Contains(values, target) {
// ...
}自定义条件:
index := slices.IndexFunc(users, func(user User) bool {
return user.Name == "Alice"
})这些操作都是线性扫描,时间复杂度为 O(n)。
14.5 二分查找
切片已经按相同规则升序排列时:
values := []int{10, 20, 30, 40}
index, found := slices.BinarySearch(values, 30)
fmt.Println(index, found) // 2 true没找到时,index 是目标按顺序应插入的位置:
index, found := slices.BinarySearch(values, 25)
fmt.Println(index, found) // 2 false可以直接插入:
if !found {
values = slices.Insert(values, index, 25)
}未排序的切片上调用二分查找没有意义。排序规则和查找比较规则也必须一致。
14.6 比较切片
切片不能使用 == 比较内容。元素可比较时:
equal := slices.Equal(a, b)自定义等价规则:
equal := slices.EqualFunc(a, b, func(x, y User) bool {
return x.ID == y.ID
})字典序比较:
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 可以遍历特定签名的迭代器函数,slices 和 maps 包也提供了与 iter.Seq、iter.Seq2 配合的函数。
15.1 正向与反向迭代
普通正向遍历不必包装:
for i, value := range values {
fmt.Println(i, value)
}需要把“索引和值的序列”作为迭代器传递时:
seq := slices.All(values)
for i, value := range seq {
fmt.Println(i, value)
}反向:
for i, value := range slices.Backward(values) {
fmt.Println(i, value)
}15.2 分块处理
for batch := range slices.Chunk(values, 100) {
if err := handleBatch(batch); err != nil {
return err
}
}每块最多 100 个元素。Chunk 返回的子切片与原切片共享元素,但容量被限制为长度,向某一块 append 不会覆盖下一块。
这很适合批量数据库请求、批量 RPC 和分页写入。
15.3 迭代器转切片
values := slices.Collect(seq)追加到已有切片:
values = slices.AppendSeq(values, seq)排序一个序列:
values := slices.Sorted(seq)15.4 不要为了新语法把简单循环复杂化
迭代器适合:
- 惰性生成数据;
- 组合标准库返回的序列;
- 隐藏集合内部表示;
- 避免为了遍历而先建立完整中间切片。
如果手里已经有切片,只做一次简单遍历,普通 range 最清楚。新特性不是必须套在每段代码上。
16. map 的类型与创建
16.1 map 类型
语法:
map[Key]Value示例:
map[string]int
map[int64]User
map[UserID]*User
map[string][]string键和值的类型在编译期确定,同一张 map 中不能临时混入不同类型的键值。
如果确实要保存不同动态类型,可以把值写成 any:
metadata := map[string]any{
"name": "Alice",
"age": 30,
"active": true,
}但读取后需要类型断言,静态类型保护也更弱。业务模型明确时优先定义结构体。
16.2 键的准确规则:必须可比较
“map 的 key 必须是基本数据类型”并不准确。规范要求键类型支持 == 和 !=。
常见可用键:
map[string]int
map[int64]string
map[bool]string
map[*User]State
map[[16]byte]Record
map[Coordinate]Place结构体所有字段都可比较时,结构体也可作为键:
type Coordinate struct {
X int
Y int
}
visited := map[Coordinate]bool{
{X: 10, Y: 20}: true,
}不可作为键:
// map[[]byte]int
// map[map[string]int]int
// map[func()]int切片、map、函数不可比较。
浮点数虽然可比较,技术上能做键,但 NaN != NaN、+0 == -0 等语义常令人意外。业务键通常不要选浮点数。
map 会复制键值。插入后修改原结构体变量,不会修改已经存入的键:
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如果键是指针,比较和哈希依据是指针身份,不是指向对象的字段内容:
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 是不同指针接口类型可以声明为键:
m := map[any]string{}但放入的动态值必须可比较。下面会在运行时 panic:
key := []int{1, 2, 3}
m[key] = "value" // panic: hash of unhashable type []int编译器只能确认接口类型本身可比较,动态值是否合法要到运行时才知道。
16.3 nil map
var users map[int64]User
fmt.Println(users == nil) // true
fmt.Println(len(users)) // 0nil map 可以读取、查询、遍历和删除:
fmt.Println(users[1]) // User 的零值
_, exists := users[1]
fmt.Println(exists) // false
delete(users, 1) // 安全,无事发生
for id, user := range users {
fmt.Println(id, user) // 循环零次
}不能写入:
users[1] = User{} // panic: assignment to entry in nil map16.4 make 初始化
users := make(map[int64]User)可以提供容量提示:
users := make(map[int64]User, expectedCount)第二个参数只是初始空间提示,不是长度,不会预先放入零值元素:
users := make(map[int64]User, 100)
fmt.Println(len(users)) // 0也没有 cap(users)。map 的桶、增长策略和容量是运行时实现细节。
16.5 map 字面量
空 map:
users := map[int64]User{}声明并填充:
statusCode := map[string]int{
"ok": 200,
"not_found": 404,
}复合值可省略重复类型:
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) 通常不是你想要的
p := new(map[string]int)p 的类型是 *map[string]int,但它指向的 map 值仍是 nil:
fmt.Println(*p == nil) // true还要再初始化才能写:
*p = make(map[string]int)
(*p)["answer"] = 42绝大多数场景直接 make。Go 的 new 只分配一个零值变量并返回指针,make 才负责初始化切片、map 和 channel 的运行时结构。
17. map 的读取、写入、删除与清空
17.1 写入和更新
scores := make(map[string]int)
scores["Alice"] = 90 // 新增
scores["Alice"] = 95 // 覆盖写法相同,键不存在时新增,存在时替换。
17.2 读取
score := scores["Alice"]键存在时返回值;不存在时返回值类型的零值:
scores := map[string]int{"Alice": 95}
fmt.Println(scores["Alice"]) // 95
fmt.Println(scores["Bob"]) // 0这不代表 "Bob" 存在且分数为 0。需要区分时使用 comma-ok。
17.3 删除
delete(scores, "Alice")删除不存在的键是安全的,不返回结果,也不报错:
delete(scores, "nobody")如果需要知道是否曾经存在,先查询:
if _, exists := scores[name]; exists {
delete(scores, name)
}不要仅仅为了“安全删除”而先查一次,delete 本身已经安全。只有业务确实关心存在性时才查。
17.4 清空
Go 1.21 起可使用:
clear(scores)它删除所有条目,scores 仍是可写的非 nil map:
fmt.Println(len(scores)) // 0
fmt.Println(scores == nil) // false也可以丢弃整张 map:
scores = nil之后读是安全的,写会 panic。如果还要继续写:
scores = make(map[string]int)选择 clear(m) 还是重新 make,先看语义和对象生命周期。不要假设 clear 会立即归还所有内部内存,也不要依赖运行时桶实现。
17.5 len
count := len(scores)返回当前键值对数量。nil map 的长度也是 0。
18. comma ok 与零值技巧
18.1 区分不存在和值为零
score, exists := scores["Bob"]
if !exists {
fmt.Println("Bob 不存在")
}变量名常用 ok 或 exists:
value, ok := m[key]只检查是否存在:
if _, ok := m[key]; ok {
// key 存在
}18.2 零值有时正好能简化代码
计数器:
counts := make(map[string]int)
for _, word := range words {
counts[word]++
}不存在的键读取为 0,所以无需先判断。
分组:
groups := make(map[string][]User)
for _, user := range users {
groups[user.Department] = append(
groups[user.Department],
user,
)
}不存在的键读取为 nil 切片,而 append(nil, user) 合法。
集合:
set := make(map[string]struct{})
set["go"] = struct{}{}struct{}{} 是零尺寸值,常用来表达“只关心键是否存在”:
if _, exists := set["go"]; exists {
fmt.Println("contains go")
}若用 map[string]bool,读取写起来更短:
if enabled["go"] {
// ...
}但 false 可能表示“明确关闭”,也可能表示“不存在”。需要区分三态时用 comma-ok,或使用 map[K]struct{} 表达纯集合。
18.3 map 中嵌套 map
内层 map 也要初始化:
matrix := make(map[string]map[string]int)
row := matrix["A"]
if row == nil {
row = make(map[string]int)
matrix["A"] = row
}
row["B"]++封装辅助函数:
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 可能更简单:
type Pair struct {
Row string
Col string
}
matrix := make(map[Pair]int)
matrix[Pair{Row: "A", Col: "B"}]++18.4 获取或创建
Go 没有内建 computeIfAbsent。普通写法:
value, ok := cache[key]
if !ok {
value = buildValue(key)
cache[key] = value
}并发环境不能把这段代码直接搬过去,因为“读取、计算、写入”不是原子操作,需要锁、sync.Map 或更高层的 singleflight 策略。
19. map 的遍历顺序
19.1 顺序没有规定
for key, value := range m {
fmt.Println(key, value)
}map 的遍历顺序未规定,也不保证两次遍历一致。不要把当前一次运行看到的顺序当成插入顺序、哈希顺序或稳定顺序。
这不是偶尔才会变化的边角行为。语言层面就没有顺序契约。
19.2 需要稳定输出时排序键
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 返回的迭代器:
keys := slices.Sorted(maps.Keys(m))
for _, key := range keys {
fmt.Println(key, m[key])
}稳定顺序对以下场景尤其重要:
- 生成文本、配置和 SQL;
- 日志与快照测试;
- 哈希或签名输入;
- 需要可复现的测试失败信息;
- 面向用户的输出。
19.3 遍历中删除
在同一个 goroutine 中遍历时删除尚未到达的条目是语言允许的;被删除条目不会再产生:
for key, value := range m {
if shouldDelete(value) {
delete(m, key)
}
}这是很实用的原地过滤方式。
遍历中新增条目时,新条目可能被遍历到,也可能不被遍历到。不要依赖某一种结果:
for key := range m {
m[newKey(key)] = value // 新键是否在本轮出现,没有保证
}这里说的是单 goroutine 内的语言语义。多个 goroutine 并发访问是另一回事。
20. map 的赋值语义与元素限制
20.1 复制 map 变量不会复制条目
a := map[string]int{"x": 1}
b := a
b["x"] = 100
fmt.Println(a["x"]) // 100a 和 b 访问同一张 map。可以把 map 值理解成由运行时管理的数据结构的描述符,但不要依赖其具体内部布局。
传参同理:
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 函数内重新赋值不会替换调用方变量
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:
func reset() map[string]int {
return map[string]int{"new": 1}
}
m = reset()传 *map[K]V 通常没必要,因为 map 本身已经具备共享修改条目的语义。只有确实需要替换 map 变量且返回值不合适时再考虑指针。
20.3 map 元素不可取地址
下面不能编译:
m := map[string]int{"x": 1}
// p := &m["x"]map 增长时元素可能迁移,语言不允许取得元素地址。
同样不能直接修改 map 中结构体值的字段:
type User struct {
Name string
}
users := map[int64]User{
1: {Name: "Alice"},
}
// users[1].Name = "Bob" // 编译错误取出、修改、放回:
user := users[1]
user.Name = "Bob"
users[1] = user或者存指针:
users := map[int64]*User{
1: {Name: "Alice"},
}
users[1].Name = "Bob"两种模型语义不同:
map[K]User保存结构体值,取出会复制,所有权更清晰;map[K]*User保存指针,便于原地修改,但会引入别名、nil和共享可变状态。
不要只为了少写两行代码就把所有 map value 改成指针。
20.4 map 本身不能比较
var a map[string]int
var b map[string]int
// fmt.Println(a == b) // 编译错误只能与 nil 比较:
fmt.Println(a == nil)内容比较使用 maps.Equal 或自定义逻辑。
21. map 的克隆、复制与比较
21.1 maps.Clone
cloned := maps.Clone(original)这是浅克隆:
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 结构处理:
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
把源条目复制进目标:
maps.Copy(dst, src)键冲突时,src 的值覆盖 dst:
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:
var dst map[string]int
// maps.Copy(dst, src) // 写入 nil map,会 panic21.3 maps.Equal
值可比较时:
equal := maps.Equal(a, b)它比较键集合以及每个键对应的值,不关心遍历顺序。
自定义 value 比较:
equal := maps.EqualFunc(a, b, func(x, y User) bool {
return x.ID == y.ID
})21.4 DeleteFunc
按条件删除:
maps.DeleteFunc(m, func(key string, value int) bool {
return value < 0
})条件返回 true 的条目被删除。
21.5 Keys 与 Values
当前标准库中:
keySeq := maps.Keys(m)
valueSeq := maps.Values(m)它们返回迭代器,不是切片。需要切片时收集:
keys := slices.Collect(maps.Keys(m))
values := slices.Collect(maps.Values(m))返回顺序仍未规定。需要稳定键序:
keys := slices.Sorted(maps.Keys(m))不要假设 Keys 与 Values 分别收集后能按索引一一对应。两次遍历顺序都没有保证。
21.6 map 与键值迭代器互转
把键值迭代器收集为新 map:
cloned := maps.Collect(maps.All(original))把序列条目插入已有 map:
maps.Insert(dst, maps.All(src))键冲突时,后写入的值覆盖原值。这两个 API 在组合迭代器时很方便;单纯克隆和复制仍优先使用意图更直接的 maps.Clone、maps.Copy。
22. 并发访问 map
22.1 普通 map 不是并发容器
多个 goroutine 访问同一张 map 时,只要存在写操作,就必须同步:
// 错误示意
go func() {
m["x"] = 1
}()
go func() {
fmt.Println(m["x"])
}()并发读写或并发写可能导致数据竞争,运行时还可能报:
fatal error: concurrent map read and map write
fatal error: concurrent map writes不要把运行时是否恰好报 fatal 当安全检测。没有同步的数据竞争本身就已经是错误。
多个 goroutine 只读同一张 map 是可以的,前提是 map 已完成构建,并且没有任何 goroutine 同时修改它或其共享可变 value。
22.2 使用互斥锁
常见封装:
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 语句。复合操作必须在同一个临界区:
func (c *Counter) Increment(key string) int {
c.mu.Lock()
defer c.mu.Unlock()
c.values[key]++
return c.values[key]
}不能把读锁和写锁拆开后期待“读—改—写”整体原子。
22.3 返回内部引用时锁可能提前失效
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 需要隔离,锁内克隆:
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 操作彼此不相交的键集合。
var cache sync.Map
cache.Store("answer", 42)
value, ok := cache.Load("answer")
if ok {
answer := value.(int)
fmt.Println(answer)
}它使用 any,需要类型断言。普通业务 map 加 Mutex 或 RWMutex 往往类型更安全,也更容易维护跨多个字段的不变量。
常用原子式操作:
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 发送请求:
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 验证
测试:
go test -race ./...运行程序:
go run -race .构建:
go build -race ./cmd/serverrace detector 只能发现实际执行路径中发生的数据竞争,不是形式化证明。并发测试要覆盖真实负载和关键分支。
23. nil 与空集合
23.1 切片的三种常见状态
var nilSlice []int
emptyLiteral := []int{}
emptyMake := make([]int, 0)它们都有:
len(s) == 0但只有第一个等于 nil:
fmt.Println(nilSlice == nil) // true
fmt.Println(emptyLiteral == nil) // false
fmt.Println(emptyMake == nil) // false集合算法通常只应关心长度:
if len(values) == 0 {
// nil 和空切片都进入这里
}只有协议明确区分“缺失”和“已提供但为空”时,才检查 nil。
23.2 map 也有 nil 和空
var nilMap map[string]int
emptyMap := map[string]int{}两者长度均为 0。不同点是空 map 可以写,nil map 不可以写。
23.3 JSON 序列化不同
标准 encoding/json 默认会区分:
type Payload struct {
Items []int `json:"items"`
Meta map[string]int `json:"meta"`
}字段为 nil:
{
"items": null,
"meta": null
}字段为空但非 nil:
{
"items": [],
"meta": {}
}对外 API 如果约定集合永远输出 [] 或 {},构造响应时就要初始化:
payload := Payload{
Items: make([]int, 0),
Meta: make(map[string]int),
}带 omitempty 时,长度为 0 的切片和 map 通常都会被省略:
Items []int `json:"items,omitempty"`“缺字段”“null”“空数组”在一些客户端和补丁协议中含义不同,不能只看 Go 内部操作是否一样。
23.4 JSON 中 map 键的限制
Go map 可以使用很多可比较类型作为键,但 JSON 对象的键必须是字符串。encoding/json 支持的 map 键类型更窄,通常是:
- 字符串类型;
- 整数类型,编码成十进制字符串;
- 实现
encoding.TextMarshaler的类型。
结构体键虽然能用于 Go map,却不能直接作为普通 JSON 对象键:
type Coordinate struct {
X int
Y int
}
points := map[Coordinate]string{}对外序列化时可以改成条目数组:
type Entry struct {
Coordinate Coordinate `json:"coordinate"`
Value string `json:"value"`
}或者为键定义稳定、可逆的文本编码。
23.5 测试中的相等
reflect.DeepEqual 区分 nil 切片与空切片,也区分 nil map 与空 map:
fmt.Println(
reflect.DeepEqual([]int(nil), []int{}),
) // false而 slices.Equal 把两个长度为 0 的切片视为内容相等:
fmt.Println(
slices.Equal([]int(nil), []int{}),
) // truemaps.Equal 也按条目内容比较,因此两个没有条目的 map 内容相等。
测试前先决定你要断言哪一种语义:
- 要求协议状态完全一致:区分
nil与空; - 只关心集合内容:把二者视为相等。
不要让断言库的默认行为替你做领域决策。
24. 泛型中的切片与 map
24.1 为什么约束常写成 ~[]E
一个只接受字面类型 []E 的函数:
func first[E any](values []E) (E, bool) {
if len(values) == 0 {
var zero E
return zero, false
}
return values[0], true
}读取普通切片完全够用。但如果函数返回同类型切片,并希望保留调用方的定义类型,常写:
func clone[S ~[]E, E any](values S) S {
return slices.Clone(values)
}~[]E 表示底层类型是 []E 的类型:
type UserList []User
users := UserList{{ID: 1}}
cloned := clone(users)
fmt.Printf("%T\n", cloned) // main.UserList如果返回固定 []E,调用方的定义类型会丢失。
24.2 comparable 与 map 键
泛型 map 的键必须满足 comparable:
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 类型:
func cloneMap[M ~map[K]V, K comparable, V any](m M) M {
return maps.Clone(m)
}24.3 一个泛型集合
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)
}使用:
languages := NewSet("Go", "Java")
languages.Add("Rust")
fmt.Println(languages.Contains("Go")) // true这个类型的零值仍是 nil map:
var languages Set[string]
fmt.Println(languages.Contains("Go")) // false
// languages.Add("Go") // panic:nil map 不可写如果希望“零值即可写”,仅靠 map 定义类型做不到;要么要求构造函数,要么用包含 map 字段的结构体并在写方法中延迟初始化:
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
返回同一种切片定义类型:
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 不要为了泛型重新实现标准库
优先查看:
slices
maps
cmp
iter标准库已经覆盖排序、查找、克隆、比较、增删、迭代等大量通用操作。自己写泛型函数的价值通常在领域语义,而不是把 slices.Contains 换个名字。
25. 性能与容量规划
25.1 切片预分配
已知最终长度,先分配长度再按索引写:
result := make([]Output, len(inputs))
for i, input := range inputs {
result[i] = transform(input)
}已知大致上限,但只有部分元素会保留:
result := make([]Output, 0, len(inputs))
for _, input := range inputs {
if shouldKeep(input) {
result = append(result, transform(input))
}
}这样通常能减少扩容和复制。不要为了省几次分配而把容量估计写得过大,长期高估也会浪费内存。
25.2 map 容量提示
index := make(map[int64]User, len(users))
for _, user := range users {
index[user.ID] = user
}容量参数是提示,不是硬上限:
m := make(map[string]int, 10)
// 仍然可以放入远超 10 个键它也不是 Java HashMap 那套公开的 capacity/load factor API。不要依赖具体桶数量。
25.3 数组复制成本
数组按值传递会复制全部元素:
func process(data [1 << 20]byte) {}这不一定都会落到堆上,编译器也可能优化,但类型语义确实是值复制。大数据通常传切片或数组指针:
func process(data []byte) {}
func processFixed(data *[1 << 20]byte) {}不要凭直觉决定,先看语义,再用基准测试与逃逸分析验证性能。
25.4 value 还是 pointer
map[int64]User
map[int64]*User选择时考虑:
User是否很大;- 是否需要共享并原地修改;
- 是否允许
nil; - 所有权是否清楚;
- 是否要减少复制;
- 指针数量对垃圾回收的影响;
- 并发访问内部对象如何同步。
小而稳定的值对象很适合直接存值。大型、需要共享身份的实体可能适合指针。不存在“map value 一律用指针”的规则。
25.5 [][]T 的分配
这样创建二维切片:
rows := make([][]int, rowCount)
for i := range rows {
rows[i] = make([]int, colCount)
}每行通常是一次独立分配。需要连续内存时:
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 基准测试
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)
}
}
}运行:
go test -bench=. -benchmem关注:
ns/op;B/op;allocs/op。
容量优化只是容器性能的一部分。数据库、网络、锁竞争或算法复杂度占主导时,少一次切片扩容可能没有实际意义。
25.7 不要用 unsafe 猜内部结构
unsafe、reflect.SliceHeader 和运行时 map 实现适合少数底层库或调试分析,不适合作为普通业务优化手段。
尤其不要把临时构造的 SliceHeader.Data 当成稳定指针,或依赖 map 桶布局。能用普通切片操作、unsafe.Slice 的受控场景以及标准库完成的,不要手工伪造内部结构。
26. 与 Java 集合的差异
| 主题 | Go | Java |
|---|---|---|
| 固定数组 | [N]T,长度属于类型,数组是值 | T[],长度是对象运行期状态,变量保存引用 |
| 动态顺序集合 | []T 切片,语言内建 | 常用 ArrayList<T> |
| 哈希映射 | map[K]V,语言内建 | HashMap<K,V> 等类 |
| 泛型元素 | 直接保存具体类型 | 泛型不能直接使用 primitive,需要装箱类型 |
| 越界 | panic | IndexOutOfBoundsException |
| 不存在的 map 键 | 返回 value 零值,可用 comma-ok 区分 | get 通常返回 null,也有 containsKey |
| nil/null 键 | map 键类型决定;指针键可为 nil | HashMap 允许一个 null key,部分实现不允许 |
| map 遍历顺序 | 未规定 | HashMap 不保证,LinkedHashMap 可保插入顺序 |
| 集合相等 | 切片/map 不能直接 ==;用 slices/maps | List/Map 通常重写 equals |
| 复制 | 数组复制元素;切片/map 复制描述并共享数据 | 集合变量赋值复制引用 |
| 线程安全 | 普通 map 非并发安全 | HashMap 非线程安全,有 ConcurrentHashMap |
| 只读视图 | 语言没有只读切片/map 类型 | 有 unmodifiable 集合包装 |
| 容量 | 切片公开 cap;map 不公开 | ArrayList/HashMap 有各自容量概念 |
几个迁移时特别值得注意的点:
Java 数组变量是引用,Go 数组变量是值
Java:
int[] a = {1, 2, 3};
int[] b = a;
b[0] = 100; // a[0] 也变Go 数组:
a := [3]int{1, 2, 3}
b := a
b[0] = 100 // a 不变Go 切片才表现出共享底层存储:
a := []int{1, 2, 3}
b := a
b[0] = 100 // a[0] 也变comma-ok 不等于只检查 null
value, ok := m[key]ok 表示键是否存在,不是 value 是否为 nil。对于指针值:
m := map[string]*User{
"known-empty": nil,
}
user, ok := m["known-empty"]
fmt.Println(user == nil, ok) // true true另一个键可能得到:
user, ok := m["missing"]
fmt.Println(user == nil, ok) // true falseGo 没有 ArrayList 那套方法调用风格
不是:
// values.add(x)
// values.size()
// values.remove(i)而是:
values = append(values, x)
len(values)
values = slices.Delete(values, i, i+1)这不是少了面向对象封装,而是语言把高频容器作为内建类型,并由标准泛型函数补充通用算法。
27. 常见误区
数组
把
[3]int和[4]int当成同类型。
长度属于数组类型。以为数组赋值后共享元素。
数组赋值和传参复制整个数组。把
[...]int当作可变长数组类型。
它只在数组字面量中推导长度。大数组无意识按值传递。
先确认是否应改为切片或数组指针。
切片
把容量当长度。
make([]T, 0, n)不能直接写s[0]。认为
s[:]会复制。
它仍共享底层数组。忽略 append 的返回值。
长度和底层数组都可能改变。认为 append 一定扩容,或一定不扩容。
是否共享取决于当前容量;正确性不能依赖猜测。在 range 的 value 副本上修改结构体。
要修改切片元素时按索引操作。返回大缓冲区中的小子切片。
可能长期保留整块底层数组。以为 slices.Clone 是递归深复制。
它只复制切片元素值。删除含指针元素后不考虑清零。
手写删除算法可能让底层数组继续持有对象。认为 slices.Clip 一定释放多余内存。
它只把容量限制为长度。把 nil 和空切片在所有边界都视为完全相同。
JSON、反射和协议可能区分。
map
认为 map key 只能是基本类型。
准确规则是键必须可比较,数组和合适的结构体也可以。向 nil map 写入。
读取安全,写入会panic。只读取一个返回值,却需要区分不存在和零值。
使用value, ok := m[key]。依赖 map 遍历顺序。
需要稳定输出时提取并排序键。直接修改 map 中结构体 value 的字段。
取出、修改、放回,或有意识地存指针。以为 maps.Clone 会深复制 value。
value 含切片、map、指针时仍可能共享内部数据。普通 map 一边读一边并发写。
使用锁、sync.Map或单 goroutine 所有权。锁住 map,却返回内部可变切片或指针。
锁释放后内部对象仍可能被并发修改。用
map[any]V后放入切片键。
编译能过,哈希动态值时会panic。认为 make(map, n) 创建了 n 个元素。
n只是初始空间提示,长度仍是 0。
28. 速查表
数组
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[:]创建切片
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切片范围
s[low:high] // len=high-low
s[low:high:max] // len=high-low,cap=max-low
s[:n]
s[n:]
s[:]追加与复制
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)增删改查
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)清理切片
s = s[:0] // 长度归零,保留底层数组
s = nil // 丢弃切片引用
clear(s) // 长度不变,所有元素设为零值创建 map
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 操作
m[key] = value
value := m[key]
value, ok := m[key]
delete(m, key)
clear(m)
count := len(m)map 工具
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))集合
set := make(map[string]struct{})
set[value] = struct{}{}
_, exists := set[value]
delete(set, value)并发检查
go test -race ./...
go run -race .29. 练习
这些题不追求炫技,重点是把共享、容量和存在性想清楚。
练习 1:解释输出
不运行,先写出输出:
a := []int{1, 2, 3}
b := a[:2]
b = append(b, 9)
fmt.Println(a)
fmt.Println(b)再把第一行改成:
a := make([]int, 3, 3)
copy(a, []int{1, 2, 3})结果是否变化?为什么?
练习 2:修复返回内部状态
下面的类型要求调用方不能修改内部数据:
type Registry struct {
names []string
}
func (r *Registry) Names() []string {
return r.names
}修改实现并写一个测试,证明调用方修改返回值不会影响 Registry。
练习 3:稳定词频
输入一段文本,统计每个单词出现次数,并按:
- 次数降序;
- 次数相同时按单词升序;
输出结果。思考为什么不能直接遍历 map 输出。
练习 4:LRU 之外先做有界缓存
实现一个线程安全缓存:
type Cache[K comparable, V any] struct {
// ...
}提供:
Get(K) (V, bool)
Put(K, V)
Delete(K)
Len() int先用 map + sync.RWMutex,通过 go test -race。不要急着做 LRU,先把锁保护范围和 value 所有权写清楚。
练习 5:深复制
为下面的配置实现 Clone:
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 会影响原切片,有时又不会?
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)
}运行:
go run ./examples/ch04/slice-sharing预期输出:
共享修改: 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]三次操作对应三种存储关系。
view := base[1:3]没有复制元素,两个切片都描述同一个底层数组的不同区间。view的容量延伸到base末尾,所以第一次append有空间原地写入,并覆盖base[3]。- 三索引切片
base[1:3:3]把容量限制为 2;下一次 append 必须另行分配数组。 - 分配发生后,
isolatedGrowth[0] = 55不再影响 base。不要依赖“append 大概会扩容”,需要隔离时应明确复制或限制容量。
把容量也打印出来。 打印每一步的 len 和 cap;再把第一次 view 改成 base[1:3:3],观察 base[3] 是否仍被覆盖。
30.2 用 comma-ok 区分零值和不存在
零值不能证明键存在。 map 的 value 本来就可能是零值时,怎样判断键是否真的存在?
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)
}运行:
go run ./examples/ch04/map-comma-ok预期输出:
alice=0 bob=0
alice: score=0 exists=true
bob: score=0 exists=false两次读取的差异只在 exists。
- 单值读取
scores[key]在键不存在时返回int的零值 0。 - alice 的 0 是已存入的真实分数,bob 的 0 是缺失键产生的零值,单看 value 完全一样。
- 双值读取的第二个结果只说明键是否存在,与 value 是否为零无关。
- 如果业务中“缺失”和“零”语义不同,不能用
value == 0代替 comma-ok。
换一种零值再确认。 把 value 类型换成 bool,存入 "enabled": false;再使用 delete(scores, "alice"),比较删除前后的 exists。
30.3 在函数边界安全复制集合
配置副本必须切断可变引用。 返回或保存配置时,怎样避免调用方通过 map 或 slice 意外修改内部状态?
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)
}运行:
go run ./examples/ch04/safe-copy预期输出:
original: env=prod ports=[80 443]
copied: env=test ports=[8080 443]克隆过程分为外层与内层。
- 只写
copied := original会复制结构体,但 map 和 slice 字段仍指向原有存储。 maps.Clone创建新 map,slices.Clone创建包含相同元素的新切片,修改顶层元素不会串回原对象。- 当前元素只有 string 和 int,本身不含引用,一层复制已经是完整隔离。
- 若字段改成
map[string][]int、指针或嵌套结构,一层 clone 仍会共享更深层数据,需要按明确的所有权契约递归复制。
先故意退回浅复制。 先把 cloneConfig 改成直接 return source,观察输出;再增加 Routes map[string][]string 字段,亲自验证为什么只 maps.Clone 还不够。
31. 官方资料
- Go 语言规范:Array types、Slice types、Map types、For statements
- Go Blog:Go Slices: usage and internals
- Go Blog:Arrays, slices (and strings): The mechanics of append
- Go Blog:Robust generic functions on slices
- 标准库 slices
- 标准库 maps
- 标准库 iter
- Go Blog:Go maps in action
- Go 内存模型
- Data Race Detector
- 标准库 encoding/json
- Go Wiki:SliceTricks
原教程适合作为语法入口,但有两点需要修正:
sList[len(sList)-2]在["a", "b", "c", "d"]中得到"c",不是"a";- map 键不要求是“基本数据类型”,而是要求键类型可比较。
读完这一章,最值得留下的不是某个删除切片的技巧,而是三句话:
数组赋值复制全部元素;切片复制描述但可能共享数组;map 复制变量后仍访问同一张表。
把这三种值语义分清,后面的函数设计、并发控制和性能优化才不会靠猜。