Go 的字符串是一串字节,而 Go 源代码和字符串字面量都是 UTF-8 编码的。byte 就是其中的一个字节。rune 是一个 Unicode 字符(一个码点),UTF-8 用 1 到 4 个字节来存储它。Go 中关于字符串的大多数困惑,都来自把这两者弄混:
語 在 UTF-8 中占三个字节,所以 "語Go" 是 5 个字节,却只有 3 个 rune。索引循环看到的是五个字节,而第 0、1、2 个字节单独拿出来没有任何意义。range 循环会解码 UTF-8,在索引 0、3 和 4 处产生三个 rune:i 始终是字节偏移量,而不是字符计数。
byte 和 rune 是别名
| 名称 | 等同于 | 字面量 | 含义 |
|---|---|---|---|
byte | uint8 | byte('A')、0x41 | 一个字节的数据 |
rune | int32 | 'A'、'é'、'世' | 一个 Unicode 码点 |
因为它们是别名,byte 就是 uint8,rune 就是 int32;别名和它的底层类型之间不需要转换。单引号表示 rune,双引号表示字符串。'ab' 会导致编译错误,因为一个 rune 字面量只能包含一个字符。
rune 是数字,所以可以对它们做算术运算:
'7' - '0' 是把数字字符转换成对应数值的经典写法。unicode 包可以对 rune 分类(IsLetter、IsDigit、IsSpace、IsUpper),并能正确地转换非 ASCII 字母的大小写。%c 把 rune 打印成字符,%U 打印成 U+4E16 的形式,%q 打印成带引号的 rune 字面量。
UTF-8 如何存储字符
| 码点范围 | 字节数 | 示例 |
|---|---|---|
| U+0000 至 U+007F | 1 | ASCII:a、7、{ |
| U+0080 至 U+07FF | 2 | é、ß、ж、ع |
| U+0800 至 U+FFFF | 3 | 語、€、한 |
| U+10000 及以上 | 4 | emoji、生僻的中日韩字符 |
ASCII 文本在 UTF-8 中完全不变,这就是为什么按字节处理的代码在测试时常常没问题,一遇到第一个带重音符号的名字就出错。文本转二进制工具可以显示你粘贴的任何文本的 UTF-8 字节。unicode/utf8 包可以直接处理这种编码:
% x(中间有一个空格)以十六进制打印字节,并用空格分隔。utf8.ValidString 报告字符串是否是合法的 UTF-8。Go 字符串可以包含任意字节,不一定是合法的 UTF-8;range 遇到非法字节时,会产生 utf8.RuneError(U+FFFD,即替换字符),并前进一个字节。
string、[]byte 和 []rune 之间的转换
| 转换 | 结果 | 开销 |
|---|---|---|
[]byte(s) | UTF-8 字节 | 复制 |
[]rune(s) | 解码后的码点 | 解码并复制(每个 rune 4 字节) |
string(b) | 由字节构成的字符串 | 复制 |
string(r),r 为 []rune | 编码为 UTF-8 | 复制 |
string(r),r 为 rune | 只有一个字符的字符串 | 很小 |
按字节反转会把 世界 打乱成非法的 UTF-8,所以 reverse 是按 rune 处理的。即使是 rune 也不能说明全部问题:像 é 这样的字符也可以写成 e 加上一个组合重音符号,也就是两个 rune。对于用户看到的字符(字素簇),请使用 github.com/rivo/uniseg 这样的库。
每次转换都会复制,因为字符串是不可变的,而 slice 是可变的。在热点代码中,避免来回转换;编译器会优化某些情况(例如把 string(b) 用作 map 的键或用于比较)来省去复制。
索引得到的是字节
s[i] 返回一个 byte,s[i:j] 按字节偏移量切片:
s := "café"
fmt.Println(s[3]) // 195: the first byte of é
fmt.Println(string(s[3])) // "Ã": that byte read as a code point
fmt.Println(s[:3]) // "caf"
fmt.Println(s[:4]) // "caf\xc3": half of é, invalid UTF-8
string(s[3]) 是一个陷阱:把单个字节转换为 string,会把它当作码点 195,也就是 Ã,而不是那个字节本身。要找到字符的边界,使用 range、strings.Index 系列函数(它们返回的字节偏移量总是落在字符边界上),或者 utf8.DecodeRuneInString。
bytes 包
bytes 为 []byte 提供了与 strings 对应的函数:bytes.Contains、bytes.Split、bytes.Fields、bytes.TrimSpace、bytes.Equal、bytes.ToUpper 等等。当数据以字节的形式到来时(文件内容、HTTP 请求体、网络缓冲区),就用它,这样不必先转成字符串再转回来:
bytes.Buffer 是一个可增长的字节缓冲区,实现了 io.Reader 和 io.Writer。既要写入又要读取字节时,它是合适的工具;如果只是构建字符串,strings.Builder 的开销略小一些。比较字节 slice 要用 bytes.Equal,因为 == 不能用于 slice。
如何选择
- 要显示、比较或搜索的文本:保持为
string,需要逐个字符遍历时使用range。 - 字符位置、反转、截取前 n 个字符:转换成
[]rune。 - I/O、哈希、编码、二进制协议:使用
[]byte和bytes包。 - 单个字符:用
rune。单个原始字节:用byte。
常见问题
Go 中的 rune 是什么?
rune 是 int32 的别名,表示一个 Unicode 码点。rune 字面量用单引号书写:'a' 是 97,'é' 是 233,'世' 是 19990。用 for i, r := range s 遍历字符串,得到的就是从字符串的 UTF-8 字节解码出的 rune。
Go 中 rune 和 byte 有什么区别?
byte(uint8 的别名)是 8 位原始数据。rune(int32 的别名)是一个完整的 Unicode 字符。在 UTF-8 字符串中,每个 ASCII 字符占一个字节,其他字符占两到四个字节,但仍然是一个 rune。len(s) 计算字节数;utf8.RuneCountInString(s) 计算 rune 数。
Go 中如何遍历字符串的每个字符?
使用 for i, r := range s。每次迭代得到字节偏移量 i 和 rune r,所以多字节字符能被正确处理。普通的索引循环 for i := 0; i < len(s); i++ 访问的是字节,会把非 ASCII 字符拆成碎片。
Go 中如何把字符串转换成字节 slice?
b := []byte(s) 会把字符串的字节复制到一个新的 slice 中。string(b) 再转换回来,同样会复制一次。当你需要按字符处理时,比如反转字符串或取前 n 个字符,应改用 []rune(s)。