前言
C++ 标准库里的std::string本质上是一个字节序列,它不知道什么是「字符」,更不知道什么是「中文」。一个汉字在 UTF-8 下占 3 个字节,std::string::size()返回的是 3 而不是 1;s[0]取到的是这个汉字三个字节中的第一个,单独拿出来既不是合法字符,也无法正确显示。字符串编码处理的全部麻烦,都源于「字节数」「码元数」「码点数」「用户感知的字符数」这四个概念并不相等。
常见的误解有四个。第一,把size()当成字数,用它做输入长度限制,结果中文用户名被截成半个字。第二,以为wchar_t是「万能的宽字符」,实际上它的宽度由实现决定:Windows 上是 16 位,通常表示 UTF-16 码元;Linux 上多为 32 位,表示 UTF-32。第三,用std::toupper逐字节处理 UTF-8 文本,非 ASCII 字节被当成独立字符,结果被破坏。第四,继续使用<codecvt>里的std::wstring_convert,而它从 C++17 起已被弃用。
另外说明一点:有些资料把 UTF-8 误写成「UF8」,这是笔误,正确写法是 UTF-8(8-bit Unicode Transformation Format)。本文按 UTF-8 讲解。
本文先建立码点与码元的概念,再讲std::string、std::wstring、std::u16string的差别,最后给出只用标准 C++ 的 UTF-8 解码、编码与安全截断代码。涉及平台 API 的地方会明确标注平台。
一、码点、码元与 UTF-8 的字节结构
Unicode 给每个字符分配一个编号,叫码点(code point),写作 U+XXXX,取值范围是 0 到 0x10FFFF。码点只是编号,真正存储时要选一种编码方式,把它表示成一个或多个码元(code unit)。UTF-8 的码元是 8 位字节,UTF-16 的码元是 16 位,UTF-32 的码元是 32 位。
UTF-8 的设计是变长编码,字节数由码点范围决定:
| 码点范围 | 字节数 | 首字节模式 | 后续字节模式 |
|---|
| U+0000 – U+007F | 1 | 0xxxxxxx | 无 |
| U+0080 – U+07FF | 2 | 110xxxxx | 10xxxxxx |
| U+0800 – U+FFFF | 3 | 1110xxxx | 两个10xxxxxx |
| U+10000 – U+10FFFF | 4 | 11110xxx | 三个10xxxxxx |
由此可以推出几条实用规则:ASCII 字符在 UTF-8 下仍是单字节,与旧代码兼容;所有多字节序列的首字节最高位都是 1,后续字节都以10开头,因此按字节扫描时不会把一个后续字节误认成首字节;中文常用字大多落在三字节区间。
UTF-8 还有几条合法性约束,解析时必须检查:不能出现过长编码(例如用两字节表示 U+0041);不能编码代理区码点 U+D800 到 U+DFFF;不能超过 U+10FFFF。很多「能显示但行为诡异」的乱码,源头就是没有做这些校验。
二、std::string、std::wstring 与 UTF-16
std::string是std::basic_string<char>,存放的是字节,编码方式完全由使用者约定。它可以存 UTF-8、GBK、Latin-1,标准库不提供任何区分手段。这也是为什么跨平台代码通常约定「内部一律 UTF-8,只在边界转换」。
std::wstring是std::basic_string<wchar_t>。wchar_t的宽度是实现定义行为:Windows 上为 16 位,一个wchar_t是一个 UTF-16 码元,超出基本多文种平面(BMP)的字符要用代理对(surrogate pair)表示;Linux 上通常为 32 位,一个wchar_t可以直接放一个码点。因此同一段std::wstring代码在两个平台上语义并不相同,wstring::size()在 Windows 上对 emoji 会返回 2。
C++11 引入了std::u16string(元素类型char16_t)和std::u32string(元素类型char32_t),语义比wstring明确:前者是 UTF-16 码元序列,后者是码点序列。C++20 又引入了char8_t与std::u8string,用类型系统把 UTF-8 字节串和普通字节串区分开。这些特性有版本门槛,使用前要确认工具链支持。
| 类型 | 元素宽度 | 编码语义 | 主要问题 |
|---|
std::string | 8 位 | 未指定,约定 UTF-8 | 长度按字节,可能截断多字节字符 |
std::wstring | 实现定义 | Windows UTF-16 / Linux UTF-32 | 跨平台语义不一致 |
std::u16string | 16 位 | UTF-16 | 非 BMP 字符占两个码元 |
std::u32string | 32 位 | UTF-32 | 内存占用大,外部协议少用 |
std::u8string(C++20) | 8 位 | UTF-8 | 需要 C++20 及以上的支持 |
<codecvt>中的std::wstring_convert和std::codecvt_utf8曾经是标准转换手段,但从 C++17 起被弃用,原因是接口设计存在缺陷、对非法输入的处理不统一。新代码不应再依赖它们;需要转换时,可以用本文的手写函数,或使用成熟的第三方库,或调用平台 API。
三、手写 UTF-8 解码、编码与安全截断
先看解码。函数从字节串的某个位置读出一个码点,成功时推进下标并返回true,遇到非法序列返回false且不保证下标位置。除了检查首字节模式,还要检查后续字节是否以10开头、是否过长编码、是否落在代理区。
// 适用于 C++17
#include <cstdint>
#include <string>
#include <string_view>
bool decode_one(std::string_view s, std::size_t& i, char32_t& cp) {
if (i >= s.size()) return false;
const unsigned char c = static_cast<unsigned 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_cast<std::size_t>(extra) >= s.size()) return false;
for (int k = 1; k <= extra; ++k) {
const unsigned char cc = static_cast<unsigned 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_cast<std::size_t>(extra) + 1;
cp = value;
return true;
}编码是逆过程,按码点范围选择字节数,再把码点的高位依次填入首字节和后续字节的低 6 位:
// 适用于 C++17
std::string encode_one(char32_t cp) {
std::string out;
if (cp <= 0x7F) {
out.push_back(static_cast<char>(cp));
} else if (cp <= 0x7FF) {
out.push_back(static_cast<char>(0xC0 | (cp >> 6)));
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
} else if (cp <= 0xFFFF) {
out.push_back(static_cast<char>(0xE0 | (cp >> 12)));
out.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
} else if (cp <= 0x10FFFF) {
out.push_back(static_cast<char>(0xF0 | (cp >> 18)));
out.push_back(static_cast<char>(0x80 | ((cp >> 12) & 0x3F)));
out.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
}
return out; // 超出范围的码点返回空串
}有了这两个函数,就可以按码点遍历文本,而不是按字节:
// 适用于 C++17
#include <iostream>
void 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';
}
}最常见的工程需求是「按字节上限截断,但不能切碎字符」。做法是从头逐字符解码,记录最后一个完整字符的结束位置,一旦下一个字符会越过上限就停下:
// 适用于 C++17
std::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 得到低位代理。也可以用平台 API:Windows 的MultiByteToWideChar/WideCharToMultiByte,POSIX 系统的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_cast<char>(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_convert<std::codecvt_utf8<wchar_t>> conv;在 C++17 起被弃用,部分实现可能不再提供。
✅ 手写转换函数,或使用维护中的第三方库;平台代码可用系统 API。
坑点六:忽略 BOM。
❌ 读取带 BOM 的 UTF-8 文件,第一个字段名前面多了 U+FEFF,解析失败。
✅ 读入后检查开头三个字节是否为EF BB BF,是则跳过;写出时一般不加 BOM。
坑点七:不校验就信任输入。
❌ 直接把网络数据当 UTF-8 使用,过长编码或代理区序列进入下游,导致显示异常或安全过滤被绕过。
✅ 在边界处完整校验,非法序列拒绝或替换为 U+FFFD。
坑点八:以为一个码点就是一个「用户看到的字符」。
❌ 按码点反转字符串,组合附加符号与 emoji 的零宽连接序列被拆散。
✅ 普通业务按码点处理即可;涉及排版、光标移动时,需要字素簇(grapheme cluster)级别的库。
总结
| 概念 | 含义 | 常见误区 |
|---|
| 字节 | std::string的存储单位 | 当成字符数 |
| 码元 | 编码的基本单位(8/16/32 位) | 与码点混为一谈 |
| 码点 | Unicode 编号,0 到 0x10FFFF | 当成用户感知字符 |
| UTF-8 | 1–4 字节变长编码 | 认为中文固定 3 字节 |
wchar_t | 宽度实现定义 | 认为跨平台一致 |
| 截断 | 必须落在字符边界 | 直接substr |
| 转换 | 手写或平台 API | 依赖已弃用的codecvt |
处理 C++ 字符串编码,核心是把「字节」和「字符」彻底分开:存储层用std::string保存 UTF-8 字节,长度与截断按码点计算,边界处做完整校验,跨平台交换统一用 UTF-8。做到这四点,绝大多数乱码与截断问题都可以避免。