8.3.2 C++11中的Unicode支持

在C++98标准中,为了支持Unicode,定义了“宽字符”的内置类型wchar_t。不过不久程序员便发现C++标准对wchar_t的“宽度”显然太过容忍,在Windows上,多数wchar_t被实现为16位宽,而在Linux上,则被实现为32位。事实上,C++98标准定义中,wchar_t的宽度是由编译器实现决定的。理论上,wchar_t的长度可以是8位、16位或者32位。这样带来的最大的问题是,程序员写出的包含wchar_t的代码通常不可移植。

这一状况在C++11中得到了一定的改善,至少C++11解决了Unicode类型数据的存储问题。C++11引入以下两种新的内置数据类型来存储不同编码长度的Unicode数据。

❑char16_t:用于存储UTF-16编码的Unicode数据。

❑char32_t:用于存储UTF-32编码的Unicode数据。

至于UTF-8编码的Unicode数据,C++11还是使用8字节宽度的char类型的数组来保存。而char16_t和char32_t的长度则犹如其名称所显示的那样,长度分别为16字节和32字节,对任何编译器或者系统都是一样的。

此外,C++11还定义了一些常量字符串的前缀。在声明常量字符串的时候,这些前缀声明可以让编译器使字符串按照前缀类型产生数据。事实上,C++11一共定义了3种这样的前缀:

❑u8表示为UTF-8编码。

❑u表示为UTF-16编码。

❑U表示为UTF-32编码。

3种前缀对应于3种不同的Unicode编码。一旦声明了这些前缀,编译器会在产生代码的时候按照相应的编码方式存储。以上3种前缀加上基于宽字符wchar_t的前缀“L”,及不加前缀的普通字符串字面量,算来在C++11中,一共有了5种方式来声明字符串字面量,其中4种是前缀表达的。

通常情况下,按照C/C++的规则,连续在代码中声明多个字符串字面量,则编译器会自动将其连接起来。比如"a""b"这样声明的方式与"ab"的声明方式毫无区别。而一旦连续声明的多个字符串字面量中的某一个是前缀的,则不带前缀的字符串字面量会被认为与带前缀的字符串字面量是同类型的。比如声明u "a" "b"和"a" u "b",其效果跟u "ab"是完全等同的,都是生成了连续的字面量等于UTF-16编码"ab"的字符串。不过最好不要将各种前缀字符串字面量连续声明,因为标准定义除了UTF-8和宽字符字符串字面量同时声明会冲突外,其他字符串字面量的组合最终会产生什么结果,以及会按照什么类型解释,是由编译器实现自行决定的。因此应该尽量避免这种不可移植的字符串字面量声明方式。

对于Unicode编码字符的书写,C++11中还规定了一些简明的方式,即在字符串中用'\u'加4个十六进制数编码的Unicode码位(UTF-16)来标识一个Unicode字符。比如'\u4F60'表示的就是Unicode中的中文字符“你”,而'\u597D'则是Unicode中的“好”。此外,也可以通过'\U'后跟8个十六进制数编码的Unicode码位(UTF-32)的方式来书写Unicode字面常量。程序员获得Unicode码位的编码的方法很多,比如在Windows系统下,可以使用系统自带的字符映射表,而在网络上,也可以轻松地找到很多免费提供的中文到Unicode的在线转换服务的网站。

我们可以来看一下代码清单8-14所示的这个例子。

代码清单8-14


include <iostream>

using namespace std;

int main(){

char utf8[]=u8 "\u4F60\u597D\u554A";

char16_t utf16[]=u "hello";

char32_t utf32[]=U "hello equals\u4F60\u597D\u554A";

cout<<utf8<<endl;

cout<<utf16<<endl;

cout<<utf32<<endl;

char32_t u2[]=u "hello";//Error

char u3[]=U "hello";//Error

char16_t u4=u8 "hello";//Error

}

//编译选项:clang++8-3-1.cpp-std=c++11


在本例中,我们声明了3种不同类型的Unicode字符串utf8、utf16和utf32。由于无论对哪种Unicode编码,英文的Unicode码位都相同,因此只有非英文使用了"\u"的码位方式来标志。我们可以看到,一旦使用了Unicode字符串前缀,这个字符串的类型就确定了,仅能放在相应类型的数组中。u2、u3、u4就是因为类型不匹配而不能通过编译。

如果我们注释掉不能通过的定义,编译并运行代码清单8-14,在我们的实验机上可以得到以下输出:


你好啊

0x7fffaf087390

0x7fffaf087340


对应于char utf8[]=u8"\u4F60\u597D\u554A"这句,该UTF-8字符串对应的中文是“你好啊”。而对于utf16和utf32变量,我们本来期望它们分别输出“hello”及“hello equals你好啊”。不过实验机上我们都只得到了一串数字输出。这是什么原因呢?

事实上,C++11虽然在语言层面对Unicode进行了支持,但语言层面并不是唯一的决定因素。用户要在自己的系统上看到正确的Unicode文字,还需要输出环境、编译器,甚至是代码编辑器等的支持。我们可以按照编写代码、编译、运行的顺序来看看它们对整个Unicode字符串输出的影响。

首先会影响Unicode正确性的过程是源文件的保存。以字符"\u4F60"为例,其保证的是输入数据等同于Unicode中码位为4F60的字符,而被保存的源代码文件中,数据采用的编码则跟编辑器有关。如编辑器采用了GB2312编码保存数据,则源代码文件中utf8变量的前2字节保存的是GB2312编码的中文字“你”。而如果编辑器采用了UTF-8编码,则源代码文件中的utf8变量的前3字节保存的是UTF-8的中文字“你”。

第二个会影响Unicode正确性的过程是编译。C++11中的u8前缀保证编译器把utf8变量中的数据以UTF-8的形式产生在目标代码的数据段中。不过通常编译器也会有自己的设定,如果编译器被设置了正确的编码形式,(比如文件保存为GB2312编码,编译器也设置了文件格式为GB2312,或者两者均为UTF-8),则u8前缀能够正常工作。

第三个会影响Unicode正确性的过程是输出。C++的操作符“<<”保证把数据以字节(char)、双字(char16_t)、四字(char32_t)的方式输出到输出设备,但输出设备(比如在Linux下的shell,或是Windows下的console)是否能够支持该编码类型的输出,则取决于设备驱动等软件层。

我们的实验机是一台Linux机器。对于Linux而言,大多数软件如shell、编辑器vi,以及编译器g++等都会根据Linux系统locale设定而采用UTF-8编码。在代码清单8-14所示的例子中,utf8变量会输出正确,而utf16、utf32数据输出均失败,原因就是因为系统并不支持UTF-16和UTF-32输出。

在现有的编程环境支持下,如果要保证在程序中直接输入中文得到正确的输出,我们建议程序员要使用与系统环境中相同的编码方式。比如在Linux下(现在很多Linux系统的发布版均使直接用UTF-8作为系统中的编码),u8前缀的UTF-8编码Unicode会得到广泛的支持。而Windows由于内部采用了UTF-16的方式保存文字编码,因此u前缀的UTF-16编码的Unicode可能会被支持得更好。而如果程序员想在不同系统下编译相同的文件(这也并不少见,比如在一些基于QT IDE的跨平台开发上,程序员会在各平台间共享源代码),程序员则应该注意查看编辑器与编译器是否使用了不同的编码方式,并按需调整。

如果在用户确认了使用环境没有问题,在程序员排除了上述环境上的困难之后,又有了char16_t、char32_t以及各种前缀表示、\u字面值等,是否意味着Unicode真的就可以良好运作了呢?让我们来看看代码清单8-15所示的这个的例子。

代码清单8-15


include <iostream>

using namespace std;

int main(){

char utf8[]=u8"\u4F60\u597D\u554A";

char16_t utf16[]=u"\u4F60\u597D\u554A";

cout<<sizeof(utf8)<<endl;//10字节

cout<<sizeof(utf16)<<endl;//8字节

cout<<utf8[1]<<endl;//输出不可见字符

cout<<utf16[1]<<endl;//输出22909(0x597D)

}

//编译选项:g++ -std=c++118-3-2.cpp


这个例子里,我们首先看不同编码情况下Unicode字符串的大小。可以看到,UTF-8由于采用了变长编码,在这里把每个中文字符编码为3字节,再加上'\0'的字符串终止符,所以utf8变量的大小是10字节。而UTF-16则是定长编码,所以utf16占用了8字节空间。倘若我们按照使用ASCII字符的思路来使用Unicode字符,比如使用数组来访问的时候,我们发现utf8的输出是不正确的(这里的utf16是正确的,只是实验机无法正常输出)。事实上,我们将UTF-8编码的数据放在了一个char类型中,所以utf8[1]只是指向了第一个UTF-8字符3字节中的第二位,因此输出不正常。

相比于定长编码的UTF-16,变长编码的UTF-8的优势在于支持更多的Unicode码位,而且也没有大数端小端段问题(而有字节序问题的UTF-16有LE和BE两种不同版本)。不过不能直接数组式访问是UTF-8的最大的缺点。此外,C++11为char16_t和char32_t分别配备了u16string及u32string等字符串类型,却没有u8string(因为从实现上讲,变长的UTF-8编码的数据也不是很容易与string配合使用)。这样一来,UTF-8的字符串不能够被方便地进行增删、查找,至于利用各种高级的STL算法,就更加困难了。

倘若用户要完成上面的各种复杂的操作,需要的是一个复杂的类型,比如说用utf8_t的类型来保存变长的UTF-8字符,而不是像现在这样用char数组来“存放”UTF-8字符。这个想法固然也有一些道理,但utf8_t类型给C++带来的冲击可能也是很大的,因为它看起来像是个基本类型,却是变长的,与已有算法结合并不一定有性能上的优势(比如计算第N个元素的时间复杂度不再是O(1))。

UTF-8变长的设定更多时候是为了在序列化时节省存储空间,定长的UTF-16编码或者UTF-32则更适合在内存环境中操作。因此,在现有C++编程中,总是倾向于在I/O读写的时候才采用UTF-8编码,即在进行I/O操作时才将定长Unicode编码转化为UTF-8使用。内存中一直操作的是定长的Unicode编码,故不过在这种使用方式下,编码转换就成了更加常用且不可或缺的功能。