Go 中的 rune 类型

rune 到底是什么?它和常见的 char、和天天在用的 string 又有什么区别?为什么 Go 要专门定义这么个类型?一篇讲透「码点 / 字符 / UTF-8 字节」这三层关系,配可直接运行的代码与避坑清单。

rune = int32 不是 char string 是字节序列 UTF-8 是根因
结论先行 rune 是什么 vs char vs string 为什么要 rune UTF-8 图解 遍历与索引 互相转换 常见坑 速查表

一句话:rune 是「一个 Unicode 码点」,string 是「一串 UTF-8 字节」

先把三个容易混的概念钉死,后面全是展开。

🔤
byte
=uint8,一个字节。string 底层的存储单位。
🧩
rune
=int32,一个 Unicode 码点(code point)。一个「逻辑字符」。
📜
string
只读的 UTF-8 字节序列,不是 rune 数组,也不是 char 数组。

最关键的一句话:Go 里没有 char 类型。你想表达的「一个字符」在 Go 里用 rune 表示;而 string 只是这些字符按 UTF-8 规则编码后的字节流。因为一个字符可能占用 1~4 个字节,所以「第 n 个字符」和「第 n 个字节」通常不是同一个位置——这正是 rune 存在的意义。

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 中

码点(code point)是什么

Unicode 给世界上每个「字符」(含汉字、emoji、甚至组合符号)都编了一个号码,这个号码就叫码点,写成 U+XXXX。例如 U+0041=A,U+4E2D=中,U+1F600=😀。码点取值范围 0~0x10FFFF(约 111 万),一个 int32 刚好能装下,所以 Go 选了 int32 当 rune 的底层类型。

rune 和 int32 完全等价,可互相赋值、参与运算。'中' 这种字符字面量,类型就是 rune;而 "中" 双引号是 string。

rune 和别的语言的 char 有什么不同

很多语言有 char,但它们的 char 含义天差地别。Go 干脆不叫 char,叫 rune,避免误导。

语言 / 类型底层能表示什么问题
C char1 字节只够 ASCII(0~127)中文等非 ASCII 根本装不下,要靠 wchar_t 补
Java / C# char2 字节(UTF-16)多数常用字符超出 BMP 的字符(如大部分 emoji)要 2 个 char(代理对),一个 char ≠ 一个字
Python str内部 Unicode 序列一个元素≈一个字符对用户友好,但底层实现与 Go 思路不同
Go rune4 字节(int32)任意 Unicode 码点,含 emoji统一为「一个 rune = 一个完整码点」,最省心
⚠️ 常见误解:「Go 的 rune 相当于 Java 的 char」。错。Java 的 char 是 UTF-16 单元、只有 2 字节,emoji 要两个 char;Go 的 rune 是完整码点、4 字节,一个 emoji 就是一个 rune。两者不是一回事。

为什么 Go 不用「char」这个名字?因为 char 在别的语言里早已被绑定成「固定宽度、可能不等于一个字」的概念。Go 用 rune(源自「runic / 字符」意象)这个独立词,明确表达「这里是完整的 Unicode 字符」,从命名上就切断了「一个 char 一定是一个可见字」的错误预期。

rune 和 string 的根本区别

这是日常最容易踩坑的地方。记住:string 是字节序列,不是 rune 序列。

📦
string 是字节流
底层是 []byte(UTF-8 编码),只读。长度 len(s) 是字节数。
🔢
rune 是单个码点
一个 rune = 一个字符的编码编号。多个 rune 组成 []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 srune自动解码出完整字符,最安全

为什么 Go 要专门定义 rune

根因只有一个:Unicode 码点数量庞大,且 UTF-8 是变长编码,导致「字符」和「字节」必须对不齐。

根因一:一个字节装不下全世界的字

ASCII 只用 0~127,一个字节足够;但 Unicode 有上百万个码点(汉字、emoji、各国文字)。一个固定宽度的字节根本表达不了「任意字符」,所以必须有一个类型专门承载「码点编号」——这就是 rune(int32)。

根因二:UTF-8 是变长的,字节 ≠ 字符

Go 选择 UTF-8 作为字符串编码(因为对英文极省空间、且与操作系统/网络天然兼容)。但 UTF-8 里一个字符可能占 1~4 字节:A=1 字节,中=3 字节,😀=4 字节。于是「第 3 个字符」的字节偏移是未知的。如果只有 string(字节流),你就无法方便地「数有几个字、取第几个字」。rune 补上了这层「按字符」的抽象。

设计权衡:Go 有意让 string 保持「字节序列」这种简单、紧凑、与 C 互操作友好的形态(不引入隐藏的复杂度、不强制每个字符串都算字符数);同时用 rune 提供「按 Unicode 字符」工作的手段。需要字节效率时用 string,需要按字处理时用 []rune 或 for range——两者各司其职,而不是硬塞成一个「既慢又怪」的宽字符字符串。

对比:有些语言(如 Python 3)的 str 内部就按 Unicode 码点索引,len("语言")==2 很自然,但代价是每个字符串都要管理码点边界、内存占用更不透明。Go 把选择权交给你:默认紧凑的字节 string,要字符语义就显式用 rune。

UTF-8 编码长什么样:为什么字节和字符错位

同一条 string,从「字节视角」和「rune 视角」看完全不同。

string "Go语" 的两种视图 字节视角 (len=5) G o E8 AF AD ↑「语」被拆成 3 个字节 rune 视角 (RuneCount=3) U+0047 G U+006F o U+8BED 语 解码 一个中文字 = 1 个 rune,但在 string 里 = 3 个 byte
这张图就是 rune 存在的全部理由:上排是 string 真实存储(字节),下排是 rune 视角(字符)。下标访问 string 取到的是上排的字节,只有解码后才能得到下排的 rune。

遍历与索引:for range vs 下标

想「按字符」处理,永远优先用 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]) // 乱码:逐字节打印
}

要「第 n 个字符」怎么办

先把 string 转成 []rune,再按下标取;或者累计计数到 n:

// 转成 []rune 后下标 = 第几个字符
rs := []rune("语言Go")
fmt.Println(string(rs[0])) // 语
fmt.Println(len(rs))        // 4(字符数)

string、[]byte、[]rune 三者互转

掌握这三组转换,就掌握了 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 更快。

五个最常见的 rune / string 坑

都是真实项目里反复出现的问题,逐个给出正确写法。

① 用 len() 当字符数

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)
}

③ 用下标取「第 n 个字符」

s := "语言Go"
fmt.Println(s[0])        // 232:字节,不是「语」
rs := []rune(s)
fmt.Println(string(rs[0])) // 语:先转 rune 切片

④ 在 []byte 上用 range 想拿字符

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])
本页所有示例均为可独立运行的 Go 代码。utf8 / unicode 是标准库,无需第三方依赖。想深入可看标准库 unicode/utf8 的 DecodeRuneInString、RuneCountInString、ValidString 等函数。