Go 从 0 到精通 · 第 11 课:字符串、byterune

学习定位:这是整套 Go 教程的第 11 课。
前置要求:已经完成第 10 课,掌握了 map 的增删改查、遍历和判断键是否存在。
本课目标:理解字符串的底层表示,掌握 byterune 的区别,能正确处理中文和多字节字符,理解字符串不可变性。


1. 本课你要解决的核心问题

你已经用过字符串很多次了,但可能遇到过这样的困惑:

  • len("你好") 返回 6 而不是 2,为什么?
  • s[0] 取第一个"字符",拿到的不是"你",而是一个奇怪的数字,为什么?
  • for range 遍历中文字符串时下标不连续(第 5 课提到过),为什么?

答案藏在 Go 字符串的底层编码方式里。

你需要搞明白以下问题:

  • Go 的字符串在内存中到底是什么
  • byterune 分别是什么
  • len() 算的是字节数还是字符数
  • 怎么正确遍历中文字符串
  • 字符串为什么不可变
  • 怎么做字符串和 []byte[]rune 之间的转换

学完这一课,你就能正确处理包含中文、emoji 等多字节字符的场景了。


2. 字符串的底层真相

2.1 字符串是字节序列

Go 的字符串底层就是一个 []byte(字节切片)。它存储的是 UTF-8 编码的字节流。

1
2
3
4
5
6
7
8
9
10
11
12
package main

import "fmt"

func main() {
s := "Hello"
fmt.Println(len(s)) // 5

for i := 0; i < len(s); i++ {
fmt.Printf("s[%d] = %d (%c)\n", i, s[i], s[i])
}
}

输出:

1
2
3
4
5
6
5
s[0] = 72 (H)
s[1] = 101 (e)
s[2] = 108 (l)
s[3] = 108 (l)
s[4] = 111 (o)

对于纯英文,每个字符恰好占 1 个字节,len 返回的字节数等于字符数。

2.2 中文字符占多个字节

1
2
3
4
5
6
7
8
9
10
11
12
package main

import "fmt"

func main() {
s := "你好"
fmt.Println(len(s)) // 6

for i := 0; i < len(s); i++ {
fmt.Printf("s[%d] = %d\n", i, s[i])
}
}

输出:

1
2
3
4
5
6
7
6
s[0] = 228
s[1] = 189
s[2] = 160
s[3] = 229
s[4] = 165
s[5] = 187

"你"在 UTF-8 中占 3 个字节(228, 189, 160),"好"也占 3 个字节(229, 165, 187)。

所以 len("你好") 返回 6(6 个字节),而不是 2(2 个字符)。

这就是第 5 课 for range 遍历中文下标不连续的原因——下标是字节位置,不是字符位置。


3. byterune

3.1 byte = uint8

byteuint8 的别名,表示一个字节(8 位)。

1
2
var b byte = 65      // 'A'
fmt.Printf("%c\n", b) // A

s[i] 访问字符串得到的就是 byte——一个字节。对于中文,一个 byte 不足以表示一个完整字符。

3.2 rune = int32

runeint32 的别名,表示一个 Unicode 码点(一个 Unicode 字符)。

1
2
var r rune = '你'
fmt.Printf("%c (U+%04X)\n", r, r) // 你 (U+4F60)
  • 一个 rune 能表示任意 Unicode 字符,包括中文、emoji
  • rune 占 4 个字节(32 位)

3.3 对比

类型 别名 含义 大小
byte uint8 一个字节 1 字节
rune int32 一个 Unicode 码点 4 字节

简单记忆:

  • byte:看字节
  • rune:看字符

4. 字符串的两种遍历方式

4.1 按字节遍历(for i

1
2
3
4
5
s := "你好Go"

for i := 0; i < len(s); i++ {
fmt.Printf("下标 %d: %d\n", i, s[i])
}

len(s) 返回字节数(7),s[i] 返回 byte。中文的单个字节没有完整含义。

4.2 按字符遍历(for range

1
2
3
4
5
s := "你好Go"

for i, r := range s {
fmt.Printf("下标 %d: %c\n", i, r)
}

输出:

1
2
3
4
下标 0: 
下标 3:
下标 6: G
下标 7: o

for range 遍历字符串时,按 rune(Unicode 码点)遍历:

  • i 是当前字符起始的字节下标
  • r 是当前字符的 rune

4.3 怎么选

  • 需要处理每个字符(包括中文)→ 用 for range
  • 需要处理每个字节(比如处理二进制数据)→ 用 for i

绝大多数文本处理场景都应该用 for range


5. 字符串不可变

5.1 字符串一旦创建就不能修改

1
2
s := "Hello"
s[0] = 'h' // 编译错误:cannot assign to s[0]

Go 的字符串是不可变的(immutable)。创建之后,内容不能被修改。

5.2 需要修改时怎么办

先把字符串转成 []byte[]rune,修改后再转回 string

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

func main() {
s := "Hello"

// 转成 []byte,修改,再转回 string
bytes := []byte(s)
bytes[0] = 'h'
s = string(bytes)

fmt.Println(s) // hello
}

对于中文,用 []rune

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func main() {
s := "你好世界"

runes := []rune(s)
runes[0] = '大'
s = string(runes)

fmt.Println(s) // 大好世界
}

5.3 不可变性的好处

  • 并发安全:多个 goroutine 可以安全地读取同一个字符串,不需要锁
  • 可以安全地共享底层字节(切片截取不会互相影响)
  • 字符串可以作为 map 的键(因为不可变,哈希值稳定)

6. 常用的字符串操作

6.1 字符串长度

1
2
3
4
s := "你好Go"

len(s) // 7(字节数)
len([]rune(s)) // 4(字符数)

需要"字符数"时,转成 []rune 再取 len

6.2 字符串拼接

1
2
3
4
5
a := "Hello"
b := "世界"
c := a + " " + b

fmt.Println(c) // Hello 世界

+ 拼接字符串简单直接,但大量拼接时效率低(每次拼接都创建新字符串)。大量拼接推荐用 strings.Builder(第 21 课会讲)。

6.3 字符串和 []byte 互转

1
2
3
s := "Hello"
b := []byte(s) // string → []byte
s2 := string(b) // []byte → string

转换会复制数据,不是共享底层内存。

6.4 字符串和 []rune 互转

1
2
3
s := "你好"
r := []rune(s) // string → []rune
s2 := string(r) // []rune → string

6.5 字符串比较

1
2
3
4
5
6
7
a := "Hello"
b := "Hello"
c := "hello"

fmt.Println(a == b) // true
fmt.Println(a == c) // false
fmt.Println(a < c) // true(按字典序,大写字母在前)

字符串比较按字节逐个比较,区分大小写。


7. strings 包速览

strings 包提供了大量字符串操作函数。这里先列举几个最常用的,第 21 课会系统讲解。

1
2
3
4
5
6
7
8
9
10
11
12
import "strings"

strings.Contains("Hello", "ell") // true
strings.HasPrefix("Hello", "He") // true
strings.HasSuffix("Hello", "lo") // true
strings.Index("Hello", "ell") // 1
strings.Replace("Hello", "l", "L", -1) // "HeLLo"(-1 表示全部替换)
strings.Split("a,b,c", ",") // ["a", "b", "c"]
strings.Join([]string{"a","b"}, "-") // "a-b"
strings.ToUpper("hello") // "HELLO"
strings.ToLower("HELLO") // "hello"
strings.TrimSpace(" hello ") // "hello"

8. 一段综合示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package main

import (
"fmt"
"strings"
)

func main() {
// 字符串底层是字节
s := "你好Go"
fmt.Println("字节数:", len(s))
fmt.Println("字符数:", len([]rune(s)))

// 按字节遍历
fmt.Println("\n=== 按字节遍历 ===")
for i := 0; i < len(s); i++ {
fmt.Printf(" [%d] = %d\n", i, s[i])
}

// 按字符遍历
fmt.Println("\n=== 按字符遍历 ===")
for i, r := range s {
fmt.Printf(" 字节下标 %d: %c (U+%04X)\n", i, r, r)
}

// 修改字符
runes := []rune(s)
runes[0] = '大'
fmt.Println("\n修改后:", string(runes))

// 字符串不可变
a := "Hello"
b := a
// b[0] = 'h' // 编译错误
fmt.Println("\n字符串赋值是拷贝:", a, b)

// 常用操作
fmt.Println("\nContains:", strings.Contains("Hello Go", "Go"))
fmt.Println("Split:", strings.Split("a,b,c", ","))
fmt.Println("Join:", strings.Join([]string{"Go", "语言"}, " "))
fmt.Println("Replace:", strings.Replace("aabaa", "a", "A", 2))

// 统计中文字符数
text := "Go语言真棒"
chineseCount := 0
for _, r := range text {
if r > 127 { // 简单判断:非 ASCII 字符
chineseCount++
}
}
fmt.Printf("\n\"%s\" 中非 ASCII 字符数:%d\n", text, chineseCount)
}

9. 常见坑总结

9.1 用 len 取"字符串长度"

1
2
s := "你好"
fmt.Println(len(s)) // 6(不是 2)

len(s) 返回的是字节数,不是字符数。

需要字符数:len([]rune(s))

9.2 用 s[i] 取中文字符

1
2
s := "你好"
fmt.Println(s[0]) // 228("你"的第一个字节,不是"你")

s[i] 返回的是 byte,不是完整字符。对于中文,需要用 for range[]rune

9.3 以为字符串可以修改

1
2
s := "Hello"
s[0] = 'h' // 编译错误

字符串不可变。需要修改就转成 []byte[]rune

9.4 字符串截取按字节截

1
2
3
s := "你好"
fmt.Println(s[0:3]) // "你"("你"恰好占 3 个字节)
fmt.Println(s[0:1]) // "�"(截断了,乱码)

对中文做字符串截取时,按字节截取可能导致字符被截断,产生乱码。

安全做法:先转 []rune,截取后转回 string

1
2
runes := []rune("你好世界")
sub := string(runes[0:2]) // "你好"

9.5 字符串比较区分大小写

1
fmt.Println("Hello" == "hello")  // false

需要不区分大小写比较时,统一转小写或大写后再比:

1
strings.ToLower("Hello") == strings.ToLower("hello")  // true

10. 本课练习

练习 1:计算字符数

要求:

  • 定义字符串 s := "Hello你好World世界"
  • 分别打印字节数和字符数
  • 解释为什么两个数字不同

练习 2:正确遍历中文

要求:

  • 定义包含中文和英文的字符串
  • for range 遍历,打印每个字符和它的字节下标
  • 观察下标的变化规律

练习 3:安全截取子串

要求:

  • 实现函数 substr(s string, start, length int) string
  • 字符数(不是字节数)截取子串
  • 例如 substr("你好世界", 1, 2) 返回 "好世"

练习 4:统计字符类型

要求:

  • 给定一个字符串,统计其中中文字符数、英文字母数、数字数、空格数
  • 例如 "Hello你好 123" → 中文 2、字母 5、数字 3、空格 1

练习 5:字符串反转

要求:

  • 实现函数 reverse(s string) string
  • 正确处理中文字符(不能按字节反转)
  • reverse("你好世界") 返回 "界世好你"

练习 6:判断回文

要求:

  • 实现函数 isPalindrome(s string) bool
  • 正确处理中文,例如 "上海自来水来自海上" 返回 true

11. 自测题

11.1 概念题

  1. Go 的字符串底层是什么?
  2. byterune 分别是什么?
  3. len("你好") 返回什么?为什么?
  4. for range 遍历字符串时,ir 分别是什么?
  5. 字符串是可变的还是不可变的?
  6. 怎么修改字符串中的某个字符?
  7. s[0] 返回什么类型?对中文字符串有意义吗?
  8. 怎么安全地截取包含中文的子串?
  9. 字符串和 []byte 互转会复制数据吗?
  10. 为什么字符串不可变是一个好设计?

12. 本课总结

这一课你学到了 Go 字符串的底层真相。

你现在应该已经理解:

  • 字符串底层是 []byte(UTF-8 编码的字节流)
  • byte = uint8(一个字节),rune = int32(一个 Unicode 码点)
  • len(s) 返回字节数,不是字符数
  • for range 遍历字符串按 rune 遍历,可以正确处理中文
  • 字符串不可变——修改需要转 []byte[]rune
  • 对中文做截取时,用 []rune 避免截断乱码
  • 不可变性带来了并发安全和哈希稳定性等好处

13. 下一课预告

下一课我们学习 Go 中最重要的自定义类型能力:结构体 struct

会重点讲:

  • 结构体是什么,为什么需要它
  • 结构体的定义、初始化、字段访问
  • 结构体嵌套(组合)
  • 结构体的值语义

学完下一课,你就能用结构体来建模现实中的业务对象了。