OpenDSA 完整目录

Chapter 0 Introduction to Software Design

| 关于   «  9. 使用列表与嵌套 for 循环对对象分组   ::   目录   ::   11. 固定大小的 1D 数组与数组惯用法  »

10. 列表、循环惯用法、泛型和 Null 关键字

上周我们讨论了 List 是什么以及它们如何工作。本周,我们将更多地关注为什么它们在更复杂的编程任务中可以有用。

10.1. 模拟图书馆的内容

作为一个例子,让我们尝试在计算机程序中模拟一个图书馆。首先,让我们创建一个 Book 类,其中的字段跟踪书名、作者和 ISBN 号码。

public class Book
{
    private String title;
    private String author;
    private int isbn;

    public Book(String aTitle, String anAuthor, int theISBN)
    {
        this.title = aTitle;
        this.author = anAuthor;
        this.isbn = theISBN;
    }

    public String getTitle()
    {
        return title;
    }

    public String getAuthor()
    {
        return author;
    }

    public int getISBN()
    {
        return isbn;
    }
}

一旦一本书被创建,我们就不需要更改这些字段中的任何一个,因此我们只需要创建取值方法。当字段为只读时不需要设值方法。

接下来,我们想表示一个 书架 ,它可以存储多达 50 本书。显然,我们不想创建 50 个字段来保存这些书,以及 50 个取值和设值方法来更改它们。相反,我们将书存储在书籍列表中,并提供一个方法将另一本书添加到书架上。

import java.uti.*;

public class Shelf
{
    private int maxCapacity;
    private List<Book> contents;

    public Shelf()
    {
        maxCapacity = 50;
        contents = new ArrayList<Book>();
    }

    // adds a book if there is space on the shelf
    public void addBook(Book book)
    {
        if (contents.size() < maxCapacity)
        {
            contents.add(book);
        }
    }

    public List<Book> getContents()
    {
        return this.contents;
    }
}

我们还将有一个包含多个书架的图书馆:

import java.util.*;

public class Library
{
    private List<Shelf> stacks;

    public Library()
    {
        stacks = new ArrayList<Shelf>();
    }

    public void addShelf(Shelf shelf)
    {
        stacks.add(shelf);
    }
}

我们将在以下部分中增强这个基本设计。

10.2. 循环惯用法

"惯用法"是一种常见的模式或表达。在 Java 中使用循环时,你可能会开始看到循环使用方式中的一些常见模式。for 循环特别适合以下情况:

  • 将代码重复特定次数

  • 在位置序列上重复

  • 在某个数值范围内累积答案

此外,循环通常用于在值序列中搜索以找到匹配某个条件的第一个(或最后一个)值。

10.2.1. 重复特定次数:上架书籍

让我们在 Library 类中通过为书籍创建一些书架来开始。

import java.util.*;

public class Library
{
    private List<Shelf> stacks;

    public Library()
    {
        stacks = new ArrayList<Shelf>();
    }

    public void addShelf(Shelf shelf)
    {
        stacks.add(shelf);
    }

    // new method
    public void createShelves()
    {
        // will add Shelf objects to our stacks
    }
}

要制作一个单独的书架,我们将创建一个书架类的对象并将其添加到我们的列表中:

// Create a single shelf
public void createShelves()
{
    Shelf shelf = new Shelf();
    this.stacks.add(shelf);
}

然而,正如 createShelves 名称所暗示的,我们可能想一次制作多个书架。相反,我们将更改 createShelves 以接受一个参数来指定我们想制作多少个书架:

public void createShelves(int n)
{
    for (int i = 0; i < n; i++)
    {
         Shelf shelf = new Shelf();
         this.stacks.add(shelf);
    }
}

当你想执行一个动作特定次数时,计数器控制循环是一个不错的选择。

10.2.2. 找到第一个匹配项:搜索一本书

10.2.2.1. 使用 For-Each 找到匹配项

假设有人来到我们的图书馆并询问我们是否有书 "Catch-22"。我们需要某种方式来确定这本书是否在我们的某个书架上。

为了实现这个目标,我们将向 Shelf 类添加一个方法,如果给定标题的书在该书架上则返回 true 。

import java.util.*;

public class Shelf
{
    private int maxCapacity;
    private List<Book> contents;

    public Shelf()
    {
        maxCapacity = 50;
        contents = new ArrayList<Book>();
    }

    // other methods omitted ...

    // new method
    public boolean hasTitle(String title)
    {
        // should return true if a book with the specified title
        // is in our list of books,
        // otherwise return false
    }
}

我们可以使用计数器控制循环或 for-each 循环来实现这个目标。让我们看看如何用 for-each 循环编写这个:

public boolean hasTitle(String title)
{
    boolean result = false;
    for (Book book : this.contents)
    {
        if (title.equals(book.getTitle()))
        {
            result = true;
        }
    }
    return result;
}

在这里,我们遍历 contents 列表中的每个 Book 。在每次迭代中,我们声明我们要与当前正在查看的任何书的标题匹配的标题。

如果我们找到一本标题与参数 title 匹配的书,我们将布尔 result 设置为 true 。一旦我们的循环完成,我们返回 result 被设置为的任何值。

然而,一旦我们找到我们要找的书,就没有必要继续查看书架的其余部分。相反,让我们去掉 result 并修改我们的代码以更好地利用 return 语句。

public boolean hasTitle(String title)
{
    for (Book book : this.contents)
    {
        if (title.equals(book.getTitle()))
        {
            return true;
        }
    }
    return false;
}

一个 return 语句在执行的行上终止方法,因此在 return 发生之后,方法中的任何其他代码都不会被执行。通过这个更改,不是查看所有书并在搜索整个书架后才返回答案,而是更改了 if 语句,以便一旦我们找到匹配的标题,方法 立即 返回 true。这立即结束方法,在找到所需书籍时立即停止循环。但是,如果没有书匹配给定标题,循环继续直到所有循环都被检查。循环之后,在该情况下单独的 return 语句返回答案。

这种特殊方法称为 提前循环终止 或 提前循环退出 ,我们在答案可用时立即停止循环,以避免任何不必要的工作。

10.2.2.2. 使用计数器控制循环找到第一个对象

使用计数器控制的 for 循环实现此方法也同样正确。在这种风格中,我们将使用索引变量通过位置访问列表。

public boolean hasTitle(String title)
{
    for (int i = 0; i < this.contents.size(); i++)
    {
        Book book = this.contents.get(i);
        if (title.equals(book.getTitle()))
        {
            return true;
        }
    }
    return false;
}

你会注意到,除了 for 循环的性质之外,这个实现与上一节中的 for-each 实现几乎相同。

10.2.2.3. Break 关键字

有时,我们可能希望循环提前结束而不导致整个方法终止。在这些情况下,我们可以使用 break 命令:

for (int i = 0; i < this.contents.size(); i++)
{
    Book book = this.contents.get(i);
    if (title.equals(book.getTitle()))
    {
        break;
    }
}
System.out.println("Found it!");

在这里,一旦找到标题匹配的书,就执行 break 语句。这立即停止(或"中断")循环,执行继续到循环后面的语句。

你可以在任何类型的循环中使用 break 语句。在 for-each 或 while 循环中执行 break 将以同样的方式停止这些循环。但是,确保你在使用 break 语句时理解其目的,因为它们可能使代码更难阅读并且更容易出错。当你想要一个提前循环退出在找到答案时终止循环时使用单个 break 是有用的,但在循环中放置许多 break 语句,或者在没有清楚理解计划的情况下使用它们,更有可能产生缺陷/瑕疵。事实上,许多程序员在提前退出情况之外远离 break ,因为它有产生问题的潜在风险。

10.2.2.4. 使用 For 循环找到最后一个对象

上面的循环将找到列表中标题匹配的第一本书。但是,有时你可能想找到列表中的最后一项。

例如,如果一个人来到图书馆索要 "The Godfather" 而我记得刚才把那本书放在了书架上,该怎么办。

如果书架的内容列表包含 50 本书,如果我知道 "The Godfather" 靠近末尾,就没有必要搜索大部分书籍。相反,我们可以使用计数器控制循环从列表的最后一个位置开始,并 反向 计数。

public boolean hasTitle(String title)
{
    for (int i = this.contents.size() - 1; i >= 0; i--)
    {
        Book book = shelfContents.get(i);
        if (title.equals(book.getTitle()))
        {
            return true;
        }
    }
    return false;
}

此循环将从列表中最高索引的书开始,一路向下到索引 0。此循环仍然使用 提前循环退出 技术在找到所需书籍时立即停止循环。

10.2.3. 累积答案:按作者计算书籍数量

假设另一位图书馆访客问我们的图书馆有多少本 Stephen King 的书。

import java.util.*;

public class Library
{
    private List<Shelf> stacks;

    public Library()
    {
        stacks = new ArrayList<Shelf>();
    }

    public void addShelf(Shelf shelf)
    {
        stack.add(shelf);
    }

    // new method
    public int countBooksByAuthor(String author)
    {
        // ...
    }
}

在此方法中,我们想使用循环来计算给定作者的书的数量。我们知道如何使用 if 语句来检查一本书是否匹配作者。如果我们将其放入循环中,每次找到匹配的书时我们可以给计数器变量加一。为此,我们需要一个变量来存储匹配书籍的数量。这种变量通常称为 累加器 ——一个"累积"我们正在计算(或计数、求和等)的答案的变量。每次我们循环迭代时,我们给累加器添加更多一点信息,当我们的循环完成重复时,累加器保存了整个答案。

public int countBooksByAuthor(String author)
{
    // declare our counter (accumulator)
    int count = 0;
    // iterate over every shelf:
    for (Shelf shelf : this.stacks)
    {
        // iterate over every book on a given shelf
        for (Book book : shelf.getContents())
        {
            // if we find a book by our desired author...
            if (book.getAuthor().equals(author))
            {
                // increase counter by 1
                count++;
            }
        }
    }
    // return our count
    return count;
}

要使用我们的累加器,我们声明一个名为 count 的 int 变量并将其初始化为 0。然后,我们使用两个循环。外层循环为图书馆中的每个书架重复。内层循环搜索每个书架以查找与参数 author 匹配的任何书籍。当我们找到一个时,我们使用 ++ 运算符递增(加一)计数器。当我们完成循环时,我们最终返回我们累积的 count 。

请注意, count 中的值不是一次计算的。它是增量构建的,每次一本书。在任何时间点, count 代表在两个循环的组合操作中我们到目前为止看到的给定作者的书籍总数。只有当循环完全完成时, count 才达到最终答案。

10.2.3.1. 累积不同类型的答案

如果我们不仅仅知道 Stephen King 的书的数量,而是想把它们全部放到一个地方呢?在这里,我们将使用与上面相同的结构来累积答案,但我们将生成并返回一个包含该作者所有书籍的列表,而不仅仅是计数。这次,我们的累加器将是一个列表,而不是一个简单的数字。

// new method
public List<Book> getAllBooksByAuthor(String author)
{
    // declare our List
    List<Book> foundBooks = new ArrayList<Book>();

    // iterate over every shelf:
    for (Shelf shelf : this.stacks)
    {
        // iterate over every book on a given shelf
        for(Book books : shelf.getContents())
        {
            // if we find a book by our desired author...
            if (book.getAuthor().equals(author))
            {
                // add the book to our List
                foundBooks.add(book);
            }
        }
    }
    // return our list
    return foundBooks;
}

在这里,不是递增计数器变量,而是每次找到匹配作者参数的书时将其添加到列表(我们的累加器)中。然后在我们查看完所有书架之后,返回该列表。

当我们有许多满足 if 语句条件的项目时,这会很有用。

10.3. 语法练习 9a:循环惯用法

10.4. 泛型回顾

到目前为止,每当我们使用变量时,我们总是知道它们是什么类型。例如,每当我们使用 String 时,我们会声明一个变量,如 String s = "Hello World"; 。但是,在 Java 中有些情况下,我们需要创建不知道将使用什么数据类型的方法——或者更准确地说,使它们适用于 任何类型 。对于这些,我们使用称为 泛型类型参数 的东西来表示我们正在使用的数据类型。

声明使用泛型类型参数的类涉及使用新的语法来引用类名。此类和接口(包括 Java 集合的那些)使用尖括号(<...>)包含一个或多个变量(用逗号分隔)来引用未指定的类型名称。例如,你会使用 <Element> 或 <Key, Value> 来引用未指定的类型名称。有时你会看到在这种方式中使用的超短变量名,如 <E> 或 <K, V>,尽管较长的名称对初学者来说更容易理解。使用此技术,使用泛型类型实现的类或接口的名称写为 ClassName<E> 语法。

让我们看看一个使用泛型类型参数的类。以下 Box 类可以容纳任何类型的数据。我们将使用参数名 Content 来引用 Box 中保存的数据类型。

public class Box <Content>
{
    private Content value;

    public Box(Content newValue)
    {
        this.value = newValue;
    }

    public Content getValue()
    {
        return this.value;
    }

    public void setValue(Content newValue)
    {
        this.value = newValue;
    }
}

然后我们可以创建一个 Box 对象。

Box<String> box1 = new Box<String>("surprise");

并且并非所有 Box 对象都需要是相同类型:

Box<String> box1 = new Box<String>("surprise");
Box<Integer> box2 = new Box<Integer>(42);

实际上, <Content> 作为将存储在 Box 中的对象类型的参数。

泛型类型提供的一大好处是在编译时检查方法参数的类型。例如,以下代码在编译时会导致错误:

Box<String> box1 = new Box<String>("surprise");
box1.setValue(42);

因此,如果程序员希望创建字符串列表,使用泛型类型将有助于保证存储的对象确实属于正确的类型。即使你可以创建任何类型对象的列表,这个特定的列表只包含字符串,没有其他内容。通过这种方式,使用泛型类型有助于减少编程错误的数量,从而使程序更安全、更健壮。同时,它允许我们创建更可重用的类,因为我们可以创建与各种其他类一起工作的类型,而不必事先知道那些类是什么。

10.5. 语法练习 9b:泛型

10.6. Null 关键字

当你声明一个对象变量时,记住你正在存储一个对象的引用。在 Java 中,关键字 null 是一个特殊值,表示"没有对象"。你可以这样声明和初始化对象变量:

Pixel pix = null;

当你不想创建新对象或将变量初始化为引用特定现有对象时,这有时很有用。当方法应该返回一个对象但有时没有对象可返回时,它也很有用。想想一个查找特定书籍并返回该书的"查找"方法。如果找不到它会怎样?在许多情况下,让这样的方法返回特殊值 null 是表示方法不返回对象的有用方式,也许因为"没有对象"是一个有意义的答案。

如果你尝试使用值为 null 的变量(或返回 null 的表达式),无论是访问字段还是调用方法,Java 都会抛出 NullPointerException 。异常表示发生了不寻常的情况,这可能意味着你的程序"违反了规则"——无论是 Java 程序行为的规则还是特定库方法或类的规则。

程序员经常使用短语"空指针异常"或首字母缩写 NPE 来指代这些类型的错误。NPE 几乎总是意味着你的程序中有缺陷/瑕疵,因为你的程序试图在需要对象的情况下使用"null",但 null 表示"没有对象",所以要求没有被满足。以下是将抛出空指针异常的代码示例。

Pixel pix = null;
pix.setRed(255); // This was a cause a NullPointerException

NullPointerException 是程序员经常遇到的错误。请注意,如果你看到它在你自己的代码中出现,你可能正在使用一个没有被设置为引用特定对象的变量,或者你正在使用一个有时不返回对象的方法。

10.7. 诊断空指针异常

首先,记住 NullPointerException (也称为"NPE")发生在你尝试使用 null 作为某些东西的接收者时——基本上,当你有一个点(".")用于调用方法或引用字段时,但点左侧的表达式("接收者")是 null 。换句话说,你有一个"."但没有对象作为接收者。

如果这在执行测试时发生,测试将失败。在测试结果窗口中,如果你单击失败的测试,窗口的下半部分将显示 NullPointerException 以及关于它发生位置的信息,如本例所示。

_images/bluej-test-npe.png

在这里,通过单击失败的测试 testMaxRed() ,BlueJ 测试窗口的下半部分显示信息。如果你在不运行测试时遇到错误,同样的信息将出现在 BlueJ 的终端窗口中。

在此信息的顶部,你可以看到 NPE 通常不包含任何异常消息或信息。你还会看到异常的类名 NullPointerException 。不要只看异常名称和/或消息——它们对于说明发生了什么很有用,但它们只是内容的一部分。此外,在异常消息下面有一个(可能很长的)方法列表。这个方法列表称为"堆栈跟踪",它基本上确切地显示了失败发生的位置以及你的程序如何到达该位置。该列表中的最顶部条目显示异常发生的类、方法名、文件名和行号。文件名和行号在括号中。有时这在你自己的代码中——如果是这样,直接转到命名文件中的特定行,你将正好在异常发生的位置。

例如,这个堆栈跟踪指向 TransformablePicture 类中的 maxRed() 方法,在该类的第 38 行。如果我们查看那里,我们会找到 NPE 发生的行(取自上一节)。

pix.setRed(255); // This was a cause a NullPointerException

如果最顶部的行不是你的代码——也许是库方法——不要绝望。虽然异常可能发生在另一个类的方法中,但它几乎肯定是因为你的代码调用某些东西的方式。只需查看列表中更低的位置。查看列出的第二个方法、第三个方法、第四个,以此类推,直到你找到你自己类中的一个方法。现在你确切地知道你自己的代码中问题发生在什么位置。同样,你可以使用文件名和行号直接转到源代码中问题发生的位置。之后,是时候调试了——找出问题发生的原因,以便你可以修复它。

一旦你找到了位置,要诊断空指针异常,只需查看异常发生行上的点(句号)。可能只有一个,或者可能有几个。仔细检查每一个。你需要找出哪一个点的左边有"null"(即没有对象)。你通常可以通过排除法来做到这一点。

在我们的示例中,问题所在的行包含 pix.setRed(255); ,所以只有一个点。点左边的名称是 pix ,所以这个问题发生是因为 pix 是 null ,但我们试图像有对象一样用它来调用方法。

本课程中 NPE 最常见的原因有:

  • 你没有正确初始化字段或变量

  • 你正在使用方法的结果,假设该方法总是返回一个对象,但该方法实际上有时可能返回 null

  • 你正在将 null 作为参数值传递给库方法,但实际需要一个对象

你需要逐个检查异常发生行上的"点"以排除这些问题,并缩小可能性范围,直到你弄清楚 null 在哪里以及为什么出现。如果你需要向下查看堆栈跟踪几行,你可能总是处于上面提到的最后一种情况——不是在"点"的左边有一个 null 值,而是你将 null 值传递给其他方法(库方法或从父类继承的方法)。

尝试一下,看看你是否能弄清楚。虽然这些是最常见的,但你可能遇到这个问题的方式是无限的,但诊断它们的过程总是相同的。如果你在确定错误位置时遇到困难,请在此处发布你的异常消息和堆栈跟踪的内容以获取更多信息。如果你找到问题所在的行但无法确定为什么/如何,在尝试上述建议后,请发布 NPE 发生的行以及堆栈跟踪,以便我们可以指导你解决它。

10.8. 使用 BlueJ 的调试器

10.9. 使用 BlueJ 的 Code Pad

10.10. 编程练习 9a:循环惯用法

10.11. 编程练习 9b:循环和泛型

   «  9. 使用列表与嵌套 for 循环对对象分组   ::   目录   ::   11. 固定大小的 1D 数组与数组惯用法  »

关闭窗口