C++ Unicode 与文本处理:编码转换与高性能字符串

std::string 不携带编码信息,这让 C++ 的文本处理布满暗礁。本文区分字节、码点与字素簇三个层次,讲清 UTF-8/UTF-16 的编码规则与代理对,演示 iconv、ICU 与 utf8cpp 的转换方案,剖析 size()、substr、大小写折叠与规范化的常见错误。

std::string 的名字里没有「编码」二字,这正是问题的根源。它是一串字节,可能是 ASCII、UTF-8、GBK,也可能是任意二进制;size() 返回字节数而非字符数,substr() 按字节切分可能把一个字符劈成两半。当程序只处理英文时一切正常,一旦遇到中文、emoji 或组合字符,各种「看起来不可能」的 bug 就冒出来:截断后的字符串是乱码、长度校验与用户认知不符、大小写转换丢字符。

Unicode 的复杂度不在于「怎么编码」,而在于**「字符」这个词本身就有好几层含义**。本文先把这几层概念理清,再给出 C++ 里可用的转换与处理方案,最后讨论高性能字符串的实践。目标不是复述 Unicode 标准,而是让读者知道在哪个层次上做哪个操作才是正确的。

字节、码点与字素簇:三个不同的「字符」

处理文本时混用「字符」这个词,几乎必然出错。至少有三层含义需要区分:

层次英文单位例子
字节byte8 bit中 在 UTF-8 里占 3 字节
码点code pointUnicode 编号(0 ~ 0x10FFFF)中 = U+4E2D
字素簇grapheme cluster用户感知的「一个字符」é 可能是 e + 组合重音
编码单元code unit编码的最小单位UTF-8 是字节,UTF-16 是 16 位

关系是:字素簇由若干码点组成,码点由若干编码单元组成。举例:

  • "é" 有两种表示:单个码点 U+00E9(预组合),或 e (U+0065) + U+0301(组合重音)。二者视觉相同,字节不同。
  • "👨‍👩‍👧" 家庭 emoji 由 4 个码点加 3 个零宽连接符(ZWJ)组成,是一个字素簇、25 个 UTF-8 字节。
  • "🇨🇳" 国旗由两个「区域指示符」码点组成。

因此下面这些「常识」在 Unicode 下全部不成立:

  • 「一个字符占一个字节」——ASCII 成立,其他都不成立。
  • 「字符串长度就是字符个数」——取决于你在哪一层数。
  • 「按位置截断字符串」——按字节截断可能破坏编码,按码点截断可能破坏字素簇。

选择哪个层次,取决于你要做什么:

  • 存储、传输、比较(二进制相等):用字节。
  • 遍历、统计码点数、做码点级替换:用码点。
  • 显示截断、光标移动、用户感知的长度:用字素簇。

绝大多数「按字符截断显示」的需求,正确层次是字素簇,而多数实现错误地用字节或码点。

UTF-8、UTF-16 与 UTF-32 的编码规则

三种编码各有取舍,理解其规则才能正确处理。

UTF-8:变长 1~4 字节,与 ASCII 兼容。

码点范围字节数首字节模式
U+0000 ~ U+007F10xxxxxxx
U+0080 ~ U+07FF2110xxxxx 10xxxxxx
U+0800 ~ U+FFFF31110xxxx 10xxxxxx 10xxxxxx
U+10000 ~ U+10FFFF411110xxx 10xxxxxx 10xxxxxx 10xxxxxx

识别要点:首字节的高位决定了这个字符有几个字节。0xxxxxxx 是单字节,110xxxxx 起两字节,1110xxxx 起三字节,11110xxx 起四字节;后续字节都以 10 开头。这个设计让「按字节同步」成为可能——从一个字节就能判断它是否是「字符的开头」,这是 UTF-8 抗损坏能力强的关键。

UTF-16:2 或 4 字节。BMP(U+0000 ~ U+FFFF)用一个 16 位单元,之外的用代理对(surrogate pair):高位代理 0xD800 ~ 0xDBFF + 低位代理 0xDC00 ~ 0xDFFF。

// 代理对解码:把两个 16 位单元合成一个码点
uint32_t decode_surrogate(uint16_t hi, uint16_t lo) {
    return 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00);
}

代理对的坑在于单独的代理是不合法的:0xD800 不能单独出现。按 16 位单元截断 UTF-16 字符串,很可能把一个代理对切开,得到非法序列。Windows 的 wchar_t 是 16 位、Java/C# 的 String 是 UTF-16,都受这个影响。

UTF-32:固定 4 字节一个码点,无代理对、可随机访问,但内存是 UTF-8 的 2~4 倍。Linux 的 wchar_t 是 32 位,但不等于 UTF-32(见下文)。

BOM 与字节序

UTF-16/UTF-32 有字节序问题,文件开头的 BOM(Byte Order Mark)用于标识:

BOM 字节含义
EF BB BFUTF-8(可选,不推荐加)
FE FFUTF-16 大端
FF FEUTF-16 小端
FF FE 00 00UTF-32 小端
00 00 FE FFUTF-32 大端

UTF-8 的 BOM 是「可选且不推荐」的,但 Windows 工具常会加上。读取时若不去掉,第一个字符会变成 U+FEFF(零宽不换行空格),导致字符串比较、JSON 解析失败——这是「读 JSON 文件报第一行有奇怪字符」的经典原因。

std::string strip_utf8_bom(std::string s) {
    if (s.size() >= 3 &&
        static_cast<unsigned char>(s[0]) == 0xEF &&
        static_cast<unsigned char>(s[1]) == 0xBB &&
        static_cast<unsigned char>(s[2]) == 0xBF) {
        s.erase(0, 3);
    }
    return s;
}

std::string 的编码无关性与常见错误

std::string 只是 std::basic_string<char>,它不知道也不关心编码。所有「按字符」的操作实际都是按字节。

std::string s = "中文";        // UTF-8,6 字节
std::cout << s.size() << "\n"; // 6,不是 2
std::cout << s.substr(0, 2) << "\n";  // 前 2 字节 = 一个不完整的字符 → 乱码

常见错误清单:

错误后果正确做法
s.size() 当字符数中文长度翻倍用码点或字素簇计数
s.substr(i, n) 按字节切切出非法 UTF-8先按码点/字素簇定位
s[i] 取「第 i 个字符」取到半个字符解码后按码点索引
std::toupper(s[0])只对 ASCII 有效用 ICU 的 Unicode 大小写
s.find("中")这个其实是对的(字节匹配)子串搜索按字节匹配是安全的
std::regex 匹配中文. 只匹配一个字节用 UTF-8 感知的正则

注意 find 这类字节序列匹配是安全的:UTF-8 是自同步的,一个合法 UTF-8 子串不会意外出现在另一个字符的中间(UTF-8 的这个性质叫「无重叠」,也是它比 GBK 等编码更安全的原因之一)。危险的是按位置切分和逐字节解释。

std::wstring 也不是解药:Windows 上是 UTF-16(有代理对),Linux 上是 UTF-32,行为不一致,且 std::wstring 同样不携带「这是 UTF-16 还是别的」的信息。跨平台代码应统一用 UTF-8 的 std::string 作为内部表示,只在调用系统 API 时转换。

编码转换:iconv、ICU 与 utf8cpp

std::codecvt:已弃用,不要用

C++11 引入的 std::codecvt_utf8_utf16 等 facet 在 C++17 被标记弃用,C++26 移除。它的问题在于设计(locale 耦合、状态管理复杂)与实现质量。新代码不要用它。

iconv:POSIX 上的通用转换

iconv 是 POSIX 标准,支持几乎所有编码,适合在 Linux 上做一次性转换。

#include <iconv.h>
#include <string>

std::string convert(const std::string& in, const char* from, const char* to) {
    iconv_t cd = iconv_open(to, from);
    if (cd == (iconv_t)-1) return {};

    std::string out(in.size() * 4, '\0');
    char* inbuf = const_cast<char*>(in.data());
    size_t inleft = in.size();
    char* outbuf = out.data();
    size_t outleft = out.size();

    while (inleft > 0) {
        if (iconv(cd, &inbuf, &inleft, &outbuf, &outleft) == (size_t)-1) {
            // EILSEQ / EINVAL / E2BIG,需按 errno 处理
            break;
        }
    }
    out.resize(out.size() - outleft);
    iconv_close(cd);
    return out;
}

iconv 的坑:输出缓冲要足够大(E2BIG 时要扩容重试)、遇到非法序列要按 errno 分别处理(EILSEQ 非法序列、EINVAL 截断序列)、iconv 会修改传入的指针。生产代码通常封装一层循环处理 E2BIG。

utf8cpp:轻量的 UTF-8 专用库

如果只需要 UTF-8 与 UTF-32 之间的转换、码点遍历与合法性校验,utf8cpp(header-only)比 ICU 轻得多:

#include <utf8.h>

// 码点遍历
std::string s = "中文👨‍👩‍👧";
for (auto it = s.begin(); it != s.end(); ) {
    uint32_t cp = utf8::next(it, s.end());   // 取一个码点并前进
    // cp 是码点
}

// UTF-8 ↔ UTF-32
std::u32string u32;
utf8::utf8to32(s.begin(), s.end(), std::back_inserter(u32));

// 合法性校验
bool ok = utf8::is_valid(s.begin(), s.end());

// 按码点数
size_t n = utf8::distance(s.begin(), s.end());

utf8cpp 的定位是「UTF-8 编解码工具」,不处理大小写、规范化、排序——那些需要完整的 Unicode 数据表,是 ICU 的领域。

ICU:完整的 Unicode 实现

ICU(International Components for Unicode)提供全套 Unicode 支持:大小写折叠、规范化、字素簇切分、排序规则(collation)、日期数字的本地化。

#include <unicode/unistr.h>
#include <unicode/ustream.h>

icu::UnicodeString us = icu::UnicodeString::fromUTF8("Straße");
us.toUpper();                       // "STRASSE"(德语 ß 展开为 SS)
std::string out; us.toUTF8String(out);

// 字素簇切分
icu::BreakIterator* bi = icu::BreakIterator::createCharacterInstance(
    icu::Locale::getDefault(), status);
bi->setText(us);
for (int32_t e = bi->first(); e != icu::BreakIterator::DONE; e = bi->next()) {
    // [bi->previous(), e) 是一个字素簇
}

ICU 的代价是体积大(数据表几十 MB)、初始化慢、API 复杂。选型原则:

  • 只需 UTF-8 编解码与校验 → utf8cpp。
  • 需要 POSIX 上的任意编码转换 → iconv。
  • 需要大小写折叠、规范化、字素簇、排序 → ICU。

「大小写转换」是最容易被低估的需求:ß 转大写是 SS(长度变化)、土耳其语的 i 大小写规则与英语不同(dotless i),ASCII 的 toupper 一个都处理不了。Unicode 的编码规则全貌可以对照 Unicode 编码完全指南 。

规范化、大小写折叠与字素簇

规范化(Normalization)

同一个「字符」可能有多种码点序列,Unicode 定义了四种规范化形式:

形式含义用途
NFC规范组合大多数场景的默认(存储、比较)
NFD规范分解macOS 文件系统用(历史原因)
NFKC兼容组合搜索、去重(① → 1,fi → fi)
NFKD兼容分解标识符处理
// ICU 规范化
icu::UnicodeString nfc = us; nfc.normalize(icu::UNormalizer2::getNFCInstance(status));

比较两个字符串前必须先规范化,否则 "é"(预组合)与 "é"(组合)会被判为不同。macOS 的文件名用 NFD,Linux 用 NFC,跨平台传文件名时不做规范化会导致「文件找不到」。

NFKC 更激进:它把 ①、fi、全角字符等「兼容等价」的字符折叠成标准形式,适合搜索与去重,但不适合存储(会丢失原始信息)。

大小写折叠与本地化

「转小写」在 Unicode 下有多个层次:简单映射(一对一)与完整映射(可能变长,如 ß → ss)。搜索场景应使用**大小写折叠(case folding)**而不是简单的 lowercase,因为折叠是为「无差别比较」设计的。

土耳其语的 I/i 问题最典型:土耳其语里 I 的小写是 ı(无点),i 的大写是 İ(有点)。用默认规则处理土耳其语文本会得到错误结果。本地化不是可选项,而是正确性问题。

字素簇切分

「按用户感知的字符切分」需要字素簇规则(UAX #29)。手写几乎不可能正确——emoji 的 ZWJ 序列、肤色修饰符、旗帜、组合重音都是特例。必须用 ICU 的 BreakIterator 或同等实现。

// 错误的「截断到 N 个字符」
std::string truncated = s.substr(0, N);        // 可能切出乱码

// 正确的做法:按字素簇截断(用 ICU BreakIterator)

显示截断、光标定位、字符计数都属此类。一个实用建议是:UI 层的字符计数统一走 ICU,业务逻辑层不要自行实现。

高性能字符串:SSO、string_view 与避免拷贝

文本处理往往在热路径上(解析、匹配、分词),字符串操作的性能至关重要。

小字符串优化(SSO):std::string 通常内联存放短字符串(libstdc++ 是 15 字节,libc++ 是 22 字节),短字符串不分配堆。这意味着「大量短字符串」的场景性能好,而「超过 SSO 阈值一个字节」会导致堆分配——把字符串从 15 字节变成 16 字节可能带来显著的开销跳变。

std::string_view 避免拷贝:只读的字符串参数应当用 string_view:

// 好:不拷贝,接受 string、字面量、子串
void parse(std::string_view sv);

// 差:传字面量会构造临时 std::string
void parse(const std::string& s);

但 string_view 不拥有数据,悬垂风险必须警惕:

std::string_view sv = std::string("temp");   // 悬垂!
auto f() -> std::string_view { std::string s = "x"; return s; }  // 悬垂!

string_view 作为函数参数是安全的(生命周期覆盖调用),作为返回值或成员则极易出错。

避免不必要的拼接:a + b + c 会构造多个临时字符串。用 reserve 预分配,或用 absl::StrCat / fmt::format 这类一次分配的实现:

std::string out;
out.reserve(a.size() + b.size() + c.size());
out += a; out += b; out += c;

UTF-8 的遍历开销:按码点遍历需要解码,比按字节遍历慢。如果只是做字节级操作(如查找分隔符、判断是否含某个 ASCII 字符),不要解码——UTF-8 的自同步性保证 ASCII 字节不会出现在多字节序列内部,所以按字节搜 ASCII 是安全的。

// 判断是否全是 ASCII,只需检查最高位
bool is_ascii(std::string_view s) {
    for (unsigned char c : s) if (c & 0x80) return false;
    return true;
}

这个技巧在解析器里很常用:先快速判断「是否纯 ASCII」,是则走快速路径,否则回退到完整 Unicode 处理。

手写一个最小 UTF-8 解码器

理解 UTF-8 的最好方式是写一遍解码。下面这个实现展示了「首字节决定长度」的规则:

// 解码一个码点,返回码点值,it 前进到下一个字符起点
// 非法序列返回 0xFFFD(替换字符)并前进一个字节
uint32_t decode_utf8(const char*& p, const char* end) {
    if (p >= end) return 0;
    unsigned char c = static_cast<unsigned char>(*p);

    int len;
    uint32_t cp;
    if      ((c & 0x80) == 0x00) { len = 1; cp = c & 0x7F; }
    else if ((c & 0xE0) == 0xC0) { len = 2; cp = c & 0x1F; }
    else if ((c & 0xF0) == 0xE0) { len = 3; cp = c & 0x0F; }
    else if ((c & 0xF8) == 0xF0) { len = 4; cp = c & 0x07; }
    else { ++p; return 0xFFFD; }          // 非法首字节

    if (p + len > end) { p = end; return 0xFFFD; }   // 截断序列

    for (int i = 1; i < len; ++i) {
        unsigned char cc = static_cast<unsigned char>(p[i]);
        if ((cc & 0xC0) != 0x80) { ++p; return 0xFFFD; }  // 续字节格式错
        cp = (cp << 6) | (cc & 0x3F);
    }
    p += len;
    return cp;
}

几个必须注意的正确性细节,生产代码还要额外处理:

  • 过长编码(overlong encoding):0xC0 0x80 解码为 U+0000,但它是非法表示。安全敏感的解析器必须拒绝,否则会绕过输入校验(历史上大量漏洞源于此)。
  • 代理区码点:UTF-8 里 U+D800 ~ U+DFFF 是非法的,必须拒绝。
  • 超出 U+10FFFF:4 字节只能表示到 U+10FFFF,但 0xF7 开头会溢出,需校验上限。
  • 替换字符策略:遇到非法序列是「跳过一字节继续」还是「整体拒绝」,取决于场景。宽松解析(如浏览器)用替换字符,严格解析(如协议)应直接报错。

用 utf8cpp 或 ICU 可以免去这些细节,但理解原理才能判断一个库的「严格程度」是否满足需求。文本解析中的这类「宽松 vs 严格」取舍,与 https://plumephp.com/cpp-serialization-libraries/ 里讨论的解析器容错策略是同一类问题。字符串与容器的关系、string_view 在接口设计中的用法,可以对照 https://plumephp.com/cpp-stl-containers/;性能优化的通用方法论见 https://plumephp.com/cpp-performance-optimization/。

正则匹配是另一个 Unicode 暗礁:std::regex 默认按字节匹配,. 只匹配一个字节,[a-z] 对非 ASCII 无效。处理 Unicode 文本的正则要么用 ICU 的 RegexPattern,要么在 UTF-32 上匹配。正则引擎的底层机制可以参看 正则表达式深入 与 正则引擎内部原理 ;URL 这类需要百分号编码的场景则见 URL 解析与编码 。

实践建议

  1. 内部统一用 UTF-8 的 std::string,不要用 std::wstring 做内部表示(平台语义不一致)。
  2. 区分三个层次:字节用于存储传输,码点用于遍历替换,字素簇用于显示与计数。
  3. 永远不要按字节位置截断用户可见文本,按字素簇截断(用 ICU BreakIterator)。
  4. 比较前先规范化(NFC),跨平台文件名要特别注意 macOS 的 NFD。
  5. 大小写转换用 ICU,std::toupper 只对 ASCII 正确;搜索用 case folding 而非 lowercase。
  6. 读取文本时检查并剥离 UTF-8 BOM,否则首字符会变成 U+FEFF 引发解析失败。
  7. 转换库按需选:utf8cpp(轻量编解码)、iconv(POSIX 任意编码)、ICU(完整 Unicode 语义)。
  8. string_view 只作参数,不要作返回值或成员,避免悬垂。
  9. 纯 ASCII 快速路径:先检查最高位,是纯 ASCII 就按字节处理,跳过解码开销。
  10. 正则匹配 Unicode 要用 Unicode 感知的引擎,std::regex 的 . 只匹配一个字节。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cpp」更多文章

  1. C++ 数值计算与线性代数:Eigen 与表达式模板
  2. C++ 静态分析与代码质量工具链:clang-tidy 与 Clang Static Analyzer
  3. C++ 日志与结构化可观测性:spdlog 与异步日志