需要预先了解:


1. map 与字符串

map 是 Go 内置的键值容器,string 是 UTF-8 编码的只读字节序列。两者都是使用频率极高的类型,也各有几个容易踩坑的地方:nil map 不能写入、map 的遍历顺序是随机的、字符串的 len 返回的是字节数而不是字符数。


1.1 map 的基本操作

声明与初始化

var m map[string]int        // 声明,零值是 nil,不能直接写入
m = make(map[string]int)    // 用 make 初始化
m2 := map[string]int{"a": 1, "b": 2} // 字面量
m3 := make(map[string]int, 16)       // 预分配容量(提示,不是硬上限)

增、删、改、查

m := map[string]int{"a": 1, "b": 2}

m["c"] = 3   // 增
m["a"] = 100 // 改
delete(m, "b") // 删

fmt.Println(m, len(m)) // map[a:100 c:3] 2

查询有两种形式。直接取值时,key 不存在返回该 value 类型的零值:

fmt.Println("不存在的 key:", m["zzz"]) // 0

想区分"值是零值"和"key 不存在",用 comma-ok 惯例:

v, ok := m["c"]
fmt.Println(v, ok) // 3 true

v, ok = m["zzz"]
fmt.Println(v, ok) // 0 false

ok 为 false 时,v 一定是零值。这个惯例在 Go 里到处出现(类型断言、channel 接收、字符串转换等),记住它的形态:第二个返回值永远是 bool,表示"是否成功/是否存在"。

nil map 可以读,不能写

var nilMap map[string]int
fmt.Println("nil map 读取:", nilMap["x"], len(nilMap)) // 0 0
for k, v := range nilMap { }                           // 安全,不执行

读取、len、range、delete 都不会 panic,但写入会直接 panic:

var nilMap map[string]int
nilMap["y"] = 1
panic: assignment to entry in nil map

注意:声明了 map 之后一定要 make 或使用字面量初始化,否则第一次写入就会 panic。


1.2 key 的类型必须可比较

map 的 key 类型必须是可比较的(能用 == 判断相等)。可以作 key 的类型:

类型 说明
数值类型 int、float64、complex128 等
string 按字节序列比较
bool
指针类型 比较地址是否相同
channel 比较是否是同一个 channel
接口类型 动态类型与动态值都可比较时才可比较
数组 元素类型可比较时
结构体 所有字段可比较时

不能作 key 的类型:切片、map、函数,以及包含这些字段的结构体。

m := map[[]int]int{} // 编译错误
invalid map key type []int
type Bad struct {
	Data []int
}

m := map[Bad]int{} // 编译错误
invalid map key type Bad

浮点数的特例:NaN

float64 可以作为 key,但 NaN 是个例外:NaN 不等于任何值,包括它自己,所以用 NaN 作为 key 写入后,永远查不到它。

nan := math.NaN()
m := map[float64]int{nan: 1}
fmt.Println("len =", len(m))   // 1
fmt.Println("m[nan] =", m[nan]) // 0
v, ok := m[nan]
fmt.Println(v, ok)             // 0 false

这个 key 确实存进去了(len 是 1),但无法被访问到,也无法用 delete 删除。实践中不要用浮点数做 key,需要精确匹配时用字符串或定点整数。


1.3 遍历顺序是随机的

Go 刻意让 map 的遍历顺序不可预测,每次 range 的起点都不同:

m := make(map[int]string)
for i := 0; i < 30; i++ {
	m[i] = fmt.Sprintf("v%d", i)
}
for round := 1; round <= 4; round++ {
	fmt.Printf("第 %d 次: ", round)
	for k := range m {
		fmt.Print(k, " ")
	}
	fmt.Println()
}

输出示例:

第 1 次: 12 21 25 27 8 19 29 2 9 24 0 5 14 17 22 28 6 13 23 3 15 16 26 4 10 11 18 20 1 7
第 2 次: 5 14 17 22 28 6 13 23 3 15 16 26 4 10 11 18 20 1 7 12 21 25 27 8 19 29 2 9 24 0
第 3 次: 10 11 18 20 1 7 12 21 25 27 8 19 29 2 9 24 0 5 14 17 22 28 6 13 23 3 15 16 26 4

注意每次的起点不同。这个设计是故意的:如果顺序固定,程序员就会依赖它,而 map 在扩容搬迁时顺序本来就会变。需要有序遍历时,先把 key 取出来排序:

keys := make([]int, 0, len(m))
for k := range m {
	keys = append(keys, k)
}
slices.Sort(keys)
for _, k := range keys {
	fmt.Println(k, m[k])
}

slices.Sort 需要 Go 1.21+ 并导入 slices。


1.4 map 是引用语义

map 变量本身只是一个指针,赋值或传参时拷贝的是这个指针,两个变量操作的是同一个 map:

m3 := map[string]int{"a": 1}
m4 := m3
m4["b"] = 2
fmt.Println("m3 =", m3, "m4 =", m4) // m3 = map[a:1 b:2] m4 = map[a:1 b:2]

因此函数内对 map 的修改,调用方一定看得到,不需要传指针。

并发读写会直接 panic

多个 goroutine 同时对同一个 map 进行读写,运行时会检测到并终止程序:

m := map[int]int{}
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
	wg.Add(1)
	go func(base int) {
		defer wg.Done()
		for j := 0; j < 100000; j++ {
			m[base*100000+j] = j
		}
	}(i)
}
wg.Wait()
fatal error: concurrent map writes

注意这是 fatal error 而不是可以被 recover 捕获的 panic。解决方案是加锁或用 sync.Map,具体选型见 sync 包与原子操作。


1.5 不能对 map 的 value 取地址

m := map[string]int{"a": 1}
p := &m["a"] // 编译错误
invalid operation: cannot take address of m["a"] (map index expression of type int)

原因是 map 在扩容搬迁时元素会被移动到新的内存位置,如果允许取地址,这个地址随时可能失效。所以下面这种写法也编译不过:

m["a"].Field = 1 // 编译错误

想修改结构体 value 的字段,必须整体替换:

type User struct{ Name string }

m := map[string]User{"u1": {"一撮灰"}}
u := m["u1"] // 取出来的是副本
u.Name = "灰灰"
m["u1"] = u // 写回去

或者把 value 类型改成指针 map[string]*User,这样取出来的是指针,可以直接改字段。


1.6 字符串的本质

string 的底层是一个只读的字节序列,可以理解成"指向字节数组的指针 + 长度"。它的关键性质是不可变:一旦创建,内容就不能修改。

s := "hello"
s[0] = 'H' // 编译错误
cannot assign to s[0] (neither addressable nor a map index expression)

需要修改时,先转成 []byte 或 []rune:

bs := []byte(s)
bs[0] = 'H'
fmt.Println(string(bs)) // Hello
fmt.Println(s)          // hello,原字符串不变

这个转换会复制一份数据,因此在热点路径上频繁转换会有性能开销(零拷贝的做法见 反射与 unsafe)。


1.7 UTF-8 与 rune

字符串按 UTF-8 编码存储,一个字符可能占多个字节。这带来三个必须记住的结论:

1. len(s) 返回字节数。

s := "Go语言"
fmt.Println("len:", len(s))                      // 8
fmt.Println("rune 数量:", utf8.RuneCountInString(s)) // 4

"Go语言" 有 4 个字符,但 G、o 各占 1 字节,两个汉字各占 3 字节,共 8 字节。

2. 下标访问得到的是 byte。

fmt.Println("s[0]:", s[0], "s[2]:", s[2]) // s[0]: 71 s[2]: 232

232 是汉字"语"的第一个字节,单独拿出来没有意义。用 s[2] 想取"第二个字符"是错的。

3. for range 按 rune 遍历,下标是字节偏移。

for i, r := range s {
	fmt.Printf("下标 %d: %q (%d 字节)\n", i, r, utf8.RuneLen(r))
}
下标 0: 'G' (1 字节)
下标 1: 'o' (1 字节)
下标 2: '语' (3 字节)
下标 5: '言' (3 字节)

注意下标是 0、1、2、5——因为"语"占了 2、3、4 三个字节。

需要按"第几个字符"来操作时,转成 []rune:

runes := []rune(s)
fmt.Println("第 3 个 rune:", string(runes[2])) // 语

注意:utf8.RuneCountInString 需要导入 unicode/utf8。rune 是 int32 的别名,表示一个 Unicode 码点;byte 是 uint8 的别名,表示一个字节。详见 变量、常量与基本类型。


1.8 strings 与 strconv

常用字符串操作

s := "Go语言"

strings.Contains(s, "语言")                 // true
strings.Split("a,b,c", ",")                // [a b c]
strings.Join([]string{"a", "b", "c"}, "-") // a-b-c
strings.Repeat("ab", 3)                    // ababab
strings.TrimSpace("  hi  ")                // hi
strings.HasPrefix(s, "Go")                 // true
strings.HasSuffix(s, "语言")                // true
strings.Index(s, "语")                      // 2,返回字节下标

这些函数都不会修改原字符串,而是返回新的字符串。

用 strings.Builder 拼接

循环里用 + 拼接字符串会反复分配内存(每次都要复制已有内容),strings.Builder 通过内部缓冲区分摊开销:

var b strings.Builder
for i := 0; i < 3; i++ {
	b.WriteString(strconv.Itoa(i))
	b.WriteByte('-')
}
b.WriteString("end")
fmt.Println(b.String(), "len =", b.Len()) // 0-1-2-end len = 9

Builder 的零值就可以直接使用,不需要初始化。它的内部机制见 切片、map 与字符串的底层实现。

strconv

strconv 负责字符串与基本类型之间的转换:

strconv.Itoa(42)                    // "42",int 转 string
strconv.Atoi("42")                  // 42, nil,string 转 int
strconv.FormatFloat(3.14159, 'f', 2, 64) // "3.14"
strconv.Quote("你好")                // "\"你好\""

Atoi 失败时返回零值加错误,不会 panic:

n, err := strconv.Atoi("abc")
fmt.Println(n, err) // 0 strconv.Atoi: parsing "abc": invalid syntax

1.9 练习

  1. 统计一段中文字符串里每个字符出现的次数,用 map[rune]int 实现,然后按字符排序输出。
  2. 写一个函数 WordCount(s string) map[string]int 统计单词出现次数,思考如何让输出顺序稳定。
  3. 用两种方式拼接 10000 次字符串(+ 与 strings.Builder),用 testing.Benchmark 或简单的计时对比耗时。