rune 到底是什么?它和常见的 char、和天天在用的 string 又有什么区别?为什么 Go 要专门定义这么个类型?一篇讲透「码点 / 字符 / UTF-8 字节」这三层关系,配可直接运行的代码与避坑清单。
先把三个容易混的概念钉死,后面全是展开。
uint8,一个字节。string 底层的存储单位。int32,一个 Unicode 码点(code point)。一个「逻辑字符」。最关键的一句话:Go 里没有 char 类型。你想表达的「一个字符」在 Go 里用 rune 表示;而 string 只是这些字符按 UTF-8 规则编码后的字节流。因为一个字符可能占用 1~4 个字节,所以「第 n 个字符」和「第 n 个字节」通常不是同一个位置——这正是 rune 存在的意义。
rune 只是 int32 的类型别名,专用来表示一个 Unicode 码点。
// builtin.go 里的官方定义 type rune = int32 // 用法:一个 rune 装的是一个「码点编号」 var r rune = '中' // 20013,即 U+4E2D var r2 rune = '\u4E2D' // 同样 20013 var r3 rune = 0x1F600 // 😀 emoji 的码点 U+1F600 fmt.Printf("%d %c\n", r, r) // 20013 中
Unicode 给世界上每个「字符」(含汉字、emoji、甚至组合符号)都编了一个号码,这个号码就叫码点,写成 U+XXXX。例如 U+0041=A,U+4E2D=中,U+1F600=😀。码点取值范围 0~0x10FFFF(约 111 万),一个 int32 刚好能装下,所以 Go 选了 int32 当 rune 的底层类型。
'中' 这种字符字面量,类型就是 rune;而 "中" 双引号是 string。很多语言有 char,但它们的 char 含义天差地别。Go 干脆不叫 char,叫 rune,避免误导。
| 语言 / 类型 | 底层 | 能表示什么 | 问题 |
|---|---|---|---|
C char | 1 字节 | 只够 ASCII(0~127) | 中文等非 ASCII 根本装不下,要靠 wchar_t 补 |
Java / C# char | 2 字节(UTF-16) | 多数常用字符 | 超出 BMP 的字符(如大部分 emoji)要 2 个 char(代理对),一个 char ≠ 一个字 |
Python str | 内部 Unicode 序列 | 一个元素≈一个字符 | 对用户友好,但底层实现与 Go 思路不同 |
Go rune | 4 字节(int32) | 任意 Unicode 码点,含 emoji | 统一为「一个 rune = 一个完整码点」,最省心 |
为什么 Go 不用「char」这个名字?因为 char 在别的语言里早已被绑定成「固定宽度、可能不等于一个字」的概念。Go 用 rune(源自「runic / 字符」意象)这个独立词,明确表达「这里是完整的 Unicode 字符」,从命名上就切断了「一个 char 一定是一个可见字」的错误预期。
这是日常最容易踩坑的地方。记住:string 是字节序列,不是 rune 序列。
[]byte(UTF-8 编码),只读。长度 len(s) 是字节数。[]rune 才「像字符串」。func main() { s := "Go语言" fmt.Println(len(s)) // 8:G(1)+o(1)+语(3)+言(3) = 8 个字节 fmt.Println(utf8.RuneCountInString(s)) // 4:4 个「字符」 fmt.Println(s[2]) // 232:拿到的是第 3 个「字节」(0xE8),不是「语」 fmt.Printf("%c\n", s[2]) // è:乱码,因为切在了 UTF-8 字符中间 for _, r := range s { // range 自动按 UTF-8 解码 fmt.Printf("%c ", r) // G o 语 言 } }
| 操作 | 返回单位 | 说明 |
|---|---|---|
len(s) | 字节数 | 中文/emoji 会「膨胀」 |
utf8.RuneCountInString(s) | 字符数 | 真正「几个字」 |
s[i] 下标 | 第 i 个字节 | 可能落在多字节字符中间,得小心 |
for i := range s | 字节下标 | 指向每个 rune 的起始字节 |
for _, r := range s | rune | 自动解码出完整字符,最安全 |
根因只有一个:Unicode 码点数量庞大,且 UTF-8 是变长编码,导致「字符」和「字节」必须对不齐。
ASCII 只用 0~127,一个字节足够;但 Unicode 有上百万个码点(汉字、emoji、各国文字)。一个固定宽度的字节根本表达不了「任意字符」,所以必须有一个类型专门承载「码点编号」——这就是 rune(int32)。
Go 选择 UTF-8 作为字符串编码(因为对英文极省空间、且与操作系统/网络天然兼容)。但 UTF-8 里一个字符可能占 1~4 字节:A=1 字节,中=3 字节,😀=4 字节。于是「第 3 个字符」的字节偏移是未知的。如果只有 string(字节流),你就无法方便地「数有几个字、取第几个字」。rune 补上了这层「按字符」的抽象。
设计权衡:Go 有意让 string 保持「字节序列」这种简单、紧凑、与 C 互操作友好的形态(不引入隐藏的复杂度、不强制每个字符串都算字符数);同时用 rune 提供「按 Unicode 字符」工作的手段。需要字节效率时用 string,需要按字处理时用 []rune 或 for range——两者各司其职,而不是硬塞成一个「既慢又怪」的宽字符字符串。
len("语言")==2 很自然,但代价是每个字符串都要管理码点边界、内存占用更不透明。Go 把选择权交给你:默认紧凑的字节 string,要字符语义就显式用 rune。同一条 string,从「字节视角」和「rune 视角」看完全不同。
想「按字符」处理,永远优先用 for range;下标只给字节。
// ✅ 正确:按 rune 遍历,自动跳过多字节 for i, r := range "Hello, 世界😀" { fmt.Printf("位置%d 字符%c 码点%U\n", i, r, r) } // 位置0 字符H 码点U+0048 // 位置9 字符世 码点U+4E16 // 位置12 字符界 码点U+754C // 位置15 字符😀 码点U+1F600 // ⚠️ 注意:range 的 i 是「该 rune 起始字节的下标」,不是第几个字符 // ❌ 危险:用下标遍历 string,会切出半截字节 s := "语言" for i := 0; i < len(s); i++ { fmt.Printf("%c ", s[i]) // 乱码:逐字节打印 }
先把 string 转成 []rune,再按下标取;或者累计计数到 n:
// 转成 []rune 后下标 = 第几个字符 rs := []rune("语言Go") fmt.Println(string(rs[0])) // 语 fmt.Println(len(rs)) // 4(字符数)
掌握这三组转换,就掌握了 Go 文本处理的主线。
"abc" ──[]byte(s)──▶ []byte{'a','b','c'} // 字节视图(ASCII 下等价) "abc" ──[]rune(s)─▶ []rune{'a','b','c'} // 字符视图 []byte(s) ──string(b)─▶ "abc" // 字节→字符串 []rune(s)──string(r)─▶ "abc" // 字符→字符串 // 中英混合时差异显现: s := "Go语言" fmt.Println(len([]byte(s))) // 8 fmt.Println(len([]rune(s))) // 4
[]rune 会发生一次 UTF-8 解码、分配新切片;在超长字符串高频循环里要注意性能。仅做简单子串/拼接时,操作 []byte 或原 string 更快。都是真实项目里反复出现的问题,逐个给出正确写法。
s := "中文" fmt.Println(len(s)) // 6(字节),常以为是 2 fmt.Println(utf8.RuneCountInString(s)) // 2(字符),要这个
// ❌ 直接按字节反转,中文全乱 func badReverse(s string) string { b := []byte(s) for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 { b[i], b[j] = b[j], b[i] } return string(b) // 中文炸裂 } // ✅ 转 []rune 再反转 func goodReverse(s string) string { r := []rune(s) for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 { r[i], r[j] = r[j], r[i] } return string(r) }
s := "语言Go" fmt.Println(s[0]) // 232:字节,不是「语」 rs := []rune(s) fmt.Println(string(rs[0])) // 语:先转 rune 切片
b := []byte("语") for _, v := range b { // v 是 byte,不是 rune fmt.Printf("%c", v) // 乱码 } // ✅ 要字符就直接在 string 上 range,或转 []rune for _, r := range "语" { fmt.Printf("%c", r) } // 语
s := "Go语言" fmt.Println(s[:3]) // "Go语" 的前 3 字节 = G o + 语的第一字节 → 乱码/非法 UTF-8 // ✅ 按 rune 截断: rs := []rune(s) fmt.Println(string(rs[:3])) // "Go语" 正确
贴在显示器旁那种。
| 你想做的事 | 正确写法 |
|---|---|
| 数有几个字符 | utf8.RuneCountInString(s) 或 len([]rune(s)) |
| 取第 n 个字符 | []rune(s)[n] |
| 遍历每个字符 | for _, r := range s |
| 遍历字节 / 下标 | for i := range s(i 是字节下标) |
| 判断某个 rune 类别 | unicode.IsLetter(r) / IsDigit 等 |
| 拼接字符 | 用 []rune 或直接 + 拼接 string |
| 反转字符串(含中文) | 转 []rune 反转再 string() |
| 截断到 n 个字符 | string([]rune(s)[:n]) |
utf8 / unicode 是标准库,无需第三方依赖。想深入可看标准库 unicode/utf8 的 DecodeRuneInString、RuneCountInString、ValidString 等函数。