Unicode 没有玄学

Unicode 是一套字符系统。UTF-8 是把这套系统变成字节的一种方式。大多数所谓的「Unicode 疑难杂症」,都是因为软件把字节变成文本的那一刻被藏了起来,然后在边界上靠猜。别猜了:标明编码,只解码一次,内部一律当文本处理,输出时只编码一次。

🎙️ 发布并录制于: ·

01把字符、码位和字节分开看

字符是人心里那个书写单位。Unicode 码位是一个带编号的条目,比如字母 A 是 U 加零零四一,笑脸表情是 U 加一 F 六零零。字节是真正存下来或传出去的那些数字。对简单的 ASCII 来说,这三层是重合的;只要一离开 ASCII,它们就各走各路。

Text seen:       A        é        😀
Code point:      U+0041   U+00E9   U+1F600
UTF-8 bytes:     41       C3 A9    F0 9F 98 80
Byte count:      1        2        4

# Python makes the boundary visible
s = "é"
ord(s)                  # 233
s.encode("utf-8").hex()  # 'c3a9'

「Unicode 字符」这个说法太含糊,拿它排错不够用。要问的是:有哪些码位,到达的是哪些字节,用的是哪种编码。十六进制输出能了结截图永远吵不明白的争论。

02除非真有硬约束,一律用 UTF-8

UTF-8 用一到四个字节表示每一个 Unicode 码位。ASCII 保持原来的单字节取值,这让 UTF-8 很容易被接受。非 ASCII 文本会占多个字节。它是变长的,但能自同步:后续字节有一个可识别的位模式,帮助解码器找到边界。

U+0000..U+007F      0xxxxxxx                         1 byte
U+0080..U+07FF      110xxxxx 10xxxxxx                2 bytes
U+0800..U+FFFF      1110xxxx 10xxxxxx 10xxxxxx       3 bytes
U+10000..U+10FFFF   11110xxx 10xxxxxx 10xxxxxx 10xxxxxx  4 bytes

"café".encode("utf-8") → 63 61 66 C3 A9

我的默认选择很直接:选 UTF-8,并且把它写明。别为了给本地文本省几个字节去挑一种老编码。重复的内容交给压缩处理更划算;而编码含糊会造出跨系统的 bug,连备份都能一起带走。

03字节只解码一次,文本只编码一次

解码是用某种编码把字节映射成文本,编码是把文本映射成字节。文件、套接字、数据库驱动和子进程都是边界。在应用内部,值一律保持 Unicode 字符串。编码不是「为了安全给字符串加一层」的东西,它是跨越字节边界时用的那份约定。

# explicit file boundaries in Python
with open("names.txt", "r", encoding="utf-8") as f:
    text = f.read()             # bytes → text

payload = text.encode("utf-8") # text → bytes
round_trip = payload.decode("utf-8")
真实的 Python 报错

UnicodeDecodeError: 'utf-8' codec can't decode byte 0x96 in position 42: invalid start byte 常常意味着这个文件其实是 Windows-1252,其中十六进制 96 这个字节是一个短横线。第一,保留原始字节。第二,找到生产这份数据的一方,或者抽一段有代表性的样本来看,不要只凭一个字符去猜。第三,用确认过的源编码解码,例如 encoding="cp1252"。第四,写出一份新的 UTF-8 文件,并且去修上游。别用 errors="ignore",悄悄删掉字符只会把看得见的损坏变成看不见的数据缺失。

04乱码是解码器用错了的证据

乱码是用错误的字符编码解码字节之后,得到的那种看起来「像文字」的垃圾。café 变成 café 就是最经典的一例:UTF-8 字节被当成 Windows-1252 或 Latin-1 解读了。替换字符 � 的含义是解码器无法映射这些字节,于是塞了一个 U+FFFD 进去。原始字节一旦被替换掉,信息可能已经丢了。

Original text:              café
Correct UTF-8 bytes:        63 61 66 C3 A9
Wrong Windows-1252 decode:  café

Danger: decode with replacement → save → original bad bytes are gone
Repair: recover original bytes → identify encoding → decode once
真实症状:带问号的菱形块

一次 CSV 导入显示成 Jos�。第一,在它覆盖掉干净记录之前先把导入停下。第二,按字节查看源文件,并拿到对方声明的导出编码。第三,让上游重新导出 UTF-8,或者用真正那种老编码解码。第四,先验证带重音符号、非拉丁文字和 emoji 的名字,再跑完整导入。手工把 � 改回 é 属于编造:丢掉的那个字符可能是任何东西。

05BOM 是元数据,有时会漏进数据里

字节序标记当年用来在 UTF-16 和 UTF-32 里标明字节顺序。UTF-8 根本没有字节序问题,但有些工具还是会在开头加上 EF BB BF 这三个字节当作 UTF-8 签名。有的读取方会吃掉它,有的会把 U+FEFF 当成第一个字符暴露出来,于是表头、JSON 和命令脚本就坏了。

UTF-8 BOM bytes: EF BB BF
Visible mojibake if decoded wrongly: 
Python reader that consumes it: encoding="utf-8-sig"
Plain UTF-8 output:              encoding="utf-8"

# Diagnose, do not blindly strip every file
raw = open("data.csv", "rb").read(4)
print(raw.hex())  # efbbbf...
真实故障: 和 unexpected BOM

CSV 表头变成了 name,或者解析器报 JSONDecodeError: Unexpected UTF-8 BOM (decode using utf-8-sig)。第一,检查开头几个字节是不是 EF BB BF。第二,确认这个文件没有先被某种老编码解码过一遍;字面出现的  就是那个错误的信号。第三,让读取方去消化一个已确认的 UTF-8 BOM,或者干脆导出不带签名的 UTF-8。第四,解析之后逐字符比对第一个字段。别做全局文本替换:出现在别处的合法 U+FEFF 是数据。

06需要判断「是否相等」时才做规范化

看起来一样的文本,可以由不同的码位序列组成。字母 é 既可能是一个预组合码位,也可能是字母 e 后面跟一个组合用锐音符。Unicode 规范化把等价的序列转换成你选定的一种形式。普通存储和比较,用 NFC 是个稳妥的默认值。NFKC 还会改掉兼容字符,所以只在你确实想要那种语义折叠时才用它。

import unicodedata

a = "é"             # U+00E9
b = "e\u0301"        # U+0065 + U+0301
a == b                      # False
unicodedata.normalize("NFC", a) == \
unicodedata.normalize("NFC", b)  # True

规范化要放在一个说得清的边界上做,而不是每次比较之前随手来一遍。当准确拼写具有法律或取证价值时,原文要留着。规范化不是大小写折叠,不是转写,不是去掉重音符号,也不是安全校验;那些都是各自独立的决定,各有各的损失。

07用户编辑的是 grapheme cluster,不是码位

grapheme cluster 大致相当于用户感知到的一个字符。它可能只有一个码位,也可能是一个基础字符加若干组合符号,或者由区域指示符拼成的旗帜,或者靠不可见控制符连起来的 emoji 序列。一家人 emoji 里可以有好几个人加连接符,显示出来却是一个字形。所以按码位做索引,不是一个安全的光标模型。

Visible unit       Possible code points
é                  U+00E9  or  U+0065 U+0301
🇯🇵                 U+1F1EF U+1F1F5
👩🏽‍💻                woman + skin tone + joiner + laptop

JavaScript:
"😀".length             // 2 UTF-16 code units
[..."😀"].length        // 1 code point
// Still use Intl.Segmenter for grapheme clusters.

请用有人维护的 Unicode 分段实现,比如平台自带的 grapheme 迭代器或者 Intl.Segmenter。别照着一篇博客抄一个 emoji 正则就宣布搞定了。Unicode 一直在变,而旗帜、修饰符、变体选择符和连接序列,会轻松击穿简单的码位区间判断。

08数长度之前,先说清「长度」是什么

长度可以指存储限制里的字节数、运行时接口里的 code unit 数、协议规则里的码位数、编辑器里的 grapheme cluster 数,或者终端里的显示宽度。没有哪一个是普遍正确的。请把单位写进变量名,也写进报错信息里。「最大长度一百」是一条没写完的需求。

LIMIT                         MEASURE
Database byte column          encoded bytes in database charset
Username policy               normalized code points or graphemes, documented
Text input counter            grapheme clusters users perceive
Terminal table                display columns, with a width library
Network packet                encoded bytes

Never: encoded[:10].decode("utf-8")
Instead: truncate text at a supported boundary, then encode
真实故障:切断的字符串解不出来

UnicodeDecodeError: 'utf-8' codec can't decode bytes in position 8-9: unexpected end of data 说明截断把一个多字节序列切成了两半。第一,回到没被切过的字节或原始文本。第二,把完整输入解码出来。第三,按产品承诺的那个单位对文本分段,面向可见输入时首选 grapheme cluster。第四,按整段截断,之后再编码。如果硬限制确实是字节数,那就一段一段加,直到下一段编码后会超出为止。

09跨系统时把编码约定写明

在很多接口上,文件名就是 Unicode 字符串,但规范化和大小写行为会随文件系统而变。数据库有服务器、库、表、列和连接这几层编码或排序规则。HTTP 传的是字节,媒体类型和协议规则说明该怎么解码。要测的是整条链路,而不只是某个进程里的一个字符串字面量。

FILES      open(path, encoding="utf-8") for text content
DATABASE   UTF-8-capable schema + UTF-8 client connection
MYSQL      prefer utf8mb4, not historical three-byte utf8
HTTP       Content-Type: text/plain; charset=utf-8
JSON       send UTF-8 bytes; do not guess from rendered text
TEST       "José · 東京 · हिंदी · 😀 · e\u0301" round trip
真实的数据库故障

当一个四字节 emoji 进到用老的三字节 utf8 字符集的列或连接时,MySQL 可能报 ERROR 1366 (HY000): Incorrect string value: '\xF0\x9F\x98\x80' for column 'name' at row 1。第一,把列、表、库和客户端连接的字符集都查一遍。第二,把相关结构迁移到 utf8mb4,并选一个合适的排序规则。第三,把驱动的连接也配成 utf8mb4。第四,重跑一次往返测试,并把值读回来看。只改列,连接照样会在写入时破坏数据。

10Unicode 排错流程与速查表

别靠反复 encode、decode,直到屏幕看着顺眼为止来修编码问题。把输入冻住,找出第一个坏掉的边界,然后为每一次转换给出证明。

DEBUG IN ORDER
1. Preserve original bytes; stop destructive imports or saves.
2. Locate the first boundary where correct text becomes wrong.
3. Print bytes as hex and text as escaped code points.
4. Find the declared encoding at the producer and transport.
5. Decode exactly once with that encoding.
6. Normalize only if the product's comparison rules require it.
7. Keep text internally; encode once at output.
8. Round-trip accents, combining marks, CJK, emoji, and empty text.

SYMPTOM                         LIKELY CAUSE
café                           UTF-8 decoded as Windows-1252/Latin-1
�                               invalid bytes replaced; possible data loss
 at the start                BOM bytes decoded as legacy text
Unexpected UTF-8 BOM            reader expects plain UTF-8
Incorrect string value \xF0…    database/connection lacks four-byte support
Different strings look equal    normalization mismatch
Broken emoji after truncation   code-unit, code-point, or byte split

DEFAULTS
Text encoding: UTF-8 · normalization when needed: NFC
MySQL Unicode: utf8mb4 · user-visible slicing: grapheme clusters

Unicode 本身不神秘,被藏起来的边界才神秘。把字节露出来,要求各方遵守 UTF-8 约定,比较和截断时选对单位。最糟的修法,是让今天这份样本看着正常,同时毁掉明天那份原始字节。

Tell me what missed

A correction is more useful than a compliment. This goes straight to the person who writes SwiftGrasp.

Was this page useful?
0/1000

Please do not include passwords, private keys, or personal information.