bigywater.tech
Go · 17

rune 与 UTF-8:for range 字符串为什么和下标遍历不一样

Go 的字符串是字节序列,range 却按 rune 遍历。两者的差别在中文、emoji 和非法字节上最明显。这篇逐个现象用真实输出验证:下标与偏移、非法字节怎么处理、截断为什么会出乱码、哪些“同一个字符”其实不相等,以及不同遍历方式的成本。

字节偏移
range 返回的下标是字节偏移:"a好😀é" 的下标是 0、1、4、8
U+FFFD
非法或被截断的字节在 range 里各变成一个替换字符,宽度 1
2222 ns vs 887 ns
1000 个汉字 for range 与逐字节遍历的耗时

字节与 rune

实测 · 本机执行输出
  s = "a好😀é":len(s) = 10 字节,utf8.RuneCountInString = 4 个 rune
  按下标遍历 for i := 0; i < len(s); i++: s[0]=0x61 s[1]=0xe5 s[2]=0xa5 s[3]=0xbd s[4]=0xf0 s[5]=0x9f s[6]=0x98 s[7]=0x80 s[8]=0xc3 s[9]=0xa9
  for i, r := range s: [i=0 r=U+0061 'a' 占 1 字节] [i=1 r=U+597D '好' 占 3 字节] [i=4 r=U+1F600 '😀' 占 4 字节] [i=8 r=U+00E9 'é' 占 2 字节]
  []rune(s):len = 4,rs[2] = U+1F600;string(rs[1:3]) = "好😀"
  EncodeRune(U+0061) → 1 字节 61;utf8.ValidRune = true
  EncodeRune(U+00E9) → 2 字节 c3 a9;utf8.ValidRune = true
  EncodeRune(U+597D) → 3 字节 e5 a5 bd;utf8.ValidRune = true
  EncodeRune(U+1F600) → 4 字节 f0 9f 98 80;utf8.ValidRune = true
  EncodeRune(U+10FFFF) → 4 字节 f4 8f bf bf;utf8.ValidRune = true
  EncodeRune(U+D800) → 3 字节 ef bf bd;utf8.ValidRune = false
  EncodeRune(U+110000) → 3 字节 ef bf bd;utf8.ValidRune = false
  'a' 的类型是 int32(无类型 rune 常量,默认类型 int32);"😀" 在 UTF-16 里是 [D83D DE00]

下标 s[i] 是第 i 个字节,for i, r := range s 是按 UTF-8 解码出的 rune,下标是它的起始字节偏移。UTF-8 里一个 rune 占 1 到 4 字节;U+10FFFF 是最大的合法码点,代理区(U+D800 起)与超过上限的值不是合法 rune,编码时会得到 U+FFFD。

非法字节与截断

实测 · 本机执行输出
  bad = "ab\xffcd\xe5\xa5"(含孤立的 0xff 和被截断的 0xe5 0xa5);ValidString = false
  for i, r := range bad: [i=0 U+0061] [i=1 U+0062] [i=2 U+FFFD] [i=3 U+0063] [i=4 U+0064] [i=5 U+FFFD] [i=6 U+FFFD]
  []rune(bad) 的长度 = 7(每个坏字节各变成一个 U+FFFD);string([]rune(bad)) = "ab�cd��",与 bad 相等?false
  "好" 是 3 字节;h[:2] 是否合法 UTF-8?false(在字节边界上切开了一个字符)
  按字节截 "你好世界"[:4] = "你\xe5"(合法?false);用 RuneStart 回退的 cut(s, 4) = "你"(合法?true)
  string(rune(0xD800)) = "�";string(rune(-1)) = "�"

range 遇到非法序列不会 panic,而是产生 U+FFFD 并只前进 1 字节,所以被截断的多字节字符的每个残留字节各变成一个替换字符(上面的 7 个 rune)。因此“字符串 → []rune → 字符串”并不总是原样往返。截断字符串时要在 rune 边界上切:utf8.RuneStart 能判断某个字节是不是一个 rune 的开头。

同一个字符,不同的字节

实测 · 本机执行输出
  "é" 与 "é":显示相同,== 比较 false;len = 2 / 3 字节,rune 数 = 1 / 2
  国旗 "🇨🇳":8 字节,2 个 rune(两个区域指示符),len([]rune) 不等于看到的字符数
  家庭 emoji(用零宽连接符拼成):18 字节,5 个 rune

Go 的字符串比较是逐字节的,不做 Unicode 规范化。所以预组合的 é 与“e + 组合重音符号”== 为 false。要按人眼看到的字符计数或比较,需要规范化与字素簇切分,这在标准库里没有(golang.org/x/text 里有,本文没有安装与试用)。

遍历的成本

实测 · 本机执行输出
  for range str(逐个 rune)               中位 2221.95 ns/op(最小 2210.96 / 最大 2302.92)
  for i := 0; i < len(str); i++(逐字节)   中位 887.32 ns/op(最小 886.22 / 最大 891.90)
  for range []rune(str)(先转换)           中位 5015.55 ns/op(最小 4990.50 / 最大 5026.61)
  utf8.RuneCountInString(str)          中位 2127.29 ns/op(最小 2122.58 / 最大 2137.41)
  len([]rune(str))                     中位 2075.91 ns/op(最小 2064.47 / 最大 2080.88)
  AllocsPerRun:for range str 0 次;[]rune(str) 后遍历 1 次;len([]rune(str)) 0 次

for range str 要边走边解码,比逐字节慢,但不分配;先转 []rune 再遍历既要分配 4 字节 × rune 数的内存又要多走一遍,最慢。len([]rune(str)) 看起来会分配,实际是 0 次分配、耗时与 utf8.RuneCountInString 接近——编译器没有真的建 []rune(我没有去看是哪条优化起的作用)。

string(int) 的陷阱

实测 · 本机执行输出
  源码:n := 65; s := string(n)
  go build:rc=0,运行输出 'A';go vet:rc=1,输出:main.go:7:7: conversion from int to string yields a string of one rune, not a string of digits

string(n) 把整数当码点,不是转十进制文本。这个误用足够常见,所以 go vet 专门检查它。要得到数字的文本,用 strconv.Itoa(n) 或 fmt.Sprint(n)。

分步演示

rune 与 UTF-8
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。基准测试每个变体 7 轮取中位数,字符串是 1000 个连续汉字(U+4E00 起);go vet 用 Go 1.27.1 自带的默认检查集
    • 没有验证:Unicode 规范化与字素簇切分(golang.org/x/text、rivo/uniseg 本机没有安装)、utf8.DecodeRune 与 range 的汇编差异、纯 ASCII 字符串的 range 快路径、amd64 上的耗时
    京ICP备2026067502号-1京公网安备11010502063487号