CS 1114 软件设计导论

第 8 章 对象的组织:列表、聚合与串

| 关于   英文版   «  7.1. 面向对象继承、重写与接口   ::   目录   ::   9.1. 固定大小的 1D 数组与数组惯用法  »

8.1. 对象分组:列表、聚合与字符串

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

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

一旦图书被创建,我们就不需要更改这些字段中的任何一个,因此我们只需要创建取值方法(getter)。当这些字段是只读的时候,就无需 setter 方法。

接下来,我们要表示一个 书架(shelf) ,它最多能存放 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);
    }
}

在接下来的各节中,我们将逐步增强这个基本设计。

8.1.2. 循环惯用法

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

  • 按指定次数重复代码

  • 在一系列位置上重复

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

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

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

当你希望按指定次数执行某个动作时,计数控制循环是一个不错的选择。

8.1.2.2. 查找第一个匹配项:搜索一本书

8.1.2.2.1. 查找匹配项的函数模拟

假设有人来到我们的图书馆,问我们是否有书 "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 语句在该情况下返回答案。

这种特殊的方法称为 循环提前终止(early loop termination) 或 提前退出循环(early loop exit) ,即一旦答案可得就立即停止循环,从而避免任何不必要的工作。

8.1.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 实现几乎完全相同。

8.1.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 语句,或者在并不清楚理解方案的情况下使用它们,则更可能制造 bug。事实上,许多程序员除了提前退出的情况外都避免使用 break ,因为它有引发问题的可能。

8.1.2.2.4. 用 for 循环查找最后一个对象

上面的循环会找到列表中标题匹配的第一本书。然而,有时你可能想找的却是列表中的最后一个条目。

例如,如果有人来到图书馆要借 "教父"(The Godfather),而我记得就在刚才把这本书放上了书架,那会怎样?

如果书架的内容列表里有 50 本书,而我知道 "教父" 靠近末尾,就没有必要搜索其中大部分书。相反,我们可以使用计数控制循环从列表的最后一个位置开始, 倒着 数。

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。这个循环仍然使用 提前退出循环 的技术,一旦找到目标书就立即停止循环。

8.1.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 语句检查一本书的作者是否匹配。如果把它放进循环里,每当我们找到一本匹配的书,就给计数器变量加一。要做到这一点,我们需要一个变量来存储匹配书籍的数量。这种变量常被称为 累加器(accumulator)——一个"累加"我们正在计算(或统计、或求和,等等)的答案的变量。每当我们再经过一次循环迭代,就向累加器添加一点信息,当我们的循环重复结束之时,累加器就持有完整的答案。

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 才达到最终答案。

8.1.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 语句中的条件时,这会很有用。

8.1.3. 再谈泛型

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

声明使用泛型类型参数的类涉及使用新的语法来引用类名。这样的类和接口,包括用于 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);

因此,如果程序员想创建一个字符串列表,使用泛型类型将有助于保证被存储的对象确实属于正确的类型。尽管你可以创建任何类型对象的列表,但这个特定的列表只包含字符串,不包含其他任何东西。这样,使用泛型类型有助于减少编程错误的数量,从而使程序更安全、更健壮。同时,它允许我们创建更具可复用性的类,因为我们可以创建能与各种其他类协同工作的类型,而不必预先知道那些类是什么。

8.1.4. null 关键字

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

Pixel pix = null;

当你不希望创建新对象,也不希望把变量初始化为引用某个特定的既有对象时,这有时会很有用。当一个方法应当返回一个对象,但有时又没有对象可返回时,它也会很有用。想一个"查找"方法:它寻找某本特定的书,如果找到就返回那本书。如果没有找到会怎么样?在许多情况下,让这样的方法返回特殊值 null 是表明该方法不返回对象的一种有用方式,也许因为"没有对象"本身就是一个有意义的答案。

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

程序员常用"空指针异常"(null pointer exception)这个说法或其缩写 NPE 来引用这类错误。NPE 几乎总是意味着你的程序中有 bug,因为你的程序在需要对象的场合试图使用 "null",而 null 的意思是"没有对象",所以这个要求并没有得到满足。下面是一个会抛出空指针异常的代码示例。

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

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

8.1.5. 诊断空指针异常

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

如果这种情况发生在执行测试的过程中,测试将会失败。在测试结果窗口中,如果你单击失败的测试,窗口的下半部分会显示 NullPointerException 以及关于它在何处发生的信息,如本例所示。

_images/bluej-test-npe.png

在这里,通过单击失败的测试 testMaxRed() ,BlueJ 测试窗口的下半部分显示了这些信息。如果错误发生在你没有运行测试的时候,同样的信息会改而出现在 BlueJ 的终端窗口中。

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

例如,这个堆栈跟踪指向 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 的那一行连同堆栈跟踪一起发出来,这样我们可以指导你解决。

8.1.6. 使用 BlueJ 的调试器

8.1.7. 使用 BlueJ 的代码垫

8.1.8. 编程练习 8

   «  7.1. 面向对象继承、重写与接口   ::   目录   ::   9.1. 固定大小的 1D 数组与数组惯用法  »

关闭窗口