C++ 11 的 codecvt 与编码转换

时间:2024-10-25 07:47:31

1 编码与乱码

乱码产生的主要原因是编码与字符集不匹配,这种不匹配时怎么造成的呢?首先要来了解一下编码和字符集的关系。

1.1 编码与字符集

由于标准的英文 ASCII 已经成了全球标准,每台电脑的 BIOS 里存着一份标准 ASCII 表(包括字模),所以,使用英文和数字基本上不会产生乱码。这里说基本上,是因为不是绝对,因为据说德国、法国和一些说拉丁语的国家搞过一次起义,试图将德语字母、法语字母或拉丁字母代替 ASCII 表中的英文字母,搞一个适用于自己的母语系统的字母表。不知道这场起义是如何被镇压的,反正现在通行的 ASCII 表还是英文字母。不过随着 UNICODE 字符集标准化的推进,重排 ASCII 表也没有什么实际的意义了。

中国人要显示汉字,日本人要平(片)假名,韩国人要思密达,总之,这些国家的文字不可能指望只留了 127 个可见字符位置的 ASCII 表。解决汉字问题最简单的方案就i是采用扩展 ASCII 的方案,具体实施方法就是对于 ASCII 表能表达的字符,仍然用一个字节的 ASCII 码表示,对于汉字这样的非英文字符则用两个字节表示。那么问题来了,对于一串既有中文字符又有英文字符的编码,如何判断某一个字节的编码到底是个英文字母呢,还是和后面一个字节一起组成一个双字节的汉字字符呢?重点来了,既然可见英文字母和数字的 ASCII 码都是小于 128 的,那么超过 128 的不就是汉字码?确实是这样的,汉字的双字节编码中,第一个字节的最高位总是 1,这也是汉字的区位码第一个字节都是从 0x80 区开始的原因,在这之前的区位是不使用的。

这种扩展 ASCII 方案,在操作系统中被称为 MBCS (Multi-Bytes Charactor Set,多字节字符集)编码。举个例子直观地理解一下这个字符编码方案吧,比如 “abc中国” 这一串文字,以 MBCS 编码后计算机内部存储的编码序列是:0x61,0x62,0x63,0xd6,0xd0,0xb9,0xfa。这 7 个字节中前 3 个分别是英文字母 “abc” 的 标准ASCII,后面 4 个字节是两个汉字的编码。汉字的编码是怎么确定的呢?这就要说说字符集和字符编码了。字符集(Charset)和编码(Encoding)是两个不同的概念,但是确实紧密相关的两个事情。以汉字为例,从最早的 GB2312,到 GBK,再到 GB18030,字符集中容纳的汉字的数量逐步增加,但是编码的规则没变,依然是用一个字节区号+一个字节区内编号组成。比如汉字 “中” ,在三个字符集中都是安排在 D6 区,区内编号是 D0(兼容性考虑)。对于 MBCS 这样的扩展 ASCII 编码方式来说,字符集就决定了编码,所以我们看到的编码就是汉字原本的区位码。

1.2 Unicode

同样的一串字符编码,在 GBK 字符集上就能显示汉字,在日文字符集上就可能显示对应的日文假名或乱码,这就是乱码产生的第一个原因:字符集与编码不匹配。Unicode 的出现就是为了解决字符集与编码不匹配的问题,Unicode的全称是:“Universal Multiple-Octet Coded Character Set”,简称为 UCS(Universal Character Set)。Unicode 的哲学非常简单,那就是全世界都用一个字符集就好了,全世界所有文字的字符,在 Unicode 中都有自己的位置和编码。Unicode 有两个分支,一个是 UCS-2,即用两个字节给每个字符编码,另一个分支是 UCS-4,即用四个字节给每个字符编码。目前主要使用的是 UCS-2,UCS-4 是为 UCS-2 不够用的时候做备份。

有了字符集,还需要与之对应的编码,目前基于 Unicode 字符集的编码格式主要有 UTF-8,UTF-16LE,UTF-16BE,UTF-32LE,UTF-32BE。后缀 “LE” 和 “BE” 是小端字节序和大端字节序的意思。在 Unicode 字符集中,汉字“中”的编码是 0x4e2d,“国”的编码是 0x56fd,如果采用 UTF-16LE 编码,“abc中国” 编码后在计算机中存储的编码序列是:0x61,0x00,0x62,0x00,0x63,0x00,0x2d,0x4e,0xfd,0x56。可能有人会有疑惑,UTF-16 或 UCS-2 的16 位编码只能表示 65536 个字符,表示全部的汉字可能都不够吧?其实 UTF-16 编码还留了一手,对于编码值小于 0x10000 的,直接用编码值,比如汉字“中”的编码。对于编码值大于 0x10000 的字符,先用字符编码减去 0x10000,剩下的数值(20位)分成两个 10 位编码,给前一个 10 位编码补上六位 110110 前缀,得到一个 16 位编码,后一个 10 位编码补上 110111 前缀,得到另一个 16 位编码,即用两个 16 位编码表示这个字符。目前 Unicode 已经安排的最大码位是 0x10FFFF,减去 0x10000 后,结果最大是 0xFFFFF,所以 20 位也就够用了。一旦 Unicode 的最大码位超过 0x10FFFF,估计 UCS-2 将会被弃用。

这里要说说第二种类型的乱码,那就是字节序问题导致的编码。在小端字节序的系统上使用大端字节序的 UCS 编码,即使都是 Unicode,也一样会出现乱码。因此,需要在不同的系统之间交换数据的文件、通过网络传出的数据,都需要注意字节序的问题,否则也会出现乱码。有没有不用考虑字节序问题的 UCS 编码格式呢?有,那就是 UTF-8。UTF-8 编码是变长码元,根据每个字符编码的数值确定这个字符的 UTF-8 编码长度。以“abc中国” 为例,采用 UTF-8 后在计算机中存储的编码序列是:0x61,0x62,0x63,0xe4,0xb8,0xad,0xe5,0x9b,0xbd。

假如一个文件用的是 UTF-8 编码,如果打开文件读取时使用 MBCS 解码读取,就会出现乱码,这是第三种类型的乱码,即编码与解码不匹配。解决的方法就是通过文件头的 BOM 探测一下文件的编码,或者用 “uchardet” 这样成熟的库来探测文件的编码

2 编码转换

有编码就有编码的转换,这是“江湖”的一体两面。C/C++ 提供了部分转码功能,比如wcstombs()mbstowcs() 函数,还有从 C++ 11 开始支持的 std::codecvt,但是总体来说,大部分 C++ 的开发者还是会求助于第三方的库,比如 libiconv 或 icu4c。

2.1 wcstombs() 和 mbstowcs()

这两个函数的作用是在 MBCS 字符编码和宽字符编码之间转换,其原型是:

size_t wcstombs (char* dest, const wchar_t* src, size_t max);
size_t mbstowcs (wchar_t* dest, const char* src, size_t max);

这两个函数的行为并不像其名字理解的那么简单,使用这两个函数的注意事项涉及字节序、本地的地域化设置以及 wchar_t 类型在不同系统上的差异。

首先说说地域化环境设置对这两个函数的影响。前面介绍过,MBCS 这样的扩展 ASCII 编码其实和字符集有紧密关系,同样的编码值在不同字符集上对应的是不同的文字,所以,不正确的本地化设置,可能会导致得不到正确的结果,举个例子:

const char* pstr = "abc中国";
wchar_t wcstr[16] = { 0 };
::mbstowcs(wcstr, pstr, 16);

如果采用默认的中性地域化设置,wcstr 中得到的编码序列(小端系统)是:0x61,0x00,0x62,0x00,0x63,0x00,0xd6,0x00,0xd0,0x00,0xb9,0x00,0xfa,0x00。可以看出来,这个函数不能正确理解汉字的编码,统统按照英文字符的处理方式改成宽字符串。并且汉字部分用的还是 GBK 字符集的区位码,不是 Unicode 字符集的编码。如果按照如下方式设置地域化环境:

std::locale::global(std::locale(""));

就能得到正确的结果:0x61,0x00,0x62,0x00,0x63,0x00,0x2d,0x4e,0xfd,0x56,这是在小端系统功能上的 UTF-16LE 编码。注意汉字的编码已经转成 Unicode 字符集编码了。反向转换时,地域化设置对 wcstombs() 函数的影响是相同的,这里不再赘述。接下来说说系统字节序对这两个函数的影响。上例中的结果是在小端字节序的系统上得到的编码序列,在大端字节序系统上,得到的将是UTF-16BE 编码,这一点应该比较容易理解。最后是 wchar_t 在 Windows 系统和 linux 系统上的差异。在 Windows 系统上,wchar_t 是 16 位宽度的 Unicode。以前都直接说是 UTF-16LE,因为 Windows 基本上都是在 Intel 的 CPU 上跑。现在不能这么肯定了,因为 Windows 也支持 ARM CPU了,未来可能还支持其他大端 CPU。在 linux 系统上,wchar_t 是 32 位宽度的 Unicode,至于是 LE 还是 BE,还要看系统是什么情况。

综上所述,使用这两个转换函数不仅要保证地域化环境设置要正确,还要正确理解系统使用的字节序以及 wchar_t 类型在不同系统上的差异。GCC 的编译器有个选项,可以设置在 linux系统上将 wchar_t 类型强制设置为 16 位,但是请慎用。因为你的 wchar_t 长度与其他模块的 wchar_t 长度不一致,会导致很多潜在的错误。

2.2 std::codecvt

2.2.1 结合文件流实现编码转换

std::codecvt 是 C++ 11 引入的一组 Facet,配合 std::locale 使用,解决一些地域化环境设置时需要的编码转换问题。

std::codecvt<char, char, std::mbstate_t> identity conversion
std::codecvt<char16_t, char, std::mbstate_t> conversion between UTF-16 and UTF-8 (since C++11)(deprecated in C++20)
std::codecvt<char16_t, char8_t, std::mbstate_t> conversion between UTF-16 and UTF-8 (since C++20)
std::codecvt<char32_t, char, std::mbstate_t> conversion between UTF-32 and UTF-8 (since C++11)(deprecated in C++20)
std::codecvt<char32_t, char8_t, std::mbstate_t> conversion between UTF-32 and UTF-8 (since C++20)
std::codecvt<wchar_t, char, std::mbstate_t> conversion between the system’s native wide and the single-byte narrow character sets

除了上述 5 个基本的转换器,C++ 还定义了一些特殊的定制版本,比如 std::codecvt_utf8,std::codecvt_utf16,std::codecvt_utf8_utf16 等等。以 std::codecvt_utf8 为例:

template<
    class Elem,
    unsigned long Maxcode = 0x10ffff,
    std::codecvt_mode Mode = (std::codecvt_mode)0
> class codecvt_utf8 : public std::codecvt<Elem, char, std::mbstate_t>;

下面的例子代码演示了如何用 std::locale 结合 std::codecvt_utf8,将 utf16 编码的字符串以 utf-8 编码形式存入文件。

auto loc_with_utf8 = std::locale(std::locale(""), new std::codecvt_utf8<wchar_t>);
std::wofstream of(L"utf8.txt");
of.imbue(loc_with_utf8);
of << L"abc中国";
of.close();

打开 utf8.txt,可以看到文件编码是 UTF-8 编码。

在这里插入图片描述
假如希望存入文件时自动添加 BOM,可以这样构造 Facet:

auto loc_with_utf8 = std::locale(std::locale(""), new std::codecvt_utf8<wchar_t, 0x10FFFF, std::generate_header>);

这样写入文件时会自动添加 BOM,效果展示:
在这里插入图片描述
从 UTF-8 编码的文件中读取字符串的过程,是写文件的逆过程,此时如果希望读入字符串时跳过文件头的 BOM,可以使用 std::consume_header:

auto loc_with_utf8 = std::locale(std::locale(""), new std::codecvt_utf8<wchar_t, 0x10FFFF, std::consume_header>);
std::wifstream infile(L"utf8.txt");
std::wstring wstr;
infile.imbue(loc_with_utf8);
infile >> wstr;

codecvt_mode 还可以组合使用,比如构造一个小端字节序,并且写入文件头 BOM 的转换器,可以这样构造:

new std::codecvt_utf16<wchar_t, 0x10ffff, std::generate_header|std::little_endian>

2.2.2 使用 wstring_convert(C++ 11)

实现字节编码的字符串和宽字符串之间转换,还可以用 std::wstring_convert 和 std::wbuffer_convert。不过,这两个类在 C++ 17 被标记为 “deprecated”,前途未卜,所以,不建议使用,这里只是简单介绍一下。std::wstring_convert 的定义如下:

template<class Codecvt,
         class Elem = wchar_t,
         class Tr = std::char_traits<Elem> >
class wbuffer_convert : public std::basic_streambuf<Elem, Tr>

可以借助 std::wstring_convert 构造一个 UTF-8 与 UTF-18 的转换器:

std::wstring_convert<std::codecvt_utf8_utf16<wchar_t>, wchar_t> converter;

将 UTF-16 编码的字符串转化成 UTF-8 编码的字符串,用 to_bytes() 函数:

std::wstring wstr = L"abc中国";
std::string utf8Str = converter.to_bytes(wstr);

将 UTF-8 编码的字符串转成 UTF-16 编码的字符串,用 from_bytes() 函数:

std::wstring wStr = conv.from_bytes(utf8Str);  

3 使用第三方编码库

3.1 icu4c

ICU 是个老牌的库,功能强大,编码转换只是它的一个子功能。如果只是简单想做个字符串的编码转换,不推荐用 ICU。不过,从 Windows 10 1703 开始,Windows 内部集成了 icu,相关的头文件和库已经包含在 Windows 10 SDK 1703 中。但是,微软的老毛病又犯了,从 1903 版本开始,Windows 10 SDK 将 icu 的两个库 icuuc 和 icuin 合并为一个,头文件也只用包含 icu.h。这使得 icu 库的老用户十分不爽,是忽略 Windows 10 的 ICU 库,继续使用独立的 ICU,还是跟着微软走,以后就用变形的 ICU 库?要好好想想。

3.2 libiconv

libiconv 也是个老牌的编码转换库,不干别的,专业转码。接口也很简单,就是 3 个函数,支持的编码格式和字符集非常全面,推荐使用。关于 libiconv 库的使用,可以参考《使用 libiconv 开源库做字符编码转换》这篇文章。

3.3 boost::codecvt

如果你对 C++ 的“半吊子且始乱终弃”的 codecvt 不感兴趣,可以考虑使用 boost 库的 codecvt Facet。

参考资料:

https://docs.microsoft.com/en-us/windows/win32/intl/international-components-for-unicode–icu-

https://www.boost.org/doc/libs/1_77_0/libs/serialization/doc/codecvt.html

https://en.cppreference.com/w/cpp/locale/locale