关于 Java IO(二):从面向字节到面向字符

96
Happioo
2018.04.09 19:16 字数 3197

在上一篇文章中,我们以面向字节的输入为例,介绍了 Java 中 IO 的结构。在这篇文章中,主要介绍面向字节的输入输出是怎么转换到面向字符的输入输出的。

面向字符的输入输出指的是输入输出的单位是字符。根据字符编码方案的不同,一个字符可能会对应多个字节,如: ASCII 码中一个字符对应一个字节,而采用 Unicode 字符集以 UTF-8 为编码转换方案时,一个字符可能对应了一到三个字节。

所以,面向字符的输入输出从本质上还是面向字节的,只是输入输出的单位不再是单个字节了。读一个字符,可能要读取多个字节。

然而,事情并没有那么简单,这中间还存在着编码转换问题。在讨论这个问题之前,我们先了解一下 Java 使用的字符编码方案。

一、Java 中的字符编码方案

Java 使用的是 Unicode 字符集,并以 UTF-16 作为编码转换方案,将 Unicode 中的码位转换为实际存储的二进制序列。也就是说,Java 中表示字符的哪些类型,像 String,char array 中表达的字符都是以 UTF-16 的方式存储的。你可能会问,char 呢,String 和 char array 本质上不都是 char 的数组吗?

其实,UTF-16 是一种可变长的编码转换方案。对于 Unicode 中码位 (code point),在 U+0000-U+FFFF 的字符,采用 2 个字节来编码,而码位在这之上的字符采用 4 个字节来编码。由于常用字符的码位都在 U+0000-U+FFFF 范围内,与定长的 4 字节编码方案相比,几乎可以节省一半的空间。

而我们的 char 有且只有两个字节长。一个 char 只能表达出 UTF-16 中 2 字节编码的部分,而那些需要用 4 字节编码的字符,其实是用 2 个 char 以代理对的形式来表达的。下面的代码是一个用 2 个 char 来表达一个字符的例子。

public static void main(String[] args) {
    String s = "𠊷";
            
    System.out.printf("The code point of the character: %02x \n", s.codePointAt(0));
        
    System.out.printf("The length of string \"%s\": %d\n", s, s.length());
        
    char[] chars = s.toCharArray();
    System.out.printf("The content of string \"%s\":", s);
    for(char c : chars) {
        System.out.printf("%02x ", (int)c);
    }
}

输出:
The code point of the character: 202b7 
The length of string "𠊷": 2
The content of string "𠊷":d840 deb7 

其中,codePointAt(int index) 方法可以得到 String 中对应字符的码位,注意这个方法的形参是字符的索引,而不是 char 数组的索引。可以看到“𠊷”在 Unicode 中的码位为 U+202B7,这在 UTF-16 中是需要用 4 个字节来编码的。而的确,虽然这个字符串只含 1 个字符,但它的长度是 2,其中包含了两个 char。在输出的第三行可以看到这两个 char 的具体内容,清楚 UTF-16 编码的同学可以手动将它的码位转换一下,U+202B7 用 UTF-16 来进行编码转换的结果就是 D840 DEB7

在 Java IO 中许多输入函数的返回值为 int,而不是 char,byte,那是因为程序通常会用返回值为 -1 来表示数据已经读取完了。你可能会将它与前面所讲的内容联系起来,但其实并不是这样。Java IO 中 read() 方法的返回值的范围是 0~65535。如果遇到了用 4 个字节来编码的字符,虽然 int 是 4 字节的,但咱们需要 read() 两次才能把它读出来。

上文中提到了许多 Unicode 中的概念,像码位,代理对,UTF……不清楚的可以看这篇文章:Unicode 的那些事儿。

二、编码转换

虽然 Java 中用 UTF-16 来表示字符,但是输入时,输入源的字符编码不一定是 UTF-16,输出时,目的地所要求的字符编码也不一定是 UTF-16。如果不加处理,直接读入数据,很可能会出现乱码。因此,在输入输出时,还需要进行编码转换。

对于 Java 的输入输出来说,输入时,要将字符串的编码方式从原先的编码方式转换为 UTF-16,输出时,要将字符串的编码方式从 UTF-16 转换为目标编码方式。在 Java 的 API 中,常常把输入时的这种转换过程叫做 Decode,而输出时的这种转换过程叫做 Encode

进行编码转换时,需要知道 2 个信息,内容在转换前是以什么编码方式进行编码的,以及要转换成用什么编码方式进行编码的。为了方便叙述,我们把前者叫做原始编码方式,把后者叫做目标编码方式。

转换时,一般根据原始编码方式,以字符为单位,一次取一个字符所对应的 01 序列进行转换。如果目标编码方式和原始编码方式有线性关系,那么就通过加减乘除位运算来将这个 01 序列转化为目标编码方式下的 01 序列,如 UTFs 之间的转换。而如果不存在线性关系,那么就会通过查表来进行转换。转换程序会事先准备一张原始编码和目标编码的字符编码对应表,其中是每个字符在两种编码方式下的 01 序列。转换程序可以通过查找这张表来得到对应的 01 序列。

而对于 Java 的输入输出来说,在输入时,目标编码是 UTF-16,原始编码未知,需要我们告诉它,在输出时,原始编码是 UTF-16,目标编码未知,需要我们告诉它。注意,如果没有告诉程序正确的编码方式,就会出现乱码。这也是大部分乱码问题出现的原因。

综上,要得到面向字符的输入,需要一个面向字节的输入来读取源的数据,以及源的编码方式来进行编码转换。要得到面向字符的输出,需要一个面向字节的输出来向目的地输出数据,以及目的地所要求的编码方式来进行编码转换。

顺便一提,Java 中对 UTF-8 的处理方式与标准的 UTF-8 有一些不同。具体可见 JDK文档中的描述。

三、InputStreamReader 和 OutputStreamWriter

在之前的内容中,我们叙述了如何从面向字节的输入输出转换到面向字符的输入输出。在 Java 的 IO 系统中,有专门的两个类来将面向字节的输入输出转化为面向字符的输入输出。它们是 InputStreamReaderOutputStreamWriter ,它们分别对应输入和输出。以下的叙述以 InputStreamReader 为例,OutputStreamWriter 同理。

以下是 InputStreamWriter 的构造函数。

  • InputStreamReader​(InputStream in). Creates an InputStreamReader that uses the default charset.
  • InputStreamReader​(InputStream in, String charsetName). Creates an InputStreamReader that uses the named charset.
  • InputStreamReader​(InputStream in, Charset cs). Creates an InputStreamReader that uses the given charset.
  • InputStreamReader​(InputStream in, CharsetDecoder dec). Creates an InputStreamReader that uses the given charset decoder.

就像前文中叙述的那样,我们要得到一个面向字符的输入,需要一个面向字节的输入和源的编码方式。这分别对应了构造函数中的两个形参。使用第一个构造函数时,会将源的编码方式指定为平台默认的编码方式。下面是一个用 InputStreamReader 进行读取的例子。

import java.io.*;

public class FileIn {
    private static String path = "/Users/grandfather/test.txt";

    public static void main(String[] args) {
        InputStreamReader isr;
        int a;

        try {
            isr = new InputStreamReader(new FileInputStream(path),"UTF-8");
            while ((a = isr.read()) != -1)
                System.out.print((char)a);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

文件内容:
你好呀!

输出:
你好呀!

上面这个例子实现了从文件中读取字符并显示。我们只需要提供对应的 InputStream 和输入源的编码方式就能实现面向字符的输入,十分方便。

至于 InputStreamReader 的实现方式。之前,我们已经提到过大致的思路,用面向字节的输入流读入数据,把字符一个个地从原始编码方式“翻译“到目标编码方式。这里只简略的讲一下。

InputStreamReader 本身其实并没有干什么事情,它的所有功能都是利用 StreamDecoder 实现的,去看源码的话会发现 InputStreamReader 只是简单地调用了一下 StreamDecoder 的方法而已。将面向字节转换为面向字符的工作都是由 StreamDecoder 完成的。StreamDecoder 的工作是组织数据的读取和字符编码的转换。其中,读取数据是通过传入的 InputStream 对象完成的。而字符编码的转换是由 CharsetDecoder 完成的,它能将以其他字符编码方式编码的字符重新编码为 UTF-16 格式。而 CharsetDecoder 则是通过我们传入的输入源的编码方式得到的,具体流程为:

  1. 通过传入的 charsetName 找到对应的 Charset,如 UTF-8 对应的 Charset 就是 UTF-8。在代码上,是通过调用 Charsetpublic static Charset forName(String charsetName) 方法来得到对应的 Charset。这个方法会根据 charsetName 去查找对应的Charset,注意不是创建,因为 Java 在启动时会自动创建一些常用的 Charset 对象,并把他们缓存起来,如果需要的 Charset 在缓存中,那么直接拿来用就行了。当没有找到时,会根据 charsetName,利用反射动态地将对应的 Charset 类加载进来,并创建出相应的对象返回回去。
  2. 通过得到的 Charset 对象创建出对应的 CharsetDecoderCharset 类中都会有一个 newDecoder() 方法,该方法会返回对应的 CharsetDecoder

以上就是 InputStreamReader 的大致逻辑。想要详细了解的,可以去看源码。不过 StreamDecoder 的代码并不是开源的,想要看到这些代码,需要去下一个 OpenJDK。

四、类结构

面向字符的 IO 的类图如下图所示:

面向字符 IO 的类图

与面向字节的输入一样,面向字符的输入也是一个装饰模式。它也分为装饰器和输入源。Reader 是所有面向字符输入类的基类。而我们提到的 InputStreamReader 其实是一个适配器类,将 InputStream 转换成一个 Reader。这个过程的类图如下所示。

InputStreamReader使用的适配器模式

从类图中可以看到,InputStreamReader 通过 StreamReader 间接地聚合了一个 InputStream。具体的实现就如我们之前讲的那样,StreamReader 通过 InputStream 来读如字节,并通过编码转化,来返回字符。而 InputStreamReader 则通过调用 StreamReader 实现了 Reader 中的接口。从而将 InputStream 转化为了 Reader

从面向字符 IO 的结构上来看,应该将 InputStreamReader 看作是输入源,它是面向对象的输入源转化而来的。

按之前的叙述来看,面向字符的输入源应该是下面这个样子的。

XXReader 相当于 InputStreamReader(new XXInputStream());

面向字符输入源即 InputStreamReader(对应的面向字节输入源)。有些面向字符的类的确是这样实现的,像 FileReader,我们可以看看他的代码。

public class FileReader extends InputStreamReader {

    public FileReader(String fileName) throws FileNotFoundException {
        super(new FileInputStream(fileName));
    }
    
    public FileReader(File file) throws FileNotFoundException {
        super(new FileInputStream(file));
    }

    public FileReader(FileDescriptor fd) {
        super(new FileInputStream(fd));
    }
}

总共就这么几行代码。它本质上就是一个 InputStreamReader(new FileInputStream()),只是包装了一下而已,换了层皮。

但是对于一些输入源来说,并不需要用到 InputStreamReader。如 CharArrayReader ,它的输入源已经是 char 了,并不需要再读入字节和进行编码转换,直接把我们要读的字符返回回来就可以完成输入了。

另外,在面向字符的 IO 中,并不是所有的适配器都是继承自 FilterReader 的,如 BufferedReader 直接继承自 Reader。暂时不清楚为什么要这么设计。这要注意的是,并不是继承自装饰器基类的才叫装饰器,装饰器类的特点是其中组合了一个被装饰对象,并在它之上扩展了功能。装饰器基类的作用是复用代码和使结构清晰,他不是必要的。

五、参考资料

Java学习日常
Web note ad 1