土法炼钢兴趣小组的算法知识备份

Unicode 算法:UTF-8 的精妙与文本处理陷阱

目录

一、引言:你真的懂字符串吗

每一个程序员都觉得自己理解字符串。毕竟,"hello" 有 5 个字符,strlen 返回 5——还能有什么复杂的?

然后你遇到了中文:"你好" 在 UTF-8 里占 6 个字节。你遇到了 emoji:"👨‍👩‍👧‍👦" 这个”一个字符”在 UTF-8 里占 25 个字节,包含 7 个码点(code point),但在屏幕上渲染为 1 个字素簇(grapheme cluster)。你遇到了阿拉伯文、泰文、藏文的复杂组合规则,你意识到——字符串可能是计算机科学中最被低估的数据结构。

Unicode 不是一张码表。它是一套算法体系。本文将深入这些算法的核心:从 UTF-8 编码的精巧设计、DFA/SIMD 验证,到规范化(normalization)、排序(collation)、字素簇分割(grapheme cluster breaking)、双向排版(bidi),直到真实世界中的安全陷阱。

我假设读者已经知道”Unicode 是什么”,但不一定知道”Unicode 是怎么工作的”。如果你在工作中写过 s.length() 却没有想过它返回的到底是什么,这篇文章就是为你准备的。

二、UTF-8 编码设计:Ken Thompson 的天才之作

起源故事

1992 年的某个晚上,Ken Thompson 和 Rob Pike 在新泽西一家餐厅的餐巾纸上设计了 UTF-8。这不是传说,这是 Rob Pike 自己讲述的真实历史。当时 Plan 9 操作系统需要一种能与 C 语言的字符串约定兼容的 Unicode 编码方式——特别是,不能在多字节序列中嵌入 \0(NUL),因为 C 的字符串以 NUL 结尾。

他们的设计在第二天早上就完成了实现。这个设计后来征服了整个互联网——截至 2025 年,超过 98% 的网页使用 UTF-8 编码。

编码规则

UTF-8 是一种变长编码,使用 1 到 4 个字节表示一个 Unicode 码点:

UTF-8 编码方案
码点范围 字节数 字节模式 有效数据位
U+0000..U+007F 1 0xxxxxxx 7
U+0080..U+07FF 2 110xxxxx 10xxxxxx 11
U+0800..U+FFFF 3 1110xxxx 10xxxxxx 10xxxxxx 16
U+10000..U+10FFFF 4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 21

设计的精妙之处

这个设计有多优雅?让我列举它的关键性质:

性质一:ASCII 完全兼容。 所有 ASCII 字符(0x00-0x7F)在 UTF-8 中就是它们自身。任何合法的 ASCII 文本同时也是合法的 UTF-8 文本。这意味着大量已有的 C 程序不需要任何修改就能处理 UTF-8——只要它们不试图按字符拆分字符串。

性质二:自同步(self-synchronizing)。 从任意字节位置开始,你最多向前扫描 3 个字节就能找到下一个码点的起始位置。因为前导字节(leading byte)的高位模式与续接字节(continuation byte)的 10xxxxxx 模式是互不重叠的。这个性质对于错误恢复和随机访问至关重要。

性质三:字节序无关(endianness-free)。 不像 UTF-16 和 UTF-32 需要 BOM(Byte Order Mark)来标识大小端,UTF-8 的字节顺序是固定的。这消除了一整类 bug。

性质四:字节排序等于码点排序。 对 UTF-8 字节流做 memcmp,其结果与对 Unicode 码点序列做比较是一致的。这意味着你可以用标准的字节排序算法对 UTF-8 字符串排序,得到的就是 Unicode 码点顺序。

性质五:前导字节直接编码序列长度。 看第一个字节就知道整个序列有多长。不需要状态机,不需要前瞻。

性质六:多字节序列中不包含 NUL。 续接字节的范围是 0x80-0xBF,永远不会是 0x00。这意味着 C 的 strchr(s, '/')strlen 在 UTF-8 字符串上仍然能正确工作(只要你搜索的是 ASCII 字符)。

性质七:不存在合法的超长编码。 每个码点有且仅有一种合法的编码形式——最短的那种。比如 NUL 必须编码为 0x00,而不能用两字节的 0xC0 0x80。这个性质是安全关键的,后面会详细讨论。

让我用一段话总结 Thompson 的设计哲学:用最小的代价实现与已有系统的最大兼容性,同时不牺牲正确性和安全性。在软件工程的历史上,能同时满足这些目标的设计屈指可数。

三、UTF-8 验证算法:从 DFA 到 SIMD

为什么需要验证

UTF-8 验证不是可选的。如果你接受来自外部的字节流并假设它是合法的 UTF-8,你就为安全漏洞打开了大门。非法的 UTF-8 序列可以导致:

DFA 方法

Bjoern Hoehrmann 提出了一种优雅的 DFA(确定性有限自动机)方法来验证 UTF-8。其核心思想是用一个状态转移表,对每个输入字节进行一次查表操作。整个验证器只需要 364 字节的查找表和几行代码。

这个 DFA 有 9 个状态,其中状态 0 是接受状态(accept),状态 1 是拒绝状态(reject)。转移函数由两个查表操作组成:首先根据字节值查出字节类型(共 12 种),然后根据当前状态和字节类型查出下一个状态。

/* Bjoern Hoehrmann 的 UTF-8 DFA 验证器 */

/* 字节分类表:将 0x00-0xFF 映射到 12 种类型 */
static const uint8_t utf8d[] = {
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,  0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,  0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,  0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,  0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,  9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,
    7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,  7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,
    8,8,2,2,2,2,2,2,2,2,2,2,2,2,2,2,  2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
   10,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3, 11,6,6,6,5,4,4,4,4,4,4,4,4,4,4,4,

    /* 状态转移表 */
    0,12,24,36,60,96,84,12,12,12,48,72, 12,12,12,12,12,12,12,12,12,12,12,12,
   12, 0,12,12,12,12,12, 0,12, 0,12,12, 12,24,12,12,12,12,12,24,12,24,12,12,
   12,12,12,12,12,12,12,24,12,12,12,12, 12,24,12,12,12,12,12,12,12,24,12,12,
   12,12,12,12,12,12,12,36,12,36,12,12, 12,36,12,12,12,12,12,36,12,36,12,12,
   12,36,12,12,12,12,12,12,12,12,12,12,
};

#define UTF8_ACCEPT 0
#define UTF8_REJECT 12

static inline uint32_t
utf8_decode(uint32_t *state, uint32_t *codep, uint32_t byte)
{
    uint32_t type = utf8d[byte];
    *codep = (*state != UTF8_ACCEPT)
        ? (byte & 0x3Fu) | (*codep << 6)
        : (0xFFu >> type) & byte;
    *state = utf8d[256 + *state + type];
    return *state;
}

这个设计的精髓在于:验证和解码是同时完成的。 每个字节只需要两次查表和几条算术指令。在整个字节流处理完之后,如果状态回到 UTF8_ACCEPT,则输入是合法的 UTF-8。

Daniel Lemire 的 SIMD 验证

DFA 方法虽然优雅,但每次处理一个字节。在现代 CPU 上,我们可以用 SIMD(Single Instruction, Multiple Data)指令一次验证 16 或 32 个字节。Daniel Lemire 和 John Keiser 在 simdjson 项目中实现了一种极快的 SIMD UTF-8 验证器。

其核心思想是:

  1. vpshufb(向量查表)替代标量查表。 将字节的高 4 位和低 4 位分别作为索引,通过两次 SIMD 查表和一次 AND 操作完成字节分类。

  2. 利用位移和 OR 操作传播前导字节的信息到续接字节的位置。 这样每个续接字节”知道”它应该跟在什么样的前导字节后面。

  3. 所有错误检测并行进行。 超长编码、非法代理对、超范围码点都在同一轮 SIMD 操作中检测。

用伪代码表示核心逻辑:

/* SIMD UTF-8 验证的核心思路(伪代码) */

/* 对 16 字节输入 input[0..15] */
__m128i classify_high = _mm_shuffle_epi8(lut_high, input >> 4);
__m128i classify_low  = _mm_shuffle_epi8(lut_low,  input & 0x0F);
__m128i category      = _mm_and_si128(classify_high, classify_low);

/* 将前导字节信息传播到续接字节位置 */
__m128i prev1 = _mm_alignr_epi8(input, prev_input, 15); /* 前移 1 字节 */
__m128i prev2 = _mm_alignr_epi8(input, prev_input, 14); /* 前移 2 字节 */
__m128i prev3 = _mm_alignr_epi8(input, prev_input, 13); /* 前移 3 字节 */

/* 检查每个续接字节的前导字节是否合法 */
__m128i expected = compute_expected(prev1, prev2, prev3);
__m128i errors   = _mm_xor_si128(category, expected);

/* 累积错误 */
error_acc = _mm_or_si128(error_acc, errors);

性能对比

在 x86-64 架构上,对随机 UTF-8 文本的验证速度:

方法 吞吐量 (GB/s) 每字节指令数
朴素逐字节(naive) ~0.8 ~12
Hoehrmann DFA ~1.5 ~6
SIMD SSE4.2 ~8.0 ~1.2
SIMD AVX-512 ~16.0 ~0.6

SIMD 方法比朴素方法快了约 20 倍。对于需要处理大量外部输入的系统(HTTP 服务器、数据库、JSON 解析器),这个差距是实实在在的。

我的观点是:如果你在写一个需要高吞吐量的文本处理系统,SIMD UTF-8 验证不是优化——它是基础设施。 simdjson 能够以 CPU 的内存带宽极限速度解析 JSON,UTF-8 验证就是其中一个关键环节。

四、完整的 UTF-8 编解码器实现

下面是一个完整的 C 语言 UTF-8 编解码器和验证器实现。代码追求清晰而非极致性能,适合嵌入到任何 C/C++ 项目中。

/*
 * utf8.h — 完整的 UTF-8 编解码器与验证器
 *
 * 功能:
 *   - utf8_encode: 将一个 Unicode 码点编码为 UTF-8 字节序列
 *   - utf8_decode: 从 UTF-8 字节流中解码一个码点
 *   - utf8_validate: 验证一段字节流是否为合法 UTF-8
 *   - utf8_codepoint_length: 根据码点值返回编码所需字节数
 *   - utf8_sequence_length: 根据前导字节返回序列长度
 *   - utf8_count_codepoints: 计算 UTF-8 字符串中的码点数量
 *
 * 安全考虑:
 *   - 拒绝超长编码(over-long encoding)
 *   - 拒绝代理对码点(U+D800..U+DFFF)
 *   - 拒绝超出 Unicode 范围的码点(> U+10FFFF)
 *   - 拒绝非法的续接字节
 */

#ifndef UTF8_H
#define UTF8_H

#include <stdint.h>
#include <stddef.h>

/* 错误码 */
#define UTF8_OK             0
#define UTF8_ERR_TRUNCATED -1  /* 序列在中间被截断 */
#define UTF8_ERR_OVERLONG  -2  /* 超长编码 */
#define UTF8_ERR_SURROGATE -3  /* 代理对码点 */
#define UTF8_ERR_RANGE     -4  /* 超出 Unicode 范围 */
#define UTF8_ERR_INVALID   -5  /* 非法的前导字节或续接字节 */

/* Unicode 相关常量 */
#define UNICODE_MAX         0x10FFFF
#define SURROGATE_MIN       0xD800
#define SURROGATE_MAX       0xDFFF
#define UTF8_REPLACEMENT    0xFFFD

/*
 * utf8_codepoint_length - 根据码点值返回 UTF-8 编码所需字节数
 *
 * 返回 1-4,或 0 表示非法码点
 */
static inline int utf8_codepoint_length(uint32_t cp)
{
    if (cp <= 0x7F)       return 1;
    if (cp <= 0x7FF)      return 2;
    if (cp <= 0xFFFF) {
        /* 排除代理对 */
        if (cp >= SURROGATE_MIN && cp <= SURROGATE_MAX) return 0;
        return 3;
    }
    if (cp <= UNICODE_MAX) return 4;
    return 0;
}

/*
 * utf8_sequence_length - 根据前导字节判断 UTF-8 序列长度
 *
 * 返回 1-4,或 0 表示非法前导字节(0x80-0xBF, 0xF8-0xFF)
 */
static inline int utf8_sequence_length(uint8_t lead)
{
    if (lead < 0x80) return 1;
    if (lead < 0xC2) return 0;  /* 0x80-0xBF 是续接字节, 0xC0-0xC1 是超长 */
    if (lead < 0xE0) return 2;
    if (lead < 0xF0) return 3;
    if (lead < 0xF5) return 4;  /* 0xF5+ 会产生 > U+10FFFF */
    return 0;
}

/*
 * utf8_encode - 将一个码点编码为 UTF-8
 *
 * buf:  输出缓冲区,至少 4 字节
 * cp:   要编码的码点
 *
 * 返回写入的字节数(1-4),或 0 表示非法码点
 */
static inline int utf8_encode(uint8_t *buf, uint32_t cp)
{
    if (cp <= 0x7F) {
        buf[0] = (uint8_t)cp;
        return 1;
    }
    if (cp <= 0x7FF) {
        buf[0] = (uint8_t)(0xC0 | (cp >> 6));
        buf[1] = (uint8_t)(0x80 | (cp & 0x3F));
        return 2;
    }
    if (cp <= 0xFFFF) {
        if (cp >= SURROGATE_MIN && cp <= SURROGATE_MAX)
            return 0;
        buf[0] = (uint8_t)(0xE0 | (cp >> 12));
        buf[1] = (uint8_t)(0x80 | ((cp >> 6) & 0x3F));
        buf[2] = (uint8_t)(0x80 | (cp & 0x3F));
        return 3;
    }
    if (cp <= UNICODE_MAX) {
        buf[0] = (uint8_t)(0xF0 | (cp >> 18));
        buf[1] = (uint8_t)(0x80 | ((cp >> 12) & 0x3F));
        buf[2] = (uint8_t)(0x80 | ((cp >> 6) & 0x3F));
        buf[3] = (uint8_t)(0x80 | (cp & 0x3F));
        return 4;
    }
    return 0;
}

/*
 * utf8_decode - 从 UTF-8 字节流解码一个码点
 *
 * data:     输入字节流
 * len:      剩余字节数
 * out_cp:   输出码点
 * out_size: 输出消耗的字节数
 *
 * 返回 UTF8_OK 或错误码
 */
static inline int utf8_decode(const uint8_t *data, size_t len,
                              uint32_t *out_cp, int *out_size)
{
    if (len == 0) return UTF8_ERR_TRUNCATED;

    uint8_t lead = data[0];
    int seq_len = utf8_sequence_length(lead);

    if (seq_len == 0) {
        *out_size = 1;
        return UTF8_ERR_INVALID;
    }

    if ((size_t)seq_len > len) {
        *out_size = (int)len;
        return UTF8_ERR_TRUNCATED;
    }

    /* 检查续接字节 */
    for (int i = 1; i < seq_len; i++) {
        if ((data[i] & 0xC0) != 0x80) {
            *out_size = i;
            return UTF8_ERR_INVALID;
        }
    }

    uint32_t cp;
    switch (seq_len) {
    case 1:
        cp = lead;
        break;
    case 2:
        cp = ((uint32_t)(lead & 0x1F) << 6)
           | ((uint32_t)(data[1] & 0x3F));
        /* 检查超长:2 字节序列最小值为 U+0080 */
        if (cp < 0x80) {
            *out_size = 2;
            return UTF8_ERR_OVERLONG;
        }
        break;
    case 3:
        cp = ((uint32_t)(lead & 0x0F) << 12)
           | ((uint32_t)(data[1] & 0x3F) << 6)
           | ((uint32_t)(data[2] & 0x3F));
        /* 检查超长:3 字节序列最小值为 U+0800 */
        if (cp < 0x800) {
            *out_size = 3;
            return UTF8_ERR_OVERLONG;
        }
        /* 检查代理对 */
        if (cp >= SURROGATE_MIN && cp <= SURROGATE_MAX) {
            *out_size = 3;
            return UTF8_ERR_SURROGATE;
        }
        break;
    case 4:
        cp = ((uint32_t)(lead & 0x07) << 18)
           | ((uint32_t)(data[1] & 0x3F) << 12)
           | ((uint32_t)(data[2] & 0x3F) << 6)
           | ((uint32_t)(data[3] & 0x3F));
        /* 检查超长:4 字节序列最小值为 U+10000 */
        if (cp < 0x10000) {
            *out_size = 4;
            return UTF8_ERR_OVERLONG;
        }
        /* 检查范围 */
        if (cp > UNICODE_MAX) {
            *out_size = 4;
            return UTF8_ERR_RANGE;
        }
        break;
    default:
        *out_size = 1;
        return UTF8_ERR_INVALID;
    }

    *out_cp = cp;
    *out_size = seq_len;
    return UTF8_OK;
}

/*
 * utf8_validate - 验证一段字节流是否为合法 UTF-8
 *
 * data:      输入字节流
 * len:       字节数
 * err_pos:   若非法,输出第一个错误位置(可为 NULL)
 *
 * 返回 UTF8_OK 或错误码
 */
static inline int utf8_validate(const uint8_t *data, size_t len,
                                size_t *err_pos)
{
    size_t pos = 0;
    while (pos < len) {
        uint32_t cp;
        int size;
        int rc = utf8_decode(data + pos, len - pos, &cp, &size);
        if (rc != UTF8_OK) {
            if (err_pos) *err_pos = pos;
            return rc;
        }
        pos += (size_t)size;
    }
    if (err_pos) *err_pos = 0;
    return UTF8_OK;
}

/*
 * utf8_count_codepoints - 计算 UTF-8 字符串中的码点数量
 *
 * 假设输入已经过验证。对于未验证的输入,请先调用 utf8_validate。
 *
 * 返回码点数量
 */
static inline size_t utf8_count_codepoints(const uint8_t *data, size_t len)
{
    size_t count = 0;
    size_t pos = 0;
    while (pos < len) {
        int seq_len = utf8_sequence_length(data[pos]);
        if (seq_len == 0) {
            pos++;
            continue;
        }
        count++;
        pos += (size_t)seq_len;
    }
    return count;
}

/*
 * utf8_next - 安全地前进到下一个码点
 *
 * 用于遍历合法的 UTF-8 字符串
 */
static inline const uint8_t *utf8_next(const uint8_t *p, const uint8_t *end)
{
    if (p >= end) return end;
    int len = utf8_sequence_length(*p);
    if (len == 0) return p + 1;
    const uint8_t *next = p + len;
    return (next > end) ? end : next;
}

#endif /* UTF8_H */

这个实现的关键设计决策:

  1. 所有函数都是 static inline:作为头文件库,零链接开销,编译器可以内联优化。
  2. 区分不同的错误类型:不是简单的”合法/非法”,而是精确报告错误原因和位置。
  3. 前导字节 0xC0 和 0xC1 在 utf8_sequence_length 中直接拒绝:因为它们只能产生超长编码(编码 U+0000..U+007F 的两字节形式)。同理,0xF5-0xFF 也直接拒绝。
  4. 解码时的超长检查是逐类进行的:不是解码后再统一检查,而是在每个 case 分支中检查该长度类的最小值。

五、Unicode 规范化:NFC、NFD 与那些看起来一样的字符串

同一个字符,多种表示

考虑字符 “é”(带锐音符的 e)。在 Unicode 中,它有两种合法的表示方式:

这两种形式在屏幕上看起来完全一样,但它们的字节序列不同。这意味着:

# Python 3 示例
s1 = "\u00e9"       # é (预合成)
s2 = "\u0065\u0301" # é (分解)

print(s1 == s2)      # False!
print(len(s1))       # 1
print(len(s2))       # 2

如果你用这样的字符串作为文件名、数据库键、或者做字符串匹配,你就会遇到”幽灵 bug”——两个看起来完全一样的字符串不相等。macOS 的 HFS+ 文件系统(以及后继的 APFS)在创建文件时会自动将文件名转换为 NFD 形式,而 Linux 不做任何转换。如果你在 macOS 上创建一个文件,用 Linux 去匹配文件名,就可能找不到。

四种规范化形式

Unicode 定义了四种规范化形式(Normalization Form):

形式 全称 描述
NFD Canonical Decomposition 规范分解:将所有预合成字符分解为基础字符 + 组合标记
NFC Canonical Decomposition + Canonical Composition 规范分解后再合成:先分解,再尽可能合成回预合成形式
NFKD Compatibility Decomposition 兼容分解:在 NFD 基础上还分解兼容等价字符
NFKC Compatibility Decomposition + Canonical Composition 兼容分解后再合成

规范等价(canonical equivalence)兼容等价(compatibility equivalence) 的区别很重要:

规范化算法

NFC 的规范化算法分三步:

第一步:规范分解。 递归地将每个字符分解为其规范分解映射(Canonical Decomposition Mapping)。例如:

U+01D5 (Ǖ) → U+00DC (Ü) + U+0304 (◌̄)
           → U+0055 (U) + U+0308 (◌̈) + U+0304 (◌̄)

分解映射是一个查表操作,数据来自 Unicode Character Database(UCD)。

第二步:规范排序。 对所有组合标记按其 Canonical Combining Class(CCC)值排序。CCC 为 0 的字符(基础字符和某些特殊标记)保持原位,CCC 非零的相邻字符按 CCC 值升序排列。这确保了无论原始输入的组合标记顺序如何,规范化后的结果是唯一的。

/* 排序规则:CCC 值相同的标记保持原始顺序(稳定排序) */
/* 例如:U+0308 (CCC=230) 和 U+0304 (CCC=230) 保持原序 */
/* 但 U+0327 (CCC=202) 会排在 U+0308 (CCC=230) 之前 */

第三步:规范合成(仅 NFC)。 从左到右扫描,尝试将基础字符与后续的组合标记合成为预合成字符。合成是有条件的——只有当基础字符和组合标记之间没有”阻塞字符”(CCC 不小于该组合标记的 CCC 的字符)时才能合成。

实用建议

我的建议很简单:

  1. 在系统边界做一次 NFC 规范化。 不要假设输入是什么形式,接收后统一转为 NFC。
  2. 数据库中存储 NFC。 W3C 和 WHATWG 都推荐 NFC。
  3. 搜索和匹配时考虑使用 NFKC。 如果你希望 “file” 和 “file” 匹配到同一个结果。
  4. 不要自己实现。 用 ICU 库。规范化涉及大量的查表数据和边界情况。

六、字素簇:什么才是一个”字符”

码点不是字符

我前面提到了一个例子:"👨‍👩‍👧‍👦" 包含 7 个码点但是”一个字符”。让我拆解给你看:

U+1F468  👨  MAN
U+200D   ‍   ZERO WIDTH JOINER
U+1F469  👩  WOMAN
U+200D   ‍   ZERO WIDTH JOINER
U+1F467  👧  GIRL
U+200D   ‍   ZERO WIDTH JOINER
U+1F466  👦  BOY

这 7 个码点通过 ZWJ(Zero Width Joiner)连接,渲染引擎将它们组合为一个家庭 emoji。在 Unicode 术语中,这整个序列是一个扩展字素簇(extended grapheme cluster)

再看韩语的例子。韩语音节 “한” 可以表示为:

两者都是一个字素簇。

UAX #29:文本分割

Unicode Standard Annex #29(UAX #29)定义了如何将文本分割为字素簇。其核心规则可以用一组 break/no-break 规则表示:

# 简化版字素簇分割规则
# "÷" 表示可以断开,"×" 表示不可断开

GB1:  sot  ÷  Any           # 文本开头
GB2:  Any  ÷  eot           # 文本结尾
GB3:  CR   ×  LF            # CR + LF 是一个簇
GB4:  Control ÷             # 控制字符后断开
GB5:         ÷  Control     # 控制字符前断开
GB6:  L    ×  (L|V|LV|LVT) # 韩语 Jamo 规则
GB7:  (LV|V) ×  (V|T)
GB8:  (LVT|T) ×  T
GB9:        ×  Extend       # 组合标记不断开
GB9a:       ×  SpacingMark
GB9b: Prepend ×
GB11: ExtPict Extend* ZWJ × ExtPict  # ZWJ emoji 序列
GB12: RI RI ÷ RI            # 区域指示符(国旗)成对
GB999: Any ÷ Any            # 默认断开

为什么这很重要

几乎所有与用户交互的文本操作都应该基于字素簇,而不是码点:

如果你用的语言/库只提供码点级别的操作(例如 Go 的 range 遍历 rune),你需要额外引入字素簇分割库。Swift 是少数原生以字素簇为基本”字符”单位的语言,这也是它的字符串 API 看起来比较”奇怪”的原因——因为它是正确的。

七、排序算法:UCA 与区域定制

排序不是 strcmp

你可能以为对字符串排序就是比较字节或码点。但是:

Unicode Collation Algorithm(UCA) 定义了一个与语言无关的默认排序,以及一套机制来为特定语言定制排序规则。

UCA 的多层级比较

UCA 使用多层级(multi-level)比较策略,典型配置为 4 个层级:

层级 名称 区分内容 示例
L1 基础字符 不同字母 a < b < c
L2 重音/变音 相同字母的不同变音 a < á < ä
L3 大小写 相同字母相同变音的大小写 a < A
L4 特殊 标点、空格等 “co-op” < “coop”

比较过程如下:

  1. 将两个字符串中的每个字符映射到其 排序元素(collation element),每个元素是一个多层级权重的元组 [L1, L2, L3, L4]
  2. 先比较所有 L1 权重。如果不同,结果确定。
  3. 如果所有 L1 权重相同,比较所有 L2 权重。
  4. 依此类推。
/* UCA 比较的核心逻辑(简化) */
int uca_compare(const collation_element *a, size_t a_len,
                const collation_element *b, size_t b_len)
{
    /* 层级 1:基础字符 */
    for (size_t i = 0; i < min(a_len, b_len); i++) {
        if (a[i].l1 != b[i].l1)
            return (a[i].l1 < b[i].l1) ? -1 : 1;
    }
    if (a_len != b_len) return (a_len < b_len) ? -1 : 1;

    /* 层级 2:重音 */
    for (size_t i = 0; i < a_len; i++) {
        if (a[i].l2 != b[i].l2)
            return (a[i].l2 < b[i].l2) ? -1 : 1;
    }

    /* 层级 3:大小写 */
    for (size_t i = 0; i < a_len; i++) {
        if (a[i].l3 != b[i].l3)
            return (a[i].l3 < b[i].l3) ? -1 : 1;
    }

    return 0;
}

区域定制(Tailoring)

CLDR(Common Locale Data Repository)为每种语言提供排序规则的定制数据。定制通过”重置”和”移位”操作实现:

# 瑞典语定制:将 ö 移到 z 之后
&z < ö

# 德语电话簿定制:ö 等价于 oe
&o << ö / e

# 中文拼音排序:按拼音首字母
&a < 啊 < 阿 < 吖
&b < 八 < 巴 < 把

排序键(Sort Key)

为了避免每次比较都执行完整的多层级算法,UCA 可以为每个字符串生成一个排序键——一个字节序列,使得对排序键的 memcmp 等价于完整的 UCA 比较。排序键的生成是将各层级权重按顺序连接,层级之间用 0x00 分隔:

"café" → [L1 weights][0x00][L2 weights][0x00][L3 weights]

这使得排序算法可以用标准的字节比较,大幅提升排序性能。

八、双向算法:当文本方向不一致

问题背景

大多数程序员使用的是从左到右(LTR)的文字。但阿拉伯语和希伯来语是从右到左(RTL)的。当 LTR 和 RTL 文本混合出现时——比如一段阿拉伯语中嵌入了英文单词和数字——确定每个字符的显示顺序就成了一个非平凡的算法问题。

Unicode Bidirectional Algorithm(UAX #9)就是解决这个问题的。

算法概述

Bidi 算法分为以下主要步骤:

第一步:确定段落方向。 找到段落中第一个具有强方向性的字符(L、R、AL),其方向就是段落方向。

第二步:确定每个字符的嵌入级别(embedding level)。 级别是一个 0-125 的整数,偶数表示 LTR,奇数表示 RTL。基础级别由段落方向决定(LTR=0,RTL=1)。显式方向标记(如 LRO、RLO、LRE、RLE、PDF,以及 Unicode 6.3 引入的 LRI、RLI、FSI、PDI)可以改变嵌入级别。

第三步:解析弱类型和中性类型。 数字、标点、空格等字符的方向由上下文决定。例如,两个阿拉伯词之间的空格被视为 RTL,而两个英文词之间的空格被视为 LTR。

第四步:处理隐式级别调整。 确保 LTR 字符的级别为偶数,RTL 字符的级别为奇数。

第五步:按级别反转。 从最高级别开始,依次反转每个级别范围内的字符顺序。这就是最终的显示顺序。

逻辑顺序: "The title is مفتاح in Arabic"
                       (mftaH)

嵌入级别: 0000000000000001111100000000000000
                       ← RTL →

显示顺序: "The title is حاتفم in Arabic"
                       (反转后)

Bidi 的安全隐患

2021 年,Nicolas Boucher 和 Ross Anderson 发表了 “Trojan Source” 攻击,利用 Unicode 方向控制字符在源代码中制造视觉与逻辑不一致:

/* 攻击示例(概念性展示)*/
/* 代码在屏幕上看起来是一回事,实际逻辑是另一回事 */
/* 通过嵌入 RLO (U+202E) 和 LRI (U+2066) 等控制字符 */
/* 可以让代码审查者看到的逻辑与编译器看到的不同 */

这导致了 Rust 编译器(CVE-2021-42574)和其他工具紧急添加了对源代码中 bidi 控制字符的警告。

我的看法:源代码应该禁止包含 bidi 控制字符。 任何代码审查工具都应该将其标记为高风险。

九、大小写折叠:Turkish İ 问题

简单的 toLowerCase 不简单

大小写转换看起来是最简单的文本操作之一。但 Unicode 的大小写映射是上下文相关的、语言相关的、一对多的。

土耳其语/阿塞拜疆语的 İ 问题 是最经典的案例:

操作 英语 土耳其语
‘I’ → lowercase ‘i’ ‘ı’ (U+0131, DOTLESS I)
‘i’ → uppercase ‘I’ ‘İ’ (U+0130, DOTTED I)
‘İ’ → lowercase ‘i’ (非标准) ‘i’
‘ı’ → uppercase ‘I’ (非标准) ‘I’

如果你在土耳其语环境下用默认的大小写转换比较文件名或用户名,"FILE""file" 就不会匹配——因为 "FILE".toLower("tr")"fıle"

这个问题在 Java 中是经典考题:

// Java 中的陷阱
"TITLE".toLowerCase();                   // "title" (英语环境)
"TITLE".toLowerCase(new Locale("tr"));   // "tıtle" (土耳其语环境!)

// 如果你的 JVM 默认 locale 是 tr_TR,所有 toLowerCase() 都会受影响
// 这就是为什么你应该总是显式传递 Locale.ROOT
"TITLE".toLowerCase(Locale.ROOT);        // "title" (总是正确的)

其他大小写陷阱

一对多映射:德语的 “ß” 大写是 “SS”(两个字符)。这意味着 toUpperCase 可能改变字符串长度。2017 年 Unicode 引入了大写的 ẞ(U+1E9E),但传统规则仍然有效。

上下文相关映射:希腊语的 σ(sigma)在词尾变为 ς(final sigma)。"ΟΔΟΣ".toLowerCase() 应该是 "οδός" 而不是 "οδοσ"

大小写折叠(case folding) 是为了大小写不敏感比较而设计的。它与 toLowerCase 不同——case folding 是幂等的、与语言无关的(除了可选的土耳其规则),并且某些字符会被折叠到既不是大写也不是小写的形式。

# Python 的 casefold() 实现了 Unicode case folding
"straße".casefold()   # "strasse" (ß → ss)
"MASSE".casefold()    # "masse"
"straße".lower()      # "straße" (lower 不做这个转换!)

# 正确的大小写不敏感比较
def case_insensitive_eq(a, b):
    return a.casefold() == b.casefold()

工程建议

  1. 永远不要用 toLowerCase() + 比较来做大小写不敏感比较。casefold() 或库提供的 case-insensitive 比较函数。
  2. 显式指定 locale。 不要依赖系统默认 locale。
  3. 在协议和标识符中使用 ASCII 大小写折叠。 HTTP 头、DNS 名、email 地址——这些应该只做 ASCII 范围的大小写折叠。
  4. 记住大小写转换可以改变字符串长度。 不要假设 buffer 大小不变。

十、安全问题:当 Unicode 成为攻击向量

同形字攻击(Homoglyph Attack)

看看这两个 URL:

apple.com
аpple.com

它们看起来一样,对吧?但第二个中的 “а” 不是 ASCII 的 “a”(U+0061),而是西里尔字母 “а”(U+0430)。攻击者可以注册 аpple.com 并用于钓鱼。

类似的同形字对比比皆是:

拉丁字母 同形字 来源
a (U+0061) а (U+0430) 西里尔
o (U+006F) о (U+043E) 西里尔
p (U+0070) р (U+0440) 西里尔
e (U+0065) е (U+0435) 西里尔
c (U+0063) с (U+0441) 西里尔
H (U+0048) Н (U+041D) 西里尔
0 (U+0030) О (U+041E) 西里尔
1 (U+0031) l (U+006C) 拉丁
1 (U+0031) Ι (U+0399) 希腊

浏览器的应对策略是 IDN(Internationalized Domain Names)同形字检测:如果一个域名混合使用了多个脚本(scripts),浏览器会显示 Punycode 形式而不是 Unicode 形式。但这个防御不是万能的。

超长编码攻击

早期的 UTF-8 实现不检查超长编码。攻击者可以用超长编码绕过安全过滤器:

'/' = 0x2F                    (正常 ASCII)
'/' = 0xC0 0xAF               (非法的 2 字节超长)
'/' = 0xE0 0x80 0xAF          (非法的 3 字节超长)
'/' = 0xF0 0x80 0x80 0xAF     (非法的 4 字节超长)

如果你的 Web 服务器先对路径做安全检查(“不允许 ../”),然后再做 UTF-8 解码,攻击者就可以用超长编码的 ./ 绕过检查。这就是 IIS/Apache 历史上多个目录遍历漏洞的根源。

防御:在处理任何语义之前先验证 UTF-8 的合法性。这是第一道防线,没有例外。

不可见字符和零宽字符

Unicode 包含大量不可见或零宽度的字符:

攻击者可以利用这些字符:

  1. 绕过关键词过滤:在敏感词中插入零宽字符,p​a​s​s​w​o​r​d 看起来像 “password” 但不匹配简单的字符串比较。
  2. 水印追踪:在文本中嵌入零宽字符的不同组合,可以唯一标识每个接收者。
  3. 污染训练数据:在 ML 训练数据中注入不可见字符。

正则表达式与 Unicode

很多程序员写的正则表达式在 Unicode 面前不堪一击:

# 这些正则表达式都有问题
import re

# "匹配单词字符" —— 取决于 re.UNICODE 标志
re.match(r'\w+', '中文')          # 在 Python 3 中匹配(默认 Unicode)

# "匹配任意字符" —— 不匹配 astral plane 字符(某些语言中)
# Python 3 正确处理,但 JavaScript 旧版本需要 /u 标志

# "匹配字母" —— 应该用 Unicode 属性
re.match(r'[\p{L}]+', '中文')     # 需要 regex 模块,标准库不支持 \p{}

# 最危险的:用 [a-zA-Z] "匹配字母"
re.match(r'[a-zA-Z]+', 'café')    # 只匹配 "caf",漏掉了 é

安全清单

下面是一份 Unicode 安全检查清单,供工程团队参考:

检查项 风险等级 建议
在处理语义前验证 UTF-8 严重 使用经过验证的库,不要自己写
拒绝超长编码 严重 现代库默认拒绝,但要确认
检测混合脚本 用于用户名、域名等安全敏感场景
规范化后再比较 使用 NFC 或 NFKC
过滤不可见字符 在用户输入中过滤零宽字符
限制组合标记数量 防止”zalgo text”式的拒绝服务
正则表达式使用 Unicode 模式 Python 3 默认,JS 需要 /u
禁止源代码中的 bidi 控制字符 配置 linter 检查
大小写比较使用 casefold 不要用 toLowerCase
字符串截断按字素簇 避免在多字节序列中间截断

十一、工程实践:常见陷阱与对策

陷阱汇总表

陷阱 错误做法 正确做法
字符串长度 strlen().length 区分字节数、码点数、字素簇数
字符串反转 按码点/字节反转 按字素簇反转
字符串截断 按字节截断到 N 按字素簇截断,或至少在码点边界截断
字符串比较 strcmp / == 规范化后比较,或使用 collation
大小写比较 toLower + == casefold() 或 ICU u_strCaseCompare
正则表达式 [a-zA-Z] 匹配”字母” \p{L} 或 Unicode-aware 库
子字符串搜索 strstr 搜索非 ASCII NFC 规范化后搜索
排序 memcmp / 默认 sort ICU collator 或等效实现
JSON 解析 不验证 UTF-8 先验证后解析
文件名比较 直接字节比较 NFC 规范化后比较(注意 macOS NFD)
URL 处理 假设 ASCII percent-encoding 处理非 ASCII
数据库 VARCHAR + 默认 collation 显式指定 utf8mb4 + collation

MySQL 的 utf8 不是 UTF-8

一个经典的坑:MySQL 的 utf8 字符集只支持最多 3 字节的 UTF-8 序列,也就是只覆盖 BMP(Basic Multilingual Plane,U+0000..U+FFFF)。emoji 和其他 supplementary plane 的字符会被截断或报错。

正确的做法是使用 utf8mb4

-- 错误
CREATE TABLE users (
    name VARCHAR(255) CHARACTER SET utf8
);

-- 正确
CREATE TABLE users (
    name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
);

MySQL 8.0 起默认字符集终于变成了 utf8mb4。如果你还在用旧版本,请检查你的表定义。

字节计数 vs 码点计数 vs 字素簇计数

s = "👨‍👩‍👧‍👦"

# Python 3(内部用 UTF-32)
len(s)                  # 7 (码点数)
len(s.encode('utf-8'))  # 25 (字节数)

# 字素簇计数需要第三方库
import grapheme
grapheme.length(s)      # 1 (字素簇数)
// Go
s := "👨‍👩‍👧‍👦"
len(s)                     // 25 (字节数)
utf8.RuneCountInString(s)  // 7 (码点数)
// 字素簇计数需要 unicode/text 或第三方库
// Rust
let s = "👨‍👩‍👧‍👦";
s.len()                    // 25 (字节数)
s.chars().count()          // 7 (码点数)
// 字素簇:使用 unicode-segmentation crate
use unicode_segmentation::UnicodeSegmentation;
s.graphemes(true).count()  // 1 (字素簇数)
// JavaScript
const s = "👨‍👩‍👧‍👦";
s.length;                  // 11 (UTF-16 码元数!)
[...s].length;             // 7 (码点数,ES6+)
// Intl.Segmenter (Chrome 87+, Node 16+)
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...segmenter.segment(s)].length;  // 1 (字素簇数)

注意 JavaScript 的 .length 返回的既不是字节数也不是码点数,而是 UTF-16 码元数。这是因为 JavaScript 内部使用 UTF-16 编码。每个 BMP 字符占 1 个码元,supplementary plane 字符占 2 个码元(代理对)。这可能是所有主流语言中最令人困惑的语义。

性能考虑

Unicode 正确性和性能之间存在张力。以下是一些实用的优化策略:

快速路径:ASCII 检测。 绝大多数现实世界的文本都包含大量 ASCII 字符。在处理循环中,先检查字节是否 < 0x80,如果是就走快速路径。很多库(包括 Go 的标准库和 V8 引擎)都使用这个策略。

/* ASCII 快速路径示例 */
size_t fast_utf8_count(const uint8_t *data, size_t len)
{
    size_t count = 0;
    size_t i = 0;

    /* 快速路径:批量处理 ASCII */
    while (i + 8 <= len) {
        uint64_t chunk;
        memcpy(&chunk, data + i, 8);
        if ((chunk & 0x8080808080808080ULL) == 0) {
            count += 8;
            i += 8;
            continue;
        }
        break;
    }

    /* 慢速路径:处理非 ASCII */
    while (i < len) {
        int seq_len = utf8_sequence_length(data[i]);
        if (seq_len == 0) { i++; continue; }
        count++;
        i += (size_t)seq_len;
    }

    return count;
}

预计算排序键。 如果你需要对大量字符串排序,预先为每个字符串计算排序键(sort key),然后用字节比较排序。这将 O(n log n) 次 UCA 比较转化为 O(n) 次排序键生成 + O(n log n) 次字节比较。

缓存规范化结果。 规范化是幂等的。如果你知道一个字符串已经是 NFC 形式(比如它来自一个总是输出 NFC 的数据源),就不要再规范化。ICU 提供了 quickCheck 函数来快速判断一个字符串是否已经是指定的规范化形式。

十二、结语:尊重文本的复杂性

写这篇文章的过程中,我反复感受到一件事:文本处理的复杂性被严重低估了。

Unicode 不仅仅是一张将数字映射到字形的表。它是人类语言多样性在计算机中的映射,而人类语言是几千年演化的结果,充满了不规则性、上下文依赖和文化特异性。UCA 的排序规则反映了不同文化对”顺序”的不同理解;bidi 算法反映了不同文字系统的书写方向;字素簇规则反映了人类对”一个字符”的直觉认知。

作为工程师,我们的责任是尊重这种复杂性,而不是假装它不存在。每次你写 s.length()s[i]s.toLower(),你都在做一个关于”字符”的假设——请确保你知道这个假设是什么。

一些最终建议:

  1. 使用 ICU。 不要自己实现 Unicode 算法(除了 UTF-8 编解码这样的简单操作)。ICU 是 Unicode Consortium 维护的参考实现,覆盖了规范化、排序、分割、大小写转换、bidi 等所有算法。
  2. 在系统边界验证和规范化。 接收外部输入时验证 UTF-8、转换为 NFC。这是防线,不是优化。
  3. 测试非 ASCII 输入。 你的测试用例中应该有中文、阿拉伯文、emoji、组合字符、零宽字符。
  4. 理解你的工具链。 知道你用的语言的字符串 .length 返回的是什么。知道你的数据库的字符集配置。知道你的正则表达式引擎是否 Unicode-aware。
  5. 保持谦逊。 Unicode 的复杂性不是设计缺陷,而是现实世界复杂性的忠实映射。我们能做的是站在 ICU、CLDR、Unicode Consortium 这些巨人的肩膀上,正确地使用他们的工作成果。

参考资料


上一篇: SIMD 字符串处理 下一篇: HyperLogLog 相关阅读: - SIMD 字符串处理 - DFA 最小化


By .