Go 字符串、byte、rune 与 UTF-8
# Go 字符串、byte、rune 与 UTF-8
理解 Go 字符串的真实存储方式,分清字节、Unicode 码点和用户看到的字符,避免中文截断、长度统计和文本校验中的常见错误。
# 先记住一句话
Go 的 string 是只读字节序列,不是字符数组;len 和下标按字节工作,range 才会按 UTF-8 解码为 rune。
文本 “Go语言”
├─ UTF-8 字节:47 6f e8 af ad e8 a8 80
├─ len:8 个字节
├─ []rune:G、o、语、言,共 4 个 Unicode 码点
└─ 用户看到的字符:通常是 4 个,但不保证总与 rune 数相同
2
3
4
5
UTF-8 是什么
Unicode 给字符分配码点,例如 “语” 是 U+8BED;UTF-8 再把码点编码成 1~4 个字节。ASCII 字符只占 1 个字节,大多数常用汉字占 3 个字节。
# string、byte 和 rune 分别表示什么
| 类型 | 本质 | 常见用途 |
|---|---|---|
string | 不可修改的字节序列 | 文本、键、路径和协议字段 |
byte | uint8 的别名 | 原始字节、网络数据和文件内容 |
rune | int32 的别名 | 一个 Unicode 码点 |
[]byte | 可修改的字节切片 | I/O、编码解码和二进制处理 |
[]rune | Unicode 码点切片 | 按码点遍历、截断或替换文本 |
// 文件位置:examples/text_basics.go
package textbasics
import "unicode/utf8" // 提供 UTF-8 合法性校验、码点计数和解码工具。
// Inspect 同时返回文本的字节数、Unicode 码点数和第一个字节。
func Inspect(text string) (byteCount int, runeCount int, firstByte byte) {
// 括号中命名了三个返回值,因此裸 return 会返回它们的当前值。
byteCount = len(text) // len 返回 UTF-8 编码后的字节数。
runeCount = utf8.RuneCountInString(text) // RuneCountInString 返回码点数。
if len(text) > 0 {
firstByte = text[0] // 下标访问得到 byte,不是第一个中文字符。
}
return byteCount, runeCount, firstByte // 明确写出三个命名返回值,便于初学时看清顺序。
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
对于 Go语言,len(text) 是 8,而 utf8.RuneCountInString(text) 是 4。直接使用 text[:3] 可能从一个汉字的 UTF-8 编码中间截断,得到无效文本。
# range 同时给出字节偏移和 rune
// 文件位置:examples/text_range.go
package textbasics
import "fmt" // 使用 Printf 按指定格式输出字节偏移、字符和 Unicode 码点。
// PrintRunes 按 UTF-8 解码文本,并输出每个 rune 的起始字节位置。
func PrintRunes(text string) {
// range 遍历字符串时按 UTF-8 解码 rune,而索引仍是字节偏移。
for byteIndex, currentRune := range text {
// byteIndex 表示从字符串开头到当前 rune 起始位置已经经过了多少个字节,不是字符序号。
fmt.Printf("byte=%d rune=%c code=%U\n", byteIndex, currentRune, currentRune)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
例如执行 PrintRunes("Go语言"),会输出:
byte=0 rune=G code=U+0047
byte=1 rune=o code=U+006F
byte=2 rune=语 code=U+8BED
byte=5 rune=言 code=U+8A00
2
3
4
G 和 o 各占 1 个字节,语 占 3 个字节,因此 言 前面已经有 5 个字节,它的字节偏移就是 5。这个 5 不表示 言 是第 6 个字符。
range 会解码 UTF-8。遇到无效编码时,它会产生 utf8.RuneError,并向前移动相应字节数。接收外部文本时,如果业务要求合法 UTF-8,应先调用 utf8.ValidString 明确拒绝异常输入。
rune 不完全等于用户眼中的一个字符
rune 表示 Unicode 码点。一个用户眼中的字符可能由多个码点组合而成,例如字母和组合音标,也可能是由多个码点组成的 Emoji。[]rune 可以避免截断单个 UTF-8 码点,但要按视觉字符限制长度时,还需要专门的 Unicode 文本分段库,例如 rivo/uniseg (opens new window)。本篇后面的昵称校验会给出完整示例。
# 一个可复用的安全预览函数
下面代码适合放在项目的文本工具包中。它先校验 UTF-8,再按 rune 截断,因此不会切断中文码点。
// 文件位置:internal/textutil/preview.go
package textutil
import (
"errors" // 创建可返回给调用方的错误值。
"strings" // Builder 用于高效地逐个追加 rune。
"unicode/utf8" // 校验字符串并统计其中的 Unicode 码点。
)
// 包级哨兵错误让调用方能够稳定判断输入无效这一类别。
var ErrInvalidUTF8 = errors.New("text is not valid UTF-8")
func Preview(text string, maxRunes int) (string, error) {
// 负数上限没有合理含义,属于调用参数错误。
if maxRunes < 0 {
return "", errors.New("maxRunes cannot be negative")
}
if !utf8.ValidString(text) {
return "", ErrInvalidUTF8 // 在业务边界拒绝无效文本,避免错误继续传播。
}
if utf8.RuneCountInString(text) <= maxRunes {
return text, nil // 未超长时直接复用原字符串。
}
// Builder 内部维护可复用缓冲区,比循环使用字符串加号更适合逐个构建结果。
var builder strings.Builder
for _, currentRune := range text {
// maxRunes 表示还可以写入多少个码点;减到 0 后停止遍历。
if maxRunes == 0 {
break
}
builder.WriteRune(currentRune) // 按完整 UTF-8 码点写入。
maxRunes--
}
builder.WriteString("…") // 原文被截断后追加省略号。
return builder.String(), nil // String 取出最终文本;nil 表示处理成功。
}
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
// 文件位置:internal/textutil/preview_test.go
package textutil
import "testing" // Go 标准测试包,提供 *testing.T 和失败报告能力。
func TestPreviewDoesNotBreakChineseRune(t *testing.T) {
// Go 测试函数接收 *testing.T;Fatalf 会记录失败信息并结束当前测试。
got, err := Preview("Go语言服务", 4)
if err != nil {
// 本用例传入合法参数,不期望错误;Fatalf 会标记失败并停止当前测试。
t.Fatalf("Preview returned error: %v", err)
}
// %q 会给字符串加引号,失败信息能够清楚显示空格等不可见差异。
if got != "Go语言…" {
t.Fatalf("Preview() = %q, want %q", got, "Go语言…")
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
这个函数按码点限制,不等于按页面渲染宽度限制。如果产品规则是 “昵称最多 20 个用户可见字符” ,需要进一步确认 Emoji 和组合字符怎样计数。
# 拼接字符串怎样选择工具
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 少量固定字符串 | a + b | 最直观 |
| 循环构建文本 | strings.Builder | 复用可扩容缓冲区,减少重复分配和复制 |
构建二进制或同时需要 []byte | bytes.Buffer | 同时支持字节与 I/O 接口 |
| 格式化少量展示文本 | fmt.Sprintf | 可读性好,但通常不是最低开销 |
为什么循环拼接推荐 strings.Builder
Go 的 string 不可修改。循环中的 result += part 通常不能直接在原字符串末尾追加内容,而要申请新内存,把旧字符串和新内容都复制进去,再用新字符串替换旧字符串。
拼接 "Go" → 创建 "Go"
再拼 "语言" → 创建 "Go语言",重新复制 "Go"
再拼 "服务" → 创建 "Go语言服务",重新复制 "Go语言"
2
3
strings.Builder 内部维护一块可以扩容的缓冲区,多次调用 WriteString 时会尽量复用这块内存,最后再通过 String() 得到结果。因此它适合循环或大量拼接;少量固定字符串直接使用 a + b 更直观。
如果需要修改文本,可以用 []byte(text) 或 []rune(text) 得到可修改切片,修改后再转换回 string;这种转换通常意味着分配和复制,性能敏感路径应通过 Benchmark 验证,而不是凭感觉优化。
# 项目中最常见的错误
- 用
len(name)限制中文昵称长度,却实际限制了字节数; - 用字节下标截断日志、标题或模型回答,生成无效 UTF-8;
- 把
rune当作完整视觉字符,错误计算复杂 Emoji; - 在循环中反复使用
result += part,产生大量中间字符串; - 默认所有外部
string都是合法 UTF-8,而没有在输入边界校验。
# 昵称长度应该怎样限制
如果规则是 “昵称为 2~20 个普通字符” ,应先校验 UTF-8,再使用 utf8.RuneCountInString 按 Unicode 码点计数,而不是使用 len:
package nickname
import (
"errors" // 创建昵称校验失败时返回的错误。
"unicode/utf8" // 校验 UTF-8,并按 Unicode 码点统计昵称长度。
)
// Validate 检查昵称编码是否合法,并限制 Unicode 码点数量在 2~20 之间。
func Validate(name string) error {
// 外部输入不一定是合法 UTF-8,应先在接口边界拒绝异常编码。
if !utf8.ValidString(name) {
return errors.New("昵称不是合法 UTF-8")
}
// RuneCountInString 按 Unicode 码点计数,不会把一个汉字算成 3 个字节。
characterCount := utf8.RuneCountInString(name)
if characterCount < 2 || characterCount > 20 {
return errors.New("昵称长度必须为 2~20 个字符")
}
return nil // 所有规则都通过,nil 表示昵称有效。
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
如果产品要求家庭 Emoji 👨👩👧👦 也只算 1 个字符,仅统计 rune 仍不准确。这时要使用字素簇库,例如 rivo/uniseg (opens new window):
package nickname
import "github.com/rivo/uniseg" // 按 Unicode 字素簇规则识别用户看到的完整字符。
func CountVisibleCharacters(name string) int {
// uniseg 按 Unicode 文本分段规则统计用户眼中的完整字符。
// 例如由多个码点组成的家庭 Emoji 会被统计为一个字素簇。
return uniseg.GraphemeClusterCount(name)
}
2
3
4
5
6
7
8
9
字素簇是用户视觉上认为的一个完整字符,它可能由多个 Unicode 码点组成。字素簇库负责识别这些码点应该合在一起还是分开,例如不会把 👨👩👧👦 拆成多个昵称字符。
业务字符数与存储字节数是两条不同规则:前者用 rune 或字素簇统计,后者仍可用 len(name) 单独设置上限,防止接口或数据库接收过大的字符串。
# 高频面试题与回答
1. Go 中 string 的长度为什么不等于中文字符数?参考答案
因为 string 本质上是只读字节序列,len 返回字节数。中文通常由多个 UTF-8 字节编码。按 Unicode 码点计数可以使用 utf8.RuneCountInString 或转换为 []rune,但码点数仍不一定等于用户看到的视觉字符数。
2. 遍历字符串时,for range 返回的下标是什么?参考答案
下标是当前 rune 在原字符串中的起始字节偏移,不是第几个字符;值是解码后的 rune。所以下标可能从 0 跳到 3 或 6,并不保证每次加一。
3. string、[]byte 和 []rune 应该怎样选择?参考答案
普通只读文本优先使用 string;文件、网络、编码或需要修改原始字节时使用 []byte;需要按 Unicode 码点处理文本时使用 []rune。转换可能带来分配,热路径要结合基准测试判断。
# 接下来学什么
下一篇学习 Go 函数、闭包与泛型,理解 Go 怎样把行为作为参数,以及怎样用类型参数复用算法。