首先,什么是 Unicode?
Unicode 是一个统一的文字编码标准,它出现目的是为了解决不同计算机之间字符编码不同而导致的灾难性不兼容问题。
Unicode 字符集与 Unicode 编码是两种不同的概念。Unicode 字符集是对进入标准的所有文字用一个唯一的数字编码指代,就像用 1 指代字母 a,用 2 指代字母 b,并以此类推。在标准规范中,这里的数字被称为 Unicode Code Point,它一般都被写为 U+xxxx
的格式。
截至目前,Unicode Code Point 能被用 4 字节长的数值完全覆盖。
但受限于编解码识别和字符分配问题,Code Point 不会覆盖完整的 2 32 − 1 2^{32} - 1 232−1 个字符,同时它的编码数字增长也不连续。
举例来说,CJK Unified Ideographs Extension
部分的 Code Point 在拓展集 B 到 I 之间就存在若干数值未被分配。
而 Unicode 编码则是对上述 Code Point 的再编码,也就是将 4 字节长的 Code Point 根据编码方案的不同压缩成不同的字节表示。
其中由于 Code Point 可以被 4 字节长的数值完全覆盖,所以 UTF-32
(下称 U32)是编码到 Code Point 的直接映射;而 UTF-16
(下称 U16)和 UTF-8
(下称 U8)则是利用了不同字节前缀长进行了变长编码,当然最长不会超过 4 字节。
所以到这里读者应该能够看出,
UTF-xx
后的数字就是这一编码方案要求的字符所占的二进制位个数。
U16 就是纯纯臭手,Unicode 设计时傲慢的洋人以为 16 位就能塞进所有字符了,所以一开始 U16 才是定长编码;没想到后来 CJK 字符集直接给大伙整不会了,于是设计 Unicode 的家伙出尔反尔让 U16 也变成了变长编码。
这一历史错误直接导致微软的 Windows C++ 底层字符编码基于 U16 而非 U32。
啊我草洋人怎么这么坏
Unicode Support in Cpp
C++ 的 Unicode 支持其实是一个老生常谈且经久不衰的问题了。
其实 Unicode 的支持可以分为两个部分:语言标识符的 Unicode 编码支持,以及 Unicode 字符串的支持;前者没什么好说,C++11 引入了一些与字符集编码相关的规范,并且从该标准后主流编译器都逐步开始支持 U8 等扩展字符集编码文本。
也就是说可以这样写代码:
#include <iostream>
int main()
{
auto 你好 = "Hello, world!";
std::cout << 你好 << std::endl;
}
见过很多初学者试图这样写但是翻车了;往往这是因为代码文本的字符集编码与编译器假断的不同;假设你是用 GCC/Clang 编译器,且代码文件用的是 GBK 编码,则可以使用命令行参数告知编译器用 GBK 规范解码
-finput-charset=GBK
。
但是如果要把字面量字符串输出到屏幕上,还需要告知编译器-fexec-charset=GBK
以更改字符串编码格式,否则就会在终端上看到经典的中文乱码。
以上操作仅限于使用 GBK 编码的系统环境。
对于 Unicode 字符串就没这么好运了;C++11 后标准引入了 3 种 Unicode 编码字符串的字面量和字符类型,它们分别是:
auto utf16 = u"这是 UTF-16 编码字符串"; // C++11
auto utf32 = U"这是 UTF-32 编码字符串"; // C++11
auto utf8 = u8"这是 UTF-8 编码字符串"; // C++20
char16_t utf16_char = u'\u4F60';
char32_t utf32_char = U'\U0001F600';
char8_t utf8_char = u8'A';
这几个类型不能说毫无作用,只能说聊胜于无。一方面,标准库根据这两个类型提供了 std::u16string
和 std::u32string
两种 UTF 编码的字符串类型;另一方面,标准库完全没有提供这几个类型的输入输出支持。
如果需要输出,还需要利用各种扭曲的类型转换将 char16_t
等编码的字符串当成 char
处理,也就是像下面这样:
#include <iostream>
#include <string>
using namespace std;
int main()
{
#ifdef _WIN32
system( "chcp 65001" );
#endif // 65001 开启的是终端环境的 U8 字符集支持
// 所以这里要用 C++20 的 std::u8string
auto u8str = std::u8string( u8"Coding in UTF-8" );
std::cout << reinterpret_cast<const char*>( u8str.data() ) << std::endl;
}
当然这几个类型也不是一无是处,它们唯一的优点就是:存储在字面量文本中的字符编码不会受到 -fexec-charset
等编译开关的影响,而是始终保持着指定的 Unicode 编码。换句话说,指针 char8_t*
指向的字符串一定是 U8 编码的字符串,但 char*
指向的字符串的编码格式只有天知道。
在 C++98 时就已经出现的 wchar_t
比起上面这两个还稍微有一点用,至少它活跃在 Windows 的底层 API 中;这个类型的大小在不同平台上是可变的(Windows 上 2 字节,Linux 及非 Windows 平台则普遍为 4 字节)。
而标准曾经还要求这个类型必须大到足以容纳所有字符编码,但很显然在 U32 出现之后,这一目标不可能在 Windows 上实现。
并且这个类型存储的数据与 char
一样,是编码无关的。
Support Unicode in Cpp
虽然标准本身并不直接支持完整的 Unicode 编码方案,但其实如果要实现 UTF 编码字符串支持也不是很困难。
从使用上来说,实现 UTF 编码字符串支持的首要工作就是数清楚字符串里面有几个 UTF 编码字符;由于不同的变长编码都是基于字节寻址的,所以这个工作并不困难:我们只需要根据不同的编码前缀识别当前字节的长度,并逐字节扫描过去就能数清楚有几个编码字符了。
对于 U8 来说是这样的:
#include <cstdint>
#include <iostream>
#include <string_view>
#include <cassert>
// 这里用了 C++17 的 std::string_view
std::size_t count_u8_char( std::string_view u8_str )
{
std::size_t num_u8_char = 0;
for ( std::size_t i = 0; i < u8_str.size(); ) {
const auto start_point = u8_str.data() + i;
// After RFC 3629, the maximum length of each standard UTF-8 character is 4 bytes.
const std::uint32_t first_byte = static_cast<std::uint32_t>( *start_point );
auto integrity_checker = [start_point, &u8_str]( std::size_t expected_len ) {
assert( start_point >= u8_str.data() );
if ( u8_str.size() - ( start_point - u8_str.data() ) < expected_len )
throw std::invalid_argument( "incomplete UTF-8 string" );
for ( std::size_t i = 1; i < expected_len; ++i )
if ( ( start_point[i] & 0xC0 ) != 0x80 )
throw std::invalid_argument( "broken UTF-8 character" );
};
if ( ( first_byte & 0x80 ) == 0 )
i += 1;
else if ( ( ( first_byte & 0xE0 ) == 0xC0 ) ) {
integrity_checker( 2 );
i += 2;
} else if ( ( first_byte & 0xF0 ) == 0xE0 ) {
integrity_checker( 3 );
i += 3;
} else if ( ( first_byte & 0xF8 ) == 0xF0 ) {
integrity_checker( 4 );
i += 4;
} else
throw std::invalid_argument( "not a standard UTF-8 string" );
++num_u8_char;
}
return num_u8_char;
}
int main()
{
std::cout << count_u8_char( "这里一共有九个字符" ) << std::endl;
}
在开启命令行参数 -finput-charset=UTF-8
时,程序的输出恰好是 9
。
不过实际上我们并不会真的去关心 U8 字符串里有几个编码字符,在项目中更常见的是找出每个编码字符然后进行其他的字符串操作。
如果说是像使用 std::string
一样使用一个 U8 字符串的话,还是尽量避免自己手搓比较好。毕竟首先 U8 编码是一个变长编码方案,要实现随机读写字符势必需要一个相对复杂的解码操作;
其次 UTF 编码字符一般都是先被解码为定长的 U32(即 Unicode Code Point)字符,再进行 CRUD 操作;而将操作结果写回到编码字符串时又需要将定长 Code Point 重新编码为变长字符。这会导致一个比较经典的问题:因为每次写回的变长字符不一定都与原先的等长,所以每次更改都有可能导致底层存储字节数据的字节数组的尾部数据在反复挪移,这是一个复杂度相对较高的操作。
因此本文并不会去探讨如何实现一个相对完备可用的 UTF 编码字符串;但除了 UTF 编码字符串外,还有一些问题是会在使用 UTF 编码时遇到的。
例如,在终端显示中常常会出现的问题:因为不同的 Unicode 字符的复杂性不同,它们被输出到终端时被渲染出来的字体宽度是不同的;对于 ASCII 字符表内的所有可显示字符一般都占 1 个字符宽,而对于中文文本、绝大多数的 emoji 字符则占