读取文本文件 – 没有你想象的那么容易

DinS          Written on 2017/4/24

如果看完介绍《字符集简史》,你感觉自己已经明白了字符集问题,那我只能说:
这个问题比你想象的要复杂。

让我们尝试一下读取文本文件,最最常见的一个操作。

同样的txt,内容看上去一样,但编码可以不同,读出来的内容就不同。文本文件的编码方式,即用何种字符集表示本身就是一个问题。如果处理不好,读出来很可能是乱码。
为了具体说明,下面使用notepad++演示。

notepad++里有一个编码选项。默认是UTF-8不带BOM,这里也能够看到ANSI的身影,即windows默认编码方式。

现在需要解释一下ANSI是什么。首先肯定不是unicode,但是并不能简单地理解为多字符集,比如你可以把不同语言存到一个txt中,并用ANSI编码。
首先选中Encode in ANSI,然后输入内容,保存。之后把相同内容用UTF-8再保存一遍。用记事本打开的话ANSI和UTF8都能够正确显示。

用notepad++打开的话,只有UTF-8能够正确显示。

区别在哪里?
在解释之前,我们进入控制面板的区域设置,把当前系统的中文设置为俄文,重启,再看看这两个文本文件的内容。

文件没有动过,也就是说其二进制是一样的,但是我们看到的内容却有区别

以ANSI编码方式存储的txt变成了乱码,以UTF-8方式存储的txt依然正常,这正是unicode要达到的目标。

有了这两个例子,就可以解释ANSI了。ANSI是一种可变的编码方式,其具体的方式跟操作系统的区域设置有关。比如在大陆地区都是简体中文操作系统,那么ANSI就是GB2312,如果在台湾,ANSI就是BIG5,如果是俄罗斯,那么就是另一种编码方式。

之前以ANSI方式存储的实际是GB2312编码方式,将区域改成俄语后,同样的ANSI编码方式变化了,自然看到的内容就是乱码。而UTF-8因为属于unicode,就不受区域设置的影响。

如此说来,notepad++的表现倒是正常的,多种语言混合在一起用多字节存储肯定不行。但是如果将ANSI简单等同于多字节,正如上例所示,不同国家的语言可以同时存在于一个txt中,用记事本可以正常打开。这其中肯定发生了一些操作。有可能是操作系统替我们做了一些工作,或者是记事本本身执行了一些操作。

饶了这么一大圈,我实际要说的是,所有的记事本的默认编码方式都是ANSI,也就是说我们从程序中读取的源文件txt,都是以ANSI方式编码的。如果改成unicode会发生什么情况?宽、窄字符方式读取会有什么区别?
接下来让我们做几个试验看看。

写代码分别一行一行读入两个文本文件,一种读取方式是窄字符(char / string),另一种方式是宽字符(wchar_t / wstring)。结果如下,左边是ANSI方式,右边是Unicode方式。

跟你想的应该有出入,依次来分析。
首先,不管是用窄字符还是宽字符,读取ANSI编码的txt都是成功了,只不过一个读出来是窄字符,另一个是宽字符。这个应该符合预期,至少可以理解
其次,不管是用窄字符还是宽字符,读取UTF8编码的txt都失败了。
再次,仔细研究宽字符的读取,好像还不是完全失败,记得文本中第二行是中文,貌似读取出了一些内容。

进一步研究,如果我们此时打开nodepad++,把utf-8编码的文件改成ANSI,会看到这样

神奇的事情发生了,这里的乱码跟调试的乱码一样!

如何解释呢?从目前的现象可以看出,二进制确实是读进去了,只不过解释的方式出了问题。
下面开始深入分析。首先需要了解string和wstring到底是什么。

所谓string和wstring,本质是模板,区别就是string里面装的是char,wstring里装的是wchar_t。这个wchar_t是C++标准库里规定的,然而遗憾的是标准库只规定了接口,并没有说明wchar_t具体如何实现,这样造成了一个恶果:在windows下wchar_t占两个byte,即16比特位;在Linux下是32位。也就是说同样的代码,平台不同结果是不同的。(当然好消息是在c++最新标准下有了挽救措施,之后再讲)

现在研究研究UTF-8。这个编码方式虽然是unicode,但是却是可变长的。
对于中文,一般而言占3个byte。具体而言,“你”在utf-8的编码方式下对应的是E4BDA0(十六进制)。

读到这里应该就发现问题了。
我们存储的txt是以utf-8方式编码的,也就是说中文占3byte。
然而我们在程序中使用的是wstring,这个在windows下一个字符只有2byte,
也就是说我们一次读入了“你”的前两个byte,即E4BD。
这个E4BD是什么呢?还记得刚才的乱码吗?第一个字是什么?
写一个小程序测试一下

E4BD!!!
至此问题就清楚了。读入的数据没有问题,但是因为wchar_t的原因,只读入了一个字符的部分数据,对于这个数据操作系统不知道如何解释,于是就按照多字节字符集的编码方式解释了,这就造成了我们看到的乱码。

如何解决这个问题?
既然问题出现在wchar_t上,解决这个问题就好了。
为了便于演示,这里仅仅将“你好,世界”单存成一个utf-8编码的txt文件

1.利用操作系统提供的API

注意这里接收utf8字符串的是string。

这段代码核心就是MultiByteToWideChar函数。在szChanged那里打个断点看看运行情况

在执行转换函数之前strRaw里的字符被视为无效

然后神奇的事情发生了
虽然strRaw里无效,但是szChanged却正确地读出了字符串,并转换为了宽字符

原理是什么?奥妙就在于code page那个参数。具体的内容可以去MSDN查看。MultiByteToWideChar还有一个相对应的是WideCharToMultiByte。
依靠这两个函数可以实现不同编码格式的互换,非常方便。Windows系统下换字符集主要靠这两个。

2.自己写转换函数

上述方法虽然方便,但是借助了windows API。实际上如果我们掌握了UTF8的编码方式,可以直接操作二进制来实现转换。操作二进制是程序员的必备技能。

首先需要说明UTF8的编码方式。因为其是变长的,必然有一个标志来标明字符长度,否则就没法处理了。

来源:http://www.cnblogs.com/chinxi/p/6129774.html

利用这个规律,我们考察一下“你”的UTF8编码是否符合

1字节    E4    11100100

2字节    BD    10111101

3字节    A0    10100000

至少高位比特是符合规律的。排除了标志比特位,剩下的就是:

01001111    01100000

这是两个字节,那么根据约定这就是“你”的unicode编码。是这样吗?

之前我们输出过“你”在unicode的十六进制,是4F60

1字节    4F    01001111

2字节    60    01100000

完全一致!
这样我们就可以开始着手处理了

直接操作比特位总是有些不令人愉快的,注释里给出了操作后的情况。这么长的代码实际上做的就是把有效的unicode比特位拼接起来

结果:

大费周折,不过最终确实达到了效果

3.利用C++11里提供的有力武器

实际上C++11提供了一系列操作宽字符的利器。可以去cplusplus reference上查看<codecvt>说明。

在这里我们的目的是把UTF-8字符串转换为UTF-16字符串
有一个现成的wstring_convert模板来做这个事。
直接上代码。

看起来有些复杂,但是实际上没什么难懂的。注意这里的char16_t类型。这是C++新标准对wchar_t的挽救。
char16_t强制规定了实现为16比特,还有char32_t,实现为32比特。
这样就无所谓windows还是linux了

另外还有一个u16string,很有喜感,看名字就知道是为utf16准备的
不出意外,还有一个u32string。在C++17标准下还增加了u8string。

实际转换的工作调用模板提供的方法就可以,简单明了。运行结果:

多少有些意外,我们本来预期的是u16string可以直接显示字符,实际上u16string是一个按比特操作的类型,无法直接看字符。不过还是能够看出其内容是正确的,
“你”的unicode编码的十六进制是4F60,换成十进制就是20320

为了直接看到字符,可以通过尴尬的wchar_t。

看到这里你应该对字符集有了整体的把握,但是还存在一个巨大的问题:编程中应该怎样处理字符集才最好呢?请看《字符集 – best practice》。