MengBridgeCourse 中文版

第 6 章 O and Sets

| 关于   英文版   «  5.4. 单元 5 实验 2 占位符   ::   目录   ::   6.2. 单元 6 实验 1 占位符  »

6.1. 变量作用域、输入、输出与异常

6.1.1. 变量作用域

现在您已经熟悉了各种情境下的字段、参数和变量,是时候进一步了解用户定义的名称究竟可以在何处访问了。用户定义名称的 作用域 是指该名称可见且可使用的源代码区域。

6.1.1.1. 局部作用域

回想一下,访问方法(或常被称为“设置器”)用于更改类中私有字段的值。例如:

public class Cat
{
    private String color;

    // ...

    public void setColor(String newColor)
    {
         color = newColor;
    }
}

此 setColor() 方法同时使用了字段和参数。需要注意的是,这两类变量的使用位置存在差异。变量或方法的 作用域 指的是它在程序中可以被使用的位置。

参数的作用域仅限于其声明所在的方法体。参数是局部变量,它们在方法头的参数列表中声明,并由方法调用中的实参指定初始值。例如,如果我们有一个类型为 Cat 的对象 c ,我们可以这样调用 setColor() 方法:

c.setColor("Black");

当我们编写该方法时,我们声明变量 newColor ;而在此处调用该方法时,我们将 s 设置为字符串 "Black"。如果我们想再次调用该方法,则需要为变量 newColor 提供一个新的值。

c.setColor("Black");
c.setColor("Grey");

此类值不会在方法调用之间保留。

参数的作用域与方法体开头声明的变量的作用域相同。一旦执行流程离开某个方法,它的参数和其他局部变量就不复存在。局部变量的作用域称为 局部作用域。

备注

Local Variables

局部变量,即方法的参数以及在方法主体中声明的变量,具有 局部作用域,其范围从定义点延伸至定义它们的代码块末尾。特别是,参数的作用域是其所在方法的整个主体。

在方法外部引用方法的参数或其他局部变量将导致语法错误。

6.1.1.2. 块级作用域

在方法体中声明的变量,其作用域从声明点延伸至它们所在代码 块 的末尾。当局部变量在方法开头声明时,它具有上述讨论的局部作用域。

然而,局部变量并不局限于方法的开头,它们的声明可以放在其他位置,这会影响其作用域。当涉及 if 语句或循环等控制结构时,作用域可能会更加具体。

public void exampleMethod(int x)
{
    if (x % 2 == 0)
    {
        int value = 4;
    }
    value = value + 2; // This will not work!
}

在大括号( {} )内声明的变量,例如循环或条件控制结构中的变量,仅在这些大括号内存在。上述方法无法工作,因为变量 value 仅在 if 语句的真分支中声明和初始化,而当执行到达标记 if 语句真分支结束的对应右大括号时,该变量便不再存在。因此,一旦执行离开其声明所在的代码块(即那对大括号),其名称就不再可见。任何尝试在大括号外使用在该处声明的变量的行为都将导致编译错误,因为该变量不再可见或可访问——即不再“在作用域内”。

循环结构也是如此:

for (int i = 0; i < 12; i++)
{
    System.out.println(i);
}
i = i + 1; // This will not work!

变量 i 作为 for 循环的一部分被定义,其作用域为 for 循环的主体——即包围循环体的花括号。变量 i 在 for 循环结束后不再存在。

为了解决这个问题,你有时会看到在控制结构 之前 声明变量的代码,以便该变量既可以在控制结构内部访问,也可以在其外部访问。

public void exampleMethod(int x)
{
    int value = 0;
    if (x % 2 == 0)
    {
        value = 4;
    }
    value = value + 2;
}

我们也可以使用 for 循环实现类似的操作:

int i = 0;
for (i = 0; i < 12; i++)
{
    System.out.println(i);
}
i = i + 1; // This will work!

6.1.1.3. 类作用域

相比之下,字段和所有方法的作用域都扩展到整个类,即 类作用域。它们可以在任何方法的主体中使用,也可以用于为类级变量赋初始值的表达式中。

备注

Class-Level Variables

字段和方法具有类作用域,其范围覆盖整个类。

6.1.1.4. 一个常见的误解

声明变量后,人们往往在提及该变量时混淆其名称与类型。例如:

public class Cat
{
    private String color;

    // ...

    public void setColor(String newColor)
    {
         String color = newColor;
    }
}

此 setter 不会 更改字段 color 的值。对 Java 而言,只要包含变量类型,就是在声明变量。Java 允许你声明同名变量,前提是它们位于不同的作用域中。在上述代码中,存在一个名为 color 的字段,其具有类级作用域, 同时 还存在一个名为 color 的局部变量,它仅在 setColor() 方法内存在。

尽管这些变量具有相同的名称和类型,但它们是不同的。修改其中一个不会影响另一个。这种情况的另一个常见示例可以在测试中看到。让我们来看一个针对 Cat 类的假设测试文件。

public class CatTest
    extends TestCase
{
    private Cat testCat;

    public void setUp()
    {
        Cat testCat = new Cat();
    }

    public void test1()
    {
        testCat.setColor("White");
    }
}

这与上一个示例中遇到的问题相同。有一个类级别的 Cat 对象被声明为字段( private Cat testCat; )。但是,我们不是在 setUp() 中初始化该字段,而是声明了一个也称为 testCat 的局部变量。这意味着字段 testCat 将不会被初始化,因为在 setUp() 内部创建的对象被用于初始化该方法内的局部变量,而当方法结束时该局部变量将不再存在。当我们在 test1() 中引用 testCat 变量时,我们引用的是该字段,它从未被初始化,因此将包含 null 。因此,此测试将产生一个 NullPointerException 。

幸运的是,这个问题很容易修复。一旦声明了变量,我们只需通过变量名来引用它。

public class CatTest
    extends TestCase
{
    private Cat testCat;

    public void setUp()
    {
        testCat = new Cat();
    }

    public void test1()
    {
        testCat.setColor("White"); // this won't work!
    }
}

这段代码可以无错误地运行。字段 testCat 仍然在任意方法之外声明,使其具有类级作用域。但这次,它在我们的 setUp() 方法中被正确初始化,并且 setUp() 会在每个测试之前运行。这意味着在 test1() 中, testCat 将引用一个 Cat 对象,而不是值 null 。

6.1.1.5. 关于命名的说明

如上所述,当两个变量在不同作用域中声明时,Java 可以处理它们具有相同名称和类型的情况。这也可能导致混淆。例如,我们可能有两个名为 color 的 String 变量,一个是字段,另一个是参数。

public class Cat
{
    private String color;

    // ...

    public void setColor(String color)
    {
         color = color;
    }
}

这段代码可以编译,但不建议使用此类命名约定。这是因为无法明确字段 color 是被赋值为参数 color ,还是反之,亦或是其他情况。让我们通过添加几条打印语句来查看此处发生的情况:

public class Cat
{
    private String color;

    public Cat()
    {
        this.color = "Black";
    }

    public void setColor(String color)
    {
        color = color;
        System.out.println(this.color);
        System.out.println(color);
    }
}

在此示例中,每当我们创建一个新的 Cat 对象时,字段 color 的值最初会被设置为 "Black"。当我们运行 setColor("Green") 时,会在打印语句中看到一个有趣的结果:

_images/ScopeCatOutput.png

首先输出的是 this.color 。我们看到它是 "Black"。该字段的值并未变为 "Green"!这意味着当我们写入 color = color 时,可以确定字段 color 不在赋值运算符的左侧。

因此,有人可能会认为参数 color 是赋值运算符左侧的值。这意味着该参数已从 "Green" 更改为 "Black"。但我们的第二条打印语句告诉我们事实并非如此。当我们打印参数 color 时,我们看到它仍然是 "Green"。这意味着字段 color 也不在等号的右侧!

这段代码中发生的情况是,我们将参数变量 color 赋值为它自身——这意味着没有任何变化!

通常,避免此类混淆的最佳方法是为变量赋予不同的名称,就像我们最初所做的那样:

public void setColor(String newColor)
{
    color = newColor;
}

或者,如果出于某种原因你 必须 在两个不同的作用域级别使用相同的变量名,使用修饰符 this 将有助于澄清你指的是哪个变量:

public void setColor(String color)
{
    this.color = color;
}

现在,字段 color 位于赋值运算符的左侧,而参数 color 位于右侧。因此,如果我们运行 setColor("Green"); ,字段 color 的值将从 "Black" 变为 "Green"。

你有时会在 setter 方法或构造函数中看到这种约定,其中程序员有意为参数和字段使用相同的名称,以表明该参数的值将被存储到字段中。使用这种方法时,引用字段名称必须始终包含 this. 作为前缀,否则所有对该名称的引用都将仅指向参数。

6.1.2. 作用域概念总结

6.1.3. 检查你的理解:作用域

6.1.4. 语法练习:作用域

6.1.5. Java 输入与输出

我们使用 System.out.println() 已经有一段时间了,但你可能没有思考过它的含义。 System 是一个类,提供了与程序运行的“系统”或环境相关的方法。它还提供了 System.out ,这是一个特殊字段,引用了一个提供输出显示方法的对象,包括 println() 。事实上,我们可以使用 System.out.println() 来显示 System.out 的值:

System.out.println(System.out);

结果是:

java.io.PrintStream@685d72cd

此输出表明 System.out 引用了一个 PrintStream 对象,该对象定义在名为 java.io 的包中。包是相关类的集合; java.io 包含用于"I/O"(即 输入和输出)的类。

6.1.5.1. 基本输入与输出概念

Java 提供了一个庞大的类库,用于管理所有形式数据的输入和输出。在 Java 中,任何输入/输出的源或目标都被视为“流”或字节或字符序列。要执行输出,我们将字节或字符插入流中。要执行输入,我们从流中提取字节或字符。即使在键盘上输入的字符(如果我们将它们视为一系列击键)也可以表示为流。

输入 操作被定义为从流中读取的三步过程:

  1. 打开流

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

  3. 关闭流。

输出 操作被定义为以类似的三步过程向流写入数据:

  1. 打开流

  2. 按顺序将数据写入流的末尾

  3. 关闭流。

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

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

6.1.6. 使用 PrintWriters 输出

6.1.6.1. 打开用于输出的流

在本课程中,我们仅处理文本形式、人类可读的输出。用于生成输出的主要类是 Java 的 PrintWriter 类,它位于 java.io 包中。要创建 PrintWriter ,我们将使用 VT 学生包( import student.*; )中 IOHelper 类的一个实用方法:

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

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

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

6.1.6.2. 写入输出流

PrintWriter 对象提供的三种基本方法几乎涵盖了本课程所需的所有输出功能:

  • <stream>.print(<value>); 将指定的 <value> 写入给定的 <stream>。实际上,该方法有许多版本,支持您可能想要打印的每种类型的 <value>。

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

  • <stream>.write(<value>); 写入由整数 <value> 指定的单个字符。当您逐个字符地生成输出,而不是以较大的块生成时,此操作最为常用。但是,如果您将整个 String 值传递给 write() ,而不是传递 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

6.1.6.3. 关闭流

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

关闭流很简单:

outStream.close();

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

6.1.6.4. 完整输出示例

我们可以将所有这些部分组合起来,展示如何生成输出到文件,例如。假设我们想创建一个名为 output.txt 的文件,其中包含来自我们程序的一些输出。我们可以通过一个方法来实现这一点(不要忘记在您的类中导入 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 ...
}

6.1.6.5. 使用 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 的一个良好特性——加号运算符通过将两个字符串前后相连来“连接”它们,从而形成一个更大的字符串。此外,当您将字符串与任何其他类型的值进行连接时,该值会首先通过调用其 toString() 方法转换为人类可读的字符串表示形式。

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

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

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

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

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

6.1.7. 检查你的理解:输出

6.1.8. 使用扫描器输入

6.1.8.1. 打开用于输入的流

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

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

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

java.io 包提供了丰富的类继承层次结构,用于从文本文件中读取数据。 Scanner 类的创建旨在简化文本输入,因此优于其他类。

6.1.8.2. 从输入流读取

Scanner 对象提供的若干方法几乎涵盖了本课程所需的全部输入功能:

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

  • <scanner>.next(); 查找并返回扫描器中的下一个完整令牌(默认情况下为下一个由空白字符分隔的字符串,例如下一行或下一个制表符分隔的单词,作为 String 对象)。如果没有更多可用令牌(即已到达输入末尾),则抛出 NoSuchElementException 。

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

  • <scanner>.nextLine(); 查找并返回下一完整行。若无更多标记可用(即已到达输入末尾),则抛出 NoSuchElementException 。

  • <scanner>.hasNext<PrimitiveType>(); <PrimitiveType> 可替换为 double 、 float 、 int 等。如果此扫描器在输入中还有另一个标记,且该标记可解释为 <PrimitiveType> 的值,则返回 true 。

  • <scanner>.next<PrimitiveType>(); <PrimitiveType> 可以被 double 、 float 、 int 等替换。该方法将输入的下一个标记扫描为 <PrimitiveType> ,并返回相应的 <PrimitiveType> 值。如果下一个标记与 <PrimitiveType> 不匹配,或者扫描的值超出范围,则抛出 InputMismatchException 。如果没有更多可用的标记,它还会抛出 NoSuchElementException 。

  • <scanner>.useDelimiter(String pattern); 默认情况下,空白字符(空格、制表符或换行符)用作分隔符,将输入分割为要返回的记号。此方法允许用户将分隔符设置为任意他们希望的字符以拆分输入。逗号是另一种常用的分隔符,因为表格或数据通常存储在所谓的 CSV(逗号分隔值)文件中。

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

要使用这些方法,通常您需要逐行扫描输入,然后扫描该行以查找所需的标记。

例如:

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

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

另外,如果您之前使用其他语言进行过编程,请注意 Java 中的字符采用 unicode 编码,这是一种 16 位字符码。其他语言的程序员可能更熟悉 ASCII(美国信息交换标准代码),这是一种 7 位字符码。幸运的是,unicode 的前 128 个码值与整个 ASCII character set 等价。因此,对于美国用户而言,在逐字符读写时可以自由使用 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

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

如果您知道 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();

6.1.9. 完整输入示例

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

public void readChars()
{
    Scanner in = IOHelper.createScanner("input.txt");
    // while NOT at the end of the stream, more input is available
    while (in.hasNextLine())
    {
        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 ...
    }
}

6.1.10. 检查你的理解:输入

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

通常,需要将从某个源读取的过程与向某个目标写入的过程结合起来。下面是一个简单的示例,它逐字符复制输入文件:

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


// -------------------------------------------------------------------------
/**
 * Shows how to read/write a file one character at a time.
 * @author Dwight Barnette
 * @version 2006.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();
    }
}

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

在测试方面,请记住为你解决方案中编写的每个方法编写一个或多个测试用例。最好在你编写方法本身之前(或同时)编写这些测试,而不是等到代码能运行后再进行测试。随着你开发越来越大的程序,培养一种能力至关重要:即使完整解决方案尚未完成,也要让自己确信已编写的部分能够按预期工作。

对于测试读取输入或产生输出的程序,当程序直接在控制台上运行时似乎很困难,因为很难“断言”屏幕上应该显示什么内容。此外,你总是需要亲自到场来“键入”所需的输入序列。

然而,为了使这些测试完全自动化,不要编写使用 System.out 或从外部源读取的测试。相反,只需创建一个 Scanner 以便作为测试用例的一部分从固定输入字符串中读取。对于输出,请创建一个 PrintWriter ,使其能够写入 String 对象而非控制台。

为使这些任务更简便,所有测试用例所继承的 TestCase 基类为您提供了一些辅助方法:

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

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

  • out(); 返回一个 PrintWriter ,您可以将其用于输出。此 PrintWriter 会捕获其自身的所有输出以供后续在断言中使用,并且其内容会在每个测试用例之前重置。

例如,考虑以下测试方法(该方法假设您的测试夹具包含一个从某个 DoIt 类创建的 doIt 对象,该类提供一个名为 processSomeInput() 的方法,该方法接受一个 Scanner 参数):

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
    assertThat( ... );
}

假设存在一个 produceOutput() 方法,它会写入 PrintWriter :

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

    // test that the result is what was expected
    assertThat("what I expect").isEqualTo(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
    assertThat("output I want").isEqualTo( out().getHistory()));
}

TestCase 基类提供了类似的方法,用于设置 System.in 或从 System.out 检索历史记录。有关更多详情,请参阅 TestCase 的 javadoc。

6.1.13. 检查你的理解:测试

6.1.14. 主方法

传统上,学习新编程语言时编写的第一个程序称为 hello world 程序。它只是在屏幕上显示"Hello, World!"字样。在 Java 中,它看起来像这样:

public class HelloWorld
{
    public static void main(String[] args)
    {
        // generate some simple output
        System.out.println("Hello, World!");
    }
}

我们从之前的模块中得知,此方法内的单行代码将打印出短语"Hello, World!"

该程序定义了一个名为 main() 的方法,其签名如下:

public static void main(String[] args)

具有此确切签名的方法是特殊的:它可以用作执行独立 Java 程序的入口点。 HelloWorld 程序是 Java 应用程序 (简称 Java 应用)的一个示例。应用程序是一种独立程序,意味着其运行不依赖任何其他程序。在 Java 中,任何你编写的类都可以包含自己的独立程序,只要它提供了具有此特殊签名的 main() 方法声明即可。

每个 Java 应用程序都必须有一个 main() 方法(具有完全相同的方法签名),这是程序运行时开始执行的地方。对于包含多个类的程序,由程序员决定哪个类应包含 main() 方法。此外,当你“启动”或“执行”一个 Java 应用程序时,执行从 main() 方法开始,而不是通过创建对象——然后由 main() 负责创建程序所需的所有对象。

如上所述,该方法的方法签名非常具体。这是因为该方法在程序中具有非常特殊的作用。方法签名必须 完全 为:

public static void main(String[] args)

你在这里看到的许多关键字都是你熟悉的。

  • public 表示该方法可被程序中的其他类访问。

  • void 表示该方法不返回任何值。

  • main 是该方法的名称。

  • String[] args 表示该方法接收一个名为 args 的字符串数组。

字符串数组 args 实际上是一个功能,允许用户在程序启动时传递字符串参数。在大多数情况下,我们在本课程中无需担心如何使用此数组。然而,它仍是方法签名的一部分,缺少它就不能视为主方法。当使用命令行以独立方式执行 Java 程序时,命令行上给出的参数将用于填充此数组,因此它们在 main() 内部是可访问的。

6.1.15. 检查你的理解:主方法

6.1.16. static 关键字

在英语中,当我们说某物是 static 时,意味着它不会移动或改变。这在 Java 中也是如此。相比之下,计算机程序是 dynamic 的。它会变化。它会执行操作并完成某些动作。

一旦放置到位,我们就知道任何带有 static 关键字的内容都意味着它不会改变。static 关键字是一个修饰符,您可以将其放在类中的声明上,它意味着相应的声明将属于类本身,而不是特定的对象(类的实例)。

让我们看看这是什么意思:

public class Ball
{
    private String color;

    public Ball()
    {
        this.color = "Red";
    }

    public void setColor(String newColor)
    {
        this.color = newColor;
    }

    public String getColor()
    {
        return this.color;
    }
}

这段代码声明了一个 Ball 类。初始化时,所有 Ball 对象的 color 字段都被设置为 "Red",但提供了一个 setter 方法,以便我们可以将其更改为其他颜色。字段 color “属于”该对象,因为 Ball 的每个实例都有自己的 color 字段,并且每个实例都可以在其自己的 color 字段中存储不同的值,而与其他所有 Ball 分开。不过,要访问或更改此字段,我们需要创建一个 Ball 对象来操作。

Ball b1 = new Ball();               // creates a red Ball
b1.setColor("Blue");                // changes the color to Blue
System.out.println(b1.getColor());  // prints out the string "Blue"

现在,让我们假设所有 Ball 对象必须具有完全相同的直径——15 厘米。这可能是使用 static 关键字的一个良好应用场景。

public class Ball
{
    private String color;
    private static int diameter = 15;

    // ...
}

使用 static 关键字的主要原因是,当您希望定义整个类的共有属性,而不仅仅是单个对象的属性时。在这里,由于 diameter 被声明为 static ,因此类本身只存储一个 diameter 字段,并且该单一副本由从该类创建的所有对象共享。每个对象无需存储自己的直径,该值仅存储在一个位置——即类本身中。当您希望类中的所有对象共享单个资源(或方法)时,会使用 static 修饰符。

然后,当访问静态方法或字段时,你的表述方式与存储在对象本身中的普通字段不同。例如,考虑 toString() 类的一个 Ball 方法:

public String toString()
{
    return this.color + " ball with diameter " + Ball.diameter;
}

在此,当提及 color 字段时,我们使用 this.color ,其中 this 指代 当前对象,即拥有字段 color 的对象。我们讨论的是存储在当前对象中的 color 值。然而,当提及 diameter 时,它不属于当前对象,而是属于类。因此,我们在点号 (.) 之前使用类名来引用它: Ball.diameter 。

同样地,如果我们有一个包含 main 方法的类,则无需创建该类的对象即可运行 main() ,因为 main() 是一个(由于它是 static)属于类本身而非在单个对象上调用的方法:

public class HelloWorld
{
    public static void main(String[] args)
    {
        // generate some simple output
        System.out.println("Hello, World!");
    }
}

通过此声明,我们可以调用 main() 方法,而无需创建 HelloWorld 类型的对象。

HelloWorld.main(null);

简言之,声明为 static 的方法或字段属于类本身,而非该类创建的任何对象,且由该类创建的所有对象共享同一份 static 字段或方法。此外,引用静态字段或方法时,应直接使用类名后跟点号(.),而非使用 this ,因为它们属于类而非类的任何实例。

6.1.16.1. 一个常见陷阱

需要注意的一点是,静态方法在某些方面与非静态方法是分离的。让我们仔细看看 Ball 示例。

public class Ball
{
    private String color;
    private static int diameter = 15;

    public static void printData()
    {
        // This line will not work!
        String message = "This ball is " + color + " and has a diameter of "
            + diameter + " cm" ;
        System.out.println(message);
    }
}

方法 printData() 是静态的。它无需创建对象即可调用。正因如此,我们无法引用字段 color ,因为该字段属于 Ball 的实例(且每个球都有自己的颜色)。更一般地说,在静态方法中,我们不能引用任何非静态的内容。

在 main() 方法中犯这种错误很常见。让我们再看一下 "Hello World" 代码的另一种实现:

public class HelloWorld
{
    public void print()
    {
        System.out.println("Hello, World!");
    }

    public static void main(String[] args)
    {
        // This will not work!
        print();
    }
}

在这种情况下,方法 print() 是非静态的——这意味着你必须拥有 HelloWorld 类的一个实例才能调用该方法。它无法从静态方法中调用!有两种方法可以解决这个问题。我们可以向 print() 方法添加 static 关键字。这是最简单的解决方案,但通常良好的设计实践是仅在绝对必要时才使用 static 关键字。

相反,我们可以在 main() 方法中创建 HelloWorld 类的新对象,并使用该对象调用 print() 方法。

public class Hello
{
    public void print()
    {
        System.out.println("Hello, World!");
    }

    public static void main(String[] args)
    {
        // This will work!
        HelloWorld hObject = new HelloWorld();
        hObject.print();
    }
}

事实上,这种模式在 main() 方法中非常常见,其中 main() 方法的核心目的就是创建程序所需的初始对象,并通过调用这些对象的一个或多个方法来“启动”它们。

然而,请记住,静态方法若没有可用的类实例对象,就无法调用非静态方法。简而言之,在静态方法中编写代码时,务必注意你所引用的内容!

6.1.17. 检查你的理解:static 关键字

6.1.18. 错误

程序中存在两类主要错误:编译时错误和运行时错误。区分它们有助于更快地定位问题。

6.1.18.1. 编译时错误

编译时错误发生在违反 Java 语言语法规则时。例如,圆括号和花括号必须成对出现。因此 int x = (1 + 2); 是合法的,但 int x = 8); 不是。在后一种情况下,程序无法被编译,编译器会显示一条错误信息。

在 Eclipse 中,编译时错误会在出错代码下方显示为红色下划线,同时也会列在主窗口底部的 Problems 选项卡中。

编译器发出的错误消息通常会指出程序中发生错误的位置,有时还能确切地告诉您错误是什么。例如,让我们回到 hello world 程序:

public class HelloWorld
{
    public static void main(String[] args)
    {
        System.out.println("Hello, World!") // there's a missing semi-colon here!
    }
}

如果您忘记在 print 语句末尾添加分号,可能会收到如下错误消息:

File: Hello.java [line: 5]
Error: ';' expected

这很不错:错误位置正确,且错误消息说明了问题所在。

但是(正如你可能在本课程中所见),错误消息并不总是容易理解。有时编译器报告的是程序中检测到错误的位置,而非错误实际发生的位置。有时,对问题的描述反而比帮助更令人困惑。

public class HelloWorld
{
    public static void main(String[] args)
    {
        System.out.println("Hello, World!");
        // there's a missing curly brace here!
}

您可能会收到如下消息:

File: Hello.java [line: 7]
Error:  reached end of file while parsing

这里有两个问题。首先,错误消息是从编译器的角度编写的,而不是从你的角度。 解析 是在翻译之前读取程序的过程;如果编译器在解析过程中到达文件末尾,这意味着某些内容被遗漏了。但编译器不知道是什么。它也不知道在哪里。编译器在程序末尾(第 7 行)发现错误,但缺失的大括号应该在前一行。

错误消息包含有用信息,因此您应努力阅读并理解它们。但重要的是要明白,它们并未讲述全部事实。

6.1.18.2. 运行时错误

在编程生涯的最初几周,您可能会花费大量时间追踪编译时错误(有时称为语法错误)。但随着经验的积累,您犯的错误会越来越少,并且能更快地发现它们。

第二类错误是运行时错误,之所以如此称呼,是因为它直到程序开始运行后才会出现。在 Java 中,这些错误发生在解释器执行字节码且出现问题时。这些错误也称为 异常,因为它们通常表明发生了某些异常(且糟糕)的情况。

当发生运行时错误时,解释器会显示一条错误消息,说明发生了什么以及在哪里发生。例如,如果您不小心除以零,您将收到如下消息:

Exception in thread "main" java.lang.ArithmeticException: / by zero at HelloWorld.main(HelloWorld.java:5)

此输出的某些部分可用于调试。第一行包含异常名称 java.lang.ArithmeticException ,以及更具体说明发生情况的消息 / by zero 。

下一行显示了发生错误的方法; HelloWorld.main 表示类 HelloWorld 中的方法 main() 。它还报告了定义该方法的文件 HelloWorld.java,以及发生错误的行号 5。

6.1.19. 抛出异常

在 Java 中,错误和其他异常情况通过 抛出 和 捕获 异常来处理。当检测到错误或异常条件时,您可以 抛出异常。这就像拉响火警警报。

假设我们有一个方法,用于将一个参数除以另一个参数:

public double div(double num, double denom)
{
    return num / denom;
}

但是,如果我们无法保证变量 denom 不为零怎么办?我们可以添加一个 if 语句,如果 denom 等于 0,则抛出异常。

public double div(double num, double denom)
    throws Exception
{
    if (denom == 0.0)
    {
        throw new Exception("Tried to divide by 0!");
    }

    return num / denom;
}

运行 div(1.0, 0.0) 会导致以下文本被打印到控制台:

java.lang.Exception: Tried to divide by 0!

我们的消息就是被打印出来的内容。

同样,抛出异常就像拉响火警警报。它会让一切停止运行。与 return 语句类似,当抛出 Exception 时,代码将在该行停止执行。

我们可以看到上方创建新 Exception 的语法。 throw 是 Java 中用于抛出异常的关键字。

你会注意到 throw 之后的语法看起来很像我们创建对象的方式——因为这正是它所做的。Exception 是 Java 中内置的一种对象。事实上, Exception 是一个父类,拥有许多不同的子类。在这种情况下,使用名为 IllegalArgumentException 的子类或许更为准确。

public double div(double num, double denom)
    throws IllegalArgumentException
{
    if (denom == 0.0)
    {
        throw new IllegalArgumentException("Tried to divide by 0!");
    }
    return num / denom;
}

这将产生错误消息:

java.lang.IllegalArgumentException: Tried to divide by 0!

你还会注意到方法签名发生了变化。在参数列表之后、左花括号之前有额外的文本。如果存在某条路径会故意抛出异常,我们就需要将其添加到方法签名中。如果不将这些行添加到方法签名中(它们用于向调用者表明可能会抛出异常),代码将无法编译。

6.1.20. 检查你的理解:抛出异常

6.1.21. 语法练习:抛出异常

6.1.22. Try/Catch 块

到目前为止,我们已经了解了如何主动抛出错误。然而,如果希望在抛出错误时让代码回退到其他处理情况,该怎么办呢?正如我们可以抛出异常一样,我们也可以 捕获 异常。让我们重新审视之前的示例:

public double div(double num, double denom)
    throws IllegalArgumentException
{
    if (denom == 0.0)
    {
        throw new IllegalArgumentException("Tried to divide by 0!");
    }
    return num / denom;
}

/**
 * divides two randomly generated numbers
 */
public double divideRandom()
{
    Random rand = Random.generator();
    double numerator = 5.5 * rand.nextInt(5);
    double denominator = 5.5 * rand.nextInt(5);

    double quotient = div(numerator, denominator);
    return quotient;
}

这里,我们生成两个随机 double 值,然后运行 div 方法。然而,回想一下我们对代码的了解: rand.nextInt(5) 。这段代码将生成一个介于 0 和 4 之间的随机整数。这意味着任一变量都有可能被设置为 5.5 * 0 的结果或 0。将 numerator 设为 0 并无大碍,但如果 denominator 为 0,我们将看到该异常被抛出。

如果我们希望优雅地退出这个潜在问题而不抛出错误,该怎么办?我们可以使用"try/catch"块。

/**
 * divides two randomly generated numbers
 */
public double divideRandom()
{
    Random r = Random.generator();
    double numerator = 5.5 * r.nextInt(5);
    double denominator = 5.5 * r.nextInt(5);

    double quotient = 0.0;
    try
    {
        quotient = div(numerator, denominator);
    }
    catch (IllegalArgumentException err)
    {
        quotient = div(numerator, 2.0);
    }
    return quotient;
}

Java 中异常的处理分为两个部分。首先,我们 尝试 执行一些可能会也可能不会导致异常的语句。这些语句被包含在 try 子句中。

try
{
    quotient = div(numerator, denominator);
}

其次,我们提供一个或多个 catch 子句来处理我们预期 可能 发生的特定类型的异常。在这种情况下,我们只处理一种 IllegalArgumentException ,因此只需提供一个 catch (尽管如果需要,您可以提供多个,每个针对不同类型的异常)。

catch (IllegalArgumentException err)
{
    quotient = div(numerator, 2.0);
}

这段代码的工作方式是:先执行 try 块中的代码。如果运行顺利,那就很好——无需额外操作。然而,如果在 try 部分内部抛出了 IllegalArgumentException ,catch 块会 捕获 该异常,而不会导致整个程序停滞。相反,若抛出该异常,则会将变量 quotient 赋值为执行 div(numerator, 2.0) 的结果。

正如我们之前所说,抛出异常就像拉响火警警报。 throw 发生在 try 块的某个作用域内。此时的“消防部门”是紧跟在 try 之后的 catch 子句中包含的代码。

6.1.22.1. Try/Catch/Finally 语法

try 块 以关键字 try 开头,后跟一对花括号括起来的代码块。 catch 子句 或 catch 块 由关键字 catch 组成,后跟一个参数声明,用于标识被捕获的 Exception 类型,再后跟一对花括号括起来的一组语句。这些语句通过采取适当措施来处理异常。因此, catch 子句也常被称为 异常处理器 。

一旦抛出异常,控制权将从 try 块转移到相应的 catch 块。控制权不会返回到 try 块。

try
{
    // block of statements at least one of which may throw an exception
}
catch (ExceptionClassName parameterName)
{
    // block of statements to be executed if ExceptionClassName is thrown
}
catch (DifferentExceptionClassName otherParameterName)
{
    // block of statements to be executed if DifferentExceptionClassName is thrown
}

我们还可以添加另一个可选子句,无论是否抛出异常都会执行,使用关键字 finally 。

try
{
    // block of statements at least one of which may throw an exception
}
catch (ExceptionClassName parameterName)
{
    // block of statements to be executed if ExceptionClassName is thrown
}
catch (DifferentExceptionClassName otherParameterName)
{
    // block of statements to be executed if DifferentExceptionClassName is thrown
}
finally
{
    // optional block of statements that is executed
    // whether or not an exception is thrown
    // this code is always run
}

在 try 中的代码执行后,以及在任何匹配的 catch 块执行后, finally 块内的代码 始终 会在最后运行。即使在 try 中抛出的异常不匹配任何 catch 子句, finally 块仍会被执行。

注意,一个给定的 try 块可以关联多个 catch 子句,且它们的排列顺序很重要。抛出的异常将由第一个匹配该异常类型的 catch 子句捕获。因此,最好将 catch 子句按从最具体到最一般的顺序排列。

try
{
    // block of statements at least one of which may throw an exception
}
catch (Exception exc)
{
    // This will catch any possible exception thrown as Exception
    // is the general parent class.
}
catch (IllegalArgumentException illegal)
{
    // This code is not reachable in this format.
}

同样重要的是要记住, try 块之后必须立即跟随一个或多个 catch 子句,并且 catch 子句只能跟在 try 块之后。

6.1.23. 检查你的理解:Try/Catch 块

6.1.24. 语法练习:Try-Catch 块

   «  5.4. 单元 5 实验 2 占位符   ::   目录   ::   6.2. 单元 6 实验 1 占位符  »

关闭窗口