文本在内存中的编码(3)——乱码探源(6)

摘要:探讨了 String 到 byte[] 的转换,并结合之前的 new String 作了综合分析。

目录
[隐藏]

先讲个小故事,虽然跟主题有点不太相关哈:

唐朝诗人李绅,身为官员,脾气暴躁,瞧不起信教的,尤其鄙视装逼之僧人,动不动就对他们拳脚相加。曾扬言:“我可以接见他们,要能答出来还好,要是答不出来,我弄死他!”有一回一个和尚来跟他宣传因果报应,李绅问:“阿师从哪里来,到哪里去呢?”僧答:“贫僧从来处来,到去处去。”李绅当时就急了,撸起袖子,亮出了手腕:“我去年买了个表!”

来自知乎问答“古人是如何「装逼」的?”,略有改动。

String 到哪里去?

有了前面僧人的教训,在这里就不故弄玄虚了,应该说 String 的去处还是蛮确定的,那就是到 byte[] 中去,方式就是通过 getBytes 这一方法。

new String 与 getBytes

如果说 new String(byte[], encoding) 是从 byte[] 到 String 的过程,那么 getBytes(encoding) 则正好与之相反:它是从 String 到 byte[] 的过程。

string and getbytes conversion

或许我们应该说,它从去处来,又到来处去。

编码的逆转

显然,我们一直在说,String 也不过是一堆 byte,getBytes 的过程不过是 UTF-16 编码的 byte[] 再转回去其它编码的 byte[] 的过程。无论是 new String 还是 getBytes,不过都是在玩编码的转换而已。
string to bytes and bytes to string

从上图中可以看出 String 作为桥梁,可以把一种编码的字节转换成另一种编码的字节。比如把一串的 UTF-8 编码的字节转换成 GBK 编码的字节的。

string as bridge

String 作为转换中的一方,它的编码始终是确定的,也即是 UTF-16;而 encoding 参数始终指的是 byte[] 的编码:

或者用于指明源字节的编码(new String 时),

或者用于表明希望转成何种编码的目标字节(getBytes 时)。

具体转换过程

以 GBK 为例,既然前面说,GBK 转 UTF-16 可以通过查表实现:

gbk to utf16

那么 UTF-16 转 GBK,我们只需要反查那张表即可。当然,考虑到效率的问题,我们可能需要另一张按 UTF-16 编码排序的表:

utf16 to gbk

当然了,这些都不需要我们去操心的。

至于 UTF-16 转成 UTF-8,依旧可以通过码点这一桥梁来进行。

这里就不再演示了,与前面码点转 UTF-8 非常类似:

image

可参见字符集与编码(四)——Unicode

剩下的如转 ISO_8859_1 以及 ASCII 之类的,那就更简单了。如果一段 String 表示的是 ISO_8859_1 或者 ASCII 中的字符,显然里面每个 char 的高位都是 00,因此只要把这些没用的 00 掐掉就行了。

getbytes ascii and iso88591

如果调用 getBytes(“UTF-16”) 呢?那就不存在转换了,相当于复制了一遍,不过要注意它会带上 BOM,除非明确指明了端序。

getbytes utf16 and utf16le

如果调用 getBytes(“UTF-16BE”),那么内存中就会出现两组一模一样的字节了。

getbytes utf16be

但是这两者还是有本质的区别的,原因就在于指向这两者的引用所代表的类型的不同。

类型赋予了一串 byte 丰富的内涵,决定了我们怎么去解释它。

String 是一种有趣得多的类型,它有明确的编码,还有丰富多样的方法与之绑定。

而另一方面,byte[] 则要原始单调乏味得多。严格地说,byte[] 只是一堆字节而已,就编码这个问题而言,它本身没有与任何编码绑定。

当然,字节间的特征也许能让你断言这不是某种编码,但你却不能肯定地说,一串字节一定是某种编码。

多种解释

我们来看个具体例子,还是拿前面说到的那串 GBK 编码的 byte 来说吧:68 69 c4 e3 ba c3。

之所以说它是 GBK,那是因为我们用 GBK 编码保存文件得到的它。但如果内存中有一段与之一模一样的 byte[],难道你能说它一定是 GBK 编码吗?

首先可以确定它不可能是 ASCII,因为有些字节最高位是1,那么它有可能是 ISO_8859_1 吗?这是有可能的。而对它的不同解释会因此在内存中生成不一样的 String:

bytes to string using diff encoding

具体的代码测试也可以反映这一点:

    @Test
    public void testReadGBKBytesAsISO_8859_1() throws Exception {
        File gbk_demo = FileUtils.toFile(getClass().getResource("/encoding/gbk_demo.txt"));
        // 当成ISO_8859_1来读取
        String content = FileUtils.readFileToString(gbk_demo, "ISO_8859_1");
        assertThat(content).isEqualTo("hiÄãºÃ");
        assertThat(content.length()).isEqualTo(6);
    }

有的时候,你没有办法拿到最底层的 byte[],你直接收到的就是一个 String,而这个 String 是通过错误的编码构建的,如下所示:

recover from wrong encoding by trick

第一步不受我们控制,这时有一种 hack 的方式,也即是通过上面的第二步再度拿回原始的 byte[],再通过第三步传入正确的编码再度构建出 String。

代码演示如下:

    @Test
    public void testISO_8859_1Hack() throws Exception {
        File gbk_demo = FileUtils.toFile(getClass().getResource("/encoding/gbk_demo.txt"));
        // 当成ISO_8859_1来读取
        String content = FileUtils.readFileToString(gbk_demo, "ISO_8859_1");
        assertThat(content).isEqualTo("hiÄãºÃ");
        assertThat(content.length()).isEqualTo(6);
        
        // 再次拿到原始byte[],并以新的编码重新构建String
        content = new String(content.getBytes("ISO_8859_1"), "GBK");
        assertThat(content).isEqualTo("hi你好");
        assertThat(content.length()).isEqualTo(4);
    }

当然,我们并不鼓励这样繁琐地转来转去,正确的姿势应该是这样的:

recover from wrong encoding by config

通过修改配置,就能一步到位得到正确的 String,不需要曲线救国。

有人可能说,我并没配置过 iso_8859_1 呀,那么这可能是某种缺省编码。

总之,如果你收到被疑似 iso_8859_1 错误编码的 String,那肯定是某个环节使用了这一编码。

当然,如果你工作在一个遗留系统上,还是要非常慎重地去改变这些缺省的编码配置,因为可能严重冲击到那些依赖于这些缺省设置的代码。

比如上述 hack 方式可能就失效了,而你可能不知道系统到底有多少地方使用了这种 hack。

关于 String 到哪里去的问题,就探讨到这里。

发表评论

电子邮件地址不会被公开。 必填项已用*标注