3.3 字符串、rune 与字节
TaskAPI 的任务标题是中文。这件事看起来无害,却会让一半的字符串操作出错:len("写第一章") 不是 4 而是 12,title[:5] 切出来的是乱码,按「字符数」做长度校验会把 7 个汉字判成 21 个字符。根源在于 Go 的 string 并不是「一串字符」,而是「一串字节」。本节就从这个事实出发,把中文标题的校验、截断与拼接全部做对。
本节把 TaskAPI 推进到「中文标题正确处理」:写出按字符而非字节校验长度的
validateTitle、按 rune 安全截断的truncateTitle,以及用strings.Builder高效拼装的render。到本节结束,第 3 章的内存版存储就完整了,下一章开始给Task加方法。
3.3.1 string 是不可变的字节序列
Go 的字符串有两个关键属性:
- 不可变。字符串一旦创建就不能改其中某个字节,
s[0] = 'x'是编译错误。所有「修改」都产生新字符串。 - 底层是 UTF-8 编码的字节序列。源码文件是 UTF-8,字符串字面量也是 UTF-8,所以中文字符占 3 个字节(BMP 范围内的汉字)。
因为不可变,字符串赋值和传参只复制「指针 + 长度」这个描述符,不复制内容——这一点和切片类似,比数组高效得多。
索引 s[i] 取到的是第 i 个字节,类型是 byte:
title := "写第一章"
fmt.Println("首字节:", title[0]) // 229
fmt.Printf("%q\n", string(title[:6])) // "写第"
首字节: 229
"写第"
229 是汉字「写」的 UTF-8 首字节,不是任何有意义的字符编码。想拿字符,必须走下面几节讲的 rune 路径。
3.3.2 len 与字符数:两个不同的量
len(s) 返回字节数。要得到字符数,用 utf8.RuneCountInString 或先转 []rune:
title := "写第一章:Go 语言编程入门"
fmt.Println("len(字节):", len(title))
fmt.Println("RuneCountInString(字符):", utf8.RuneCountInString(title))
fmt.Println("len([]rune):", len([]rune(title)))
len(字节): 36
RuneCountInString(字符): 14
len([]rune): 14
同一句话,字节数是 36,字符数是 14。中文字符每个占 3 字节,ASCII 字符占 1 字节,所以两个数字差了一倍多。三种取字符数的方式里,utf8.RuneCountInString 不需要分配新切片,是首选;len([]rune(s)) 更直观但要分配内存。
这个差别直接决定了长度校验怎么写。如果按字节校验「标题不超过 20」,7 个汉字就会超限——而按字符校验,20 个汉字才到上限。
3.3.3 range 解码 UTF-8
range 一个字符串时,Go 会按 UTF-8 解码,每次给出一个 rune 和该字符的起始字节偏移:
for i, r := range "Go中" {
fmt.Printf("byteIdx=%d rune=%c code=%d\n", i, r, r)
}
byteIdx=0 rune=G code=71
byteIdx=1 rune=o code=111
byteIdx=2 rune=中 code=20013
注意索引是字节偏移而不是字符序号:G 在 0、o 在 1、中 在 2(因为前面两个 ASCII 字符各占 1 字节)。如果字符串是 "中Go",那么 中 在 0、G 在 3、o 在 4。这个「索引不连续」的特性是很多 bug 的源头,写循环时别把 i 当成第几个字符。
遇到非法 UTF-8 字节时,range 会给出 U+FFFD(替换字符)并只前进一个字节,不会 panic——这让字符串遍历对脏数据有一定韧性。
3.3.4 按字节切 vs 按 rune 切
最危险的操作是按字节切片。因为汉字占 3 字节,切在字符中间就会得到非法 UTF-8:
title := "写第一章"
for _, n := range []int{3, 4, 5, 6} {
s := title[:n]
fmt.Printf("n=%d %q valid=%v runes=%d\n", n, s, utf8.ValidString(s), utf8.RuneCountInString(s))
}
n=3 "写" valid=true runes=1
n=4 "写\xe7" valid=false runes=2
n=5 "写\xe7\xac" valid=false runes=3
n=6 "写第" valid=true runes=2
n=3 恰好切在字符边界上,得到合法的「写」;n=4 和 n=5 切进了「第」字的中间,utf8.ValidString 报 false,%q 显示出 \xe7、\xac 这样的裸字节。这种字符串打印出来是乱码,送去 JSON 序列化会失败,存进数据库可能报错。
正确的做法是先转 []rune,按 rune 索引切,再转回 string:
good := string([]rune(title)[:3]) // "写第一"
"写第一"
代价是一次完整拷贝([]rune(title) 会分配一个 []int32)。对短标题无所谓,对长文本要留意内存开销。
3.3.5 []byte 与 []rune 的转换
两种转换的语义完全不同:
| 转换 | 含义 | 元素数 |
|---|---|---|
[]byte(s) | 按 UTF-8 重新编码为字节 | 等于 len(s) |
[]rune(s) | 按 UTF-8 解码为码点 | 等于字符数 |
bs := []byte(title)
rs := []rune(title)
fmt.Println(len(bs), len(rs)) // 36 14
string(bs) 和 string(rs) 都能转回字符串。选哪个取决于你要做什么:处理字节流(网络、文件、哈希)用 []byte;处理字符(计数、截断、大小写)用 []rune。搞混了就会写出「用字节数当字符数」的 bug。
顺带说,for i := 0; i < len(bs); i++ 遍历字节是合法的,但如果你在遍历中修改 bs[i],改的是那份拷贝,原字符串不受影响——因为字符串本来就不可变。
3.3.6 strings 包常用函数
strings 包是字符串处理的主力,先把最常用的一批过一遍:
title := "写第一章:Go 语言编程入门"
fmt.Println("Contains:", strings.Contains(title, "Go")) // true
fmt.Println("HasPrefix:", strings.HasPrefix(title, "写第")) // true
fmt.Println("Count:", strings.Count("aaa", "a")) // 3
fmt.Println("Fields:", strings.Fields(" go map ")) // [go map]
fmt.Println("Join:", strings.Join([]string{"a", "b"}, "-")) // a-b
fmt.Println("TrimSpace:", strings.TrimSpace(" hi ")) // hi
fmt.Println("EqualFold:", strings.EqualFold("Go", "GO")) // true
fmt.Println("ToUpper(中文不变):", strings.ToUpper("go中文")) // GO中文
Contains: true
HasPrefix: true
Count: 3
Fields: [go map]
Join: a-b
TrimSpace: hi
EqualFold: true
ToUpper(中文不变): GO中文
几个值得单独讲的:
strings.Cut是 Go 1.18 起推荐的「按分隔符切两半」,比Split更适合「只关心第一刀」的场景,且返回found布尔值省去一次len判断:
before, after, found := strings.Cut(title, ":")
fmt.Printf("Cut: before=%q after=%q found=%v\n", before, after, found)
Cut: before="写第一章" after="Go 语言编程入门" found=true
ToUpper/ToLower对中文无效,因为汉字没有大小写概念,只有英文字母会被转换。做标题去重时如果依赖大小写归一,别忘了中文部分本来就是「一样」的。EqualFold做 Unicode 大小写无关比较,比strings.ToLower(a) == strings.ToLower(b)更正确也更省分配。Fields按 Unicode 空白切分,比Split(s, " ")更健壮,能处理多个连续空格与制表符。
需要按字符(而不是按字节)查找时,还有 strings.IndexRune、strings.ContainsRune 这类带 Rune 后缀的函数,它们能正确处理中文。
3.3.7 strings.Builder:高效拼接
用 += 反复拼字符串是 O(n²) 的——每次都要分配新字符串并拷贝已有内容。strings.Builder 内部维护一个 []byte 缓冲区,把多次写入合并成一次分配:
var sb strings.Builder
for _, w := range []string{"#1", "写第一章", "[未完成]"} {
sb.WriteString(w)
sb.WriteByte(' ')
}
fmt.Println("Builder:", strings.TrimSpace(sb.String()))
fmt.Println("Builder Len:", sb.Len())
Builder: #1 写第一章 [未完成]
Builder Len: 28
Builder 有两个要注意的点:不要拷贝它(内部含指针,拷贝会导致状态不一致),所以通常以 *Builder 传递;以及 String() 是零拷贝地把内部缓冲区直接当成 string 返回(借用了 unsafe),返回的字符串与内部缓冲区共享同一块内存——正常调用 Write* 只会往缓冲区末尾追加,不会改写已经取出的字符串,前提是这个 Builder 没有被拷贝。把 Builder 当成「拼完就用一次」的临时对象最省心。
Builder 还有一个 Grow(n) 方法可以预分配容量,知道大概长度时用它进一步减少扩容,思路和 3.1 数组、切片与扩容
里的 make([]T, 0, n) 完全一致。
3.3.8 让 TaskAPI 正确处理中文标题
现在把本节的知识落成三个函数。先是按字符校验的 validateTitle:
package main
import (
"errors"
"fmt"
"strings"
"unicode/utf8"
)
type Task struct {
ID int64
Title string
Done bool
}
const maxTitleLen = 20
var ErrInvalidTitle = errors.New("标题不合法")
func validateTitle(title string) (string, error) {
title = strings.TrimSpace(title)
if title == "" {
return "", fmt.Errorf("%w: 不能为空", ErrInvalidTitle)
}
if utf8.RuneCountInString(title) > maxTitleLen {
return "", fmt.Errorf("%w: 超过 %d 个字符", ErrInvalidTitle, maxTitleLen)
}
return title, nil
}
清洗后: "写第一章" err=<nil>
空标题: 标题不合法: 不能为空
超长: 标题不合法: 超过 20 个字符
注意 maxTitleLen = 20 是字符数上限,靠 utf8.RuneCountInString 判断。如果这里误用 len(title) > 20,那么 7 个汉字(21 字节)就会被误判为超长——这是中文项目里最常见的一类 bug。
再是按 rune 安全截断的 truncateTitle:
func truncateTitle(title string, max int) string {
if utf8.RuneCountInString(title) <= max {
return title
}
return string([]rune(title)[:max]) + "…"
}
截断: 写第一章:…
原串是「写第一章:Go 语言编程入门」,按 5 个字符截断得到「写第一章:」加省略号。全程按 rune 操作,不会出现半个汉字。
最后是拼接渲染,用 Builder 把多个片段合成一行:
func render(t Task) string {
var b strings.Builder
b.WriteString("#")
fmt.Fprintf(&b, "%d ", t.ID)
if t.Done {
b.WriteString("[x] ")
} else {
b.WriteString("[ ] ")
}
b.WriteString(truncateTitle(t.Title, 8))
return b.String()
}
#1 [x] 写第一章
#2 [ ] 写第二章
fmt.Fprintf 的第一个参数是 io.Writer,*strings.Builder 实现了它,所以能直接往里格式化——这比 b.WriteString(strconv.FormatInt(...)) 干净得多。
小结
string是不可变的 UTF-8 字节序列,赋值只拷贝描述符,不拷贝内容。len(s)是字节数,字符数要用utf8.RuneCountInString(不分配)或len([]rune(s))。s[i]取的是字节不是字符;range会解码 UTF-8,给出 rune 与字节偏移(偏移不连续,别当字符序号用)。- 按字节切片可能切出非法 UTF-8(
utf8.ValidString为 false),要按字符截断必须转[]rune再切。 []byte(s)按 UTF-8 编码(长度 = 字节数),[]rune(s)按 UTF-8 解码(长度 = 字符数);字节流用前者,字符操作用后者。strings.Cut切第一刀并返回found;ToUpper/ToLower对中文无效;EqualFold做 Unicode 大小写无关比较;Fields按 Unicode 空白切分。strings.Builder把多次拼接合并为一次分配,不要拷贝它、调用String()后不能再写。- TaskAPI 的长度校验必须用字符数:
maxTitleLen配utf8.RuneCountInString,否则 7 个汉字就会被误判超长。
第 3 章到此结束:任务列表、ID 索引、中文标题三件事都做完了,内存版存储 MemStore 也已成型。但它现在还只是一堆数据和函数——下一章 4.1 结构体
会正式讲结构体的组合与零值设计,紧接着 4.2 方法与接收者
把 Complete()、Rename() 挂到 Task 上,让 MemStore 那套 Add/Toggle 的接收者语义得到解释。想回看索引是怎么建的,见 3.2 map 与集合惯用法
。
阅读导航:上一节:3.2 map 与集合惯用法 · 下一节:4.1 结构体定义与初始化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。