转载

深入理解文本在内存中的编码(2)——乱码探源(5)

在前面我们探讨了String是什么的问题,现在来看String从哪来的问题。

String从哪里来?

所谓从哪里来也可以看作是String的构造问题,因此我们会从String的构造函数说起。

String的构造函数

在前面我们知道String的内部就是char[],因此它可以根据一组char[]来构建,String中有这样的构造函数:

public String(char value[]) {}

那么char[]又从何而来呢?char的底层是byte,String从根本上讲还是字节序列,而一个文本文件从根本上讲它也是字节序列,那是不是直接把一个文本文件按字节读取上来就成了一个String呢?

答案是否定的。因为我们知道String不但是byte[],而且它是一个有特定编码的byte[],具体为UTF-16。

而一个文本文件的字节序列有它自己特定的编码,当然它也可能是UTF-16,但更可能是如UTF-8或者是GBK之类的,所以通常要涉及编码间的一个转换过程。我们来看下通过字节序列来构造String的几种方式:

public String(byte bytes[]) {}

public String(byte bytes[], String charsetName) throws UnsupportedEncodingException {}      

public String(byte bytes[], Charset charset) {}

第一个只有byte[]参数的构造函数实质上会使用缺省编码;而剩余的两种方式没有本质的区别。

后两种方式的差别在于第二个参数是用更加安全的Charset类型还是没那么安全的String类型来指明编码。

实质上可以概括为一种构造方式:也即是通过一个byte[]和一个编码来构造一个String。(没有指定则使用缺省)

由于历史的原因,这里沿用了charset这种叫法,更加准确的说法是encoding。可参见之前的 深入图解字符集与字符集编码(一)——charset vs encoding  

具体示例

录入以下内容“ hi你好 ”,并以两种不同编码的保存成两个不同文件:

深入理解文本在内存中的编码(2)——乱码探源(5)

那么,两种字节序列是有些不同的,当然,两个英文字母是相同的。

深入理解文本在内存中的编码(2)——乱码探源(5)

那么我们如何把它们读取并转换成内存中的String呢?当然我们可以用一些工具类,比如apache common中的一些:

@Test public void testReadGBK() throws Exception {  File gbk_demo = FileUtils.toFile(getClass().getResource("/encoding/gbk_demo.txt"));  String content = FileUtils.readFileToString(gbk_demo, "GBK");  assertThat(content).isEqualTo("hi你好");  assertThat(content.length()).isEqualTo(4); } @Test public void testReadUTF8() throws Exception {  File utf8_demo = FileUtils.toFile(getClass().getResource("/encoding/utf8_demo.txt"));  String content = FileUtils.readFileToString(utf8_demo, "UTF-8");  assertThat(content).isEqualTo("hi你好");  assertThat(content.length()).isEqualTo(4); } 

在这里,file作为byte[],加上我们指定的编码参数,这一参数必须与保存文件时所用的参数一致,那么构造String就不成问题了,下图显示了这一过程:

深入理解文本在内存中的编码(2)——乱码探源(5)

以上四个字节序列都是对四个抽象字符“hi你好”的编码,转换成string后,特定编码统一成了UTF-16的编码。

现在,如果我们要进行比较呀,拼接呀,都方便了。如果只是把两个文件作为原始的byte[]直接读取上来,那么我们甚至连一些很简单的问题,比如“在抽象的字符层面,这两个文件的内容是不是相同的”,都没办法去回答。

从这个角度来看,string不过就是统一了编码的byte[]。

而另一方面,我们看到,构造string的这一过程也就是不同编码的byte[]转换到统一编码的byte[]的过程,我们自然要问:它具体是怎么转换的呢?

转换的过程

让我们一一来分析下:

1. UTF-16 BE :假如文本文件本身的编码就是UTF-16 BE,那么自然不需要任何的转换了,逐字节拷贝到内存中就是了。

2. UTF-16 LE :LE跟BE的差别在于字节序,因此只要每两个字节交换一下位置即可。

关于字节序跟BOM的话题,可见 深入图解字符集与字符集编码(七)——BOM

3. ASCII和ISO_8859_1 :这两种都是单字节的编码,因此只需要前补零补成双字节即可。如上图中68,69转换成0068,0069那样。

4. UTF-8 :这是变长编码,首先按照UTF-8的规则分隔编码,如把8个字节“68 69 e4 bd a0 e5 a5 bd”分成“1|1|3|3”四个部分:

68 | 69 | e4 bd a0 | e5 a5 bd

然后,编码可转换成码点,然后就可以转换成UTF-16的编码了。

关于码点及Unicode的话题,可见 深入图解字符集与字符集编码(四)——Unicode

我们来看一个具体的转换,比如字符“你”从“e4 bd a0”转换到“4f 60”的过程:

深入理解文本在内存中的编码(2)——乱码探源(5)

真正的转换代码,未必真要转换到码点,可能就是一些移位操作,移完了就算转换好了。如果涉及增补字符,这个过程还会更加复杂

5. GBK :GBK也是变长编码,首先还是按照GBK的编码规则将字节分隔,把“68 69 c4 e3 ba c3”分成“1|1|2|2”四个部分。

68 | 69 | c4 e3 | ba c3

之后,比如对于字符“好”来说,编码是如何从GBK的“ba c3”变成UTF-16的“59 7d”呢?

这一下,我们没法简单地用有限的一条或几条规则就能完成转换了,因为不像Unicode的几种编码之间有码点这一桥梁。

这时候只能依靠查表这种原始的方式。首先需要建立一个对应表,要把一个GBK编码表和一个UTF-16编码表放一块,以字符为纽带,一一找到那些对应,如下图:

深入理解文本在内存中的编码(2)——乱码探源(5)

很明显,由于有众多的字符,要建立这样一个对应表还是蛮大的工作量的。自然,曾经有那么些苦逼的程序员在那里把这些关系一一建立了起来。不过,好在这些工作只须做一次就行了。如果他们藏着掖着,我们就向他们宣扬“开源”精神,等他们拿出来共享后,我们再发挥“拿来主义”精神。

那么,有了上图中最右边的表之后,转换就能进行了。

当然,我们只需要扔给String的构造函数一个byte[],以及告诉它这是”GBK“编码即可,怎么去查不用我们操心,那是JVM的事,当然它可能也没有这样的表,它也许只是转手又委托给操作系统去转换。

不支持的情况

如果我们看前面String构造函数的声明,有一个会抛出UnsupportedEncodingException(不支持的编码)的异常。如果一个比较小众的编码,JVM没有转换表,操作系统也没有转换表,String的构建过程就没法进行下去了,这时只好抛个异常,罢工了。

当然了,很多时候抛了异常也许只是粗心把编码写错了而已。

至此,我们基本明白了String从哪里来的问题,它从其它编码的byte[]转换而来,它自身不过也是某种编码的byte[]而已。

字节流与字符流

如果你此时认为前面的FileUtils.readFileToString(gbk_demo, "GBK")就是读取到一堆的byte[],然后调用构造函数String(byte[], encoding)来生成String,不过,实际过程并不是这样的,通常的方式是使用所谓的“字符流”。

那么什么是字符流呢?它是为专门方便我们读取(以及写入)文本文件而建立的一种抽象。

文件始终是字节流,这一点对于文本文件自然也是成立的,你始终可以按照字节流并结合编码的方式去处理文本文件。不过,另外一种更方便处理文本文件的方式是把它们看成是某种”抽象的“字符流。

设想一个很大的文本文件,我们通常不会说一下就把它全部读取上来并指定对应编码来构建出一个String,更可能的需求是要一个一个字符的读取。

比如对于前述的”hi你好“四个字符,我们希望说,把”h“读取上来,再把”i“读上来,再读”你“,再读”好“,如此这般,至于编码怎么分隔呀,转换呀,我们都不关心。

Reader跟Writer是字符流的最基本抽象,分别用于读跟写,我们先看Reader。可以用以下方式来尝试依次读取字符:

@Test public void testReader() {     File gbk_demo = FileUtils.toFile(getClass().getResource("/encoding/gbk_demo.txt"));     Reader reader = null;     try {  InputStream is = new FileInputStream(gbk_demo);  reader = new InputStreamReader(is, "GBK");  char c = (char) reader.read();  assertThat(c).isEqualTo('h');  c = (char) reader.read();  assertThat(c).isEqualTo('i');  c = (char) reader.read();  assertThat(c).isEqualTo('你');  c = (char) reader.read();  assertThat(c).isEqualTo('好');     } catch (FileNotFoundException e) {  e.printStackTrace();     } catch (IOException e) {  e.printStackTrace();     } finally {  IOUtils.closeQuietly(reader);     } } 

这里,read()方法返回是一个int,把它转换成char即可,另一种方式是read(char cbuf[]),直接把字符读取到一个char[],之后,如果有必要,可以用这个char[]去构建String,因为我们知道String其实就是一个char[].

 

很显然,一个Reader不但要构建在一个字节流(InputStream)基础上,而且它与具体的编码也是息息相关的。

编码用于指导分隔底层字节数组,然后再转成UTF-16编码的字符。

那么这其实与String的构造没有本质的区别,事实上也是如此,一个字符流实质所做的工作依旧是把一种编码的byte[]转换成UTF-16编码的byte[]。

而那些需要两个char才能表示的增补字符,如前面提到的音乐符,事实上你要read两次。所以字符流这种抽象还是要打个折扣的,准确地讲是char流,而非真正的抽象的字符。

关于String从哪里来的话题,就讲到这里,下一篇再继续探讨它到哪里去的问题。

正文到此结束
Loading...