Java I/O简介

600 查看

相对于PythonC来说,Java的I/O操作API比较复杂,因此本文打算做个简单的介绍。

1. I/O分类

总的来说Java的I/O按照处理数据的粒度和方向来划分,一共可以分为4类:

  • 基于字节

    • 输入 InputStream

    • 输出 OutputStream

  • 基于字符

    • 输入 Reader

    • 输出 Writer

使用原则:要读写二进制数据时,使用基于字节的API;要读写文本数据时,使用基于字符的API,文本数据操作需要指定字符编码。强调一点,本文说的字符是指Java的数据类型char类型,并不是C语言中的char类型(该类型长度为8位,一个字节),即Java中的一个字符有可能包含多个字节。

这里提到的InputStream, OutputStream, ReaderWriter 是Java API里的4个抽象类,不能用来初始化新的实例,我们只能从这4个类的子类(或者后代类)来创建I/O操作的实例,而且正是这些子类实现类不同介质和不同功能的I/O。另外,两个用于输入的抽象类都定义了一个抽象的int read()方法,两个用于输出的抽象类都定义了一个抽象的void write()方法,这些抽象方法则由子类来实现。

2. 文件I/O的使用

Java I/O可以可以应用于各种输入输出介质,包括文件、控制台(也是文件的一种)、内存、网络等。这里先介绍文件I/O,搞懂了文件I/O相关的API后,其他的I/O就都好理解了。

最基本方法

根据第一节的分类,文件I/O的API也分为基于字节基于字符 的两大类。我们先来看最基础的文件I/O的类:

  • 基于字节

    • FileInputStream
      该类的read()方法每次从文件读取一个字节。

    • FileOutputStream
      该类的write()方法每次向文件写入一个字节。

  • 基于字符

    • InputStreamReader
      该类的read()方法每次从一个输入流中读取一个字符。该类的构造函数的第一个参数是一个InputStream实例,也就是将说该类将一个基于字节的输入流变成一个基于字符的输入流。如果不指定字符集,则使用系统默认字符编码,Ubuntu系统的默认字符编码一般是UTF-8。所以更准确的说,是将一个字节输入流按照给定的字符编码来解码,从而得到一个字符输入流。

    • OutputStreamWriter
      该类的write()方法每次向一个输出流中写入一个字符。该类的构造函数的第一个参数是一个OutputStream实例,也就是说该类将一个基于字节的输出流变成一个基于字符的输出流。如果不指定字符集,则使用系统默认字符编码,Ubuntu系统的默认字符编码一般是UTF-8。所以更准确的说,是将一个字节输出流按照给定的字符编码来编码(把要输出的字符转换成二进制数据),从而得到一个字符输出流。

    • FileReader
      该类的read()方法每次从文件读取一个字符。这个类的作用等于如下代码:

    InputStreamReader in = new InputStreamReader(new FileInputStream(pathToFile));
    也就是先从一个文件创建一个字节输入流,然后再采用系统默认编码方式转换成一个字符输入流。当然,缺点就是不能选择使用的字符编码。

    • FileWriter
      该类的write()方法每次向文件写入一个字符。这个类的作用等于如下代码:

    `OutputStreamWriter out = new OutputStreamWriter(new FileOutputStream(pathToFile));
    具体解释和FileReader类似。

介绍完常用类,我们来讲下如何使用。

  • 二进制数据读写(基于字节)

    • 如果要从文件读入二进制数据,则先构造一个FileInputStream实例,然后调用read()方法每次读入一个字节,也可以调用read方法的其他实现,每次读入多个字节。

    • 如果要向文件写入二进制数据,则先构造一个FileOutputStream示例,然后调用write()方法每次写入一个字节,也可以调用write方法的其他实现,每次写入多个字节。

  • 文本数据读写(基于字符)

    • 如果要从文件读入文本数据,可以选择如下两种方式:

      • 构造一个FileReader实例,使用系统默认编码,然后调用read()方法每次读入一个字符,也可以调用read方法的其他实现,每次读入多个字符。

      • 采用如下方式构造一个InputStreamReader实例:new InputStreamReader(new FileInputStream(pathToFile), codecName),使用指定的字符编码,然后调用read()方法每次读入一个字符,也可以调用read方法的其他实现,每次读入多个字符。

  • 如果要向文件写入文本数据,可以选择如下两种方式:

    • 构造一个FileWriter实例,使用系统默认编码,然后调用write()方法每次写入一个字符,也可以调用write方法的其他实现,每次写入多个字符。

    • 采用如下方式构造一个OutputStreamwriter实例:new OutputStreamWriter(new FileOutputStream(pathToFile), codecName),使用指定的字符编码,然后调用write()方法每次写入一个字符,也可以调用write方法的其他实现,每次写入多个字符。

来看下代码实例,文件~/tmp/words中存放了一行中文字符,采用的是UTF-8编码方式:

~/tmp/words 
你好

下面的代码展示了基于字节和基于字符两种方式读取文件内容的区别:

import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;

public class Hello {

  public static void main(String[] args) {
    String wordFileName = "~/tmp/words";
    String wordFilePath = wordFileName.replace("~", System.getProperty("user.home"));

    try {
      // byte-based input
      System.out.println("InputStream");
      FileInputStream fin = new FileInputStream(wordFilePath);
      int c;
      while ((c = fin.read()) != -1) {
        System.out.printf("%02x ", c);
      }
      System.out.println();

      // char-based input
      System.out.println("Reader");
      InputStreamReader rin = new InputStreamReader(
          new FileInputStream(wordFilePath), "UTF-8");
      while ((c = rin.read()) != -1) {
        System.out.printf("%02x ", c);
      }
      System.out.println();
    } catch (IOException e) {
      System.out.println(e);
      System.exit(1);
    }
  }
}

运行结果为:

InputStream
e4 bd a0 e5 a5 bd 0a 
Reader
4f60 597d 0a 

可以看出,基于字节的输入每次读入一个字节,两个汉字一共6个字节,换行符一个字节(0x0a),一共读了7次。而基于字符的输入每次读入后保存的结果为两个字节(因为Java内部都是UTF-16表示的,因此从文件读入字符的时候已经做了UTF-8到UTF-16转换),两个汉字和一个换行符一共读了三次。

更方便的方法

上一小节说的方法其实相当于C语言中的中的如下函数:

  • FileInputStream: read, fgetc, fread等

  • FileOutputStream: write, fputc, fwrite等

  • InputStreamReader, FileReader: 如果文件不是ASCII编码的,则相当于fgetwc(wchar.h文件中定义)等;如果文件是ASCII编码,则相当于fgetc等。

  • OutputStreamWriter, FileWriter: 同上,相当于fputwc和fputc等。

这些只能基于单个字符或者单个字节进行输入输出的API使用起来比较麻烦,比较使用用来操作二进制数据。本节会介绍一些更方便的文件I/O方法。

读写二进制文件

在不考虑对象序列化等更复杂的方法时,Java也提供了DataInputStreamDataOutputStream 的类,用来从二进制流中读取Java的基本类型数据或者向二进制流中写入基本类型数据,比如读一个整型和写入一个整型。

DataInputStream是FilterInputStream的子类,DataOutputStream则是FilterOutputStream的子类,都属于过滤类的流,其作用是从一个流读入数据,然后转换一下表达方式在输出,比如DataInputStream可以从FileInputStream连续读出4个字节,然后转换成一个整型返回给调用者。

读写二进制文件更高级的就是各种对象序列化方法了,这个本文不讨论。

读写文本文件

读写文本文件我们很习惯于按照行来进行读写,比如C语言的scanfprintf 函数。在Java中分别使用下面两个类来进行:

  • Scanner: 有众多构造函数,其中一个可以从指定输入流,然后实现类似scanf函数的效果。

  • PrintWritter: 该类在一个Writer的基础上实现了常用的print, println和printf接口。

如下代码从一个文件构造一个Scanner实例,然后你就可以调用Scanner类的next, nextInt, nextLine的函数来从文件读取输入:

Scanner in = new Scanner(new FileInputStream(pathToFile));

Scanner类还有其他构造函数,能够指定字符编码,以及从其他类型的参数构造出一个实例。

如下代码从一个文件构造出一个PrintWriter实例,然后你就可以调用print, println和printf了:

PrintWriter out = new PrintWriter(new FileWriter(pathToFile));

不过还有个更方便的构造函数:

PrintWriter out = new PrintWriter(pathToFile);

3. 标准输入,标准输出和标准错误

System类的三个成员in, out, err分别系统的标准输入、标准输出和标准错误,通过查看源码可以发现他们的定义是这样的:

public final class System {
  ...
  public static final InputStream in = null;
  public static final PrintStream out = null;
  public static final PrintStream err = null;
  ...
}

因此要从标准输出读取数据的化,可以直接使用InputStream的read方法,或者构造一个Scanner实例来使用:

System.in.read();
Scanner in = new Scanner(System.in);

标准输出和标准错误则是两个PrintStream类的实例,查看代码可以发现,这个类的实现基本上和PrintWriter一样,也就是说可以直接使用print, println和printf等方法进行数据输出。还有,PrintStream类是继承自FilterOutputStream,因此write方法也是可用的。