软件设计与数据结构

Chapter 16 Reference

| 关于   «  2. 使用 student.TestCase 编写 JUnit 测试   ::   目录   ::   4. 注释编写规范  »

3. Java I/O 教程

3.1. 文件与基于流的输入和输出

在某个时刻,每个程序员都需要处理从某个来源(文件、键盘、URL 等)读取数据,或向某个目标(另一个文件、屏幕等)写入数据。这些基本的输入和输出能力(I/O)被用于大多数实际程序中。

本页介绍在 Java 中一次一个字符或一次一行地读写数据的基础知识:

3.2. 输入和输出的基本概念

Java 提供了大量用于管理各种形式数据输入和输出的类。这些类建立在流(stream)模型之上。你可以把 流(stream) 理解为可按顺序访问的字符序列(对于文本数据而言)或字节序列(对于二进制数据而言)。

输入(Input) 操作可以概括为从流中读取数据的三步过程:

  1. 打开流,

  2. 按从前到后的顺序从流中读取数据项,并且

  3. 关闭流。

输出(Output) 操作可以概括为向流写入数据的三步过程:

  1. 打开流,

  2. 按顺序把数据写入流的末尾,并且

  3. 关闭流。

要使用 Java 的输入/输出类,请确保除了所需的任何其他包之外,还要导入 java.io 包和 java.util 包:

import java.io.*;
import java.util.*;

3.3. 打开输出流

在本课程中,我们只处理可供人阅读的文本输出。我们用于生成输出的主要类是 Java 的 PrintWriter 类,它来自 java.io 包。要创建 PrintWriter,我们将使用弗吉尼亚理工大学 student 包中 IOHelper 类的一个实用方法:( import student.*; )

PrintWriter outStream = IOHelper.createPrintWriter("output.txt");

这一行声明了一个新的引用 outStream,并创建一个新的 PrintWriter 对象,把输出发送到文件系统中的一个全新文件。如果项目目录中已经存在同名文件,则会在创建同名的新空文件之前将其删除。

PrintWriter 对象提供格式化和转换操作。PrintWriter 对象被设计为把输出发送到某个流。它不知道(也不关心)该流连接的是磁盘文件、网络连接还是其他设备。IOHelper 类还提供了几个其他创建 PrintWriter 对象的方法,包括追加到现有文件而不是覆盖它的方法,以及连接到控制台以进行输出的流。

3.4. 向输出流写入

PrintWriter (PrintWriter Documentation) 对象提供的三个基本方法几乎涵盖了你在本课程中所需的全部输出能力:

  • <stream>.print(<value>); 将指定的 <value> 写入给定的 <stream>。实际上这个方法的版本很多,支持你可能想要打印的各种类型的 <value>。

  • <stream>.println(<value>); 将指定的 <value> 写入给定的 <stream>,然后接着写入一个“行终结符”来标记当前行的结束(Java 会根据当前操作系统的文本文件格式约定,写入适当的行终止字符序列)。与 print() 一样,你可以向 println() 提供任何类型的值。你甚至可以完全不提供参数来调用 println(),例如在几次 print() 调用之后终止当前行。

  • <stream>.write(<value>); 写入一个由整数 <value> 指定的字符。当你一次一个字符而不是成块地产生输出时,最常使用这个操作。不过,如果你向 write() 传入整个 String 值而不是 int 值,那么整个字符串将被写入 PrintWriter(),就像你使用了 print() 一样。

例如:

outStream.print("This is a message, and ");
outStream.println("these words appear on the same line as those above");
outStream.println(100 / 2); // prints the value "50"
outStream.write(65); // writes the letter 'A', whose ASCII code is 65

3.5. 关闭流

一旦你完成了打算在给定流上执行的所有操作,就应该 关闭 该流。关闭流会释放用于连接并与之通信的操作系统资源,并确保你写入流的任何缓冲数据都被刷新到相关的物理设备(如果有的话)。

关闭流很简单:

outStream.close();

你应该用这种方式同时关闭输入流和输出流。在许多简单程序中,一条很好的经验法则是:确保创建流的方法同时也是负责关闭它的方法。

另外请注意,在某些情况下,close() 可能抛出异常。如果你编写了对 close() 的调用,而编译器抱怨可能抛出 IOException,请参考下面的 Dealing with Exceptions 一节。

3.6. 完整的输出示例

我们可以把所有部分组合起来,例如展示如何向文件生成输出。假设我们要创建一个名为 output.txt 的文件,其中包含程序的一些输出。我们可以像这样在一个方法中完成(别忘了在你的类中 import java.io.*):

public void printResultFile(int result)
{
    PrintWriter out = IOHelper.createPrintWriter("output.txt");
    out.println("This is the first line of output.");
    out.print("The result is: ");
    out.print(result);
    out.println();
    out.close();
}

如果以特定参数调用,例如 printResultFile(42);,该方法将在你的项目目录中生成一个名为 output.txt 的文件,包含以下内容:

This is the first line of output.
The result is: 42

其他时候,当需要产生大量输出时,你可能希望把所有 println() 调用放在一个或多个其他方法中。然后,你可以像这个例子一样,把 PrintWriter 对象作为参数传递:

public void printResultFile()
{
    PrintWriter out = IOHelper.createPrintWriter("output.txt");
    printHeader(out);
    printData(out);
    out.close();
}

 public void printHeader(PrintWriter outStream)
{
    outStream.println("This is the output for ...");
    // other output commands go here.
}

public void printData(PrintWriter outStream)
{
    outStream.print(/* ... */);
    // more, as needed ...
}

3.7. 使用 System.out 输出

事实证明,向终端打印是常见的操作,以至于 Java 专门为此提供了一个预初始化的输出流,名为 System.out。System.out 的优点在于它已经声明,随时可以使用,而且你的程序无需负责关闭它。因此,你可以在任何地方直接对 System.out 调用 print()、println() 或 write()。

这常常被用作一种简单而有效的调试技术,让你可以在程序执行时打印出相关信息:

System.out.println("beginning the code ...");
...
if (someCondition())
{
    System.out.println("someCondition() is true");
    x = ...;
    System.out.println("x = " + x);
}
else
{
    System.out.println("someCondition() is false");
    y = ...;
    System.out.println("y = " + y);
}

在上面,请注意加号运算符(+)是如何被用来把文本字符串与另一个值组合成更大的消息的。这是 Java 的一个很棒的特性——加号运算符通过把一个字符串放在另一个字符串之后,将两个字符串“拼接”成更大的字符串。此外,当你把字符串与任何其他值拼接时,另一个值会首先被转换成可读的字符串表示。试试吧,你会喜欢的!

System.out 的优点在于它始终就绪可用,而且使用起来非常方便。这个对象的创建和设置已由 Java 虚拟机为你完成,清理工作也会自动处理。此外,由于 print()、println() 和 write() 方法不会抛出任何异常(无论是对 System.out 调用,还是对你自己创建的 PrintWriter 调用),因此你无需在输出消息周围添加 try/catch 块。

不过,使用 System.out 也有一些 缺点。首先,当你的代码直接写入 System.out 时,很难更改“目的地”,例如让代码写入一个或多个指定名称的文件,或让代码写入内部数据结构,以便信息能在程序的其他地方使用。其次,System.out 实际上并不是 PrintWriter 对象,而是 Java.io.PrintStream,它几乎支持所有相同的方法,但并不完全像 PrintWriter 那样。

因此,以下是本课程中关于输出的一些建议:

  • 当你只想在终端窗口中生成简单的消息,以帮助调试代码问题时,请使用 System.out。

  • 当你只想以交互方式提示用户输入某些值时,请使用 System.out。

  • 当你的程序需要在文件中产生一系列输出行时,请使用 PrintWriter。

  • 当你的程序需要产生一系列既可以输出到终端窗口,也可以输出到文件的输出行时,请编写一个或多个使用 PrintWriter 作为参数的方法。你总是可以调用这样的方法,并向它传入一个由 System.out 产生的 PrintWriter,以便在屏幕上产生输出(参见 IOHelper.createConsoleWriter() 方法)。或者,你也可以改而传入一个连接到文件的 PrintWriter (甚至可以传入一个连接到网络套接字、用于与另一台机器上的另一个程序通信的 PrintWriter!)。

3.8. 打开输入流

我们用于读取输入的主要类是 Java 的 Scanner 类,来自 java.io 包。创建一个 Scanner 很简单:

Scanner inStream = IOHelper.createScanner("input.txt");

这一行声明了一个新名字 inStream,并创建一个从文件中读取字符的 Scanner 对象。createScanner() 方法使用相对于你的项目目录的路径名打开文件,因此名为 input.txt 的文件应该位于该目录下。如果你愿意,也可以提供完整限定的路径名,而不是相对路径名。

java.io 包提供了用于从文本文件读取内容的丰富的类继承层次结构。添加 Scanner 类是为了简化文本输入,因此它比其他类更受青睐。

3.9. 从输入流读取

Scanner (Scanner Documentation) 对象提供的几个方法几乎涵盖了你在本课程中所需的全部输入能力:

  • <scanner>.hasNext(); 如果该扫描器在其输入中还有另一个 token(记号),则返回 true。

  • <scanner>.next(); 查找并返回该扫描器的下一个完整 token(即默认情况下,下一个由空白分隔的字符串,作为 String 对象返回)。如果没有更多可用的 token(即你已经到达输入末尾),则会抛出 NoSuchElementException (NSE Documentation)。

  • <scanner>.hasNextLine(); 如果该扫描器在其输入中还有另一行,则返回 true。

  • <scanner>.nextLine(); 查找并返回下一行完整内容。如果没有更多可用的 token(即你已经到达输入末尾),则会抛出 NoSuchElementException。

  • <scanner>.hasNext<PrimitiveType>(); 我们可以把 <PrimitiveType> 替换为 Double、Float、Int 等。如果该扫描器在其输入中还有另一个 token,且它可以被解释为 <PrimitiveType> 的值,则返回 true。

  • <scanner>.next<PrimitiveType>(); 我们可以把 <PrimitiveType> 替换为 Double、Float、Int 等。该方法把输入的下一个 token 按 <PrimitiveType> 扫描,并返回相应的 <PrimitiveType> 值。如果下一个 token 与 <PrimitiveType> 不匹配,或者扫描出的值超出范围,则会抛出 InputMismatchException (IME Documentation)。如果不再有可用的 token,它还会抛出 NoSuchElementException。

  • <scanner>.useDelimiter(String pattern); 默认情况下,空白(空格、制表符和换行字符)被用作分隔符,把输入分隔成要返回的 token。该方法允许用户把分隔符字符设置为他们希望用于切分输入的任何字符。

  • <scanner>.close(); 关闭扫描器,以释放扫描器正在使用的系统资源。

要使用这些方法,通常你会一次扫描一行来处理输入,然后在该行中扫描所需的 token。

例如:

Scanner inStream = IOHelper.createScanner("input.txt");
if (inStream.hasNextLine()) // NOT at the end of the stream, more input is available
{
    String thisLine = inStream.nextLine(); // Get an entire line
    Scanner line = new Scanner(thisLine); // Create a scanner to process the line
    if (line.hasNextInt()) // Check for the next whitespace delimited int
    {
        System.out.println(line.nextInt());
    }
}
inStream.close();

请注意,在提取每个输入之前都要先检查它是否存在,以避免异常。

此外,如果你以前使用过其他语言编程,请注意 Java 中的字符使用 unicode 编码,这是一种 16 位字符编码。使用其他语言的程序员可能更熟悉 ASCII——美国信息交换标准代码,它是一种 7 位字符编码。幸运的是,unicode 的前 128 个编码与 完整 ASCII 字符集 等价。因此,对于美国用户来说,可以放心按字符读写 ASCII 值而不会出错,尽管这种方法不能直接推广到面向国际用户的程序。

Scanner 类可以用于从任何输入流读取内容,包括文件、通过终端窗口读取的键盘输入,甚至 URL。例如,要从键盘读取:

Scanner keyBoard = IOHelper.createKeyboardScanner();

System.out.print("Enter your name: ");
// Prompt the user String name = keyBoard.nextLine();
System.out.println("Hello " + name); // Echo input

在进行交互式键盘输入时,无需检查下一个 token 是否存在。扫描器会自动阻塞(即等待)用户的输入。

如果你知道 URL,也可以使用 Scanner 从 Web 上公开可用的文件读取内容:

Scanner inWebFile = IOHelper.createScannerForURL( "http://server.subdomain.domain/dir/file.txt");
while (inWebFile.hasNextLine())
{
    String line = inWebFile.nextLine();
    System.out.println(line); // Echo input
}
inWebFile.close();

3.10. 完整的输入示例

我们可以把所有部分组合起来,例如展示如何一次一个字符地从文件读取输入。假设我们要从名为 input.txt 的文件读取字符。我们可以像这样在一个方法中完成(别忘了在你的类中导入 java.io.* 和 java.util.*):

public void readChars()
{
    Scanner in = IOHelper.createScanner("input.txt");
    while (in.hasNextLine()) // NOT at the end of the stream, more input is available
    {
        String thisLine = in.nextLine(); // Get an entire line
        for (int index=0; index < thisLine.length(); index++)
        {
            char ch = thisLine.charAt(index);
            System.out.print(ch);
        }
        System.out.println();
    }
    in.close();
}

其他时候,当需要产生大量输出时,你可能希望把所有 read() 调用放在一个或多个其他方法中。然后,你可以把 Scanner 对象作为参数传递:

public void processInputFile()
{
    Scanner in = IOHelper.createScanner("input.txt");
    readHeader(in);
    readData(in);
    in.close();
}


public void readHeader(Scanner inStream)
{
    String nextLine = null;
    if (inStream.hasNextLine())
    {
        nextLine = inStream.nextLine();
        // other input commands go here.
    }
}


public void readData(Scanner inStream)
{
    String nextLine = null;
    if (inStream.hasNext() )
    {
        nextLine = inStream.nextLine();
        // more, as needed ...
    }
}

3.11. 完整的输入/输出示例

通常,有必要把从某个来源读取和向某个目标写入的过程结合起来。这里有一个简单的例子,它逐字符复制输入文件:

import java.io.*;
import java.util.*;


// -------------------------------------------------------------------------
/**
 * Shows how to read/write a file one character at a time.
 * @author CS Staff
 * @version 2020.03.09
 */
public class CopyFileByLine
{
    // ----------------------------------------------------------
    /**
     * Copy the source file to the specified destination file.
     * @param fromFile the name of the file to copy from
     * @param toFile the name of the file to copy to
     */
    public void copyFile(String fromFile, String toFile)
    {
        Scanner source = IOHelper.createScanner(inFile);
        PrintWriter dest = IOHelper.createPrintWriter(toFile);

        while (source.hasNextLine())
        {
            String thisLine = source.nextLine();
            for (int index = 0; index < thisLine.length(); index++)
            {
                char ch = thisLine.charAt(index);
                dest.print(ch);
            }
            dest.println();
        }
        source.close();
        dest.close();
    }
}

3.12. 测试基于 I/O 的操作

谈到测试,请记住要为你编写的解决方案中的每个方法编写一个或多个测试用例。最好在这些方法编写之前(或编写时)就写好测试,而不是等到代码能运行了再来测试。随着你处理越来越大的程序,建立这样的技能很重要:即使完整解决方案尚未完成,也能让自己相信已经写好的部分能按你的预期工作。

对于读取输入或产生输出的程序,当程序直接在控制台上运行时,测试似乎很困难,因为很难“断言”屏幕上应该出现什么。而且你总是需要亲自在场“输入”所需的输入序列。不过,为了让这些测试完全自动化,请不要编写使用 System.out 或从外部来源读取的测试。相反,只需创建一个 Scanner,在测试用例中让它从一个固定的输入字符串读取。对于输出,创建一个可以写入 String 对象而不是控制台的 PrintWriter。

为了让这些任务变得容易,所有测试用例所继承的 TestCase 基类为你提供了一些辅助方法:

  • setIn(<contents>); 接受一个字符串,并用它创建一个供测试用作输入的 Scanner。该扫描器会在每个测试用例之前自动清空,因此,如果你希望所有测试使用相同的输入序列,可以在 setUp() 中调用它。

  • in(); 返回当前用于输入的 Scanner。你可以把它与 setIn() 结合使用,在测试用例中为自己的基于输入的方法设置输入流。该扫描器会在每个测试用例开始时自动清空。

  • out(); 返回一个你可以用于输出的 PrintWriter。这个打印写入器会捕获它自己的全部输出,供后面的断言使用,而且它的内容会在每个测试用例之前重置。

作为一个例子,考虑下面的测试方法(它假定你的测试夹具(fixture)包含一个由某个 DoIt 类创建的 doIt 对象,该类提供了一个接受 Scanner 参数的 processSomeInput() 方法):

public void testProcessSomeInput()
{
    // set up the input stream
    setIn("some test input");

    // run the method to get results
    doIt.processSomeInput(in());

    // test that the result is what was expected
    assert...( ... );
}

假设有一个 produceOutput() 方法向某个 PrintWriter 写入内容:

public void testProcessSomeInput()
{
    // run the method to get results
    doIt.produceOutput(out());

    // test that the result is what was expected
    assertEquals("what I expect", out().getHistory());
}

最后,你甚至可以同时处理输入和输出:

public void testProcessSomeInput()
{
    setIn("some test input");

    // run the method to get results
    doIt.processSomeStuff(in(), out());

    // test that the result is what was expected
    assertEquals("output I want", out().getHistory());
}

TestCase 基类提供了类似的方法来设置 System.in,或从 System.out 获取历史记录。更多细节请参见 javadoc for TestCaseLinks。

3.13. 处理异常

当程序与外部因素打交道时,比如磁盘上的文件,有时可能出现无法预先预测的错误或其他情况。例如:

  • 用户可能输入了错误的、将被程序读取的信息。

  • 以前存在的文件可能被意外删除了。

  • 可能没有足够的硬盘空间来写入你打算写的所有数据。

  • 用户可能输入了一个错误的文件名——一个不存在的文件的文件名。

因此,Java 使用 异常 来表示出现问题的情况。异常是被“抛入”程序中间的、会中断正常执行流的对象。通常,异常会导致你正在做的其余工作被中止并跳过,程序执行转而由有机会采取适当行动的 异常处理器(exception handler) 接管。

虽然在本课程中我们不会深入研究异常,但它们是深深嵌入 Java I/O 库某些部分中的现实。通常,你可以编写读写数据而不会抛出异常的代码。但是,如果你尝试调用可能产生异常的 I/O 方法,编译器会抱怨:

unreported exception java.io.IOException;
must be caught or declared to be thrown

幸运的是,当这个问题出现时,有一种简单的处理方法。如果你收到这条消息,可以把三步过程(打开流、读或写、关闭)包在下面的 Java 语法中:

try
{
    // open stream
    ...
    // operate on stream contents
    ...
    // close stream
}
 catch (Exception e)
{
    e.printStackTrace();
}

   «  2. 使用 student.TestCase 编写 JUnit 测试   ::   目录   ::   4. 注释编写规范  »

关闭窗口