MengBridgeCourse 中文版

第 4 章 更多列表、循环、数组与分组

| 关于   英文版   «  4.2. 单元 4 实验 1 占位符   ::   目录   ::   4.4. 单元 4 实验 2 占位符  »

4.3. 使用列表、嵌套 for 循环和循环惯用法对对象进行分组

4.3.1. 对象的集合

虽然我们一直使用单独的变量或字段来引用单个对象,但最终你将需要处理管理大量对象的程序。当你处理大量对象时,将每个对象分别放入单独的变量或字段中会变得繁琐,最终会使代码更加复杂和臃肿。相反,在处理大量数据时,我们需要 容器 对象,以便能够存储和管理对象集合。

Java 提供了一组实用类库,用于协助完成许多常见任务,该类库包含多个 Collection 类。这些类大致可分为三类,这在许多编程语言中都很常见:

  • 列表允许我们按顺序存储一系列值。

  • 集合允许我们存储无序的值集合。

  • 映射允许我们存储查找表,使我们可以用一个数据项来查找与之关联的另一个数据项,例如使用单词在字典中查找其定义。除了“映射”外,它们也常被称为“字典”、“关联数组”或“哈希”。

集合将对象组合在一起。所有这些集合都具有若干共同属性:

  • 它们会根据需要增加容量,以容纳所需的所有数据。

  • 它们会记录所包含的数值个数。

  • 它们在内部保持这些值的有序组织,使我们可以在需要时添加或移除它们。

  • 所有这些操作的细节都被隐藏了。实际上,我们无需了解其内部工作原理即可 使用 一个集合。相反,我们依赖集合来为我们完成其任务。

4.3.2. 接口

由于有时我们只想了解如何使用一个类,而无需关心其内部细节,因此仅讨论该类提供的服务(或方法)会非常方便。Java 提供了一种工具,可用来描述一个类所提供的方法集合,而无需关注其内部实现,这种工具称为 接口。一个 接口 类似于类,但仅列出类中公共方法的声明(以及您希望提供的任何公共常量)。它不包含字段、构造函数、方法实现或任何私有部分——仅包含允许使用的声明中的公共部分。

与类相比,接口仅提供足够的信息,使你能够调用其公共方法并使用它,而类则提供了这些方法实际实现的全部细节。因此,你可以使用类来创建对象,因为完整的实现信息是可用的。然而,你不能单独使用接口来创建对象——所有对象都属于某个类,而接口仅描述了一组类可能提供的方法声明。

为什么要使用接口?在编程中,接口主要用于三个主要原因:

  1. 通过将方法的声明分离到一个接口中,再将实现移至另一个类,使得同一接口可以使用不同的策略以多种不同方式实现。在没有接口的情况下,多重实现既复杂又容易出错;但由于任何数量的类都可以通过提供所需的方法来实现一个接口,因此当存在多种实现相同算法或数据结构的技术时,接口非常有用。

  2. 通过接口,你可以捕获一组期望出现在多个类中的公共方法,从而为这组方法命名,并确保提供该组方法的类都能一致地实现。

  3. 通过将方法的声明分离到接口中,你使需要 使用 该代码的程序员能够基于接口进行开发,同时其他程序员也可以并行地编写底层实现。即使底层软件尚未实现,接口也能让团队之间进行沟通和相互依赖。

举个例子,假设我们正在开发一个管理图形形状的程序,并希望所有类型的图形形状都能在屏幕上绘制。我们可以设想每种形状都具有一个 draw() 方法,用于在屏幕上绘制该形状。我们可以通过如下接口来实现这一设计:

public interface Drawable
{
    public void draw();
}

你會注意到這裡有一些與我們之前見過的其他 Java 程式碼不同的地方。首先,我們看到使用了關鍵字 interface ,而不是 public class 。其次,方法簽名後面跟著一個 ; ,而非大括號。該方法完全沒有實作內容——這部分由提供此方法的各個類別自行決定。介面僅宣告方法名稱、參數與傳回型態。

接口的更通用写法如下:

public interface InterfaceName
{
    // any number of constant values

    // any number of method signatures WITHOUT implementation.
}

仅凭此代码本身不会产生任何效果。然而,它捕捉到了提供单一 draw() 方法的思路。我们无法用它来创建对象——那需要一个类。任何我们打算符合此接口的类都应 实现 该接口:

public class Rectangle
    implements Drawable
{
    // ...
    public void draw()
    {
        // ...
    }
}

public class Circle
    implements Drawable
{
    // ...
    public void draw()
    {
        // ...
    }
}

在这两个类定义中,我们使用关键字 implements 后接接口名称,以声明该类提供了该接口中包含的所有方法。当我们说 class Rectangle implements Drawable 时,我们是在声明类 Rectangle 提供了接口 Drawable 中声明的所有方法。此外,这是一个保证,如果我们在拼写 draw() 的名称时出错,或以与 Drawable 中声明方式不一致的方式声明它,编译器将报错。 Rectangle 类在我们实现签名 public void draw() 的方法之前将无法通过编译。我们可以添加任何其他字段或方法,但那个 draw() 方法 必须 被实现。

然而,通过声明 class Rectangle implements Drawable ,现在所有使用 Rectangle 类的程序员(或源代码)都将知道该类提供了一个 draw() 方法,且该方法可像其他可绘制对象一样被使用。

单独来看,这在一种语言中可能显得有些奇怪。开发者难道不能自己记住实现那个方法吗?在我们的例子中,也许可以。但接口为我们提供了一种明确写下这些要求的方式,以便共享,同时也为编译器提供了检查我们是否已包含具有正确声明的所需方法的机制,并提醒我们在这一方面可能犯的任何错误。因此,接口提供了更好的错误检查以及程序员之间的更好沟通。

4.3.3. 理解自测:接口

4.3.4. 语法练习 8a:字符串

4.3.5. 列表接口

之前,我们曾将特定的数据保存到变量中。例如,假设我们正在处理一个以字符串形式存储的姓名列表——可以想象你所有同学的姓名。我们可以将每个姓名存储在单独的变量中。

String name01 = "Anna";
String name02 = "Joey";
String name03 = "Maria";
String name04 = "Chris";

然而,当你处理大量姓名时,这种方法很快就会变得非常繁琐且效率低下。例如,如果你需要处理100个姓名,就需要100个不同的变量。现在想想你该如何将它们全部打印出来。你为每个变量都需要一条独立的语句,因此要打印所有姓名将需要100行代码。

  • 一种替代方式是,我们可以用另一种方法来存储多个值。与其将每个值放入单独的变量中,不如使用一个变量作为大 容器,把每个独立的名称都放进这个容器里。Java 使用术语 Collection 来表示那些像容器一样存放其他对象组的对象。事实上, Collection 在 Java 中实际上是一个 接口,它定义了所有容器对象提供的通用方法。顺便说一下,容器通常被称为 数据结构,因为它们以有组织的方式排列一组数据值,以解决特定类型的问题。

目前,我们将重点关注一类特定的容器:列表。在 Java 中, List 是另一个定义了各类列表通用方法的接口。Java 提供了多种类来以不同方式存储元素序列:有些更注重通过指定元素的位置来实现对单个对象的快速访问,另一些则更注重插入和删除操作的高效性。但其中存在权衡,因为大多数容器在加快某些操作的同时,会减慢其他操作。使用统一的接口使程序员能够将这些不同的实现视为在方法使用上完全可互换的对象,即使某些方法的运行速度会因底层具体类的不同而有所差异。

下列表格总结了最常见的 List 方法:

Some List Interface Methods

方法名称

用途

add(<some value>)

向列表添加一个元素

get(int <some index>)

返回此索引处存储的元素

set(int <some index>, <some value>)

将指定索引处的元素设置为某个值

clear()

从列表中移除所有元素

isEmpty()

若列表中未存储任何值,则返回 true ,否则返回 false

remove(int <some index>)

从列表中移除指定索引处的元素

size()

返回列表中元素的个数

contains(<some value>)

若该值在列表中,则返回 true ,否则返回 false

add(<some index>, <some value>)

在指定位置向列表插入一个元素,将其他元素向后移动一位以腾出空间

4.3.6. 泛型

List 接口也标志着我们在 Java 中首次遇到 泛型类型。 List 接口是 泛型 的,这意味着它要求我们指定另一个它所操作的类型。我们通过在每次使用 List 接口名称时提供另一个类型作为 参数 来实现这一点。对于 List ,另一个类型表示列表将持有的对象类型。

List<String> names = ...;

names.add("Sara");        // works, since value is a String
names.add(new Jeroo());   // compiler error, since it is not a String

List<Jeroo> jeroos = ...;

jeroos.add("Sara");        // compiler error, since it is not a Jeroo
jeroos.add(new Jeroo());   // works, since value is a Jeroo

泛型类型是一种需要一个或多个其他类型作为参数的类或接口。我们通过在尖括号(<...>)内指定这些其他类型来定义。请记住,每次声明字段、变量、参数或返回类型时,都必须明确指定类型。例如,使用 List 时,始终应提供类型,以明确列表中存放的项目类型。

4.3.7. 数组

请注意,由于 List 是一个接口,它不会提供创建对象所需的信息——仅指定所需的方法。要创建实际的对象,需要一个实现该接口的类——通常称为 具体类,因为它提供了所有字段如何初始化以及所有方法内部行为的具体实现细节。尽管 List 接口有多种实现,但在本课程中我们将依赖最常用的一种:ArrayList。

因为 ArrayList 实现了 List ,所以你知道它提供了前一节中描述的所有方法。 ArrayList 也是一个泛型类型,使用尖括号(<...>)中的参数来表示列表中存放的元素类型。

花几分钟观看以下视频:

在一个 ArrayList 中,数据以线性或顺序结构排列,一个元素紧随另一个元素。例如,如果我们有一个 ArrayList 整数的列表,它可能看起来像这样:

_images/ArrIdea.png

列表中的大数字是 ArrayList 的元素。框外的小数字是用于标识 ArrayList 中每个位置的 索引 (或称下标,或位置)。请注意,第一个元素的索引是 0,而不是 1。这一点非常重要:就像图片中的 Pixel 一样, ArrayList 的索引从 0 开始,而非从 1 开始。忘记这一事实是一个很容易犯的错误。

4.3.7.1. 使用数组

让我们尝试在代码中重新创建上方的图像,作为一个 ArrayList 。

添加导入

在开始之前,我们还需要在代码中添加一个导入语句:

import java.util.*;

若无此设置,Java 将无法识别名称 List 或 ArrayList 。

声明并实例化一个数组

由于 List 接口向我们展示了列表上所有方法的全部信息,因此我们可以使用它来声明变量,如下所示(记得在尖括号中包含元素类型):

List<Integer> list = ...;

然而,我们不能将 new 与接口名称如 List 一起使用。我们只能将 new 与类的名称一起使用,因为 new 是通过将类作为模板来创建新对象的。接口无法以这种方式使用。因此,当使用 new 时,我们可以使用 ArrayList 作为希望实例化的特定实现类的名称。

List<Integer> list = new ArrayList<Integer>();

记得当我们说 <Integer> 之后接 List 时,表示该列表将存储整数对象。同样地,当我们将其用于 ArrayList 之后时,含义也是一样的。我们稍后会深入探讨这种类型声明所能实现的功能,但目前只需知道,无论我们存储何种类型的数据,都必须在变量声明中使用 <> 来指定其类型。例如,若我们存储 Jeroo 对象,则应声明 <Jeroo> ;若存储 Pixel 对象,则应声明 <Pixel> 。

你可能还注意到,我们使用了符号 Integer 而非 int。这与所谓的“基本类型”和对象之间的区别有关。稍后我们会更深入探讨这两者之间的差异。目前只需知道,如果你想要创建一个 ArrayList 的 double,应指定 <Double>。对于 boolean,同样使用 <Boolean>。

添加我们的数字

列表 List 有一组可供调用的方法。要添加一项,我们可以使用 add() 方法。

List<Integer> list = new ArrayList<Integer>();
list.add(-2);

代码执行后,我们的列表将如下所示:

_images/ArrayListAfterOneAdd.png

如果我们将另一个值添加……

list.add(8);

我们的列表将如下所示:

_images/ArrayListAfterTwoAdds.png

访问列表元素

假设我们已将上图所示的全部15个数字添加到列表中,但随后想要访问第二个数字。

要访问我们列表中的第二个元素,我们会运行如下代码。

int x = list.get(1); // gets the second item in our list, which is 8

重要的是要注意,尽管这是列表中的第二个元素,但它位于索引 1。这是因为位置从零开始计算。列表的第一个元素始终位于索引 0。

Indexing

对于任意长度为 n 的 List ,第一个元素位于索引 0,最后一个元素位于索引 n - 1 。

更改项目

虽然我们可以使用 get 方法通过指定位置来访问列表中的任意元素,但它仅 返回 列表中存储的值。如果我们希望更改指定位置存储的值,则不能使用 get() 。例如,输入 list.get(0) = 4; 将无法成功编译。它不允许我们将列表中第一个元素从 -2 更改为 4。相反,我们需要使用另一种 List 方法来更改现有条目的值。

list.set(1, 4);

当调用此 set() 方法时,必须指定两件事。首先,要更改的位置(其索引或位置)。在本例中,我们试图修改列表中的第二个元素,该元素位于索引 1。第一个参数始终是一个数字。

我们想要将列表中第二个元素的值改为 4,因此这是我们的第二个参数。如果我们有一个 Pixel 个元素的列表并想使用 set 方法,其写法可能如下:

Pixel p = new Pixel(1, 0);
list.set(1, p);

但请记住,列表的长度仅取决于你添加的元素数量。因此,以下代码会出错:

List<String> names = new ArrayList<String>();
names.add("Anna");
names.add("Joey");
names.add("Maria");
names.set(3, "Chris"); // error, since there is no index 3

上述代码可以成功编译,但在运行时将失败。它会生成一个 IndexOutOfBoundsException ,表示提供了非法索引(索引值为负数,或超出现有位置的范围)。再次说明,“Anna”存储在索引 0 处,“Joey”在索引 1 处,“Maria”在索引 2 处。该列表包含 3 个元素,但由于其结束于索引 2,因此调用 set() 将失败。

简言之,如果你的代码出错并看到 IndexOutOfBoundsException ,说明你正在尝试访问列表中不存在的位置。

4.3.8. 理解自测:数组

4.3.9. Syntax Practice 8b: Lists

4.3.10. 嵌套 for 循环

在迄今为止的类中遍历 Pixel 个对象时,我们是这样做的(假设我们有一个名为 picture 的 Picture 对象)

for (Pixel p: picture.getPixels())
{
    // do some transformation
}

然而,如果我们只想改变每隔一个 Pixel 呢?或者每隔一行或一列呢?在这些情况下,使用计数器控制的循环可能更合适。

假设我们已知图片为 100 像素宽、200 像素高的矩形,并且有一个名为 pic 的 Picture 变量。我们可以这样编写一个 for 循环。

int width = 100;
int height = 200;

for (int x = 0; x < width; x++)
{
    Pixel p = pic.getPixel(x, 0);
    p.setColor(Color.BLACK);
}

你将注意到,这段代码通过一系列 Pixel 对象进行操作,将其 RGB 值设为黑色,即 (0, 0, 0)。然而,这段代码仅会处理 y == 0 时的顶层 Pixel 对象。它访问像素 (0, 0),然后是 (1, 0),一直到 (99, 0)。但我们从未使用上方定义的 height 变量,也从未改变 y 坐标从 0。如果仅需处理一行,这完全是可以接受的。然而,若要处理多行,则需要更复杂的处理方式。我们还需要为 y 坐标添加一个循环。

int width = 100;
int height = 200;

for (int x = 0; x < width; x++)
{
    for (int y = 0; y < height; y++)
    {
        Pixel p = pic.getPixel(x, y);
        p.setColor(Color.black);
    }

}

与条件语句类似, for 循环也可以 嵌套。

在精神上(事实上),我们已经将两个循环合并在一起。一个用于 x 坐标的循环,针对每个可能的 x 值(图像中的每一列像素)重复执行。另一个用于 y 坐标的循环,针对每个可能的 y 值(图像中的每一行像素)重复执行。

逐步执行这段代码,当外层 for 循环开始时, x 被初始化为 0,且已知 0 小于 100,因此可以进入循环。接着, y 被初始化为 0,该值小于 200,因此第二个循环可以开始。当 x 为 0 时,内层 for 循环将 y 从 0 递增到 199。这意味着我们将访问坐标为 (0, 0) 的像素,然后是 (0, 1),一直到 (0, 199)。随后,内层 for 循环结束,外层 for 循环将 x 的值递增为 1。接下来整个过程重复进行,这次将访问坐标为 (1, 0) 的像素,然后是 (1, 1),一直到 (1, 199)。此过程将持续进行,从特定 x 对应的最上方 y == 0 的像素开始,垂直向下遍历至最下方 y ,然后在 x 方向上向右推进,直至所有像素均被处理完毕。

这种结构称为 嵌套 for 循环。它是一种极为常见的模式,特别是在使用两个变量在二维坐标空间中递增时,例如图像中的二维像素网格。

4.3.11. 理解自测:嵌套的 for 循环

4.3.12. 语法练习 8c:嵌套循环

4.3.13. 检查你的理解

4.3.14. 编程实践 8a

4.3.15. 编程实践 8b

4.3.16. 建立图书馆藏书内容的模型

以一个例子说明,让我们尝试在计算机程序中建模一个图书库。首先,创建一个 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);
    }
}

我们将在接下来的章节中逐步完善这一基本设计。

4.3.17. 循环惯用法

“惯用法”是一种常见的模式或表达方式。在使用 Java 循环时,你可能会开始注意到循环使用的某些常见模式。对于需要:的情况,for 循环尤其适用。

  • 重复执行特定次数的代码

  • 在一系列位置上重复执行

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

此外,循环常用于遍历一系列值,以查找第一个(或最后一个)满足某种条件的值。

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

计数控制的循环在需要执行某个操作特定次数时是合适的选择。

4.3.17.2. 查找第一个匹配项:查找一本书

使用 for-each 查找匹配项

有人来到我们的图书馆,询问我们是否有《第二十二条军规》这本书。我们需要有一种方法来判断这本书是否在我们的书架上。

为实现此功能,我们将在 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
    }
}

我们可以使用计数控制的循环或遍历循环来实现这一点。让我们看看如何用遍历循环来编写:

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 语句在此情况下返回答案。

这种特定的方法称为 提前循环终止 或 提前循环退出,即一旦答案可用就立即停止循环,从而避免任何不必要的工作。

使用计数器控制的循环查找第一个对象

使用计数控制的 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 实现几乎完全相同。

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 。

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

上述循环将找到列表中标题匹配的第一个书籍。然而,有时你可能希望找到列表中的最后一个项目。

例如,如果有人来图书馆询问《教父》这本书,而我刚刚还记得把这本书放回书架上。

如果书架上书籍的列表包含 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。该循环仍使用 提前退出 技术,一旦找到目标书即刻停止循环。

4.3.18. 理解自测:循环惯用法

4.3.18.1. 累积答案:按作者统计书籍数量

假设另一位图书馆访客询问我们馆藏的史蒂芬·金著作有多少本。

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

为了使用我们的累加器,我们声明一个值为 int 的变量 count 并将其初始化为 0。接着,我们使用两个循环。外层循环对图书馆中的每一层书架重复执行一次。内层循环在每层书架上搜索与参数 author 匹配的任何书籍。当我们找到一本时,使用 ++ 运算符将计数器加一(即增加 1)。完成循环后,我们最终返回已累计的 count 。

注意, count 中的值并非一次性计算完成。它是逐本书逐步构建的。在任意时刻, count 表示在两个循环合并操作过程中,到目前为止我们已遇到的该作者的书籍总数。只有当循环完全结束后, count 才达到最终答案。

积累另一种类型的答案

如果除了知道我们图书馆中史蒂芬·金的书籍数量之外,我们还想把这些书全部集中到一个地方呢?这里,我们将使用与上面相同的结构来累积答案,但这次会生成并返回一个包含该作者所有书籍的书籍列表,而不仅仅是计数。这一次,我们的累加器将是一个列表,而不是一个简单的数字。

// 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 语句中的条件时,这可能很有用。

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

4.3.20. 泛型再探

到目前为止,每当我们在处理变量时,始终清楚其类型。例如,当我们处理一个 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);

因此,如果程序员希望创建一个字符串的列表,使用泛型类型将有助于确保存储的对象确实属于正确的类型。尽管可以创建任意类型对象的列表,但此特定列表仅包含字符串,不包含其他任何类型。通过这种方式,使用泛型类型有助于减少编程错误,从而让程序更安全、更健壮。同时,它还使我们能够创建更具可重用性的类,因为我们可以在事先不知道这些类具体是什么的情况下,创建适用于多种其他类的类型。

4.3.21. 理解自测:泛型

4.3.22. 语法练习 9b:泛型

4.3.23. 空关键字

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

Pixel pix = null;

这在某些情况下可能很有用,当你不希望创建新对象或初始化变量以引用特定的现有对象时。当一个方法应当返回一个对象,但有时并无对象可返回时,这也可能很有用。考虑一个“查找”方法,用于寻找特定书籍,如果找到则返回该书籍。如果未找到会怎样?在许多情况下,让此类方法返回特殊值 null 是一种有效的方式,用以表示方法不返回任何对象,也许因为“无对象”本身就是一个有意义的答案。

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

程序员经常使用短语“空指针异常”或缩写 NPE 来指代这类错误。NPE 几乎总是意味着你的程序中存在错误,因为你的程序试图在需要对象的情况下使用了“空”,但空表示“无对象”,因此要求未被满足。以下是会抛出空指针异常的代码示例。

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

NullPointerException 指针是程序员常遇到的错误。请注意,若在自己的代码中发现此类错误,很可能是正在操作一个尚未指向特定对象的变量,或是使用了一个有时不返回任何对象的方法。

4.3.24. 诊断空指针异常

首先,请记住,当尝试将一个值为 null 的对象(称为 "NPE")作为一个 NullPointerException 的接收者来使用时,就会发生这个异常——基本上,当你在调用方法或引用字段时使用了点号("."),但点号左侧的表达式(即"接收者")为 null 。换句话说,你在没有对象作为接收者的情况下使用了点号。

如果在执行测试时发生这种情况,测试将会失败。在测试结果窗口中,若点击失败的测试,窗口下半部分将显示 NullPointerException 及其发生位置的相关信息,如本示例所示。

_images/bare-test-npe.png

此处,通过点击失败的测试 testMaxRed() ,测试窗口的下半部分将显示相关信息。如果错误发生在未运行测试时,相同的信息将出现在 Eclipse 的 Console 视图中。

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

例如,此堆栈跟踪指向 maxRed() 类中的 TransformablePicture 方法,位于该类第 38 行。如果我们查看此处,会找到发生指针的那行代码(摘自上一节)。

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

如果最上面的那行不是你的代码——也许它是某个库方法——不要灰心。尽管异常可能发生在另一个类的方法中,但几乎可以肯定是因为你的代码调用某处的方式导致的。只需往下看列表。查看列出的第二个方法、第三个方法、第四个方法,依此类推,直到找到你自己的类中的一个方法。现在你确切地知道问题发生在你自己代码的哪个位置。再次强调,你可以使用文件名和行号直接跳转到源代码中问题发生的位置。之后,就该进行调试了——弄清楚问题发生的原因,以便修复它。

一旦找到空指针异常的位置,只需查看异常发生行中的点(句号)。可能只有一个,也可能有多个。请逐一检查每一个。你需要找出哪一个点的左边恰好是“null”(即没有对象)。通常可以通过排除法来实现。

在我们的示例中,出问题的那行代码包含 pix.setRed(255); ,因此只有一个点。点左边的名字是 pix ,所以这个问题的发生是因为 pix 是 null ,但随后我们却试图将其当作对象来调用方法。

本课程中导致指针的最常见原因包括:

  • 未正确初始化字段或变量

  • 使用方法的结果时,假设该方法始终返回对象,但实际上该方法有时可能返回空值

  • 向库方法传递了空值作为参数,而该方法实际需要一个具体对象

你需要逐个检查异常发生位置的“点”来排除这类问题,并缩小可能性范围,直到确定空指针出现的位置及其原因。如果你需要查看堆栈跟踪中多行之后的内容,那么你很可能处于上述最后一种情况——不是在“点”的左侧有一个空值,而是将一个空值传递给了其他某个方法(可能是库方法,或从父类继承的方法)。

试试看,看看能否自己找出问题所在。虽然这些是最常见的,但你可能会遇到无数种类似的问题,不过诊断过程始终是一样的。如果无法确定错误位置,请在此处跟进,贴出异常信息和堆栈跟踪以获取更多信息。如果你已经找到问题发生的位置,但尝试了上述建议后仍无法理解原因或机制,请贴出出现指针的行以及堆栈跟踪,以便我们指导你解决。

4.3.25. 理解自测:空指针

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

4.3.27. 编程练习 9b:循环与泛型

   «  4.2. 单元 4 实验 1 占位符   ::   目录   ::   4.4. 单元 4 实验 2 占位符  »

关闭窗口