文本在内存中的字符集编码(2)--String 的构造--乱码探源(5)

摘要: 深入探讨了 String 的构造, 编码间的转换以及字节流, 字符流等.

目录

在前面我们探讨了 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你好", 并以两种不同编码的保存成两个不同文件:

两种不同编码的保存成两个不同文件

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

16进制形式

那么我们如何把它们读取并转换成内存中的 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 就不成问题了, 下图显示了这一过程:

encoding from hardisk to memory

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

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

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

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

转换的过程

让我们一一来分析下:

UTF-16 BE

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

UTF-16 LE

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

关于字节序跟 BOM 的话题, 可见 字符集与编码(七)--BOM

ASCII 和 ISO_8859_1

这两种都是单字节的编码, 因此只需要前补零补成双字节即可.

如上图中 68, 69 转换成 0068, 0069 那样.

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"的过程:

utf8 to utf16

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

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 编码表放一块, 以字符为纽带, 一一找到那些对应, 如下图:

gbk to utf16

很明显, 由于有众多的字符, 要建立这样一个对应表还是蛮大的工作量的.

自然, 曾经有那么些苦逼的程序员在那里把这些关系一一建立了起来. 不过, 好在这些工作只须做一次就行了. 如果他们藏着掖着, 我们就向他们宣扬"开源"精神, 等他们拿出来共享后, 我们再发挥"拿来主义"精神.

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

当然, 我们只需要扔给 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" 读上来, 再读"你", 再读"好", 如此这般, 至于编码怎么分隔呀, 转换呀, 我们都不关心.

ReaderWriter 是字符流的最基本抽象, 分别用于读跟写, 我们先看 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 从哪里来的话题, 就讲到这里, 下一篇再继续探讨它 到哪里去的问题.