《Go 语言编程入门》3.3 字符串、rune 与字节

本节处理 TaskAPI 的中文标题:先立住「string 是不可变 UTF-8 字节序列」这个事实,用实测数据说明 len 与字符数为何不同、按字节切会切出非法 UTF-8,再讲 range 如何解码、[]byte 与 []rune 的转换代价,最后用 strings 包与 Builder 写出按字符校验、按字符截断的标题处理函数。

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 结构体定义与初始化 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练