尧图精选

C++中的字符串编码处理方法

🕒 发布时间:2026/11/6 3:54:10 📁 来源:尧图网络
前言C 标准库里的std::string本质上是一个字节序列它不知道什么是「字符」更不知道什么是「中文」。一个汉字在 UTF-8 下占 3 个字节std::string::size()返回的是 3 而不是 1s[0]取到的是这个汉字三个字节中的第一个单独拿出来既不是合法字符也无法正确显示。字符串编码处理的全部麻烦都源于「字节数」「码元数」「码点数」「用户感知的字符数」这四个概念并不相等。常见的误解有四个。第一把size()当成字数用它做输入长度限制结果中文用户名被截成半个字。第二以为wchar_t是「万能的宽字符」实际上它的宽度由实现决定Windows 上是 16 位通常表示 UTF-16 码元Linux 上多为 32 位表示 UTF-32。第三用std::toupper逐字节处理 UTF-8 文本非 ASCII 字节被当成独立字符结果被破坏。第四继续使用codecvt里的std::wstring_convert而它从 C17 起已被弃用。另外说明一点有些资料把 UTF-8 误写成「UF8」这是笔误正确写法是 UTF-88-bit Unicode Transformation Format。本文按 UTF-8 讲解。本文先建立码点与码元的概念再讲std::string、std::wstring、std::u16string的差别最后给出只用标准 C 的 UTF-8 解码、编码与安全截断代码。涉及平台 API 的地方会明确标注平台。一、码点、码元与 UTF-8 的字节结构Unicode 给每个字符分配一个编号叫码点code point写作 UXXXX取值范围是 0 到 0x10FFFF。码点只是编号真正存储时要选一种编码方式把它表示成一个或多个码元code unit。UTF-8 的码元是 8 位字节UTF-16 的码元是 16 位UTF-32 的码元是 32 位。UTF-8 的设计是变长编码字节数由码点范围决定码点范围字节数首字节模式后续字节模式U0000 – U007F10xxxxxxx无U0080 – U07FF2110xxxxx10xxxxxxU0800 – UFFFF31110xxxx两个10xxxxxxU10000 – U10FFFF411110xxx三个10xxxxxx由此可以推出几条实用规则ASCII 字符在 UTF-8 下仍是单字节与旧代码兼容所有多字节序列的首字节最高位都是 1后续字节都以10开头因此按字节扫描时不会把一个后续字节误认成首字节中文常用字大多落在三字节区间。UTF-8 还有几条合法性约束解析时必须检查不能出现过长编码例如用两字节表示 U0041不能编码代理区码点 UD800 到 UDFFF不能超过 U10FFFF。很多「能显示但行为诡异」的乱码源头就是没有做这些校验。二、std::string、std::wstring 与 UTF-16std::string是std::basic_stringchar存放的是字节编码方式完全由使用者约定。它可以存 UTF-8、GBK、Latin-1标准库不提供任何区分手段。这也是为什么跨平台代码通常约定「内部一律 UTF-8只在边界转换」。std::wstring是std::basic_stringwchar_t。wchar_t的宽度是实现定义行为Windows 上为 16 位一个wchar_t是一个 UTF-16 码元超出基本多文种平面BMP的字符要用代理对surrogate pair表示Linux 上通常为 32 位一个wchar_t可以直接放一个码点。因此同一段std::wstring代码在两个平台上语义并不相同wstring::size()在 Windows 上对 emoji 会返回 2。C11 引入了std::u16string元素类型char16_t和std::u32string元素类型char32_t语义比wstring明确前者是 UTF-16 码元序列后者是码点序列。C20 又引入了char8_t与std::u8string用类型系统把 UTF-8 字节串和普通字节串区分开。这些特性有版本门槛使用前要确认工具链支持。类型元素宽度编码语义主要问题std::string8 位未指定约定 UTF-8长度按字节可能截断多字节字符std::wstring实现定义Windows UTF-16 / Linux UTF-32跨平台语义不一致std::u16string16 位UTF-16非 BMP 字符占两个码元std::u32string32 位UTF-32内存占用大外部协议少用std::u8stringC208 位UTF-8需要 C20 及以上的支持codecvt中的std::wstring_convert和std::codecvt_utf8曾经是标准转换手段但从 C17 起被弃用原因是接口设计存在缺陷、对非法输入的处理不统一。新代码不应再依赖它们需要转换时可以用本文的手写函数或使用成熟的第三方库或调用平台 API。三、手写 UTF-8 解码、编码与安全截断先看解码。函数从字节串的某个位置读出一个码点成功时推进下标并返回true遇到非法序列返回false且不保证下标位置。除了检查首字节模式还要检查后续字节是否以10开头、是否过长编码、是否落在代理区。// 适用于 C17#include cstdint#include string#include string_viewbool decode_one(std::string_view s, std::size_t i, char32_t cp) {if (i s.size()) return false;const unsigned char c static_castunsigned char(s[i]);int extra 0;char32_t value 0;if (c 0x80) {cp c;i;return true;} else if ((c 0xE0) 0xC0) {extra 1;value c 0x1F;} else if ((c 0xF0) 0xE0) {extra 2;value c 0x0F;} else if ((c 0xF8) 0xF0) {extra 3;value c 0x07;} else {return false; // 10xxxxxx 或 11111xxx 都不是合法首字节}if (i static_caststd::size_t(extra) s.size()) return false;for (int k 1; k extra; k) {const unsigned char cc static_castunsigned char(s[i k]);if ((cc 0xC0) ! 0x80) return false;value (value 6) | (cc 0x3F);}static const char32_t min_for_len[4] {0, 0x80, 0x800, 0x10000};if (value min_for_len[extra]) return false; // 过长编码if (value 0x10FFFF) return false;if (value 0xD800 value 0xDFFF) return false; // 代理区i static_caststd::size_t(extra) 1;cp value;return true;}编码是逆过程按码点范围选择字节数再把码点的高位依次填入首字节和后续字节的低 6 位// 适用于 C17std::string encode_one(char32_t cp) {std::string out;if (cp 0x7F) {out.push_back(static_castchar(cp));} else if (cp 0x7FF) {out.push_back(static_castchar(0xC0 | (cp 6)));out.push_back(static_castchar(0x80 | (cp 0x3F)));} else if (cp 0xFFFF) {out.push_back(static_castchar(0xE0 | (cp 12)));out.push_back(static_castchar(0x80 | ((cp 6) 0x3F)));out.push_back(static_castchar(0x80 | (cp 0x3F)));} else if (cp 0x10FFFF) {out.push_back(static_castchar(0xF0 | (cp 18)));out.push_back(static_castchar(0x80 | ((cp 12) 0x3F)));out.push_back(static_castchar(0x80 | ((cp 6) 0x3F)));out.push_back(static_castchar(0x80 | (cp 0x3F)));}return out; // 超出范围的码点返回空串}有了这两个函数就可以按码点遍历文本而不是按字节// 适用于 C17#include iostreamvoid dump_codepoints(std::string_view s) {std::size_t i 0;while (i s.size()) {const std::size_t start i;char32_t cp 0;if (!decode_one(s, i, cp)) {std::cout invalid byte at start \n;return;}std::cout U std::hex std::uppercase cp std::dec bytes (i - start) \n;}}最常见的工程需求是「按字节上限截断但不能切碎字符」。做法是从头逐字符解码记录最后一个完整字符的结束位置一旦下一个字符会越过上限就停下// 适用于 C17std::string truncate_utf8(std::string_view s, std::size_t max_bytes) {if (s.size() max_bytes) return std::string(s);std::size_t i 0;std::size_t last_good 0;while (i s.size() i max_bytes) {const std::size_t start i;char32_t cp 0;if (!decode_one(s, i, cp)) break;if (i max_bytes) { // 这个字符越界回退到上一个边界i start;break;}last_good i;}return std::string(s.substr(0, last_good));}这段函数保证返回值一定是若干完整 UTF-8 字符的拼接。注意它按「字节上限」工作如果业务要求的是「最多 N 个字符」应当在循环里计数码点而不是比较i与max_bytes。与 UTF-16 互转时需要处理代理对码点大于 0xFFFF 时减去 0x10000高 10 位加上 0xD800 得到高位代理低 10 位加上 0xDC00 得到低位代理。也可以用平台 APIWindows 的MultiByteToWideChar/WideCharToMultiBytePOSIX 系统的iconv。这些接口的细节以各自平台文档为准本文不展开。常见坑点坑点一用std::string::size()当字符数。❌if (name.size() 20) reject();中文名 7 个字就可能超过 20 字节被误判超长。✅ 用解码循环数码点或按业务定义「字符」的口径统计。坑点二用substr按字节截断。❌s.substr(0, 10)可能把一个三字节汉字切成两半产生非法 UTF-8。✅ 用逐字符解码找到边界再截断如上面的truncate_utf8。坑点三对 UTF-8 字节调用std::toupper。❌for (char c : s) c static_castchar(std::toupper(c));非 ASCII 字节被当成独立字符处理文本损坏。✅ 只对 ASCII 范围做大小写转换Unicode 大小写映射需要专门的库或平台 API。坑点四假设wchar_t一定是 2 字节或一定是 4 字节。❌ 把std::wstring直接按sizeof(wchar_t) 2序列化后跨平台传输。✅ 明确目标平台的宽度跨平台传输统一用 UTF-8需要定长码元时用char16_t/char32_t。坑点五继续使用std::wstring_convert。❌std::wstring_convertstd::codecvt_utf8wchar_t conv;在 C17 起被弃用部分实现可能不再提供。✅ 手写转换函数或使用维护中的第三方库平台代码可用系统 API。坑点六忽略 BOM。❌ 读取带 BOM 的 UTF-8 文件第一个字段名前面多了 UFEFF解析失败。✅ 读入后检查开头三个字节是否为EF BB BF是则跳过写出时一般不加 BOM。坑点七不校验就信任输入。❌ 直接把网络数据当 UTF-8 使用过长编码或代理区序列进入下游导致显示异常或安全过滤被绕过。✅ 在边界处完整校验非法序列拒绝或替换为 UFFFD。坑点八以为一个码点就是一个「用户看到的字符」。❌ 按码点反转字符串组合附加符号与 emoji 的零宽连接序列被拆散。✅ 普通业务按码点处理即可涉及排版、光标移动时需要字素簇grapheme cluster级别的库。总结概念含义常见误区字节std::string的存储单位当成字符数码元编码的基本单位8/16/32 位与码点混为一谈码点Unicode 编号0 到 0x10FFFF当成用户感知字符UTF-81–4 字节变长编码认为中文固定 3 字节wchar_t宽度实现定义认为跨平台一致截断必须落在字符边界直接substr转换手写或平台 API依赖已弃用的codecvt处理 C 字符串编码核心是把「字节」和「字符」彻底分开存储层用std::string保存 UTF-8 字节长度与截断按码点计算边界处做完整校验跨平台交换统一用 UTF-8。做到这四点绝大多数乱码与截断问题都可以避免。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →