Skip to content

Go 的数据结构:数组、切片、map 和字符串

这一篇我们来深入 Go 中最常用的数据结构:数组、切片、map、字符串,以及和文本处理强相关的 rune。你可以把它理解成一条递进路线:

  • 数组:定长、连续、值语义
  • 切片:动态视图、引用底层数组
  • map:键值映射、哈希存储
  • 字符串:只读字节序列
  • rune:Unicode code point 的抽象

理解这些结构的行为边界,通常比背 API 更重要。很多线上问题都不是“不会写”,而是“误解了它到底怎么存”。

1. 数组

数组是固定长度的连续内存块,元素类型相同。声明时必须指定长度,且长度是类型的一部分。

1.1 声明与使用

go
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 索引与遍历

go
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 数组为什么在业务代码里不常见

数组本身并不“落后”,它只是使用场景更偏底层:

  • 长度固定,适合协议头、固定窗口、状态机等场景
  • 值语义,函数传参数组会发生完整拷贝
go
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

go
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 3

cap(sub) 之所以是 3,是因为它从原数组索引 1 开始,到原数组末尾还有 3 个槽位可用。

2.2 创建与扩容

go
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 高风险点:共享底层数组

go
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]

ab 共享同一块底层数组,修改会互相影响。

如果你需要“切出来的新副本”,请显式复制:

go
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

go
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 声明与初始化

go
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 读写、删除与存在性判断

go
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 不能作为键。

go
// 合法
_ = 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 不可变性

go
s := "hello"
// s[0] = 'H' // 编译错误:字符串元素不可修改

b := []byte(s)
b[0] = 'H'
s2 := string(b)
fmt.Println(s2) // Hello

字符串不可变带来两个直接收益:

  • 作为 map key 时更安全
  • 共享与传递时语义稳定,不怕被意外修改

4.2 常见操作与性能提示

频繁拼接字符串不要用 + 循环叠加,优先 strings.Builder

go
var builder strings.Builder
builder.WriteString("Go")
builder.WriteString(" ")
builder.WriteString("Lang")
fmt.Println(builder.String()) // Go Lang

4.3 索引是 byte,不是字符

go
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

runeint32 的别名,用来表示一个 Unicode code point。

5.1 len 和字符数不是一回事

go
s := "A中😊"
fmt.Println(len(s))                    // 8,字节长度
fmt.Println(utf8.RuneCountInString(s)) // 3,字符数

在 UTF-8 编码下,一个字符可能占 1 到 4 个字节。

5.2 正确处理“按字符”逻辑

比如字符串反转,如果按 byte 反转会把多字节字符拆坏,应先转 rune 切片:

go
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
  • 面向自然语言文本:用 runerange string
  • 统计“字符数”时:用 utf8.RuneCountInString

6. 性能与并发安全速查

结构常见成本并发安全建议
数组值传递会整体拷贝只读并发安全固定长度场景可优先使用
切片append 触发扩容与拷贝共享底层数组时不安全预分配容量,必要时复制隔离
map哈希、扩容和迁移成本原生不安全多协程场景配合锁或 sync.Map
字符串频繁拼接有分配开销不可变,读安全拼接用 strings.Builder
rune解码有额外成本本身无共享状态问题仅在字符语义场景使用

7. 小结

掌握 Go 数据结构,关键不在“会不会用”,而在“知道它何时会拷贝、何时会共享、何时不安全”。

可以记住三条主线:

  • 空间模型:数组定长,切片是视图,map 是哈希桶
  • 文本模型:字符串看字节,字符语义看 rune
  • 并发模型:map 和共享切片都要明确同步策略

当你把这三条主线带进日常编码,很多性能抖动和诡异 bug 都能提前规避。

上次更新于: