OpenDSA 完整目录

Chapter 0 Introduction to Software Design

| 关于   «  15. 映射、集合与关联数据建模   ::   目录   ::   1. 命令行  »

16. 静态成员、主方法与异常处理

16.1. 主方法

传统上,学习新编程语言时编写的第一个程序称为 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 实际上是一项功能,允许用户在程序启动时向其传递字符串参数。在大多数情况下,我们在本课程中无需担心如何使用此数组。然而,它仍然是方法签名的一部分,缺少它就不能被视为 main 方法。当使用命令行以独立方式执行 Java 程序时,命令行上给出的参数将用于填充此数组,因此它们在 main() 内部是可访问的。

16.2. 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!");
    }
}

通过此声明,我们可以在不创建 HelloWorld 类型对象的情况下调用 main() 方法。

HelloWorld.main(null);

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

16.2.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() 方法的核心目的就是创建程序所需的初始对象,并通过调用这些对象的一个或多个方法来“启动”它们。

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

16.3. 错误

程序中可能发生两种主要错误:编译时错误和运行时错误。区分它们有助于更快地定位问题。

16.3.1. 编译时错误

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

在 BlueJ 中,编译时错误会在你按下“编译”之前就以红色下划线的形式出现在出错代码下方。

编译器发出的错误消息通常会指出程序中发生错误的位置,有时还能确切地告诉您错误是什么。例如,让我们回到 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 行)发现了错误,但缺失的大括号应该在前一行。

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

16.3.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。

16.4. 抛出异常

在 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!

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

16.5. 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;
}

在这里,我们生成两个随机双精度浮点数,然后运行 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 子句中。

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 子句中包含的代码。

16.5.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 块之后。

16.6. 编程练习 14

   «  15. 映射、集合与关联数据建模   ::   目录   ::   1. 命令行  »

关闭窗口