现代C++:深入学习Unicode

📅 2026/7/12 9:41:54
现代C++:深入学习Unicode
这一讲我们来讲一个新话题Unicode。我们会从编码的历史谈起讨论编程中对中文和多语言的支持然后重点看一下 C 中应该如何处理这些问题。一些历史ASCII 是一种创立于 1963 年的 7 位编码用 0 到 127 之间的数值来代表最常用的字符包含了控制字符很多在今天已不再使用、数字、大小写拉丁字母、空格和基本标点。它在编码上具有简单性字母和数字的编码位置非常容易记忆相比之下设计 EBCDIC 的人感觉是脑子进了水哦不进了穿孔卡片了难怪它和 IBM 的那些过时老古董一起已经几乎被人遗忘。时至今日ASCII 可以看作是字符编码的基础主要的编码方式都保持着与 ASCII 的兼容性。ASCII 里只有基本的拉丁字母它既没有带变音符的拉丁字母如 é 和 ä 也不支持像希腊字母如 α、β、γ、西里尔字母如 Пушкин这样的其他欧洲文字也难怪毕竟它是 American Standard Code for Information Interchange。很多其他编码方式纷纷应运而生包括 ISO 646 系列、ISO/IEC 8859 系列等等大部分编码方式都是头 128 个字符与 ASCII 兼容后 128 个字符是自己的扩展总共最多是 256 个字符。每次只有一套方式可以生效称之为一个代码页code page。这种做法只能适用于文字相近、且字符数不多的国家。比如下图表示了 ISO-8859-1也称作 Latin-1和后面的 Windows 扩展代码页 1252下图中绿框部分为 Windows 的扩展就只能适用于西欧国家。最早的中文字符集标准是 1980 年的国标 GB2312 [3]其中收录了 6763 个常用汉字和 682 个其他符号。我们平时会用到编码 GB2312其实更正确的名字是 EUC-CN它是一种与 ASCII 兼容的编码方式。它用单字节表示 ASCII 字符而用双字节表示 GB2312 中的字符由于 GB2312 中本身也含有 ASCII 中包含的字符在使用中逐渐就形成了“半角”和“全角”的区别。国标字符集后面又有扩展这个扩展后的字符集就是 GBK是中文版 Windows 使用的标准编码方式。GB2312 和 GBK 所占用的编码位置可以参看下面的图由 John M. Długosz 为 Wikipedia 绘制图中 GBK/1 和 GBK/2 为 GB2312 中已经定义的区域其他的则是后面添加的字符总共定义了两万多个编码点支持了绝大部分现代汉语中还在使用的字。Unicode作为一种统一编码的努力诞生于八十年代末九十年代初标准的第一版出版于 1991—1992 年。由于最初发明者的目标放得太低只期望对活跃使用中的现代文字进行编码他们认为 16 比特的“宽 ASCII”就够用了。这就导致了早期采纳 Unicode 的组织特别是微软在其操作系统和工具链中广泛采用了 16 比特的编码方式。在今天微软的系统中宽字符类型 wchar_t 仍然是 16 位的操作系统底层接口大量使用 16 位字符编码的 API说到 Unicode 编码时仍然指的是 16 位的编码 UTF-16这一不太正确的名字跟中文 GBK 编码居然可以被叫做 ANSI 相比实在是小巫见大巫了。在微软以外的世界Unicode 本身不作编码名称用并且最主流的编码方式并不是 UTF-16而是和 ASCII 全兼容的 UTF-8。早期 Unicode 组织的另一个决定是不同语言里的同一个字符使用同一个编码点来减少总编码点的数量。中日韩三国使用的汉字就这么被统一了像“将”、“径”、“网”等字每个字在 Unicode 中只占一个编码点。这对网页的字体选择也造成了不少麻烦时至今日我们仍然可以看到这个问题 [7]。不过这和我们的主题无关就不再多费笔墨了。Unicode 简介Unicode 在今天已经大大超出了最初的目标。到 Unicode 12.1 为止Unicode 已经包含了 137,994 个字符囊括所有主要语言使用中的和已经不再使用的并包含了表情符号、数学符号等各种特殊字符。仍然要指出一下Unicode 字符是根据含义来区分的而非根据字形。除了前面提到过中日韩汉字没有分开像斜体italics、小大写字母small caps等排版效果在 Unicode 里也没有独立的对应。不过因为 Unicode 里包含了很多数学、物理等自然科学中使用的特殊符号某些情况下你也可以找到对应的符号可以用在聊天中耍酷如 但不适合严肃的排版。Unicode 的编码点是从 0x0 到 0x10FFFF一共 1,114,112 个位置。一般用“U”后面跟 16 进制的数值来表示一个 Unicode 字符如 U0020 表示空格U6C49 表示“汉”U1F600 表示“”等等不足四位的一般写四位。Unicode 字符的常见编码方式有UTF-32 [8]32 比特是编码点的直接映射。UTF-16 [9]对于从 U0000 到 UFFFF 的字符使用 16 比特的直接映射对于大于 UFFFF 的字符使用 32 比特的特殊映射关系——在 Unicode 的 16 比特编码点中 0xD800–0xDFFF 是一段空隙使得这种变长编码成为可能。在一个 UTF-16 的序列中如果看到内容是 0xD800–0xDBFF那这就是 32 比特编码的前 16 比特如果看到内容是 0xDC00–0xDFFF那这是 32 比特编码的后 16 比特如果内容在 0xD800–0xDFFF 之外那就是一个 16 比特的映射。UTF-8 [10]1 到 4 字节的变长编码。在一个合法的 UTF-8 的序列中如果看到一个字节的最高位是 0那就是一个单字节的 Unicode 字符如果一个字节的最高两比特是 10那这是一个 Unicode 字符在编码后的后续字节否则这就是一个 Unicode 字符在编码后的首字节且最高位开始连续 1 的个数表示了这个字符按 UTF-8 的方式编码有几个字节。在上面三种编码方式里只有 UTF-8 完全保持了和 ASCII 的兼容性目前得到了最广泛的使用。在我们下面讲具体编码方式之前我们先看一下上面提到的三个字符在这三种方式下的编码结果UTF-32U0020 映射为 0x00000020U6C49 映射为 0x00006C49U1F600 映射为 0x0001F600。UTF-16U0020 映射为 0x0020U6C49 映射为 0x6C49而 U1F600 会映射为 0xD83D DE00。UTF-8U0020 映射为 0x20U6C49 映射为 0xE6 B1 89而 U1F600 会映射为 0xF0 9F 98 80。Unicode 有好几种上面还不是全部不同的编码方式上面的 16 比特和 32 比特编码方式还有小头党和大头党之争“汉”按字节读取时是 6C 49 呢还是 49 6C同时任何一种编码方式还需要跟传统的编码方式容易区分。因此Unicode 文本文件通常有一个使用 BOMbyte order mark字符的约定即字符 UFEFF [11]。由于 Unicode 不使用 UFFFE在文件开头加一个 BOM 即可区分各种不同编码如果文件开头是 0x00 00 FE FF那这是大头在前的 UTF-32 编码否则如果文件开头是 0xFF FE 00 00那这是小头在前的 UTF-32 编码否则如果文件开头是 0xFE FF那这是大头在前的 UTF-16 编码否则如果文件开头是 0xFF FE那这是小头在前的 UTF-16 编码注意这条规则和第二条的顺序不能相反否则如果文件开头是 0xEF BB BF那这是 UTF-8 编码否则编码方式使用其他算法来确定。编辑器可以有些在配置之后根据 BOM 字符来自动决定文本文件的编码。比如我一般在 Vim 中配置 set fileencodingsucs-bom,utf-8,gbk,latin1。这样Vim 在读入文件时会首先检查 BOM 字符有 BOM 字符按 BOM 字符决定文件编码否则试图将文件按 UTF-8 来解码由于 UTF-8 有格式要求非 UTF-8 编码的文件通常会导致失败不行则试图按 GBK 来解码失败的概率就很低了还不行就把文件当作 Latin1 来处理永远不会失败。在 UTF-8 编码下使用 BOM 字符并非必需尤其在 Unix 上。但 Windows 上通常会使用 BOM 字符以方便区分 UTF-8 和传统编码。C 中的 Unicode 字符类型C98 中有 char 和 wchar_t 两种不同的字符类型其中 char 的长度是单字节而 wchar_t 的长度不确定。在 Windows 上它是双字节只能代表 UTF-16而在 Unix 上一般是四字节可以代表 UTF-32。为了解决这种混乱目前我们有了下面的改进C11 引入了 char16_t 和 char32_t 两个独立的字符类型不是类型别名分别代表 UTF-16 和 UTF-32。C20 将引入 char8_t 类型进一步区分了可能使用传统编码的窄字符类型和 UTF-8 字符类型。除了 string 和 wstring我们也相应地有了 u16string、u32string和将来的 u8string。除了传统的窄字符 / 字符串字面量如 hi和宽字符 / 字符串字面量如 Lhi引入了新的 UTF-8、UTF-16 和 UTF-32 字面量分别形如 u8hi、uhi 和 Uhi。为了确保非 ASCII 字符在源代码中可以简单地输入引入了新的 Unicode 换码序列。比如我们前面说到的三个字符可以这样表达成一个 UTF-32 字符串字面量U \u6C49\U0001F600。要生成 UTF-16 或 UTF-8 字符串字面量只需要更改前缀即可。使用这些新的字符串类型我们可以用下面的代码表达出 UTF-32 和其他两种 UTF 编码间是如何转换的#include iomanip #include iostream #include stdexcept #include string using namespace std; const char32_t unicode_max 0x10FFFF; void to_utf_16(char32_t ch, u16string result) { if (ch unicode_max) { throw runtime_error( invalid code point); } if (ch 0x10000) { result char16_t(ch); } else { char16_t first 0xD800 | ((ch - 0x10000) 10); char16_t second 0xDC00 | (ch 0x3FF); result first; result second; } } void to_utf_8(char32_t ch, string result) { if (ch unicode_max) { throw runtime_error( invalid code point); } if (ch 0x80) { result ch; } else if (ch 0x800) { result 0xC0 | (ch 6); result 0x80 | (ch 0x3F); } else if (ch 0x10000) { result 0xE0 | (ch 12); result 0x80 | ((ch 6) 0x3F); result 0x80 | (ch 0x3F); } else { result 0xF0 | (ch 18); result 0x80 | ((ch 12) 0x3F); result 0x80 | ((ch 6) 0x3F); result 0x80 | (ch 0x3F); } } int main() { char32_t str[] U \u6C49\U0001F600; u16string u16str; string u8str; for (auto ch : str) { if (ch 0) { break; } to_utf_16(ch, u16str); to_utf_8(ch, u8str); } cout hex setfill(0); for (char16_t ch : u16str) { cout setw(4) unsigned(ch) ; } cout endl; for (unsigned char ch : u8str) { cout setw(2) unsigned(ch) ; } cout endl; }输出结果是0020 6c49 d83d de0020 e6 b1 89 f0 9f 98 80平台区别下面我们看一下在两个主流的平台上一般是如何处理 Unicode 编码问题的。Unix现代 Unix 系统包括 Linux 和 macOS 在内已经全面转向了 UTF-8。这样的系统中一般直接使用 char[] 和 string 来代表 UTF-8 字符串包括输入、输出和文件名非常简单。不过由于一个字符单位不能代表一个完整的 Unicode 字符在需要真正进行文字处理的场合转换到 UTF-32 往往会更简单。在以前及需要和 C 兼容的场合会使用 wchar_t、uint32_t 或某个等价的类型别名在新的纯 C 代码里就没有理由不使用 char32_t 和 u32string 了。Unix 下输出宽字符串需要使用 wcout这点和 Windows 相同并且需要进行区域设置如下所示std::locale::global( std::locale()); std::wcout.imbue(std::locale());由于没有什么额外好处反而可能在某些环境因为区域设置失败而引发问题Unix 平台下一般只用 cout不用 wcout。WindowsWindows 由于历史原因和保留向后兼容性的需要Windows 为了向后兼容性已经到了大规模放弃优雅的程度了一直用 char 表示传统编码如英文 Windows 上是 Windows-1252简体中文 Windows 上是 GBK用 wchar_t 表示 UTF-16。由于传统编码一次只有一种、且需要重启才能生效要得到好的多语言支持在和操作系统交互时必须使用 UTF-16。对于纯 Windows 编程全面使用宽字符串是最简单的处理方式。当然源代码和文本很少用 UTF-16 存储通常还是 UTF-8除非是纯 ASCII否则必须加入 BOM 字符来和传统编码相区分。这时可能会有一个小小的令人惊讶的地方微软的编译器会把源代码里窄字符串字面量中的非 ASCII 字符转换成传统编码。换句话说同样的源代码在不同编码的 Windows 下编译可能会产生不同的结果如果你希望保留 UTF-8 序列的话就应该使用 UTF-8 字面量并在将来使用 char8_t 字符类型。#include stdio.h template typename T void dump(const T str) { for (char ch : str) { printf( %.2x , static_castunsigned char(ch)); } putchar(\n); } int main() { char str[] 你好; char u8str[] u8你好; dump(str); dump(u8str); }下面展示的是以上代码在 Windows 下系统传统编码设置为简体中文时的编译、运行结果c4 e3 ba c3 00e4 bd a0 e5 a5 bd 00Windows 下的 wcout 主要用在配合宽字符的输出此外没什么大用处。原因一样只有进行了正确的区域设置才能输出跟该区域相匹配的宽字符串不匹配的字符将导致后续输出全部消失。如果要输出中文得写 setlocale(LC_ALL, Chinese_China.936);这显然就让“统一码”输出失去意义了。但是还是有个“但是”如果你只用 wcout不用 cout 或任何使用窄字符输出到 stdout 的函数如 puts这时倒有个还不错的解决方案可以在终端输出多语言。我也是偶然才发现这一用法并且没有在微软的网站上找到清晰的文档……代码如下所示#include fcntl.h #include io.h #include iostream int main() { _setmode(_fileno(stdout), _O_WTEXT); std::wcout L中文 Español Français\n; std::wcout Narrow characters are also OK on wcout\n; // but not on cout... }由于窄字符在大部分 Windows 系统上只支持传统编码要打开一个当前编码不支持的文件名称就必需使用宽字符的文件名。微软的 fstream 系列类及其 open 成员函数都支持 const wchar_t* 类型的文件名这是 C 标准里所没有的。统一化处理要想写出跨平台的处理字符串的代码我们一般考虑两种方式之一源代码级兼容但内码不同源代码和内码都完全兼容微软推荐的方式一般是前者。做 Windows 开发的人很多都知道 tchar.h 和 _T 宏它们就起着类似的作用虽然目的不同。根据预定义宏的不同系统会在同一套代码下选择不同的编码方式及对应的函数。拿一个最小的例子来说#include stdio.h #include tchar.h int _tmain(int argc, TCHAR* argv[]) { _putts(_T(Hello world!\n)); }如果用缺省的命令行参数进行编译上面的代码相当于#include stdio.h int main(int argc, char* argv[]) { puts(Hello world!\n); }而如果在命令行上加上了 /D_UNICODE那代码则相当于#include stdio.h int wmain(int argc, wchar_t* argv[]) { _putws(LHello world!\n); }当然这个代码还是只能在 Windows 上用并且仍然不漂亮所有的字符和字符串字面量都得套上 _T。后者无解前者则可以找到替代方案甚至自己写也不复杂。C REST SDK 中就提供了类似的封装可以跨平台地开发网络应用。但可以说这种方式是一种主要照顾 Windows 的开发方式。相应的对 Unix 开发者而言更自然的方式是全面使用 UTF-8仅在跟操作系统、文件系统打交道时把字符串转换成需要的编码。利用临时对象的生命周期我们可以像下面这样写帮助函数和宏。utf8_to_native.hpp#ifndef UTF8_TO_NATIVE_HPP #define UTF8_TO_NATIVE_HPP #include string #if defined(_WIN32) || \ defined(_UNICODE) std::wstring utf8_to_wstring( const char* str); std::wstring utf8_to_wstring( const std::string str); #define NATIVE_STR(s) \ utf8_to_wstring(s).c_str() #else inline const char* to_c_str(const char* str) { return str; } inline const char* to_c_str(const std::string str) { return str.c_str(); } #define NATIVE_STR(s) \ to_c_str(s) #endif #endif // UTF8_TO_NATIVE_HPPutf8_to_native.cpp#include utf8_to_native.hpp #if defined(_WIN32) || \ defined(_UNICODE) #include windows.h #include system_error namespace { void throw_system_error( const char* reason) { std::string msg(reason); msg failed; std::error_code ec( GetLastError(), std::system_category()); throw std::system_error(ec, msg); } } /* unnamed namespace */ std::wstring utf8_to_wstring( const char* str) { int len MultiByteToWideChar( CP_UTF8, 0, str, -1, nullptr, 0); if (len 0) { throw_system_error( utf8_to_wstring); } std::wstring result(len - 1, L\0); if (MultiByteToWideChar( CP_UTF8, 0, str, -1, result.data(), len) 0) { throw_system_error( utf8_to_wstring); } return result; } std::wstring utf8_to_wstring( const std::string str) { return utf8_to_wstring( str.c_str()); } #endif在头文件里定义了在 Windows 下会做 UTF-8 到 UTF-16 的转换在其他环境下则不真正做转换而是不管提供的是字符指针还是 string 都会转换成字符指针。在 Windows 下每次调用 NATIVE_STR 会生成一个临时对象当前语句执行结束后这个临时对象会自动销毁。使用该功能的代码是这样的#include fstream #include utf8_to_native.hpp int main() { using namespace std; const char filename[] u8测试.txt; ifstream ifs( NATIVE_STR(filename)); // 对 ifs 进行操作 }上面这样的代码可以同时适用于现代 Unix 和现代 Windows任何语言设置下用来读取名为“测试.txt”的文件。编程支持结束之前我们快速介绍一下其他的一些支持 Unicode 及其转换的 API。Windows API上一节的代码在 Windows 下用到了 MultiByteToWideChar 从某个编码转到 UTF-16。Windows 也提供了反向的 WideCharToMultiByte [13]从 UTF-16 转到某个编码。从上面可以看到C 接口用起来并不方便可以考虑自己封装一下。iconvUnix 下最常用的底层编码转换接口是 iconv [14]提供 iconv_open、iconv_close 和 iconv 三个函数。这同样是 C 接口实践中应该封装一下。ICU4CICU [15] 是一个完整的 Unicode 支持库提供大量的方法ICU4C 是其 C/C 的版本。ICU 有专门的字符串类型内码是 UTF-16但可以直接用于 IO streams 的输出。下面的程序应该在所有平台上都有同样的输出但在 Windows 上要求当前系统传统编码能支持待输出的字符#include iostream #include string #include unicode/unistr.h #include unicode/ustream.h using namespace std; using icu::UnicodeString; int main() { auto str UnicodeString::fromUTF8( u8你好); cout str endl; string u8str; str.toUTF8String(u8str); cout In UTF-8 it is u8str.size() bytes endl; }codecvtC11 曾经引入了一个头文件 [16] 用作 UTF 编码间的转换但很遗憾那个头文件目前已因为存在安全性和易用性问题被宣告放弃deprecated。 中有另外一个 codecvt 模板本身接口不那么好用而且到 C20 还会发生变化这儿也不详细介绍了。有兴趣的话可以直接看参考资料。内容小结本讲我们讨论了 Unicode以及 C 中对 Unicode 的支持。我们也讨论了在两大主流桌面平台上关于 Unicode 编码支持的一些惯用法。希望你在本讲之后能清楚地知道 Unicode 和各种 UTF 编码是怎么回事。