Java 的 IO 系统是比较庞杂的,各种流特别多,其中有一种就是字符流。
在本系列前面的一些文章中,也曾涉及过字符流的话题,不过没有详细展开讨论,这次准备具体综合地谈一谈。
你可能听过不少关于字节流与字符流对比的介绍,不过严格地说,我认为把“字节流”和“字符流”去对比这种说法不是特别妥当,为什么呢?
首先,这两种流实际上处在不同的层次,字节流是基础,而字符流是构建在其上的:
对于不同层次上的事物,我认为用“对比”这个词是不太恰当的。
打个不太恰当的比方,比如说你跟隔壁老王同龄,然后你“拿你的儿子跟隔壁老王对比”,那隔壁老王可能就会质问你:“你什么意思?拿你儿子跟我比?你咋不拿你爹跟我比呢?”
你可以“拿自己跟老王对比”,也可以“拿自己儿子跟老王儿子对比”,但你不能”拿你的儿子跟隔壁老王对比“,这就不恰当了,对吧?
要是一对比,发现儿子长得还挺像隔壁老王,那就尴尬了……
那么这里是想说明一个道理,就是不是一个辈分的,不应该去对比。
再打一个比方,就是好比你把“中国”去跟“美国的加利福尼亚州”去对比,通常意义都不大,我们一般都不会这样去做。但在某些特殊情况下,比如想说明加州的经济特别发达,可以拿加州的 GDP 跟整个中国的 GDP 对比,但这种对比只是为了突出一方而不是为了对比双方。
那么同理,处在不同层次的字节流与字符流,它们也不适合去“对比”。但是,可以研究“字节流”和“字符流”之间的关系,这是没有问题的。那么它们之间到底什么关系呢?其实前面那个图也揭示地很清楚了:
字节流是基础,字符流是其上的抽象与封装。
狭隘地讲,比如仅仅从读取或写入文件方面去探讨的话,那么“字符流”是为方便我们读取或写入“文本文件”而引入的抽象。也就是说:
字符流只对应文本文件;
而字节流则对应所有文件,自然也包括文本文件。
文本文件是开发活动中会大量接触到的一类文件。所有语言的源代码文件,像什么 .java,.js 这些都是;还有很多比如 html,xml,css 之类的以及很多的配置文件也是文本文件。
简单讲,就是你可以用“记事本”打开查看的那类文件。
假如你一个文本文件要读取,自然,最好的选择是使用字符流。当然,你也可以选择用字节流来读取。(在后面,会给出一些具体的例子和代码)
如果只要一下子就把整个文件读取上来,那么用字符流跟字节流的差别并不大。
但是,面临某些具体的需求时,比方说,想一个一个“字符”的读取上来,或者想一行一行地读取时,用字符流就会很方便,而用字节流就会非常的麻烦。
另一方面,如果你要读取的并不是一个文本文件,那就不能用字符流了。比如说,读取一个图片文件,或者是一个压缩包,又或者是一个 word 文件或 pdf 文件。这些都不是“文本文件”,因此你不可以用字符流去读取它们。
简单讲,就是你不可以用“记事本”打开去查看它们的那类文件。当然,如果你硬是要用记事本打开也不是不可以,毕竟在最底层,大家都不过是一堆 0 和 1 而已,但这种强行打开通常只会呈现为一堆乱七八糟的东西。
非文本文件不可以用字符流去读取,但它们都可以用字节流去读取,因为本质上来讲,任何的文件都不过是字节的序列而已。
我看到有些介绍字节流与字符流的文章没有特别去强调这一点,给人一种感觉,就好像这两种流都能做任何事情,这显然是错误的。
其实从名字上也不难想到,既然叫字符流,那肯定跟字符有关了。而对于图片来说,构成元素是一个个“像素”,而不是什么字符,所以自然不能用字符流去读取图片。初学者或许能意识到不能用字符流去读图片,但未必能清楚明白,其实还有很多的文件都不能用字符流去读取的。
所以,前面强调两者不能对比,强调两者处在不同层次,在能力上,两者也是有区别的,而所有的字符流最终它的底层其实还是字节流,字符流只是一种抽象。(甚至还不算是一个好的抽象,我们将在后面说明为什么)
对于构建一个字符流而言,其实有一个很重要的参数,却经常被大家所忽略,就是字符集编码。
为什么你可以忽略它呢?因为有缺省的存在。就好像你用记事本保存一个文本文件时,系统通常只提示你输入文件名,而不会提示你选择一个字符集编码,但这并不是说编码是可有可无的,当你没有选择编码时,其实是系统悄悄地给你分配了一个缺省编码。
某些时候,这带来了方便,但也常常给我们带来很多问题。
其实在前面的图中,我把字符流写成“gbk 字符流”,“utf-8 字符流”也是为了凸显这个编码的存在,它不应该被忽略。
前面说,字符流构建在字节流基础之上,其实更加确切的说法应该是:字符流构建在“字节流+字符集编码”基础之上,像这样:
最后,字符流中所谓的“字符”其实跟我们认知还是有区别的,前面也说了,这里的字符其实并不是一个很好的抽象。
这里的“字符”严格说,只是 BMP 内的字符,对于 BMP 外的,情况就不同了。
使用字节流读取文本文件
上篇中说到,无论是字符流还是字节流,都可以用于读取文本文件,特别是对于一整个文件的读取,两者的差别并不大。来看一个具体的示例,假如有如下 gbk 编码的 txt 文件一枚,具体内容为“hi你好”,对应二进制如下:
那么可以这样去读取:
也就是先原封不动地把对于字节拷贝到内存中,再通过 new String 即可构建出相应的字符串。
注意 new String 时传入的编码是 gbk。
使用字符流读取文本文件
对于同样的这个文件,现在采用字符流的方式。那么首先是构建相应编码(也即是 gbk)的字符流:
不过 InputStreamReader 本身的 API 依然不是很好用,这里用它主要是为了显式传入编码的参数,之后可以进一步用 BufferedReader 包装,以使用其 readLine 方法,完整结果如下:
字节流 vs 字符流
如果一下子就把整个文件读取上来,那么通过以上两个示例,可以看出使用字节流和字符流的差别并不大。但假如现在有一个多行的文本文件,然后打算一行一行地读取,读一行就打印一行,那么使用字符流就很方便,比如 BufferedReader 它有一个 readLine 方法,让你可以一下子读取一行。
但如果使用字节流呢?那就麻烦很多了。你要自己去读取并判断换行符的位置,然后自己去断行。
糟糕的是,不同系统下生成的换行符还可能不同。比如 Windows 是 \r\n(0a 0d),其它的一般为 \n(0d)。
另一方面,假如想一个一个字符的读取,字符流也有很好的支持,比如像这样:
使用 Reader 下面的 read 方法,每 read 一下就读取上来一个字符。
但使用字节流就很麻烦了,因为很多编码它不是定长的,就比如前面的这个例子,前面两个字符 hi 各使用一个字节保存,而后面的两个汉字而各使用两个字节保存。
所以要读取这四个字符,前面两个你每读一个字节就要构建一个字符,而后面两个你一下读取两个字节后才能构建一个字符。
那怎么区分什么时候要读取一个字节,什么时候又要读取两个字节呢?那么你就得理解 gbk 的编码方式。
gbk 编码它是兼容 ascii 的,是变长编码,ascii 字符都是一字节,二进制形式最高位都是 0;而汉字字符用两字节,首字节高位是 1,第二个字节高位通常也是 1.(但也有为 0 的)
这就是 gbk 编码的模式,你得理解这些编码的细节才能按字节去读取它们。
假如要读取的是 utf-8 的文本文件呢?它也是变长编码,有的字符是一字节,有的是两字节,有的是三字节,有的是四字节。
假如现在有一个 utf-8 的字节流要你去读取,那么你就得很清楚什么时候要读一字节,什么时候又要读三字节。你要非常清楚 utf-8 编码的规律,各种不同字节间如何去区分。
假如现在你有一个 utf-8 编码的 txt 文件,内容同样是”hi你好“,那么它的十六进制形式是这样的:
它也是兼容 ascii 的,ascii 也是一字节;但对于常用汉字,都是三字节编码。具体的二进制则是这样:
你要按字节读取它,就要熟知一字节是什么模式(如上,以 0 开头),三字节又是什么模式(如上,首字节以 1110 开头,后接两字节以 10 开头)。
所以你看,如果采用字节流的方式,是不是特别麻烦?你要读取一个字节上来,然后判断它是不是 0 开头,是不是 110 开头,是不是 1110 开头等等,然后再进一步决定下来还要读取多少字节才能构成一个字符,特别的麻烦。
而字符流呢?我们说了,它是针对文本文件对字节流的一个抽象与封装,为方便我们读取文本文件而设计的。
有了它,这些个麻烦事都不用我们去操心了,你只要在构建字符流时给它一个字节流并告诉它这个字节流所使用的编码就行了,它就知道怎么去断这些字节,这些编码的知识都封装到它里面去了,别人已经实现了,不用我们去管。
所以,读取文本文件,特别是逐个字符的读取,应该采用字符流。
不同编码的字符流
通过以上的一些示例,相信对于前面所说的“字符流=字节流+编码”你应该有了更好的理解。这里所谓的编码就是指相应文件保存时所使用的编码。
假如你读取的文件是 utf-8 编码的,那么构建字符流时的编码参数就要传“utf-8”:
Reader 是读取,而对于写入的 Writer,原理也是一样的。如果你想用 gbk 编码保存一段字符串,你就构建一个 gbk 的写入字符流,比如这样:
那么写入到硬盘中的文件的编码就是 gbk,就像你使用记事本另存为时选择编码为 gbk 那样(具体为选择 ANSI)。
你不用去操心什么字符要写入一字节,什么字符又要写入两字节,都不用你管了。你就操作那些抽象的“字符”就可以了,不用管字节的事。
而如果你构建的是 utf-8 的写入字符流,那么同样的,最终写入到硬盘中的文件的编码就是 utf-8,就像你使用记事本另存为时选择编码为 utf-8 那样。
不过要注意,记事本的 utf-8 是带 BOM 的,而这里的是不带 BOM 的,除非你显式加入 BOM。
由于篇幅关系,关于使用缺省编码的例子及对于“到底怎样才算一个字符”的分析留待下篇再谈。
Java 字节流与字符流(3)
在上一篇中比较了使用字节流和字符流来读取(写入)文本文件的优劣后,这一篇主要探讨缺省编码这个主题。
字符流使用缺省编码
通过前面的例子,已经得出了一个结论:字符流=字节流+编码。
可以在构建字符流时显示传入编码参数,那么所得到的字符流就会以该编码来编码(encode)或解码(decode)字节流,这会给文本数据处理带来极大方便。
但有时,构建字符流时也可以不传入编码参数,比如如下直接构建一个 InputStreamReader :
对比注释掉的代码,可以看出此时只用了字节流,没有指定编码。
那么这时它到底是以什么编码来解码(decode)它要读取的字节流呢?比如上面的 utf-8 编码的文本文件它能否正常读取呢?我们想知道,编码参数是必要的吗?如果没有指定,它是否会自动猜测出正确的编码呢?
这里先给出结论,那就是编码参数是必要的,是 decode(或 encode)所必不可少的,之所以可以省略那是因为系统会为我们提供缺省值。
然后,它也不会去自动猜测。(至少这里的 BufferedReader 之类的不会去猜测,如果你使用其它的第三方增强的工具类,那就未可知)没有指定它就用缺省去 decode(或 encode)。
那么,这就可能导致一个问题。比如现在要读取的文件是 utf-8 编码的,而假如系统的缺省编码值是 gbk 的话,显然,它将不能正确地解码!
缺省编码来自哪里?
但是,系统的缺省编码到底是什么呢?这个缺省值可以通过这样来得到:
也就是用 Charset.defaultCharset() 或 System.getProperty(“file.encoding”) 的方式可以得到它。
当我在本地的 Windows 系统执行这段程序时,结果如下:
缺省编码是 gbk。
而当我把这个 class 类上传到云主机时,那是个 linux 系统(具体为 centos 7),再执行时发现结果又不同了:
此时的结果是 ASCII(ANSI_X3.4-1968 是它的一个别名)。不同系统,甚至不同版本这个值可能有很大差异,跟具体的环境配置也有关,在你的 linux 系统里可能输出是 utf-8 等值。
如果你用的是 Mac,你也可以试一试看看结果是什么。(我没有 Mac,所以没有测试这个~)
不过可以通过增加运行参数调整这个值,比如以这样的方式
java -Dfile.encoding=utf-8 DefaultEncoding
来运行,缺省编码就变成 utf-8 了:
在 linux 系统上也是同样的:
而我在 Windows 系统下的 Eclipse 工程里用它的“运行”来做这个测试时,结果是 UTF-8 而不是 GBK,因为工程我设置了缺省编码是 UTF-8,而 Eclipse 运行时会根据你工程设置传入这个参数,具体如下,在 Debug 视图中,选中运行的实例–右键–选择“properties”,在弹出的窗口中的 Command Line(命令行)部分可以看到指定了编码:
如果你工程指定的编码是 GBK 或没有指定,在 Windows 下输出结果就会跟在 CMD 命令行窗口中那样,结果应该为 GBK。
在 Idea IDE 中也是类似的,比如工程编码是 GBK,那么运行出来的结果也是 GBK:
在上图中,勾选“use soft wraps”(使用软换行),
这里因为是这个生成的命令行特别长,主要是那一堆的 classpath,跟 eclipse 类似,所以让其换行显示。
然后单击所运行的命令展开它的细节,可以发现也传入了相关 file.encoding 参数:
这个参数决定了缺省值是什么。如果你没有指定,JVM 将询问它所在的运行环境(一般也就是操作系统了)来得到一个缺省值。
如果你运行 web server 的程序,比如 tomcat,也可以照此查看它的参数,或通过它改变缺省值。通过显式指定一个缺省值,可以在各种运行环境下保持一致。
比如这是我本机 Eclipse 上运行 tomcat 插件时的命令行:
可以看到也是传入了这个参数。不过有一点要注意的是,至少就 server 中的这个缺省值而言,它影响的是上述所说的 new InputStreamReader 以及 getBytes 之类的方法。而在 servlet 的响应流中,如果使用 response.getWriter 得到的 PrintWriter 这个字符流,它的缺省又是另外一个值,具体为 iso-8859-1.
这个缺省值来自于 servlet 的规范,进一步的原因则是来源于 http 规范。关于这一话题将在另一篇文章中再去分析。
所以,总体而言,如果你构建字符流时使用缺省编码,那么情况会比较混乱,也很容易出错。
使用缺省编码去读取
现在来看一些具体的例子。假如要读取的还是之前那个 utf-8 编码的文本文件,内容还是“hi你好”,总计 8 个字节,具体十六进制如下:
写一段读取它的程序如下:
然后在 CMD 中编译并运行,那么结果是这样的:
可以看到出来的并不是想要的“hi你好”,而是很奇怪的“hi浣犲ソ ”。那么其中的原因也不难理解,因为没有指定编码,所以程序就用缺省编码去解码这段字节流,此时是 gbk,所以就把那六字节按每两字节去解析,结果得到三个汉字:
对于我们中国人来说,会意识到出了问题,因为这三字很不常见,但对于歪果仁来说,他可能没有什么感觉,反正就是一些方块字而已,反正他都不认识。
假如现在显式指定 –Dfile.encoding=utf-8,那么结果就 OK 了:
又假如指定为 –Dfile.encoding=iso-8859-1 呢?那么结果就按 iso-8859-1 解析了:
这里它显示为六个问号,其实正常来说,应该是有对应字符的。如果在 eclipse 本身执行,console 中的输出是这样的:
所以显示为问号可能是 CMD 程序本身的一些问题。
毕竟,前面也一再提到,文本文件内容本身是不包含有编码信息的(BOM 的情况除外),就这么 8 个字节,你有很多的解析可能,按 gbk,按 utf-8,按 iso-8859-1,都能解析出一些字符来。
总结
由此也不难明白,缺省绝对不是什么好主意。假如你不知道别人启动 web server 时传入了什么参数,或者没传参数时你不知道程序到底运行在什么操作系统上,你就完全无法预料你的程序会运行出什么结果。
通常,我们把这种情况称为对环境形成了依赖,是种不稳定因素。程序员在开发活动中常遇到的一种情况是:“在我这里运行明明是正常的,怎么放到其它地方去就不正常了呢?”就有可能是这种环境差异导致的。
假如你想把这段字节流稳定地解析成“hi你好”,那么最好的方式就是在你的代码里显式构建 utf-8 的字符流,而不是依赖缺省。
当然,有人可能会说,每次都要显式指定,我觉得太麻烦了,我还是希望用缺省,那么这种情况你就要保证所有环节都使用统一的编码,比如所有你要读入的文件都是用这一编码编码的等等,然后用 –Dfile.encoding 参数指定这一编码。假如你无法保证在所有环节统一,恐怕你就不能如此依赖缺省了。
关于字符流使用缺省编码的话题就讨论到这里。在最后一篇,将讨论最后的一个话题:到底怎样才算是一个‘字符’?