Golang byte/rune/string

byte、rune 和 string

在 Go 的源码中,byterune 都是已有类型的别名:

type byte = uint8 // byte 就是 uint8
type rune = int32 // rune 就是 int32

注意:它们不是新类型。Go 定义新类型时不使用 =

string 则表示一段不可变的字节序列。Go 本身并不要求字符串必须采用 UTF-8 编码,但在处理文本时,通常使用 UTF-8。

从存储大小来看,byte 占 1 个字节,rune 占 4 个字节,string 的长度不固定。但更重要的是它们各自表达的语义:

  • byte 表示一个原始字节,不关心该字节对应文本还是二进制数据;
  • rune 表示一个 Unicode 码点,例如 '中''😀'
  • string 表示一段不可变的字节序列,通常用于存储和处理文本。

字符 != 字节

从 Unicode 到序列化 - libilibi

对于 ASCII 字符,一个字符恰好占一个字节,因此容易将“字节”和“字符”混为一谈。但在 UTF-8 编码下,一个 Unicode 码点可能由多个字节表示。

例如:

s := "a中b"
fmt.Println(len(s)) // 输出 5,而不是 3

len() 计算的是字符串的字节数。其中 ab 各占 1 个字节, 的 UTF-8 编码占 3 个字节,因此总长度为 5。

如果需要统计 Unicode 码点的数量,可以转换为 []rune

fmt.Println(len([]rune(s))) // 输出 3

需要注意,rune 对应的是 Unicode 码点,并不严格等同于用户视觉上看到的一个“字符”。例如部分 emoji 和带组合符号的文字可能由多个码点组成。

字符串遍历的两种方式

Go 中遍历字符串常见的方式有两种,它们处理的维度不同。

第一种是使用索引遍历:

s := "a中b"
for i := 0; i < len(s); i++ {
    fmt.Printf("%d ", s[i])
}
// 输出:97 228 184 173 98

s[i] 得到的是 byte。因此, 的 UTF-8 编码会被拆成 228 184 173 三个字节,一共输出 5 个数字。

通过这种方式无法直接按 Unicode 码点读取多字节字符,只能逐个读取其底层字节。

第二种是使用 range 遍历:

for i, r := range s {
    fmt.Printf("(索引%d: %c) ", i, r)
}
// 输出:(索引0: a) (索引1: 中) (索引4: b)

此时 r 的类型是 rune,表示解码后的 Unicode 码点。

同时需要注意索引 i:它依次为 0、1、4,而不是 0、1、2。这是因为 i 表示当前 rune 在原字符串中的字节偏移量 占用索引 1、2、3 三个字节,因此下一个字符 b 从字节索引 4 开始。

总结:

使用下标 s[i] 得到的是字节。

使用 range 遍历字符串时,value 是 rune,index 是该 rune 在字符串中的字节偏移量,而不是字符序号。

字符串截取

理解字节和 rune 的区别后,下面的问题就比较明确:

s := "中文字符"
fmt.Println(s[0:2]) // 得到无效的 UTF-8 字节序列

字符串切片 s[0:2] 是按字节索引进行的。 的 UTF-8 编码占 3 个字节,而这里仅截取了前 2 个字节,因此得到的结果不再是完整、有效的 UTF-8 编码。

如果需要按 Unicode 码点截取,可以先转换为 []rune

runes := []rune(s)
fmt.Println(string(runes[0:2])) // 输出 "中文"

因此,当字符串中可能包含中文、日文、emoji 等多字节编码内容,并且需要按 Unicode 码点进行截取时,不应直接使用字节索引,而应先转换为 []rune

不过,[]rune 只能保证不会截断单个 Unicode 码点。对于由多个码点组成的 emoji 或组合字符,如果需要按照用户视觉上的完整字符截取,还需要进一步进行 Unicode 字素簇处理。

类型转换

明确了各自的语义后,类型转换的区别主要在于按“字节”还是按“Unicode 码点”处理:

s := "你好"

// 按字节拆分和拼接
bs := []byte(s)  // [228 189 160 229 165 189]
s1 := string(bs) // "你好"

// 按 Unicode 码点拆分和拼接
rs := []rune(s)  // [20320 22909]
s2 := string(rs) // "你好"

另一个需要注意的细节是,将整数直接转换为 string 时,Go 会将这个整数视为 Unicode 码点:

fmt.Println(string(rune(65))) // "A",因为 65 是 'A' 的 Unicode 码点

它不会得到字符串 "65"。如果需要将整数转换为对应的十进制文本,应使用 strconv.Itoa

strconv.Itoa(65) // "65"

选择

网络传输、文件读写、二进制协议解析等以原始字节为处理对象的场景中,通常使用 []byte。这些场景关注的是字节流,而不是 Unicode 文本。

当需要按 Unicode 码点处理文本,例如统计码点数量、按码点截取或反转文本时,可以使用 []rune,避免将 UTF-8 编码中的单个码点从中间截断。

如果只是存储、传递或拼接文本,通常直接使用 string。字符串不可变,是 Go 中表示文本最常用的类型。

如果需要频繁构造或拼接字符串,则不适合反复使用 + 生成新的字符串。此时可以根据具体场景使用 []bytestrings.Builderbytes.Buffer,以减少不必要的内存分配。

评论