Go 的数据结构:数组、切片、map 和字符串
这一篇我们来深入 Go 中最常用的数据结构:数组、切片、map、字符串,以及和文本处理强相关的 rune。你可以把它理解成一条递进路线:
- 数组:定长、连续、值语义
- 切片:动态视图、引用底层数组
- map:键值映射、哈希存储
- 字符串:只读字节序列
- rune:Unicode code point 的抽象
理解这些结构的行为边界,通常比背 API 更重要。很多线上问题都不是“不会写”,而是“误解了它到底怎么存”。
1. 数组
数组是固定长度的连续内存块,元素类型相同。声明时必须指定长度,且长度是类型的一部分。
1.1 声明与使用
arr := [5]int{1, 2, 3} // 长度为 5,未赋值元素是零值
fmt.Println(arr) // [1 2 3 0 0]
arr1 := [...]string{"a", "b", "c"} // 让编译器推断长度为 3
fmt.Println(arr1) // [a b c]注意:[3]int 和 [4]int 是两种不同类型,不能直接互相赋值。
1.2 索引与遍历
nums := [4]int{10, 20, 30, 40}
for i := 0; i < len(nums); i++ {
fmt.Println(i, nums[i])
}
for idx, val := range nums {
fmt.Println(idx, val)
}数组的下标访问是 $O(1)$。但越界会直接触发 panic,这是 Go 在运行时做的边界保护。
1.3 数组为什么在业务代码里不常见
数组本身并不“落后”,它只是使用场景更偏底层:
- 长度固定,适合协议头、固定窗口、状态机等场景
- 值语义,函数传参数组会发生完整拷贝
func modify(a [3]int) {
a[0] = 999
}
func main() {
src := [3]int{1, 2, 3}
modify(src)
fmt.Println(src) // [1 2 3],原数组不变
}如果你希望共享数据并减少拷贝,通常会用切片而不是数组。
2. 切片
切片可以理解为“对数组的一层轻量视图”。它本身不是存储容器,而是一个描述符,内部包含指针、长度、容量。
2.1 切片的本质:ptr + len + cap
s := []int{1, 2, 3, 4}
fmt.Println(len(s), cap(s)) // 4 4
sub := s[1:3]
fmt.Println(sub) // [2 3]
fmt.Println(len(sub), cap(sub)) // 2 3cap(sub) 之所以是 3,是因为它从原数组索引 1 开始,到原数组末尾还有 3 个槽位可用。
2.2 创建与扩容
buf := make([]int, 0, 2)
for i := 0; i < 6; i++ {
buf = append(buf, i)
fmt.Printf("i=%d len=%d cap=%d data=%v\n", i, len(buf), cap(buf), buf)
}append 的核心规则是:
- 容量足够:复用原底层数组
- 容量不足:分配新数组并拷贝旧数据
工程实践里,不要把扩容系数当作“固定公式”依赖,只把它当作运行时实现细节。你能做的是根据预估规模提前 make 容量,减少重分配。
2.3 高风险点:共享底层数组
base := []int{1, 2, 3, 4}
a := base[:2] // [1 2]
b := base[1:3] // [2 3]
a[1] = 200
fmt.Println(base) // [1 200 3 4]
fmt.Println(b) // [200 3]a 和 b 共享同一块底层数组,修改会互相影响。
如果你需要“切出来的新副本”,请显式复制:
src := []int{1, 2, 3}
dst := append([]int(nil), src...) // 或者 make + copy
dst[0] = 999
fmt.Println(src, dst) // [1 2 3] [999 2 3]2.4 nil slice 与 empty slice
var s1 []int // nil slice
s2 := []int{} // empty slice
fmt.Println(s1 == nil) // true
fmt.Println(s2 == nil) // false
fmt.Println(len(s1), len(s2)) // 0 0两者都可 append,但在 JSON 序列化等场景可能语义不同:一个是 null,一个是 []。
3. map
map 是 Go 的哈希表实现,适合做快速查找、去重、计数。
3.1 声明与初始化
var m1 map[string]int // 零值是 nil
fmt.Println(m1 == nil) // true
m2 := make(map[string]int)
m2["go"] = 1
fmt.Println(m2) // map[go:1]nil map 可以读、不能写。对 nil map 赋值会 panic。
3.2 读写、删除与存在性判断
scores := map[string]int{
"alice": 95,
"bob": 88,
}
scores["charlie"] = 90
delete(scores, "bob")
v, ok := scores["alice"]
fmt.Println(v, ok) // 95 true
v2, ok2 := scores["nobody"]
fmt.Println(v2, ok2) // 0 false使用 v, ok := m[k] 是判断 key 是否存在的标准姿势,尤其当值类型有零值语义时更重要。
3.3 键类型限制与遍历特性
map 的 key 必须是可比较类型,所以 slice、map、func 不能作为键。
// 合法
_ = map[string]int{}
_ = map[int]bool{}
_ = map[[2]int]string{}
// 非法:slice 不可比较
// _ = map[[]int]string{}另外,map 的遍历顺序是刻意打乱的,不应依赖顺序写业务逻辑。
3.4 并发安全
原生 map 不是并发安全的。多 goroutine 同时读写同一个 map 会触发数据竞争,严重时直接 panic。
常见方案:
sync.RWMutex + map:可控、通用sync.Map:读多写少、键稳定时通常更方便
4. 字符串
在 Go 中,字符串是只读字节序列,底层一般按 UTF-8 存储文本。
4.1 不可变性
s := "hello"
// s[0] = 'H' // 编译错误:字符串元素不可修改
b := []byte(s)
b[0] = 'H'
s2 := string(b)
fmt.Println(s2) // Hello字符串不可变带来两个直接收益:
- 作为 map key 时更安全
- 共享与传递时语义稳定,不怕被意外修改
4.2 常见操作与性能提示
频繁拼接字符串不要用 + 循环叠加,优先 strings.Builder:
var builder strings.Builder
builder.WriteString("Go")
builder.WriteString(" ")
builder.WriteString("Lang")
fmt.Println(builder.String()) // Go Lang4.3 索引是 byte,不是字符
s := "Go语言"
fmt.Println(len(s)) // 8(字节数)
fmt.Println(s[0]) // 71,对应 'G'
for i, r := range s {
fmt.Println(i, string(r))
}range string 返回的是 rune(Unicode code point),这也是处理中文等多字节字符的正确方式。
5. rune 与 Unicode
rune 是 int32 的别名,用来表示一个 Unicode code point。
5.1 len 和字符数不是一回事
s := "A中😊"
fmt.Println(len(s)) // 8,字节长度
fmt.Println(utf8.RuneCountInString(s)) // 3,字符数在 UTF-8 编码下,一个字符可能占 1 到 4 个字节。
5.2 正确处理“按字符”逻辑
比如字符串反转,如果按 byte 反转会把多字节字符拆坏,应先转 rune 切片:
func reverseString(s string) string {
rs := []rune(s)
for i, j := 0, len(rs)-1; i < j; i, j = i+1, j-1 {
rs[i], rs[j] = rs[j], rs[i]
}
return string(rs)
}5.3 实战建议
- 面向网络协议、二进制文件:用
[]byte - 面向自然语言文本:用
rune或range string - 统计“字符数”时:用
utf8.RuneCountInString
6. 性能与并发安全速查
| 结构 | 常见成本 | 并发安全 | 建议 |
|---|---|---|---|
| 数组 | 值传递会整体拷贝 | 只读并发安全 | 固定长度场景可优先使用 |
| 切片 | append 触发扩容与拷贝 | 共享底层数组时不安全 | 预分配容量,必要时复制隔离 |
| map | 哈希、扩容和迁移成本 | 原生不安全 | 多协程场景配合锁或 sync.Map |
| 字符串 | 频繁拼接有分配开销 | 不可变,读安全 | 拼接用 strings.Builder |
| rune | 解码有额外成本 | 本身无共享状态问题 | 仅在字符语义场景使用 |
7. 小结
掌握 Go 数据结构,关键不在“会不会用”,而在“知道它何时会拷贝、何时会共享、何时不安全”。
可以记住三条主线:
- 空间模型:数组定长,切片是视图,map 是哈希桶
- 文本模型:字符串看字节,字符语义看 rune
- 并发模型:map 和共享切片都要明确同步策略
当你把这三条主线带进日常编码,很多性能抖动和诡异 bug 都能提前规避。