Skip to content

Commit

Permalink
✓ 附录:IO 流
Browse files Browse the repository at this point in the history
  • Loading branch information
Moilk committed Dec 1, 2019
1 parent 83a3bb8 commit 9a3fe12
Showing 1 changed file with 307 additions and 8 deletions.
315 changes: 307 additions & 8 deletions docs/book/Appendix-IO-Streams.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,47 +195,346 @@ Java 5 添加了几种 `PrintWriter` 构造器,以便在将输出写入时简

最初,我们可能难以相信 `RandomAccessFile` 不是 `InputStream` 或者 `OutputStream` 继承体系中的一部分。除了实现了 `DataInput``DataOutput` 接口(`DataInputStream``DataOutputStream` 也实现了这两个接口)之外,它和这两个继承体系没有任何关系。它甚至都不使用 `InputStream``OutputStream` 类中已有的任何功能。它是一个完全独立的类,其所有的方法(大多数都是 `native` 方法)都是从头开始编写的。这么做是因为 `RandomAccessFile` 拥有和别的 I/O 类型本质上不同的行为,因为我们可以在一个文件内向前和向后移动。在任何情况下,它都是自我独立的,直接继承自 `Object`

从本质上来讲,`RandomAccessFile` 的工作方式类似于把 `DataIunputStream``DataOutputStream` 组合起来使用。另外它还有一些额外的方法,比如使用 `getFilePointer()` 可以得到当前文件指针在文件中的位置,使用 `seek()` 可以移动文件指针,使用 `length()` 可以得到文件的长度另外,其构造器还需要传入第二个参数(和 C 语言中的 `fopen()` 相同)用来表示我们是准备对文件进行 “随机读”(r)还是“读写”(rw)。它并不支持只写文件,从这点来看,如果当初 `RandomAccessFile` 能设计成继承自 `DataInputStream`,可能也是个不错的实现方式。
从本质上来讲,`RandomAccessFile` 的工作方式类似于把 `DataIunputStream``DataOutputStream` 组合起来使用。另外它还有一些额外的方法,比如使用 `getFilePointer()` 可以得到当前文件指针在文件中的位置,使用 `seek()` 可以移动文件指针,使用 `length()` 可以得到文件的长度另外,其构造器还需要传入第二个参数(和 C 语言中的 `fopen()` 相同)用来表示我们是准备对文件进行 “随机读”(r)还是“读写”(rw)。它并不支持只写文件,从这点来看,如果当初 `RandomAccessFile` 能设计成继承自 `DataInputStream`,可能也是个不错的实现方式。

在 Java 1.4 中,`RandomAccessFile` 的大多数功能(但不是全部)都被 nio 中的**内存映射文件(mmap)**取代,详见[附录:新 I/O](./Appendix-New-IO.md)
在 Java 1.4 中,`RandomAccessFile` 的大多数功能(但不是全部)都被 nio 中的**内存映射文件**(mmap)取代,详见[附录:新 I/O](./Appendix-New-IO.md)

<!-- Typical Uses of I/O Streams -->

## IO流典型用途

尽管我们可以用不同的方式来组合 I/O 流类,但常用的也就其中几种。你可以下面的例子可以作为 I/O 典型用法的基本参照(在你确定无法使用[文件](./17-Files.md)这一章所述的库之后)。

在这些示例中,异常处理都被简化为将异常传递给控制台,但是这样做只适用于小型的示例和工具。在你自己的代码中,你需要考虑更加复杂的错误处理方式。

### 缓冲输入文件


如果想要打开一个文件进行字符输入,我们可以使用一个 `FileInputReader` 对象,然后传入一个 `String` 或者 `File` 对象作为文件名。为了提高速度,我们希望对那个文件进行缓冲,那么我们可以将所产生的引用传递给一个 `BufferedReader` 构造器。`BufferedReader` 提供了 `line()` 方法,它会产生一个 `Stream<String>` 对象:

```java
// iostreams/BufferedInputFile.java
// {VisuallyInspectOutput}
import java.io.*;
import java.util.stream.*;

public class BufferedInputFile {
public static String read(String filename) {
try (BufferedReader in = new BufferedReader(
new FileReader(filename))) {
return in.lines()
.collect(Collectors.joining("\n"));
} catch (IOException e) {
throw new RuntimeException(e);
}
}

public static void main(String[] args) {
System.out.print(
read("BufferedInputFile.java"));
}
}
```

`Collectors.joining()` 在其内部使用了一个 `StringBuilder` 来累加其运行结果。该文件会通过 `try-with-resources` 子句自动关闭。

### 从内存输入

下面示例中,从 `BufferedInputFile.read()` 读入的 `String` 被用来创建一个 `StringReader` 对象。然后调用其 `read()` 方法,每次读取一个字符,并把它显示在控制台上:

```java
// iostreams/MemoryInput.java
// {VisuallyInspectOutput}
import java.io.*;

### 格式化内存输入
public class MemoryInput {
public static void
main(String[] args) throws IOException {
StringReader in = new StringReader(
BufferedInputFile.read("MemoryInput.java"));
int c;
while ((c = in.read()) != -1)
System.out.print((char) c);
}
}
```

注意 `read()` 是以 `int` 形式返回下一个字节,所以必须类型转换为 `char` 才能正确打印。

### 格式化内存输入

### 基本文件的输出
要读取格式化数据,我们可以使用 `DataInputStream`,它是一个面向字节的 I/O 类(不是面向字符的)。这样我们就必须使用 `InputStream` 类而不是 `Reader` 类。我们可以使用 `InputStream` 以字节形式读取任何数据(比如一个文件),但这里使用的是字符串。

```java
// iostreams/FormattedMemoryInput.java
// {VisuallyInspectOutput}
import java.io.*;

public class FormattedMemoryInput {
public static void main(String[] args) {
try (
DataInputStream in = new DataInputStream(
new ByteArrayInputStream(
BufferedInputFile.read(
"FormattedMemoryInput.java")
.getBytes()))
) {
while (true)
System.out.write((char) in.readByte());
} catch (EOFException e) {
System.out.println("\nEnd of stream");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
```

`ByteArrayInputStream` 必须接收一个字节数组,所以这里我们调用了 `String.getBytes()` 方法。所产生的的 `ByteArrayInputStream` 是一个适合传递给 `DataInputStream``InputStream`

如果我们用 `readByte()``DataInputStream` 一次一个字节地读取字符,那么任何字节的值都是合法结果,因此返回值不能用来检测输入是否结束。取而代之的是,我们可以使用 `available()` 方法得到剩余可用字符的数量。下面例子演示了怎么一次一个字节地读取文件:

```java
// iostreams/TestEOF.java
// Testing for end of file
// {VisuallyInspectOutput}
import java.io.*;

public class TestEOF {
public static void main(String[] args) {
try (
DataInputStream in = new DataInputStream(
new BufferedInputStream(
new FileInputStream("TestEOF.java")))
) {
while (in.available() != 0)
System.out.write(in.readByte());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
```

注意,`available()` 的工作方式会随着所读取媒介类型的不同而有所差异,它的字面意思就是“在没有阻塞的情况下所能读取的字节数”。对于文件,能够读取的是整个文件;但是对于其它类型的“流”,可能就不是这样,所以要谨慎使用。

我们也可以通过捕获异常来检测输入的末尾。但是,用异常作为控制流是对异常的一种错误使用方式。

### 基本文件的输出

`FileWriter` 对象用于向文件写入数据。实际使用时,我们通常会用 `BufferedWriter` 将其包装起来以增加缓冲的功能(可以试试移除此包装来感受一下它对性能的影响——缓冲往往能显著地增加 I/O 操作的性能)。在本例中,为了提供格式化功能,它又被装饰成了 `PrintWriter`。按照这种方式创建的数据文件可作为普通文本文件来读取。

```java
// iostreams/BasicFileOutput.java
// {VisuallyInspectOutput}
import java.io.*;

public class BasicFileOutput {
static String file = "BasicFileOutput.dat";

public static void main(String[] args) {
try (
BufferedReader in = new BufferedReader(
new StringReader(
BufferedInputFile.read(
"BasicFileOutput.java")));
PrintWriter out = new PrintWriter(
new BufferedWriter(new FileWriter(file)))
) {
in.lines().forEach(out::println);
} catch (IOException e) {
throw new RuntimeException(e);
}
// Show the stored file:
System.out.println(BufferedInputFile.read(file));
}
}
```

`try-with-resources` 语句会自动 flush 并关闭文件。

### 文本文件输出快捷方式


Java 5 在 `PrintWriter` 中添加了一个辅助构造器,有了它,你在创建并写入文件时,就不必每次都手动执行一些装饰的工作。下面的代码使用这种快捷方式重写了 `BasicFileOutput.java`

```java
// iostreams/FileOutputShortcut.java
// {VisuallyInspectOutput}
import java.io.*;

public class FileOutputShortcut {
static String file = "FileOutputShortcut.dat";

public static void main(String[] args) {
try (
BufferedReader in = new BufferedReader(
new StringReader(BufferedInputFile.read(
"FileOutputShortcut.java")));
// Here's the shortcut:
PrintWriter out = new PrintWriter(file)
) {
in.lines().forEach(out::println);
} catch (IOException e) {
throw new RuntimeException(e);
}
System.out.println(BufferedInputFile.read(file));
}
}
```

使用这种方式仍具备了缓冲的功能,只是现在不必自己手动添加缓冲了。但遗憾的是,其它常见的写入任务都没有快捷方式,因此典型的 I/O 流依旧涉及大量冗余的代码。本书[文件](./17-Files.md)一章中介绍的另一种方式,对此类任务进行了极大的简化。

### 存储和恢复数据


`PrintWriter` 是用来对可读的数据进行格式化。但如果要输出可供另一个“流”恢复的数据,我们可以用 `DataOutputStream` 写入数据,然后用 `DataInputStream` 恢复数据。当然,这些流可能是任何形式,在下面的示例中使用的是一个文件,并且对读写都进行了缓冲。注意 `DataOutputStream``DataInputStream` 是面向字节的,因此要使用 `InputStream``OutputStream` 体系的类。

```java
// iostreams/StoringAndRecoveringData.java
import java.io.*;

public class StoringAndRecoveringData {
public static void main(String[] args) {
try (
DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(
new FileOutputStream("Data.txt")))
) {
out.writeDouble(3.14159);
out.writeUTF("That was pi");
out.writeDouble(1.41413);
out.writeUTF("Square root of 2");
} catch (IOException e) {
throw new RuntimeException(e);
}
try (
DataInputStream in = new DataInputStream(
new BufferedInputStream(
new FileInputStream("Data.txt")))
) {
System.out.println(in.readDouble());
// Only readUTF() will recover the
// Java-UTF String properly:
System.out.println(in.readUTF());
System.out.println(in.readDouble());
System.out.println(in.readUTF());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
```

输出结果:

```
3.14159
That was pi
1.41413
Square root of 2
```

如果我们使用 `DataOutputStream` 进行数据写入,那么 Java 就保证了即便读和写数据的平台多么不同,我们仍可以使用 `DataInputStream` 准确地读取数据。这一点很有价值,众所周知,人们曾把大量精力耗费在数据的平台相关性问题上。但现在,只要两个平台上都有 Java,就不会存在这样的问题[^3]

当我们使用 `DastaOutputStream` 时,写字符串并且让 `DataInputStream` 能够恢复它的唯一可靠方式就是使用 UTF-8 编码,在这个示例中是用 `writeUTF()``readUTF()` 来实现的。UTF-8 是一种多字节格式,其编码长度根据实际使用的字符集会有所变化。如果我们使用的只是 ASCII 或者几乎都是 ASCII 字符(只占 7 比特),那么就显得及其浪费空间和带宽,所以 UTF-8 将 ASCII 字符编码成一个字节的形式,而非 ASCII 字符则编码成两到三个字节的形式。另外,字符串的长度保存在 UTF-8 字符串的前两个字节中。但是,`writeUTF()``readUTF()` 使用的是一种适用于 Java 的 UTF-8 变体(JDK 文档中有这些方法的详尽描述),因此如果我们用一个非 Java 程序读取用 `writeUTF()` 所写的字符串时,必须编写一些特殊的代码才能正确读取。

有了 `writeUTF()``readUTF()`,我们就可以在 `DataOutputStream` 中把字符串和其它数据类型混合使用。因为字符串完全可以作为 Unicode 格式存储,并且可以很容易地使用 `DataInputStream` 来恢复它。

`writeDouble()``double` 类型的数字存储在流中,并用相应的 `readDouble()` 恢复它(对于其它的书类型,也有类似的方法用于读写)。但是为了保证所有的读方法都能够正常工作,我们必须知道流中数据项所在的确切位置,因为极有可能将保存的 `double` 数据作为一个简单的字节序列、`char` 或其它类型读入。因此,我们必须:要么为文件中的数据采用固定的格式;要么将额外的信息保存到文件中,通过解析额外信息来确定数据的存放位置。注意,对象序列化和 XML (二者都在[附录:对象序列化](Appendix-Object-Serialization.md)中介绍)是存储和读取复杂数据结构的更简单的方式。

### 读写随机访问文件


使用 `RandomAccessFile` 就像是使用了一个 `DataInputStream``DataOutputStream` 的结合体(因为它实现了相同的接口:`DataInput``DataOutput`)。另外,我们还可以使用 `seek()` 方法移动文件指针并修改对应位置的值。

在使用 `RandomAccessFile` 时,你必须清楚文件的结构,否则没法正确使用它。`RandomAccessFile` 有一套专门的方法来读写基本数据类型的数据和 UTF-8 编码的字符串:

```java
// iostreams/UsingRandomAccessFile.java
import java.io.*;

public class UsingRandomAccessFile {
static String file = "rtest.dat";

public static void display() {
try (
RandomAccessFile rf =
new RandomAccessFile(file, "r")
) {
for (int i = 0; i < 7; i++)
System.out.println(
"Value " + i + ": " + rf.readDouble());
System.out.println(rf.readUTF());
} catch (IOException e) {
throw new RuntimeException(e);
}
}

public static void main(String[] args) {
try (
RandomAccessFile rf =
new RandomAccessFile(file, "rw")
) {
for (int i = 0; i < 7; i++)
rf.writeDouble(i * 1.414);
rf.writeUTF("The end of the file");
rf.close();
display();
} catch (IOException e) {
throw new RuntimeException(e);
}
try (
RandomAccessFile rf =
new RandomAccessFile(file, "rw")
) {
rf.seek(5 * 8);
rf.writeDouble(47.0001);
rf.close();
display();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
```

输出结果:

```
Value 0: 0.0
Value 1: 1.414
Value 2: 2.828
Value 3: 4.242
Value 4: 5.656
Value 5: 7.069999999999999
Value 6: 8.484
The end of the file
Value 0: 0.0
Value 1: 1.414
Value 2: 2.828
Value 3: 4.242
Value 4: 5.656
Value 5: 47.0001
Value 6: 8.484
The end of the file
```

`display()` 方法打开了一个文件,并以 `double` 值的形式显示了其中的七个元素。在 `main()` 中,首先创建了文件,然后打开并修改了它。因为 `double` 总是 8 字节长,所以如果要用 `seek()` 定位到第 5 个(从 0 开始计数) `double` 值,则要传入的地址值应该为 `5*8`

正如前面所诉,虽然 `RandomAccess` 实现了 `DataInput``DataOutput` 接口,但实际上它和 I/O 继承体系中的其它部分是分离的。它不支持装饰,故而不能将其与 `InputStream``OutputStream` 子类中的任何一个组合起来,所以我们也没法给它添加缓冲的功能。

该类的构造器还有第二个必选参数:我们可以指定让 `RandomAccessFile` 以“只读”(r)方式或“读写”
(rw)方式打开文件。

除此之外,还可以使用 `nio` 中的“内存映射文件”代替 `RandomAccessFile`,这在[附录:新 I/O](Appendix-New-IO.md)中有介绍。

<!-- Summary -->
## 本章小结

Java 的 I/O 流类库的确能够满足我们的基本需求:我们可以通过控制台、文件、内存块,甚至因特网进行读写。通过继承,我们可以创建新类型的输入和输出对象。并且我们甚至可以通过重新定义“流”所接受对象类型的 `toString()` 方法,进行简单的扩展。当我们向一个期望收到字符串的方法传送一个非字符串对象时,会自动调用对象的 `toString()` 方法(这是 Java 中有限的“自动类型转换”功能之一)。

在 I/O 流类库的文档和设计中,仍留有一些没有解决的问题。例如,我们打开一个文件用于输出,如果在我们试图覆盖这个文件时能抛出一个异常,这样会比较好(有的编程系统只有当该文件不存在时,才允许你将其作为输出文件打开)。在 Java 中,我们应该使用一个 `File` 对象来判断文件是否存在,因为如果我们用 `FileOutputStream` 或者 `FileWriter` 打开,那么这个文件肯定会被覆盖。

I/O 流类库让我们喜忧参半。它确实挺有用的,而且还具有可移植性。但是如果我们没有理解“装饰器”模式,那么这种设计就会显得不是很直观。所以,它的学习成本相对较高。而且它并不完善,比如说在过去,我不得不编写相当数量的代码去实现一个读取文本文件的工具——所幸的是,Java 7 中的 nio 消除了此类需求。

一旦你理解了装饰器模式,并且开始在某些需要这种灵活性的场景中使用该类库,那么你就开始能从这种设计中受益了。到那时候,为此额外多写几行代码的开销应该不至于让人觉得太麻烦。但还是请务必检查一下,确保使用[文件](./17-Files.md)一章中的库和技术没法解决问题后,再考虑使用本章的 I/O 流库。

[^1]: 很难说这就是一个很好的设计选择,尤其是与其它编程语言中简单的 I/O 类库相比较。但它确实是如此选择的一个正当理由。

Expand Down

0 comments on commit 9a3fe12

Please sign in to comment.