Go 的字符串是字节序列,range 却按 rune 遍历。两者的差别在中文、emoji 和非法字节上最明显。这篇逐个现象用真实输出验证:下标与偏移、非法字节怎么处理、截断为什么会出乱码、哪些“同一个字符”其实不相等,以及不同遍历方式的成本。
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(我没有去看是哪条优化起的作用)。
源码: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)。