Web

Go 的集合与字符串

Go 使用 Array 和 Slice 表示一组相同类型的值,使用 Map 保存键值数据,使用 String 处理只读文本。它们的语法并不复杂,但在复制、共享存储和零值行为上各有不同。

本文从固定长度的 Array 开始,依次介绍 Slice 的长度与容量、Map 的读写方式,以及 String、byte 和 rune 之间的关系。

Array

Array 是长度固定的同类型元素序列。声明数组时,长度是类型的一部分:

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

fmt.Println(numbers[0]) // 10
fmt.Println(len(numbers)) // 3

[3]int[4]int 是两种不同的类型。数组创建后不能改变长度,因此更适合元素数量在编译时已经确定的场景。

数组也是值类型。把一个数组赋给另一个变量时,Go 会复制其中的全部元素,修改副本不会影响原数组:

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

copied[0] = 100

fmt.Println(original) // [10 20 30]
fmt.Println(copied)   // [100 20 30]

Slice

实际开发中,元素数量经常需要动态变化,这时通常使用 Slice。Slice 的字面量与数组相似,但类型中不包含固定长度:

go
numbers := []int{10, 20, 30}
numbers = append(numbers, 40)

fmt.Println(numbers) // [10 20 30 40]

Slice 并不直接保存全部元素,它描述的是底层数组中的一段连续区域,主要包含长度和容量两个信息:

  • 长度是当前 Slice 中可以访问的元素数量,通过 len 获取;
  • 容量是从 Slice 起点到底层数组末尾可以容纳的元素数量,通过 cap 获取。
go
numbers := make([]int, 2, 4)
numbers[0] = 10
numbers[1] = 20

fmt.Println(len(numbers)) // 2
fmt.Println(cap(numbers)) // 4

这里的 make([]int, 2, 4) 创建了一个长度为 2、容量为 4 的 Slice。前两个元素已经存在,并获得 int 的零值;剩余容量暂时不能直接通过下标访问。

截取 Slice

切片表达式使用左闭右开的区间。numbers[1:3] 包含索引 12,不包含索引 3

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

fmt.Println(middle) // [20 30]

截取得到的新 Slice 通常与原 Slice 共享底层数组,因此通过其中一个 Slice 修改元素,另一个也能看到变化:

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

middle[0] = 200

fmt.Println(numbers) // [10 200 30 40]

NOTE

Slice 赋值和截取复制的是 Slice 的描述信息,而不是底层元素。需要互不影响的数据时,应显式复制元素。

使用 append 追加元素

append 会返回追加后的 Slice,因此必须接收它的返回值:

go
numbers := []int{10, 20}
numbers = append(numbers, 30, 40)

如果原 Slice 的剩余容量足够,追加的元素可以继续写入现有底层数组;如果容量不足,运行时会分配一个更大的底层数组并复制已有元素。因此,不能假设 append 前后的 Slice 始终共享同一个底层数组。

使用 make 和 copy

当长度或容量需要在运行时确定时,可以使用 make 创建 Slice:

go
empty := make([]int, 0, 4)
ready := make([]int, 3)

fmt.Println(len(empty), cap(empty)) // 0 4
fmt.Println(ready)                  // [0 0 0]

第一个参数是 Slice 类型,第二个参数是长度,可选的第三个参数是容量。容量不能小于长度。

需要创建独立副本时,先创建目标 Slice,再使用 copy 复制元素:

go
numbers := []int{10, 20, 30}
cloned := make([]int, len(numbers))

copy(cloned, numbers)
cloned[0] = 100

fmt.Println(numbers) // [10 20 30]
fmt.Println(cloned)  // [100 20 30]

Map

Map 用于保存键值对。下面的 Map 使用 string 作为键,使用 int 作为值:

go
scores := map[string]int{
	"Alice": 90,
	"Bob":   85,
}

scores["Carol"] = 92
fmt.Println(scores["Alice"]) // 90

也可以使用 make 创建空 Map,再逐项写入数据:

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

读取一个不存在的键不会报错,而是返回值类型的零值。需要区分“键不存在”和“值恰好为零”时,应接收第二个布尔返回值:

go
score, exists := scores["Carol"]
if !exists {
	fmt.Println("score not found")
} else {
	fmt.Println(score)
}

使用 delete 删除键。删除不存在的键也是安全的:

go
delete(scores, "Bob")

遍历 Map 时可以使用 range,但遍历顺序没有保证。如果输出顺序有要求,需要在业务代码中另行排序。

go
for name, score := range scores {
	fmt.Println(name, score)
}

nil Slice 与 nil Map

Slice 和 Map 的零值都是 nil,但两者的行为并不相同。

nil Slice 的长度和容量都是 0,可以读取、遍历,也可以直接使用 append

go
var numbers []int

fmt.Println(len(numbers), cap(numbers)) // 0 0

numbers = append(numbers, 10)
fmt.Println(numbers) // [10]

nil Map 可以读取,结果是值类型的零值,但不能写入:

go
var scores map[string]int

fmt.Println(scores["Alice"]) // 0
// scores["Alice"] = 90      // panic: assignment to entry in nil map

需要写入 Map 时,应先通过字面量或 make 完成初始化。

String、byte 与 rune

Go 的 String 是不可变的只读字节序列,通常用 UTF-8 编码保存文本。len 返回字符串占用的字节数,而不是人眼看到的字符数量:

go
text := "Go语言"

fmt.Println(len(text)) // 8
fmt.Println(text[0])   // 71

通过下标读取字符串时,得到的是一个 bytebyteuint8 的别名,适合表示原始字节。

需要按 Unicode 码点遍历文本时,可以使用 range。每次循环得到的字符是 rune,索引则是该字符在字符串中的起始字节位置:

go
text := "Go语言"

for index, char := range text {
	fmt.Printf("%d: %c\n", index, char)
}

runeint32 的别名,用于表示 Unicode 码点。上面的索引依次为 0125,因为“语”和“言”在 UTF-8 中各占三个字节。

字符串不能直接修改。处理原始字节时可以转换为 []byte,按 Unicode 码点修改文本时可以转换为 []rune,完成后再转回 String:

go
text := "Go语言"
chars := []rune(text)

chars[2] = ''
text = string(chars)

fmt.Println(text) // Go编言

总结

Array 的长度固定,赋值时会复制全部元素;Slice 通过底层数组提供可变长度的序列,并使用 lencapappendcopy 管理可见范围与数据副本。Map 适合保存键值数据,读取时要根据需要检查键是否存在。

处理零值时,需要记住 nil Slice 可以直接追加,而 nil Map 必须初始化后才能写入。处理字符串时,则应先分清操作目标是原始字节还是 Unicode 码点,再选择 byterune