首页 > 编程笔记

C++ char16_t和char32_t字符类型详解

在 C++11 标准中添加两种新的字符类型 char16_t 和 char32_t,它们分别用来对应 Unicode 字符集的 UTF-16 和 UTF-32 两种编码方法。

正式介绍 char16_t 和 char32_t 之前,需要先弄清楚字符集和编码方法的区别。

字符集和编码方法

通常我们所说的字符集是指系统支持的所有抽象字符的集合,通常一个字符集的字符是稳定的。

编码方法是利用数字和字符集建立对应关系的一套方法,这个方法可以有很多种,比如 Unicode 字符集就有 UTF-8、UTF-16 和 UTF-32 这 3 种编码方法。除了 Unicode 字符集,我们常见的字符集还包括 ASCII 字符集、GB2312 字符集、BIG5 字符集等,它们都有各自的编码方法。

字符集需要和编码方式对应,如果这个对应关系发生了错乱,那么我们就会看到计算机世界中令人深恶痛绝的乱码。不过,现在的计算机世界逐渐达成了一致,就是尽量以 Unicode 作为字符集标准,那么剩下的工作就是处理 UTF-8、UTF-16 和 UTF-32 这 3 种编码方法的问题了。

UTF-8、UTF-16 和 UTF-32 简单来说是使用不同大小内存空间的编码方法。

UTF-32 是最简单的编码方法,该方法用一个 32 位的内存空间(也就是 4 字节)存储一个字符编码,由于 Unicode 字符集的最大个数为 0x10FFFF(ISO 10646),因此 4 字节的空间完全能够容纳任何一个字符编码。UTF-32 编码方法的优点显而易见,它非常简单,计算字符串长度和查找字符都很方便;缺点也很明显,太占用内存空间。

UTF-16 编码方法所需的内存空间从 32 位缩小到 16 位(占用 2 字节),但是由于存储空间的缩小,因此 UTF-16 最多只能支持 0xFFFF 个字符,这显然不太够用,于是 UTF-16 采用了一种特殊的方法来表达无法表示的字符。

简单来说,从 0x0000~0xD7FF 以及 0xE000~0xFFFF 直接映射到 Unicode 字符集,而剩下的 0xD800~0xDFFF 则用于映射 0x10000~0x10FFFF 的 Unicode 字符集,映射方法为:字符编码减去 0x10000 后剩下的 20 比特位分为高位和低位,高 10 位的映射范围为 0xD800~0xDBFF,低 10 位的映射范围为 0xDC00~0xDFFF。

例如 0x10437,减去 0x10000 后的高低位分别为 0x1 和 0x37,分别加上 0xD800 和 0xDC00 的结果是 0xD801 和 0xDC37。

幸运的是,一般情况下 0xFFFF 足以覆盖日常字符需求,我们也不必为了 UTF-16 的特殊编码方法而烦恼。UTF-16 编码的优势是可以用固定长度的编码表达常用的字符,所以计算字符长度和查找字符也比较方便。另外,在内存空间使用上也比 UTF-32 好得多。

最后说一下我们最常用的 UTF-8 编码方法,它是一种可变长度的编码方法。

由于 UTF-8 编码方法只占用 8 比特位(1 字节),因此要表达完数量高达 0x10FFFF 的字符集,它采用了一种前缀编码的方法。这个方法可以用 1~4 字节表示字符个数为 0x10FFFF 的 Unicode(ISO 10646)字符集。为了尽量节约空间,常用的字符通常用 1~2 字节就能表达,其他的字符才会用到 3~4 字节,所以在内存空间可以使用 UTF-8,但是计算字符串长度和查找字符在 UTF-8 中却是一个令人头痛的问题。

下表展示了 UTF-8 对应的范围:

代码范围十六进制 UTF-8二进制 注释
000000~00007F
128 个代码
0ZZZZZZZ ASCII 字符范围,字节由零开始
000080~0007FF
1920 个代码
110yyyyy 10zzzzzz 第 1 字节由 110 开始,接着的字节由 10 开始
000800~00D7FF
00E000~00FFFF
61440 个代码
1110xxxx 10yyyyyy 10zzzzzz 第 1 字节 1110 开始,接着的字节由 10 开始
010000~10FFFF
1048576 个代码
11110WWW 10XXXXXX
10yyyyyy 10zzzzzz
将由 11110 开始,接着的字节从 10 开始

char16_t和char32_t

对于 UTF-8 编码方法而言,普通类型似乎是无法满足需求的,毕竟普通类型无法表达变长的内存空间。所以一般情况下我们直接使用基本类型 char 进行处理,而过去也没有一个针对 UTF-16 和 UTF-32 的字符类型。

到了 C++11,char16_t 和 char32_t 的出现打破了这个尴尬的局面。除此之外,C++11 标准还为 3 种编码提供了新前缀用于声明 3 种编码字符和字符串的字面量,它们分别是 UTF-8 的前缀 u8、UTF-16 的前缀 u 和 UTF-32 的前缀 U:
char utf8c = u8'a'; // C++17标准
//char utf8c = u8'好';
char16_t utf16c = u'好';
char32_t utf32c = U'好';
char utf8[] = u8"你好世界";
char16_t utf16[] = u"你好世界";
char32_t utf32[] = U"你好世界";
在上面的代码中,分别使用 UTF-8、UTF-16 和 UTF-32 编码的字符和字符串对变量进行了初始化,代码很简单,不过还是有两个地方值得一提。

char utf8c = u8'a'; 在 C++11 标准中实际上是无法编译成功的,因为在 C++11 标准中 u8 只能作为字符串字面量的前缀,而无法作为字符的前缀。这个问题直到 C++17 标准才得以解决,所以上述代码需要 C++17 的环境来执行编译。

char utf8c = u8'好'; 是无法通过编译的,因为存储“好”需要 3 字节,显然 utf8c 只能存储 1 字节,所以会编译失败。

wchar_t存在的问题

在 C++98 的标准中提供了一个 wchar_t 字符类型,并且还提供了前缀 L,用它表示一个宽字符。事实上 Windows 系统的 API 使用的就是 wchar_t,它在 Windows 内核中是一个最基础的字符类型:
HANDLE CreateFileW(
    LPCWSTR lpFileName,
    ...
);
CreateFileW(L"c:\\tmp.txt",...);
上面是一段在 Windows 系统上创建文件的伪代码,可以看出 Windows 为创建文件的 API 提供了宽字符版本,其中 LPCWSTR 实际上是 const wchar_t 的指针类型,我们可以通过 L 前缀来定义一个 wchar_t 类型的字符串字面量,并且将其作为实参传入 API。

讨论到这里读者会产生一个疑问,既然已经有了处理宽字符的字符类型,那么为什么又要加入新的字符类型呢?

没错,wchar_t 确实在一定程度上能够满足我们对于字符表达的需求,但是起初在定义 wchar_t 时并没有规定其占用内存的大小。于是就给了实现者充分的自由,以至于在 Windows 上 wchar_t 是一个 16 位长度的类型(2 字节),而在 Linux 和 macOS 上 wchar_t 却是 32 位的(4 字节)。这导致了一个严重的后果,我们写出的代码无法在不同平台上保持相同行为。而 char16_t 和 char32_t 的出现解决了这个问题,它们明确规定了其所占内存空间的大小,让代码在任何平台上都能够有一致的表现。

新字符串连接

由于字符类型增多,因此我们还需要了解一下字符串连接的规则:如果两个字符串字面量具有相同的前缀,则生成的连接字符串字面量也具有该前缀,如下表所示。

源代码 等同于   源代码 等同于   源代码 等同于
u"a" u"b" u"ab"   U"a" U"b" U"ab"   L"a" L"b" L"ab"
u"a" "b" u"ab"   U"a" "b" U"ab"   L"a" "b" L"ab"
"a" u"b" u"ab"   "a" U"b" U"ab"   "a" L"b" L"ab"

如果其中一个字符串字面量没有前缀,则将其视为与另一个字符串字面量具有相同前缀的字符串字面量,其他的连接行为由具体实现者定义。另外,这里的连接操作是编译时的行为,而不是一个转换。

需要注意的是,进行连接的字符依然是保持独立的,也就是说不会因为字符串连接,将两个字符合并为一个,例如连接 "\xA" "B"的结果应该是 "\nB"(换行符和字符B),而不是一个字符 "\xAB"。

库对新字符类型的支持

随着新字符类型加入 C++11 标准,相应的库函数也加入进来。

C11 在中增加了 4 个字符的转换函数,包括:
size_t mbrtoc16(char16_t* pc16, const char* s, size_t n, mbstate_t* ps);
size_t c16rtomb(char* s, char16_t c16, mbstate_t* ps);
size_t mbrtoc32(char32_t* pc32, const char* s, size_t n, mbstate_t* ps);
size_t c32rtomb(char* s, char32_t c32, mbstate_t* ps);
它们的功能分别是多字节字符和 UTF-16 编码字符互转,以及多字节字符和 UTF-32 编码字符互转。在 C++11 中,我们可以通过包含<cuchar>来使用这 4 个函数。

当然 C++11 中也添加了 C++ 风格的转发方法 std::wstring_convert 以及 std::codecvt。使用类模板 std::wstring_convert 和 std::codecvt 相结合,可以对多字节字符串和宽字符串进行转换。不过这里并不打算花费篇幅介绍这些转换方法,因为它们在 C++17 标准中已经不被推荐使用了,所以应该尽量避免使用它们。

除此之外,C++ 标准库的字符串也加入了对新字符类型的支持,例如:
using u16string = basic_string;
using u32string = basic_string;
using wstring = basic_string;

推荐阅读