跳转到内容

第 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。

同一个 héllo😀 在字形簇、码点、UTF-16 码元、UTF-8 字节四层上各占几格

从左往右看:é 是 1 个码点、1 个码元、2 个字节;😀 是 1 个码点、2 个码元(代理对)、4 个字节。

用户眼里的“一个字”又是另一层,叫字形簇(grapheme cluster)。e 加组合重音符 U+0301 显示成一个 é,却是两个码点;国旗、带肤色的手势由多个码点拼成。按码点切分仍可能把一个字形簇切成两半。本例的六个码点恰好是六个字形簇,图里第一列就是它们。最后,四种语言都把字符串设计成不可变(immutable)的:可以放心共享、当字典的键、在多线程间传递,代价是每次“修改”都生成新字符串,拼接大量片段要用专门的构建器。

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 起成为默认。

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# 的 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 的 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 的语言在每一次输入输出时都要转换一遍。

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。