第 14 章 · 字符串与编码:码点、码元与 rune
解决什么问题:同一段文字在内存、文件和网络里是一串数字,“它有多长”“第几个字”取决于按哪一层去数,数错了就会截坏文字、超出数据库字段长度。
四种语言的态度:Python 的
str是码点序列;JS/TS 和 C# 的字符串是 UTF-16 码元序列;Go 的string是一串只读字节,通常装 UTF-8。四种语言的字符串都不可变。
用户注册时填了昵称 héllo😀,其中 é 是一个预组合字符 U+00E9,不是 e 加一个附加符号。要分别算出它在 UTF-8 下占多少字节、在 UTF-16 下占多少码元、一共有多少个码点,再按码点把它反转。数据库字段按字节限长、前端按 length 限长、后端按码点限长,三个数字对不上,就是“前端校验通过、入库却报字段太长”这类事故的来源。四种语言都用本语言的标准做法算这三个数,打印同样的五行。
字符集(character set)给每个字符分配一个编号,Unicode 的编号叫码点(code point),写作 U+0000 到 U+10FFFF,一共一百多万个位置;é 是 U+00E9,😀 是 U+1F600。编码(encoding)规定怎样把码点变成存储用的单元,这个单元叫码元(code unit):UTF-8 的码元是 8 位,UTF-16 的码元是 16 位。所以“长度”至少有三种:字节数、码元数、码点数。
UTF-8 用 1 到 4 个字节表示一个码点:ASCII 字符一个字节,和老的 ASCII 文件完全兼容;é 两个字节 C3 A9;😀 四个字节 F0 9F 98 80。多字节序列的首字节说明后面还跟几个字节,后续字节都以二进制 10 开头,从任何位置都能找到下一个字符的起点。UTF-16 用一个 16 位码元表示 U+FFFF 以内的码点(基本多文种平面,BMP);超出的码点拆成两个码元,叫代理对(surrogate pair):前一个落在 D800–DBFF,后一个落在 DC00–DFFF。😀 就是 D83D DE00。
从左往右看:é 是 1 个码点、1 个码元、2 个字节;😀 是 1 个码点、2 个码元(代理对)、4 个字节。
用户眼里的“一个字”又是另一层,叫字形簇(grapheme cluster)。e 加组合重音符 U+0301 显示成一个 é,却是两个码点;国旗、带肤色的手势由多个码点拼成。按码点切分仍可能把一个字形簇切成两半。本例的六个码点恰好是六个字形簇,图里第一列就是它们。最后,四种语言都把字符串设计成不可变(immutable)的:可以放心共享、当字典的键、在多线程间传递,代价是每次“修改”都生成新字符串,拼接大量片段要用专门的构建器。
Python 怎么做
Section titled “Python 怎么做”Python 3 的 str 是码点序列,原生支持;字节另有 bytes 类型,两者之间必须显式 encode 和 decode。
text = "héllo😀" # é 是一个码点 U+00E9
byte_count = len(text.encode("utf-8"))unit_count = len(text.encode("utf-16-le")) // 2 # 每个码元 2 字节point_count = len(text) # str 是码点序列,len 数码点reversed_text = text[::-1] # 切片也按码点
print(f"文本:{text}")print(f"字节数(UTF-8):{byte_count}")print(f"码元数(UTF-16):{unit_count}")print(f"码点数:{point_count}")print(f"按码点反转:{reversed_text}")
# 输出:# 文本:héllo😀# 字节数(UTF-8):10# 码元数(UTF-16):7# 码点数:6# 按码点反转:😀olléh运行:python nickname.py。len、下标、切片都按码点,😀 就是一个元素。Python 没有“UTF-16 长度”这个概念,要数码元只能真的编码一遍再除以 2。这里写 utf-16-le 而不是 utf-16,因为后者会在开头加两个字节的字节序标记(BOM),算出来就成了 8。
底层怎么实现。 CPython 3.3 起按 PEP 393 存储字符串:看整个字符串里最大的码点,全在 U+00FF 以内就每个码点占 1 字节,在 U+FFFF 以内占 2 字节,否则占 4 字节。本例因为有 😀,六个码点都按 4 字节存。这样下标取值始终是一步到位,代价是一个 emoji 会让整串的内存翻几倍。3.3 之前 Windows 上的“窄构建”内部用 UTF-16,len("😀") 得到 2。
- 按码点切片会切开字形簇:
"he\u0301llo"[:2]得到he,重音符被切掉了。3.14 及以前的标准库没有按字形簇切分的函数,3.15 新增了unicodedata.iter_graphemes()。 - 在 Windows 上
open()不写encoding,用的是系统区域编码(中文系统是 GBK):写😀直接抛UnicodeEncodeError: 'gbk' codec can't encode character '\U0001f600',读 UTF-8 文件则不报错地读成乱码。读写文件一律写encoding="utf-8";按 PEP 686,UTF-8 模式从 3.15 起成为默认。
JS/TS 怎么做
Section titled “JS/TS 怎么做”JavaScript 的字符串按规范是 16 位码元序列,length 数码元;字符串迭代器(for...of、展开 [...s])按码点走;UTF-8 字节靠 TextEncoder,字形簇靠 Intl.Segmenter。
const text = "héllo😀"; // é 是一个码点 U+00E9
const byteCount = new TextEncoder().encode(text).length; // 编成 UTF-8 字节const unitCount = text.length; // 字符串是 UTF-16 码元序列const points = [...text]; // 字符串迭代器按码点走const pointCount = points.length;const reversed = points.reverse().join("");
console.log(`文本:${text}`);console.log(`字节数(UTF-8):${byteCount}`);console.log(`码元数(UTF-16):${unitCount}`);console.log(`码点数:${pointCount}`);console.log(`按码点反转:${reversed}`);
// 输出:// 文本:héllo😀// 字节数(UTF-8):10// 码元数(UTF-16):7// 码点数:6// 按码点反转:😀olléh运行:tsx nickname.ts。text.length 是 7,因为 😀 占两个码元;[...text] 把代理对合成一个元素,所以是 6。按字形簇切分用 new Intl.Segmenter("zh", { granularity: "grapheme" }),它的类型在 es2022.intl 里,target 为 ES2022 时不用另外引入。
底层怎么实现。 V8 内部按内容挑存储方式:所有码元都在 U+00FF 以内时每个码元存 1 字节,否则存 2 字节;拼接的结果先存成指向两半的“绳”(cons string),取子串可能只记下原串和偏移,用到时才压平成连续内存。这些都对程序不可见,程序看到的始终是码元序列,下标 s[i] 和 charCodeAt 取的都是码元。
text.slice(0, 6)只拿到代理对的前一半,得到"héllo\ud83d";显示成�,TextEncoder把它编成EF BF BD,存进数据库就再也找不回原来的 emoji。text.split("").reverse().join("")按码元反转,代理对的两半换了位置,结果开头是两个�。
C# 怎么做
Section titled “C# 怎么做”C# 的 string 是 UTF-16 码元序列,Length 数码元,char 就是一个码元;.NET Core 3.0 起有 Rune 表示一个码点,EnumerateRunes() 按码点遍历;StringInfo 按字形簇(文档叫“文本元素”)。
using System;using System.Linq;using System.Text;
Console.OutputEncoding = Encoding.UTF8; // 控制台按 UTF-8 输出,避免 emoji 显示成问号或乱码
var text = "héllo😀"; // é 是一个码点 U+00E9
var byteCount = Encoding.UTF8.GetByteCount(text);var unitCount = text.Length; // string 是 UTF-16 码元序列var pointCount = text.EnumerateRunes().Count(); // Rune 是一个码点var reversed = string.Concat(text.EnumerateRunes().Reverse().Select(r => r.ToString()));
Console.WriteLine($"文本:{text}");Console.WriteLine($"字节数(UTF-8):{byteCount}");Console.WriteLine($"码元数(UTF-16):{unitCount}");Console.WriteLine($"码点数:{pointCount}");Console.WriteLine($"按码点反转:{reversed}");
// 输出:// 文本:héllo😀// 字节数(UTF-8):10// 码元数(UTF-16):7// 码点数:6// 按码点反转:😀olléh运行:dotnet run nickname.cs。Encoding.UTF8.GetByteCount 只数不分配数组。第一行设置控制台输出编码:Windows 控制台的代码页不是 UTF-8 时(中文系统默认 936),emoji 可能打印成问号;输出被重定向到文件或管道时不受影响。new StringInfo(text).LengthInTextElements 数字形簇,本例也是 6。
底层怎么实现。 string 对象在堆上,对象里先存长度,后面紧跟一段连续的 UTF-16 码元,创建后运行时不允许改动;Length 直接读字段。EnumerateRunes() 返回一个结构体枚举器,每次看当前码元是不是高代理,是就和下一个合成一个 Rune。.NET 5 起 StringInfo 改按 Unicode 的扩展字形簇规则切分,之前的版本会把部分组合序列拆开。
text.Substring(0, 6)切开代理对,Encoding.UTF8.GetBytes把落单的半个代理编成EF BF BD。new string(text.Reverse().ToArray())按char反转,和 JS 的split("")一样把代理对弄反。
Go 怎么做
Section titled “Go 怎么做”Go 的 string 是只读的字节序列,len 数字节;它不检查内容,但源代码规定是 UTF-8,字符串字面量自然也是 UTF-8。rune 是 int32 的别名,表示一个码点;for range 按 UTF-8 解码出 rune;unicode/utf8 和 unicode/utf16 两个标准库包负责计数和转换。
package main
import ( "fmt" "slices" "unicode/utf16" "unicode/utf8")
func main() { text := "héllo😀" // é 是一个码点 U+00E9
byteCount := len(text) // string 是字节序列,len 数字节 unitCount := 0 for _, r := range text { // range 按 UTF-8 解码出 rune unitCount += utf16.RuneLen(r) } pointCount := utf8.RuneCountInString(text) runes := []rune(text) // 解码成码点切片 slices.Reverse(runes)
fmt.Printf("文本:%s\n", text) fmt.Printf("字节数(UTF-8):%d\n", byteCount) fmt.Printf("码元数(UTF-16):%d\n", unitCount) fmt.Printf("码点数:%d\n", pointCount) fmt.Printf("按码点反转:%s\n", string(runes))}
// 输出:// 文本:héllo😀// 字节数(UTF-8):10// 码元数(UTF-16):7// 码点数:6// 按码点反转:😀olléh运行:go run nickname.go。utf16.RuneLen 是 1.23 新增的,返回一个码点在 UTF-16 下占几个码元;也可以写 len(utf16.Encode([]rune(text))),只是多分配一个切片。[]rune(text) 把字符串解码成可修改的码点切片,反转后再 string(runes) 编回 UTF-8。标准库没有按字形簇切分的功能。
底层怎么实现。 字符串值是一个两字段的头:指向字节的指针和长度。字节不可改,所以切片 s[2:5] 只造一个新头、共用原来的字节,不复制。for i, r := range s 由编译器展开成对 UTF-8 解码函数的调用:i 是这个码点首字节的下标,遇到不合法的字节就给出 U+FFFD,只前进一个字节。string 和 []byte、[]rune 互转时要复制,因为切片可改、字符串不可改。
text[1]取到的是字节195(即0xC3),不是é;text[:2]得到"h\xc3",半个é。- 非法 UTF-8 不会报错:
range和[]rune都把坏字节换成 U+FFFD 悄悄继续,数据已经坏了还要靠utf8.ValidString自己检查。
| 维度 | Python | JS/TS | C# | Go |
|---|---|---|---|---|
| 支持程度 | 原生 | 原生 | 原生 | 原生 |
| 实现机制(内部编码) | 按最大码点选 1、2、4 字节 | UTF-16(引擎可存成 1 字节) | UTF-16 | 字节序列,通常是 UTF-8 |
len / length 数的是 |
码点 | UTF-16 码元 | UTF-16 码元 | 字节 |
| 按码点遍历 | for ch in s |
for (const ch of s) |
s.EnumerateRunes() |
for _, r := range s |
| UTF-8 字节数 | len(s.encode("utf-8")) |
new TextEncoder().encode(s).length |
Encoding.UTF8.GetByteCount(s) |
len(s) |
| 按字形簇 | 3.15 起 unicodedata.iter_graphemes() |
Intl.Segmenter |
StringInfo |
标准库没有 |
| 可变性 | 不可变,bytearray 可变 |
不可变 | 不可变,StringBuilder |
不可变,[]byte 可变 |
最大的差异在”len / length 数的是“一行:同一个昵称,四种语言给出 6、7、7、10 三种答案,跨语言传长度限制时必须先约定单位。
1990 年代初的 Unicode 1.0 以为 16 位、65536 个位置就能装下世界上所有的字符,每个字符固定两个字节,这种编码叫 UCS-2。Windows NT、Java、JavaScript 都在这个时期押注 16 位字符;C# 出生在 Windows 和 Java 的环境里,沿用了同样的选择。1996 年的 Unicode 2.0 承认 16 位不够,引入代理对,UCS-2 变成了 UTF-16。这些语言为了兼容已有代码,只能保留“一个 char 是 16 位”,于是 length 从“字符数”变成了“码元数”,emoji 普及以后这个差别天天都会碰到。
Go 走了另一条路。UTF-8 是 Ken Thompson 和 Rob Pike 在 1992 年设计的,他们后来又和 Robert Griesemer 一起设计了 Go。Go 把字符串当成字节,源文件规定用 UTF-8,range 内置 UTF-8 解码,和网络、文件里的数据格式完全一致,读写不用转换;代价是 len 不是“字数”,下标取到的是字节。
Python 2 的 str 是字节,unicode 另成一类,两者能隐式混用,一遇到非 ASCII 字符就在离出错点很远的地方报 UnicodeDecodeError。Python 3 在 2008 年把文本和字节彻底分开,混用直接报 TypeError;3.3 的 PEP 393 再让 str 真正按码点计数,不再受内部编码影响。代价是每次读写文件、网络都要明确编码。
后果有两条。一是互操作时长度错位:前端按 length 限 20 个字,后端按码点,数据库按 UTF-8 字节,带 emoji 的输入在三处得到三个数字。二是 UTF-8 成了网络和存储的事实标准:JSON 规定在系统之间交换时必须用 UTF-8,HTML 标准要求新网页用 UTF-8;内部用 UTF-16 的语言在每一次输入输出时都要转换一遍。
旁注:别的语言
Section titled “旁注:别的语言”Rust 的 String 保证是合法的 UTF-8,len() 数字节,chars() 按码点。它干脆不允许 s[i] 这样按下标取字符,编译直接报错;切片 &s[0..2] 如果落在一个字符中间,运行时 panic,而不是像 Go 那样得到半个字符。
- 先定单位再谈长度:接口文档和数据库字段说清楚限制的是字节、码元还是码点,面向用户显示的“字数”按字形簇。
- 截断、反转、逐字处理一律按码点或字形簇,不按下标:Python 直接切片(按码点),按字形簇用 3.15 的
unicodedata.iter_graphemes();JS 用[...s]或Intl.Segmenter;C# 用EnumerateRunes()或StringInfo;Go 用range或[]rune。 - 所有输入输出都显式写编码,默认用 UTF-8:Python 的
open(..., encoding="utf-8"),C# 的Encoding.UTF8,Node 读写文件时传"utf8";Go 读写的就是字节,不做转换。 - 大量拼接用构建器:Python 的
"".join(parts)、JS 的数组join、C# 的StringBuilder、Go 的strings.Builder。
用 Python 和 Go 各写一个函数,把字符串安全地截断到最多 N 个码点,不切坏任何一个字符;用 héllo😀 演示 N=5 和 N=6,每行形如 截断到 5 个码点:héllo。
提示:Python 的切片本来就按码点,想想为什么它天然安全。Go 的 s[:n] 按字节切,要先找到第 N 个码点的起始字节下标;for i := range s 给出的 i 正是每个码点的起点。
- 一段文字至少有四种长度:UTF-8 字节、UTF-16 码元、码点、字形簇;
héllo😀是 10、7、6、6。 - Python 的
len数码点,JS/TS 和 C# 的length数 UTF-16 码元,Go 的len数字节。 - 码点超出 U+FFFF 时 UTF-16 用代理对,按码元截断或反转会切开它,得到 U+FFFD。
- 码点仍不等于用户眼里的一个字,按字形簇要用
Intl.Segmenter、StringInfo,Python 3.15 起有unicodedata.iter_graphemes(),Go 的标准库没有。 - 四种语言的字符串都不可变;读写文件和网络时一律显式用 UTF-8。