Unicode 与字符编码:处理多语言文本

聊到多语言文本,我第一个想到的坑就是——乱码。早年我做国际电商项目,日本用户提交的订单地址到了数据库全变成了问号。排查了大半天,最后发现是页面声明了 UTF-8,但数据库字段用的 latin1。嗯,这就是字符编码没对齐的典型事故。

说白了,你要让程序正确处理中文、日文、阿拉伯文甚至 emoji,就必须搞懂 Unicode 和它底层的编码方案。这节咱们就把它彻底理清。

1. 字符编码的基本问题

计算机只认识 0 和 1。要表示文字,就得给每个字符编个号。早期的 ASCII 用 7 位就够,但全世界语言加起来,几千个字符都不止。于是有了 Unicode:给每个字符分配一个唯一的码点(Code Point),范围从 U+0000 到 U+10FFFF。

码点是逻辑概念,怎么存储是另一回事。这里最关键的区别来了:字符集(Unicode) 和 编码方案(UTF-8/UTF-16) 是两码事。我见过不少新手把 “Unicode” 和 “UTF-8” 混着用,其实后者只是前者的一个存储方式。

核心概念:Unicode 是抽象的码点集合;UTF-8 / UTF-16 / UTF-32 是具体的字节序列规则。

2. 三大编码方案对比

实际项目里你大概率只跟 UTF-8 和 UTF-16 打交道。我列个表,你一看就明白差异在哪。

方案 最小存储单位 可变长? 典型场景 字节序问题
UTF-8 1 字节 是(1~4 字节) Web、JSON、大多数 API 无(字节序固定)
UTF-16 2 字节 是(2 或 4 字节) Windows 内部、Java 字符串、某些文件 有(需 BOM 或约定)
UTF-32 4 字节 否(固定 4 字节) 很少用,内存浪费大 有

我自己做项目几乎只用 UTF-8。原因很简单:兼容 ASCII、无字节序烦恼、网络传输高效。但你要解析 Windows 记事本保存的文本时,可能会碰到带 BOM 的 UTF-16,那就得单独处理了。

3. 字节序与 BOM

UTF-16 和 UTF-32 都有大端(Big Endian)和小端(Little Endian)的问题。比如 U+4E2D(“中”字的码点),大端存成 4E 2D,小端存成 2D 4E。要区分两者,Windows 喜欢在文件开头放一个 BOM(Byte Order Mark):U+FEFF。

举个例子,一个 UTF-16 LE 文件,头两个字节是 FF FE。很多 Unix 工具不认这个,直接把它当字符解析,结果就出幺蛾子。我曾经给一个跨平台日志系统写解析器,Windows 端传过来的文件带 BOM,Linux 端程序直接崩溃。解决办法是在读文件时明确指定编码并跳过 BOM。

我的习惯:内部存储统一用 UTF-8 无 BOM;与外部系统交互时,读取前先检测前几个字节是不是 BOM 标识,有则跳过。

4. Python 中处理多语言文本

Python 2 时代乱码是家常便饭,Python 3 好多了——字符串默认就是 Unicode,但 I/O 还是得小心。下面是我常用的写法:

# 写入 UTF-8 文件(推荐)
with open("output.txt", "w", encoding="utf-8") as f:
    f.write("你好,世界!")

# 读取时指定编码
with open("input.txt", "r", encoding="utf-8") as f:
    text = f.read()

# 处理带 BOM 的 UTF-16 文件
with open("data.txt", "rb") as f:
    raw = f.read()
    if raw[:2] in (b'\xff\xfe', b'\xfe\xff'):
        # 有 BOM,跳过
        text = raw[2:].decode('utf-16')
    else:
        text = raw.decode('utf-16')

表面看很简单,但坑常在字符串切片或者 len() 上。比如 emoji “😀” 的码点是 U+1F600,UTF-8 占 4 字节,但在 Python 字符串里它算一个字符。len("😀") 返回 1。而如果你用 .encode('utf-8') 得到字节序列,len 就是 4。这两个概念别搞混。

我曾经掉过的坑:用 len() 判断用户输入的字符数,结果用户发了个带肤色修饰的 emoji(比如 👍🏻),实际占用两个码点但视觉是一个。业务挂了。解决方案:使用 unicodedata 或 regex 库进行结构化拆分。

5. 核心流程:字符到字节

为了让你在脑子里建立清晰的模型,我画了一张流程图。它展示了从字符 -> 码点 -> 编码方案 -> 字节序列的完整链条。你写 Tokenization 时,这一步就是分词的前置基础。

字符编码核心流程 字符 “中” U+4E2D UTF-8 3 字节 UTF-16 2 字节 E4 B8 AD 4E 2D 说明: 同一字符 “中” 在不同编码方案下产生的字节序列完全不同。UTF-8 向后兼容 ASCII,UTF-16 常用于 Windows/Java 平台。 字符 → 码点 → 编码方案选择 → 字节序列

6. 实战中的编码陷阱

  • 程序源文件编码:Python 3 默认 UTF-8,但如果你在 Windows 上用记事本保存 .py 文件,可能带 BOM。最好用 VS Code 或编辑器统一设为 UTF-8 无 BOM。
  • 网络传输:HTTP 响应头 Content-Type: text/html; charset=utf-8 必须配成和你实际返回的编码一致。我曾经见过 charset=gbk 但实际发 UTF-8,浏览器直接乱码。
  • 数据库连接:MySQL 连接字符串里加 ?charset=utf8mb4(不是 utf8!),否则 emoji 存不进去。utf8mb4 才是真正的四字节 UTF-8。
  • 文件名编码:Linux 文件名是纯字节序列,如果文件名里有 UTF-8 字符而你的程序用 ASCII 读,会报错。Python 的 os.listdir() 会解码成 str,但跨平台时小心。

7. 字符正则与 Tokenization 的前奏

做 Tokenization 时,你必须先解决字符边界。比如中文一个汉字算一个 ,还是按词切?这取决于你的分词策略。但无论如何,字符层级的正确解析是基础。Python 的 re 模块默认按 Unicode 属性匹配,比如 \w 默认包含中文。

import re

text = "Hello 你好 😀"
# 匹配所有非空白字符序列(包含中文和 emoji)
tokens = re.findall(r'\S+', text)
print(tokens)  # ['Hello', '你好', '😀']

# 更细粒度:按 Unicode 类别匹配中文
pattern = r'[\u4e00-\u9fff]'  # 基本汉字
chinese_chars = re.findall(pattern, text)
print(chinese_chars)  # ['你', '好']

小结:处理多语言文本,核心就是三件事——选对编码方案、统一编码环境、在字符层面做正确切分。