字符数与字节数的区别 - 理解 UTF-8 与编码差异

8 分钟阅读

在编程和数据库设计中,理解"字符数"和"字节数"的区别至关重要。中文和日文等语言使用多字节字符,一个可见字符可能占用多个字节。误解这种区别会导致数据截断、编码错误和乱码。

各编码的字节数

编码ASCII (A-Z, 0-9)中日韩字符Emoji
UTF-81 字节3 字节4 字节
UTF-162 字节2 字节4 字节
ASCII1 字节不支持不支持

例如,单词"Hello"在 UTF-8 中是 5 字节,而 5 个中文字符在 UTF-8 中是 15 字节,在 UTF-16 中则只有 10 字节。

常见陷阱

各编程语言的字符串长度行为

语言长度方法返回值"🎉"的长度"𠮷"的长度准确字符计数
JavaScript.lengthUTF-16 代码单元22[...str].length
Python 3len()码位11len(s.encode('utf-8')) 获取字节数
Java.length()UTF-16 代码单元22.codePointCount(0, s.length())
Golen()字节数44utf8.RuneCountInString()
Rust.len()字节数44.chars().count()
Swift.count字形簇11.utf8.count 获取字节数

Swift 的设计尤其值得关注。其 .count 属性返回字形簇数量,因此 ZWJ 连接的 emoji 如 👨‍👩‍👧‍👦 正确计为 1 - 最接近"用户所见"的近似值。代价是 Swift 字符串无法在 O(1) 时间内索引访问;需要从头遍历。

Rust 对字符串处理采取了最严格的方法。String 类型内部存储为 UTF-8 字节序列,像 s[0] 这样的索引访问是编译时错误。这迫使开发者明确选择字节级还是字符级访问 - 一种在语言层面防止编码 bug 的设计哲学。

UTF-8 的工作原理

UTF-8 是当前的 Web 标准编码,能够表示 Unicode 标准中的每个字符。它使用可变长度方案,根据码位范围为每个字符分配 1 到 4 个字节。这种设计的核心是每个字节的前导位模式唯一标识它是起始字节还是延续字节,以及该字符占用多少字节。关于编码系统的深入讲解,查看仿真人偶 (Amazon) 提供了有价值的参考资料。

这种设计特别优雅的一点是其"自同步"特性。你可以从字节流中的任意位置开始读取,通过检查前导位模式立即识别字符边界。延续字节始终以 10 开头,使其与起始字节可区分。这使得从损坏数据中部分恢复和文件内的高效随机访问成为可能。UTF-16 缺乏这种自同步特性 - 从流中间开始读取可能会误解代理对的前半部分和后半部分。

UTF-8 的关键优势是与 ASCII 的向后兼容性。英语文本保持每字符恰好 1 字节,这就是现有基于 ASCII 的系统能与 UTF-8 无缝协作的原因。此外,UTF-8 字节序列的排序与 Unicode 码位顺序一致。这意味着简单的字节级比较就能产生正确的字典序排列,这对数据库索引和文件系统排序非常有利。

遗留编码:Shift_JIS 和 EUC-JP

Shift_JISEUC-JP 是专为日语文本开发的编码,使用了数十年。虽然 UTF-8 迁移已在进行中,但这些编码仍出现在遗留系统、电子邮件传输和 CSV 文件处理中。

Shift_JIS 的名称来源于它将字节值"移位"到 JIS X 0201 (半角片假名) 的未使用区域以与 ASCII 共存的方式。然而,这种设计引入了臭名昭著的"0x5C 问题":某些汉字的第二字节为 0x5C (ASCII 反斜杠"\"),与 C 语言转义序列和文件路径分隔符冲突。表 (0x955C)、能 (0x945C) 和 ソ (0x835C) 是众所周知的问题字符,在文件名或路径中使用时可能导致 bug - 这个问题至今仍有报告。

EUC-JP 在 UNIX 环境中广泛使用。由于其第二字节始终在 0xA1-0xFE 范围内,完全避免了 0x5C 问题。这种安全特性是 UNIX 环境中 EUC-JP 比 Shift_JIS 更受青睐的原因之一。

特性Shift_JISEUC-JP
主要用途Windows、遗留系统UNIX/Linux 环境
每个中日韩字符字节数2 字节2 字节
Emoji 支持
多语言支持仅日语仅日语
当前建议已弃用 (仅限遗留系统)已弃用 (仅限遗留系统)

开发者的实际考量

字符与字节的区别在日常开发中会造成实际问题。以下是最常见的需要注意的场景。

文本示例字符数UTF-8 字节UTF-16 字节字节/字符比 (UTF-8)
Hello55101.0
café4581.25
日本語3963.0
𠮷野家31083.3
🎉🎊🎈312124.0
👨‍👩‍👧‍👦 (家庭 emoji)视觉上 1 个2522-

最后一行的"家庭 emoji"是由四个独立 emoji 通过 ZWJ (Zero Width Joiner, U+200D) 连接形成的组合字符。它显示为单个字符,但内部由 7 个码位 (4 个 emoji + 3 个 ZWJ 字符) 组成。JavaScript 的 String.length 返回 11,[...str].length 返回 7。这是"视觉字符数"和"内部字符数"可能急剧分歧的典型案例,也是字符计数实现中最具挑战性的情况。

资深工程师的实用技巧

日常处理编码问题的工程师依赖一套实用技巧来避免常见陷阱。

真实的编码故障案例

编码问题可能导致大规模系统故障。以下是在生产环境中造成过实际事故的模式。

关于字符编码的趣闻

UTF-8 由 Rob Pike 和 Ken Thompson 于 1992 年设计。据一个广为流传的轶事,最初的方案是在新泽西州一家餐厅的餐垫背面草拟的。当时有多个 Unicode 编码方案在竞争,UTF-8 被采纳的决定性因素是它与 C 语言空终止字符串的兼容性。在 UTF-8 中,空字节 (0x00) 除了作为 ASCII NUL 字符外永远不会出现,因此 C 函数如 strlen()strcpy() 无需修改即可工作。没有这个特性,大量现有的 C/UNIX 软件代码库将需要重写,采纳将会大大延迟。

另一个鲜为人知的事实:虽然大多数日语汉字在 UTF-8 中占 3 字节,但 CJK 统一表意文字扩展 B 区及更高区块中的某些罕见汉字需要 4 字节。字符"𠮷" (吉野家官方名称中使用的"吉"的异体字) 就是这样一个 4 字节字符。技术上说,BMP (基本多文种平面,U+0000-U+FFFF) 内的汉字在 UTF-8 中是 3 字节,而放在补充平面 (U+10000 及以上) 的汉字需要 4 字节。仅扩展 B 区就包含约 42,000 个字符,许多用于人名和地名的异体字都在此范围内。

总结

理解字符与字节的区别是构建健壮软件的基础。对于新项目,标准化使用 UTF-8 (MySQL 中特别使用 utf8mb4),始终按字符数而非字节数截断,并准备包含 emoji 和异体字的测试用例。这些实践是在问题到达生产环境之前预防编码相关问题的最有效方法。使用字数计数器同时检查文本的字符数和字节数。

分享这篇文章