在开始之前先说明一点:这是 C 语言中一个仍在活跃发展的领域,它正努力走出一些,呃,成长中的烦恼。既然 C23 已经发布,这里的更新很可能还会继续。
大多数人关心的其实是个看似简单的问题:“我怎么在 C 里使用某某字符集?”我们会讲到。但正如我们将看到的,它也许在你的系统上已经能用了;也可能你得把问题甩给第三方库。
本章我们要谈很多东西——有些与平台无关,有些则是 C 特有的。
先来列一下要看的内容大纲:
wchar_t、char16_t 和 char32_t我们开始吧!
早年,在美国以及世界许多地方,流行用 7 位或 8 位编码在内存中表示字符。这意味着总共只有 128 或 256 个字符(含不可打印字符)可用。对一个以美国为中心的世界来说这没什么问题,但事实证明世上还有别的字母表——谁知道呢?中文有五万多个字符,一个字节可装不下。
于是人们发明了各种替代方式来表示自己定制的字符集。这本身没问题,却演变成了一场兼容性噩梦。
为了摆脱这种困境,Unicode 被发明出来。一个字符集统治一切。它(实际上)一直延伸到无穷远,所以我们永远不会缺新字符的空间。它包含中文、拉丁文、希腊文、楔形文字、国际象棋符号、emoji……几乎应有尽有!而且新字符还在不断加入!
这里我想谈两个概念。混乱之处在于它们都是数字……同一件东西的两个不同数字。请耐心听我说。
我们大致把码点定义为表示一个字符的数值。(码点也能表示不可打印的控制字符,但你就当我说的是字母“B”或字符“π”这类东西。)
每个码点代表一个唯一的字符。而每个字符都关联着一个唯一的数值码点。
例如在 Unicode 中,数值 66 表示“B”,960 表示“π”。其他非 Unicode 的字符映射可能用不同的值,但咱们先不管它们,专心看 Unicode——未来!
这是第一件事:每个字符都有一个数字来表示。在 Unicode 中,这些数字从 0 一直到一百多万。
明白了吗?
因为我们马上就要把桌子掀一下。
回想一下,一个 8 位字节能容纳 0 到 255(含)的值。这对“B”来说很棒,它是 66——一个字节装得下。但“π”是 960,一个字节可装不下!我们需要另一个字节。我们怎么把这些存进内存?那更大的数字呢,比如 195,024?那得用好几个字节才装得下。
关键问题来了:这些数字在内存中是如何表示的?这就是我们所说的字符编码。
所以我们有两样东西:一是码点,它实际上告诉我们某个字符的序号;二是编码,它告诉我们打算如何在内存中表示那个数字。
编码有很多。如果你愿意,现在就可以自己发明一个158。不过我们接下来要看的是 Unicode 中一些非常常用的编码。
| 编码 | 说明 |
|---|---|
| UTF-8 | 一种面向字节的编码,每个字符使用可变数量的字节。就该用它。 |
| UTF-16 | 一种每个字符 16 位的编码159。 |
| UTF-32 | 一种每个字符 32 位的编码。 |
对 UTF-16 和 UTF-32 来说,字节序很要紧,所以你可能会看到表示大端的 UTF-16BE 和表示小端的 UTF-16LE。UTF-32 也一样。严格来说,如果未加指定,你应当假定是大端。但由于 Windows 大量使用 UTF-16,而它又是小端,所以有时会默认成小端160。
来看几个例子。我打算用十六进制写这些值,因为每个 8 位字节正好对应两位十六进制数字,这样更容易看清它们在内存里是怎么排布的。
| 字符 | 码点 | UTF-16BE(大端) | UTF-32BE(大端) | UTF-16LE(小端) | UTF-32LE(小端) | UTF-8 | |
|---|---|---|---|---|---|---|---|
A |
41 | 0041 | 00000041 | 4100 | 41000000 | 41 | |
B |
42 | 0042 | 00000042 | 4200 | 42000000 | 42 | |
~ |
7E | 007E | 0000007E | 7E00 | 7E000000 | 7E | |
π |
3C0 | 03C0 | 000003C0 | C003 | C0030000 | CF80 | |
€ |
20AC | 20AC | 000020AC | AC20 | AC200000 | E282AC |
从里面找找规律。注意,UTF-16BE 和 UTF-32BE 不过就是把码点直接表示成 16 位和 32 位的值161。
小端序也一样,只不过字节按小端序排列。
最后是 UTF-8。首先你可能会注意到,单字节的码点就用一个字节表示。这很不错。你可能还会注意到,不同的码点占用的字节数不同。这是一种变宽编码。
所以一旦超过某个值,UTF-8 就开始用额外的字节来存储这些值。而且这些字节看起来也和码点的值没有对应关系。
UTF-8 编码的细节162超出了本指南的讨论范围,但知道它每个码点占用的字节数不定、而且这些字节值除了前 128 个码点之外都与码点对不上,这就足够了。如果你真想了解更多,Computerphile 有一期 Tom Scott 出镜的精彩 UTF-8 视频163。
最后这一点从北美视角看是 Unicode 和 UTF-8 的妙处:它与 7 位 ASCII 编码向后兼容!所以如果你习惯了 ASCII,那 UTF-8 就是一样的!每一份 ASCII 编码的文档也都是 UTF-8 编码的!(但反过来显然不成立。)
推动 UTF-8 在全世界普及的,很可能正是最后这一点,而非其他任何因素。
用 C 编程时,至少有三个字符集在起作用:
你的编译器多半有选项可以在构建时选择这些字符集。
源和执行两者的基本字符集都包含下列字符:
A B C D E F G H I J K L M
N O P Q R S T U V W X Y Z
a b c d e f g h i j k l m
n o p q r s t u v w x y z
0 1 2 3 4 5 6 7 8 9
! " # % & ' ( ) * + , - . / :
; < = > ? [ \ ] ^ _ { | } ~
space tab vertical-tab
form-feed end-of-line这些就是你在源代码里使用后仍能保持 100% 可移植的字符。
执行字符集还会额外包含用于警报(响铃/闪烁)、退格、回车和换行的字符。
但大多数人不会做到那么极端,而是随意在源代码和可执行文件里使用自己的扩展字符集,尤其是如今 Unicode 和 UTF-8 越来越普遍。我是说,基本字符集里连 @、$ 和 ` 都没有!
值得注意的是,只用基本字符集输入 Unicode 字符很麻烦(虽然用转义序列也能做到)。
在讲 C 中的编码之前,我们先从码点的角度谈谈 Unicode。C 里有办法指定 Unicode 字符,编译器会把它们转换成执行字符集164。
那该怎么做呢?
以欧元符号为例,码点是 0x20AC。(我写成十六进制,是因为在 C 中表示它的两种方式都要求十六进制。)我们怎么把它放进 C 代码里?
在字符串里用 \u 转义,例如 "\u20AC"(十六进制的大小写无所谓)。\u 后面必须正好四位十六进制数字,必要时用前导零补齐。
下面是个例子:
char *s = "\u20AC1.23";
printf("%s\n", s); // €1.23可见 \u 适用于 16 位的 Unicode 码点,那比 16 位更大的呢?那就得用大写:\U。
例如:
char *s = "\U0001D4D1";
printf("%s\n", s); // 输出数学字母 "B"它和 \u 一样,只是用 32 位而不是 16 位。下面这两种写法是等价的:
\u03C0
\U000003C0
再强调一次,这些在编译期间会被转换成执行字符集。它们表示的是 Unicode 码点,而不是任何具体的编码。此外,如果某个 Unicode 码点无法在执行字符集中表示,编译器想怎么处理就怎么处理。
现在你可能会想,为什么不能直接这样写:
char *s = "€1.23";
printf("%s\n", s); // €1.23在现代编译器上你大概确实可以。编译器会替你把源字符集转换成执行字符集。但如果它发现任何不在其扩展字符集中的字符,它完全可以当场撂挑子,而 € 符号肯定不在基本字符集里。
规范里有个告诫:你不能用 \u 或 \U 编码 0xA0 以下的码点,0x24($)、0x40(@)和 0x60(`)除外——没错,它们恰好就是基本字符集里缺失的那三个常见标点符号。据说在即将发布的规范版本中,这一限制会放宽。
最后,你也可以在代码的标识符里用这些,但有一些限制。不过这里我不想深入。本章我们讲的全是字符串处理。
C 中的 Unicode 差不多就这些(编码除外)。
有可能你磁盘上的源文件、扩展源字符和扩展执行字符全都是 UTF-8 格式,而你用的库也期望 UTF-8。这就是“到处都有 UTF-8”的光辉未来。
如果是这样,而且你不在乎对那些并非如此的系统的可移植性,那就放手用吧。随便把 Unicode 字符塞进源代码和数据里。用普通的 C 字符串,开心就好。
很多东西会直接就能用(尽管不可移植),因为 UTF-8 字符串和任何其他 C 字符串一样,可以安全地以 NUL 结尾。不过,为了更省事的字符处理而牺牲可移植性,对你来说也许是笔划算的买卖。
不过有几个告诫:
像 strlen() 这样的函数报告的是字符串中的字节数,未必是字符数。(把字符串转换成宽字符时,mbstowcs() 会返回字符串中的字符数。POSIX 对此做了扩展,如果你只想要字符数,可以给第一个实参传 NULL。)
下列函数无法正确处理多于一个字节的字符:strtok()、strchr()(改用 strstr())、strspn() 那一类函数、toupper()、tolower()、isalpha() 那一类函数,可能还有更多。凡是按字节操作的东西都要当心。
printf() 的各变体提供了一种只打印字符串中指定字节数的方法165。你要确保所打印的字节数正好停在字符边界上。
如果你想用 malloc() 为字符串分配空间,或者为它声明一个 char 数组,要知道最大尺寸可能超出你的预期。每个字符最多可能占 MB_LEN_MAX 个字节(来自 <limits.h>)——基本字符集中的字符除外,它们保证只占一个字节。
可能还有我尚未发现的其他问题。要是你知道还有什么坑,告诉我……
我想再介绍几种字符类型。我们对 char 已经习以为常了,对吧?
但那太简单了。咱们把难度提高一大截吧!耶!
首先,我希望能改变一下你对字符串(char 的数组)的看法。它们是由多字节字符组成的多字节字符串。
没错——你那些普普通通的字符串都是多字节的。当有人说“C 字符串”时,他们指的是“C 多字节字符串”。
即使字符串里某个字符只有一个字节,或者整个字符串只由单字节字符组成,它仍然叫多字节字符串。
例如:
char c[128] = "Hello, world!"; // 多字节字符串我们这里说的是:不在基本字符集里的某个字符可能由多个字节组成,最多可达 MB_LEN_MAX 个(来自 <limits.h>)。当然,它在屏幕上看着只是一个字符,但实际上可能是多个字节。
也可以把 Unicode 值塞进去,就像我们前面看到的:
char *s = "\u20AC1.23";
printf("%s\n", s); // €1.23但这里就有点怪了,因为看这个:
char *s = "\u20AC1.23"; // €1.23
printf("%zu\n", strlen(s)); // 7!"€1.23" 的字符串长度是 7?!没错!好吧,在我的系统上确实如此!记住,strlen() 返回的是字符串中的字节数,而不是字符数。(等我们后面讲到“宽字符”时,会看到获取字符串字符数的方法。)
注意,C 允许单独的多字节 char 常量(与 char* 相对),但它们的行为因实现而异,你的编译器可能会对此发出警告。
例如 GCC 对下面两行会警告“多字符字符常量”(而且在我的系统上还会打印出 UTF-8 编码):
printf("%x\n", '€');
printf("%x\n", '\u20ac');如果你不是多字节字符,那你就是宽字符。
宽字符是一个单一的值,能唯一表示当前区域设置中的任何字符。它类似于 Unicode 码点。但也可能不是。也可能就是。
基本上,多字节字符串是字节的数组,而宽字符字符串是字符的数组。于是你可以开始按字符、而不是按字节来思考(当字符开始占用不同数量的字节时,后者会变得一团糟)。
宽字符可以由多种类型表示,但最出挑的是 wchar_t。它是主角。它就像 char,只不过是宽的。
你可能会想,如果连它是不是 Unicode 都说不准,那写代码时还能有多少灵活性?wchar_t 打开了其中一些门,因为有一整套丰富的函数可以用来处理 wchar_t 字符串(比如求长度等),而无需关心编码。
wchar_t该介绍新类型了:wchar_t。这是主要的宽字符类型。还记得 char 只有一个字节吗?而一个字节可能不足以表示所有字符?嗯,这个类型够用。
要使用 wchar_t,请包含 <wchar.h>。
它占多少个字节?嗯,这并不完全明确。可能是 16 位,也可能是 32 位。
但是等等,你会说——如果它只有 16 位,那就装不下所有 Unicode 码点,不是吗?你说得对——确实装不下。规范并不要求它装得下,它只要能表示当前区域设置中的所有字符就行。
在 wchar_t 为 16 位的平台上(咳咳——Windows),这会给 Unicode 带来麻烦。但这超出了本指南的范围。
你可以用 L 前缀声明这种类型的字符串或字符,并可以用 %ls(“ell ess”)格式说明符打印它们。或者用 %lc 打印单个 wchar_t。
wchar_t *s = L"Hello, world!";
wchar_t c = L'B';
printf("%ls %lc\n", s, c);那么——这些字符是以 Unicode 码点的形式存储的吗?取决于实现。不过你可以用宏 __STDC_ISO_10646__ 来测试。如果它被定义,答案就是:“是 Unicode!”
更详细地说,该宏的值是一个 yyyymm 形式的整数,让你知道可以依赖哪个 Unicode 标准——也就是那个日期时生效的版本。
但怎么用它们呢?
wchar_t 的转换那么,怎么从面向字节的标准字符串转到面向字符的宽字符串,再转回来呢?
我们可以用几个字符串转换函数来做到这一点。
首先,这些函数里有一些命名约定:
mb:多字节wc:宽字符mbs:多字节字符串wcs:宽字符字符串所以,如果想把多字节字符串转换成宽字符字符串,我们可以调用 mbstowcs()。反过来则是 wcstombs()。
| 转换函数 | 说明 |
|---|---|
mbtowc() |
将多字节字符转换为宽字符。 |
wctomb() |
将宽字符转换为多字节字符。 |
mbstowcs() |
将多字节字符串转换为宽字符串。 |
wcstombs() |
将宽字符串转换为多字节字符串。 |
我们来做个小演示:把一个多字节字符串转换成宽字符字符串,再用各自对应的函数比较这两者的字符串长度。
#include <stdio.h>
#include <stdlib.h>
#include <wchar.h>
#include <string.h>
#include <locale.h>
int main(void)
{
// 离开 C 区域设置,转到一个很可能包含欧元符号的区域设置
setlocale(LC_ALL, "");
// 含欧元符号的原始多字节字符串(Unicode 码点 20ac)
char *mb_string = "The cost is \u20ac1.23"; // €1.23
size_t mb_len = strlen(mb_string);
// 用于存放转换后字符串的宽字符数组
wchar_t wc_string[128]; // 最多可容纳 128 个宽字符
// 把 MB 字符串转换为 WC;返回宽字符的个数
size_t wc_len = mbstowcs(wc_string, mb_string, 128);
// 输出结果——注意宽字符串用的是 %ls
printf("multibyte: \"%s\" (%zu bytes)\n", mb_string, mb_len);
printf("wide char: \"%ls\" (%zu characters)\n", wc_string, wc_len);
}在我的系统上,输出如下:
multibyte: "The cost is €1.23" (19 bytes)
wide char: "The cost is €1.23" (17 characters)(你的系统输出的字节数可能因区域设置而异。)
有一点很值得一提:mbstowcs() 除了把多字节字符串转换成宽字符字符串,还会返回宽字符字符串的长度(以字符计)。在符合 POSIX 的系统上,你可以利用一种特殊模式,让它只返回给定多字节字符串的字符长度:只需把目标位置传 NULL,并把要转换的最大字符数传 0(这个值会被忽略)。
(在下面的代码里,我用的是自己扩展的源字符集——你可能得把它们替换成 \u 转义。)
setlocale(LC_ALL, "");
// 下面的字符串有 7 个字符
size_t len_in_chars = mbstowcs(NULL, "§¶°±π€•", 0);
printf("%zu", len_in_chars); // 7再说一遍,这是不可移植的 POSIX 扩展。
当然,如果你想往另一个方向转换,那就用 wcstombs()。
一旦进入宽字符的世界,各种功能就任我们取用了。这里我只打算概述其中一部分函数,但基本上,我们看到的这些就是我们熟悉的多字节字符串函数的宽字符版本。(例如,我们熟悉多字节字符串的 strlen();宽字符字符串则对应 wcslen()。)
wint_t这些函数中有许多用 wint_t 存放单个字符,无论是传入的字符还是返回的字符。
它在本质上与 wchar_t 相关。wint_t 是一种整数类型,能表示扩展字符集中的所有值,还能表示一个特殊的文件结束字符 WEOF。
许多面向单个字符的宽字符函数都会用到它。
这里的要点是:不要把面向字节的函数(比如 fprintf())与面向宽字符的函数(比如 fwprintf())混着用。先决定一个流是面向字节还是面向宽字符,然后坚持只用那一类 I/O 函数。
更详细地说:流可以是面向字节的,也可以是面向宽字符的。流刚被创建时没有定向,但第一次读或写会决定它的定向。
如果你先使用宽字符操作(比如 fwprintf()),流就会被定向为宽字符。
如果你先使用字节操作(比如 fprintf()),流就会被定向为字节。
你可以调用 fwide() 手动把未定向的流设成其中一种方向。同一个函数也可以用来获取流的当前定向。
如果你需要在中途改变定向,可以用 freopen() 做到。
这些通常需要包含 <stdio.h> 和 <wchar.h>。
| I/O 函数 | 说明 |
|---|---|
wprintf() |
格式化的控制台输出。 |
wscanf() |
格式化的控制台输入。 |
getwchar() |
基于字符的控制台输入。 |
putwchar() |
基于字符的控制台输出。 |
fwprintf() |
格式化的文件输出。 |
fwscanf() |
格式化的文件输入。 |
fgetwc() |
基于字符的文件输入。 |
fputwc() |
基于字符的文件输出。 |
fgetws() |
基于字符串的输入。 |
fputws() |
基于字符串的输出。 |
swprintf() |
格式化的字符串输出。 |
swscanf() |
格式化的字符串输入。 |
vfwprintf() |
可变参数的格式化文件输出。 |
vfwscanf() |
可变参数的格式化文件输入。 |
vswprintf() |
可变参数的格式化字符串输出。 |
vswscanf() |
可变参数的格式化字符串输入。 |
vwprintf() |
可变参数的格式化控制台输出。 |
vwscanf() |
可变参数的格式化控制台输入。 |
ungetwc() |
把宽字符推回输出流。 |
fwide() |
获取或设置流的多字节/宽字符定向。 |
这些通常需要包含 <wchar.h>。
| 转换函数 | 说明 |
|---|---|
wcstod() |
把字符串转换为 double。 |
wcstof() |
把字符串转换为 float。 |
wcstold() |
把字符串转换为 long double。 |
wcstol() |
把字符串转换为 long。 |
wcstoll() |
把字符串转换为 long long。 |
wcstoul() |
把字符串转换为 unsigned long。 |
wcstoull() |
把字符串转换为 unsigned long long。 |
这些通常需要包含 <wchar.h>。
| 复制函数 | 说明 |
|---|---|
wcscpy() |
复制字符串。 |
wcsncpy() |
复制字符串,可限制长度。 |
wmemcpy() |
复制内存。 |
wmemmove() |
复制可能重叠的内存。 |
wcscat() |
拼接字符串。 |
wcsncat() |
拼接字符串,可限制长度。 |
这些通常需要包含 <wchar.h>。
| 比较函数 | 说明 |
|---|---|
wcscmp() |
按字典序比较字符串。 |
wcsncmp() |
按字典序比较字符串,可限制长度。 |
wcscoll() |
按区域设置的字典序比较字符串。 |
wmemcmp() |
按字典序比较内存。 |
wcsxfrm() |
把字符串转换成这样的版本:使 wcscmp() 的行为与 wcscoll() 一致166。 |
这些通常需要包含 <wchar.h>。
| 查找函数 | 说明 |
|---|---|
wcschr() |
在字符串中查找字符。 |
wcsrchr() |
从后往前在字符串中查找字符。 |
wmemchr() |
在内存中查找字符。 |
wcsstr() |
在字符串中查找子串。 |
wcspbrk() |
在字符串中查找一组字符中的任意一个。 |
wcsspn() |
求由一组字符中任意字符组成的子串长度。 |
wcscspn() |
求位于一组字符中任意字符之前的子串长度。 |
wcstok() |
在字符串中切分标记。 |
这些通常需要包含 <wchar.h>。
| 长度/其他函数 | 说明 |
|---|---|
wcslen() |
返回字符串的长度。 |
wmemset() |
在内存中设置字符。 |
wcsftime() |
格式化的日期和时间输出。 |
这些需要包含 <wctype.h>。
| 长度/其他函数 | 说明 |
|---|---|
iswalnum() |
字符为字母或数字时为真。 |
iswalpha() |
字符为字母时为真。 |
iswblank() |
字符为空白(类似空格,但不是换行符)时为真。 |
iswcntrl() |
字符为控制字符时为真。 |
iswdigit() |
字符为数字时为真。 |
iswgraph() |
字符可打印(空格除外)时为真。 |
iswlower() |
字符为小写时为真。 |
iswprint() |
字符可打印(包括空格)时为真。 |
iswpunct() |
字符为标点时为真。 |
iswspace() |
字符为空白字符时为真。 |
iswupper() |
字符为大写时为真。 |
iswxdigit() |
字符为十六进制数字时为真。 |
towlower() |
把字符转换为小写。 |
towupper() |
把字符转换为大写。 |
我们要稍微深入一点多字节转换的内部机制,而从概念上理解它很有好处。
想象一下你的程序如何把一串多字节字符变成宽字符,或者反过来。它在某个时刻可能正解析到某个字符的中间,也可能必须等到更多字节到齐才能确定最终的值。
这个解析状态存放在一个类型为 mbstate_t 的不透明变量里,每次执行转换时都会用到。转换函数正是靠它记录自己干到哪儿了。
而如果你在中途换成另一段字符序列,或者试图跳到输入序列的另一个位置,它就可能因此搞混。
现在你可能会就这一点质问我:我们刚刚在上面做了些转换,而我压根儿没在任何地方提到 mbstate_t。
这是因为像 mbstowcs()、wctomb() 这类转换函数各自都有一个自己使用的 mbstate_t 变量。不过每个函数只有一个,所以如果你在写多线程代码,它们用起来并不安全。
幸运的是,C 为这些函数定义了可重启版本,需要的话你可以按线程传入自己的 mbstate_t。如果你在做多线程的东西,就用这些版本!
关于初始化 mbstate_t 变量的简短提示:直接用 memset() 把它置零。没有内置函数能强制初始化它。
mbstate_t mbs;
// 把状态设为初始状态
memset(&mbs, 0, sizeof mbs);下面是可重启转换函数的列表——注意这个命名惯例:在“来源”类型之后加一个“r”:
mbrtowc()——多字节转宽字符wcrtomb()——宽字符转多字节mbsrtowcs()——多字节字符串转宽字符字符串wcsrtombs()——宽字符字符串转多字节字符串这些函数与对应的不可重启版本非常相似,区别在于它们要求你传入一个指针,指向你自己的 mbstate_t 变量。此外,它们还会修改源字符串指针(万一发现无效字节,这能帮上你的忙),所以保存一份原始指针的副本可能很有用。
下面是本章前面的示例改写后的版本,改为传入我们自己的 mbstate_t。
#include <stdio.h>
#include <stdlib.h>
#include <stddef.h>
#include <wchar.h>
#include <string.h>
#include <locale.h>
int main(void)
{
// 离开 C 区域设置,转到一个很可能包含欧元符号的区域设置
setlocale(LC_ALL, "");
// 含欧元符号的原始多字节字符串(Unicode 码点 20ac)
char *mb_string = "The cost is \u20ac1.23"; // €1.23
size_t mb_len = strlen(mb_string);
// 用于存放转换后字符串的宽字符数组
wchar_t wc_string[128]; // 最多可容纳 128 个宽字符
// 设置转换状态
mbstate_t mbs;
memset(&mbs, 0, sizeof mbs); // 初始状态
// mbsrtowcs() 会修改输入指针,使其指向第一个
// 无效字符;成功时为 NULL。我们把指针复制一份
// 交给 mbsrtowcs() 去折腾,这样我们的原始指针
// 就保持不变。
//
// 这个示例很可能成功,但我们还是往下
// 检查看看。
const char *invalid = mb_string;
// 把 MB 字符串转换为 WC;返回宽字符的个数
size_t wc_len = mbsrtowcs(wc_string, &invalid, 128, &mbs);
if (invalid == NULL) {
printf("No invalid characters found\n");
// 输出结果——注意宽字符串用的是 %ls
printf("multibyte: \"%s\" (%zu bytes)\n", mb_string, mb_len);
printf("wide char: \"%ls\" (%zu characters)\n", wc_string, wc_len);
} else {
ptrdiff_t offset = invalid - mb_string;
printf("Invalid character at offset %td\n", offset);
}
}对于自行管理状态的转换函数,只要给它们的 char* 参数传入 NULL,就能把其内部状态重置为初始状态,例如:
mbstowcs(NULL, NULL, 0); // 重置 mbstowcs() 的解析状态
mbstowcs(dest, src, 100); // 解析一些内容在 I/O 方面,每个宽字符流都管理着自己的 mbstate_t,并在进行输入、输出转换时随时使用它。
而像 printf() 和 scanf() 这样一些面向字节的 I/O 函数,在工作时也会维护自己的内部状态。
最后,如果你给 mbstate_t 参数传入 NULL,这些可重启转换函数其实也会用上自己的内部状态。这让它们的行为更接近对应的不可重启版本。
本节我们来看看,面对三种具体的 Unicode 编码——UTF-8、UTF-16 和 UTF-32——C 能做(以及不能做)什么。
开始本节之前,建议先回顾一下上面的 UTF-8 简明说明。
除此之外,C 在 UTF-8 方面还有哪些能力?
唉,很遗憾,没什么了。
你可以明确告诉 C,你想要某个字符串字面量采用 UTF-8 编码,它就会照办。只要在字符串前加上 u8:
char *s = u8"Hello, world!";
printf("%s\n", s); // Hello, world!——前提是你能输出 UTF-8那么,能往里面放 Unicode 字符吗?
char *s = u8"€123";当然可以!只要扩展源字符集支持就行。(gcc 就支持。)
要是不支持呢?你可以用随手可用的 \u 和 \U 指定 Unicode 码点,如前所述。
但也仅此而已。标准库里没有可移植的办法把任意输入转换成 UTF-8——除非你的区域设置就是 UTF-8;也没有可移植的办法解析 UTF-8——除非你的区域设置就是 UTF-8。
所以,如果你确实想这么做,要么就处在 UTF-8 区域设置下:
setlocale(LC_ALL, "");要么弄清你本机上的 UTF-8 区域设置名称,然后像这样显式设置:
setlocale(LC_ALL, "en_US.UTF-8"); // 不可移植的名称或者用一个第三方库。
char16_t 与 char32_t
char16_t 和 char32_t 是另外两种潜在的宽字符类型,大小分别为 16 位和 32 位。它们未必就是宽字符,因为如果它们无法表示当前区域设置中的每一个字符,就失去了宽字符的本质。但规范到处都把这类类型称作“宽字符”类型,所以也就这么叫了。
引入它们,是为了让事情有可能更贴近 Unicode 一些。
要使用它们,请包含 <uchar.h>。(是“u”,不是“w”。)
这个头文件在 OS X 上并不存在——真扫兴。如果你只想要这些类型,可以这样做:
#include <stdint.h>
typedef int_least16_t char16_t;
typedef int_least32_t char32_t;但如果你还想要那些函数,那就得自己想办法了。
假设你这边仍然一切就绪,就可以用 u 和 U 前缀声明这些类型的字符串或字符:
char16_t *s = u"Hello, world!";
char16_t c = u'B';
char32_t *t = U"Hello, world!";
char32_t d = U'B';
那么——这些类型里存的值是按 UTF-16 还是 UTF-32 存储的?取决于实现。
不过你可以测一测。如果宏 __STDC_UTF_16__ 或 __STDC_UTF_32__ 被定义(定义为 1),就说明这些类型分别存放的是 UTF-16 或 UTF-32。
如果你好奇(我知道你好奇),那么当这些值采用 UTF-16 或 UTF-32 时,它们按本机字节序存储。也就是说,你应该能直接拿它们和 Unicode 码点值比较:
char16_t pi = u"\u03C0"; // pi 符号
#if __STDC_UTF_16__
pi == 0x3C0; // 恒为真
#else
pi == 0x3C0; // 大概不为真
#endif
你可以用若干辅助函数,把多字节编码转换为 char16_t 或 char32_t。
(不过,就像我说过的,除非相应的宏被设为 1,结果未必就是 UTF-16 或 UTF-32。)
这些函数全都是可重启的(也就是说,你要传入自己的 mbstate_t),而且它们全都逐字符操作167。
| 转换函数 | 说明 |
|---|---|
mbrtoc16() |
将多字节字符转换为 char16_t 字符。 |
mbrtoc32() |
将多字节字符转换为 char32_t 字符。 |
c16rtomb() |
将 char16_t 字符转换为多字节字符。 |
c32rtomb() |
将 char32_t 字符转换为多字节字符。 |
如果需要在不同具体编码之间做繁重的转换,有几个成熟的库值得一看。注意:这两个我都没用过。
如果你还知道别的值得关注的库,请告诉我。