OpenDSA 完整目录

Chapter 0 Introduction to Software Design

| 关于   «  8. 聚合、字符串与更多循环   ::   目录   ::   10. 列表、循环惯用法、泛型和 Null 关键字  »

9. 使用列表与嵌套 for 循环对对象分组

9.1. 对象的集合

虽然我们一直在用单独的变量或字段来引用单个对象,但最终你需要处理的程序往往要管理远不止一两个对象。 当处理大量对象时,把每个对象都放进单独的变量或字段会非常笨拙,最终只会让代码变得更复杂、更臃肿。 相反,当数据量较大时,我们需要 容器 (container)对象,用来保存和管理对象的集合。

Java 提供了一个帮助完成许多常见任务的工具类库,其中就包含几个 Collection 类。 这些类大体上分为三类,在众多编程语言中都很常见:

  • 列表 (List)允许我们按顺序存储值的序列。

  • 集合 (Set)允许我们存储一个无序的值集合。

  • 映射 (Map)允许我们存储 查找表,即用一条数据查找与它关联的另一条数据,就像在字典中用单词查找它的释义一样。 除了 map 这个词,它们也常被称为 字典、关联数组 或 散列 (hash)。

集合把对象组织在一起。所有这些集合都具备一些共同属性:

  • 它们会在必要时扩充容量,以容纳所需数量的数据。

  • 它们会记录自己所保存的值的个数。

  • 它们在内部维护这些值的有序组织,允许我们随时添加或移除元素。

  • 这些工作是如何完成的细节都被隐藏了起来。实际上,为了 * 使用* 集合,我们并不需要了解其内部机制。相反,我们信赖集合替我们完成它的工作。

9.2. 接口

因为有些时候我们只想了解如何 使用 一个类,而不关心它的内部细节,所以最好能只谈论类提供的服务(或方法)。 Java 为我们提供了一种工具,用来描述类提供哪些方法而不必关心其内部实现,这个工具称为 接口 (interface)。 接口 与类相似,但它只列出类中公有方法的声明(以及你想提供的任何公有常量)。 它不包括字段、构造方法、方法实现或任何私有内容——只包括允许你使用该类的那些公有声明部分。

与类相比,接口只提供足以让你调用公有方法并使用它的信息,而类则完整提供这些方法实际上是如何实现的全部细节。 因此,你可以用类来创建对象,因为你拥有完整的实现。 但是,你不能只用接口本身来创建对象——所有对象都属于某个类,而接口只是描述一个类可能提供的若干方法声明。

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

  1. 把方法的声明分离到接口中,再把实现放到单独的类里,就使得同一个接口能够用不同的策略以多种不同方式来实现。 没有接口时,多种实现 既棘手又容易出错;但由于任何数量的类都可以通过提供所需的方法来实现一个接口,所以当同一个算法或数据结构有多种实现技术时,接口就非常有用了。

  2. 通过接口,你可以捕获预期在多个类中出现的一组公共方法,这样既能给这组方法起一个名字,又能确保提供这组方法的类都以一致的方式实现。

  3. 把方法的声明分离到接口中,可以让需要 使用 代码的程序员在接口之上进行开发,与正在编写底层实现的程序员并行工作。 即使底层软件尚未实现,接口也能让各团队互相沟通、相互依赖。

举个例子,假设我们在编写一个管理图形形状的程序,并希望各种类型的图形形状都能在屏幕上被绘制出来。 我们可以这样设想:每个形状都有一个 draw() 方法,把该形状绘制到屏幕上。 我们可以用一个接口把这一点描述出来,如下所示:

public interface Drawable
{
    public void draw();
}

你会注意到这里有几处与我们见过的其他 Java 代码不同。 首先,我们看到的不是 public class,而是使用了 interface 关键字。 其次,方法签名后面跟的是 ;,而不是花括号。 这个方法完全没有实现——实现留给提供该方法的各个类去完成。 接口只声明方法的名称、参数和返回类型。

编写接口的更一般语法如下所示:

public interface InterfaceName
{
    // any number of constant values

    // any number of method signatures WITHOUT implementation.
}

这段代码本身不会做任何事。 但是,它描述了提供单个 draw() 方法这种设计的要点。 我们不能用它来创建对象——创建对象需要类。 我们编写的任何想要符合该接口的类都应该 实现 (implement)它:

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 中的声明不一致,就会收到编译错误。 在我们实现一个签名为 public void draw() 的方法之前,Rectangle 类将无法编译。 我们可以添加任何想要的字段或方法,但那个 draw() 方法 必须 被实现。

然而,通过声明 class Rectangle implements Drawable ,现在任何使用 Rectangle 类的程序员(或源代码)都将知道它提供了一个 draw() 方法,并且该方法的使用方式与任何其他可绘制对象相同。

单看这一点,这种语言结构似乎有些奇怪。 开发者难道就不能记住去实现那一个方法吗? 在我们的例子里,大概可以。 但接口为我们提供了一种把需求显式写下来以便共享的方式,同时也为编译器提供了一种机制来检查我们是否以正确的声明包含了所需的方法,并提醒我们在这方面可能犯下的错误。 因此,接口提供了更好的错误检查,也让程序员之间的沟通更顺畅。

9.3. 语法练习 8a: 串

9.4. List 接口

此前,我们一直在学习把特定的数据保存到变量中。 例如,假设我们要处理一份以串形式存储的名字列表——想想你所有同学的名字。 我们可以在单独的变量中存储每个名字。

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

但是,当需要处理许多名字时,这种做法很快就会变得相当繁琐和低效。 例如,如果你要处理 100 个名字,就需要 100 个不同的变量。 现在想想你会如何把它们全部打印出来。 你需要为每个变量单独写一条语句,因此打印所有名字同样需要 100 行代码。

其实,还有另一种存储大量值的方式。 与其把每个值放进单独的变量,不如用一个就像一个大 容器 的变量,把每个名字丢进这个容器。 Java 用 Collection 一词来称呼那些像容器一样保存其他对象组的对象。 事实上,Collection 是 Java 中的一个 接口,它定义了所有容器对象都会提供的通用方法。 顺便说一下,容器常被称为 数据结构 (data structure),因为它们以结构化的方式组织一组数据值,用来解决特定类型的问题。

现在,我们将重点关注一组特定的容器: 列表 。 在 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>)

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

9.5. 泛型

List 接口也标志着我们第一次接触到 Java 中的 泛型 (generic type)。 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 时你应该始终提供类型,这样要放进列表的元素的种类就一目了然。

9.6. ArrayList

请记住,由于 List 是接口,它不提供任何用来创建对象的信息——它只规定必需的方法。 要创建实际的对象,你需要一个实现该接口的类——这种类常被称为 具体类 (concrete class),因为它提供了所有字段如何初始化、所有方法内部如何表现的具体的实现细节。 虽然 List 接口有多种实现,但在本课程中我们将使用最常用的一种: ArrayList 。

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

花几分钟时间观看以下视频:

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

_images/ArrIdea.png

方框中较大的数字是 ArrayList 的元素。 方框外较小的数字是用于标识 ArrayList 中每个位置的 索引 (index,也叫下标或位置)。 请注意,第一个元素的索引是 0,而不是 1。 一定要记住,与图片中的 Pixel 类似,ArrayList 的索引从 0 开始而不是从 1 开始。 忘记这一点很容易出错。

9.6.1. 使用 ArrayList 编程

让我们尝试在代码中用 ArrayList 重现上图。

9.6.1.1. 添加 import 语句

不过,在开始之前,我们需要在代码中添加一条 import 语句:

import java.util.*;

如果没有它,Java 将无法识别 List 或 ArrayList 这两个名字。

9.6.1.2. 声明并实例化 ArrayList

由于 List 接口告诉了我们关于列表可用方法的一切,我们可以用它这样声明一个变量(记得在尖括号内包含元素类型):

List<Integer> list = ...;

但是,我们不能把 new 与 List 这样的接口名一起使用。 我们只能把 new 与类的名字一起使用,因为 new 是以类为模板来创建新对象的。 接口不能这样使用。 所以在使用 new 时,我们可以用 ArrayList 作为我们想要实例化的具体实现类的名字。

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

请记住,当我们在 List 后面写 <Integer> 时,是在表示这个列表将保存整数对象。 同样,把它放在 ArrayList 后面也表示同样的意思。 关于这种类型规范我们能做些什么,我们以后再深入讨论;现在只需知道,无论存储什么类型的数据,都需要在变量声明中用 <> 把它指定出来。 例如,如果存储 Jeroo 对象,就写 <Jeroo>;如果存储 Pixel 对象,就写 <Pixel>。

你可能还会注意到,我们用的是 Integer 而不是 int。 这与所谓的"基本类型"和对象之间的区别有关。 关于这两者之间的区别,我们以后也会更深入地讨论。 现在只需知道,如果你想创建一个 double 的 ArrayList,就要写 <Double>。 对于 boolean,你同样需要使用 <Boolean>。

9.6.1.3. 添加数据

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

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

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

_images/ArrayListAfterOneAdd.png

如果我们再添加一个值……

list.add(8);

我们的列表将如下所示:

_images/ArrayListAfterTwoAdds.png

9.6.1.4. 访问列表元素

假设我们已经按照上面的示意图把全部 15 个数字添加到了列表里,然后想访问第二个数字。

要访问列表中的第二项,我们会运行这样的代码。

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

需要注意的是,虽然这是列表中的第二项,但它位于索引 1。 这是因为位置从零开始。 列表的第一项总是在索引 0。

索引

对于长度为 n 的任何 List,第一项位于索引 0,最后一项位于索引 n - 1。

9.6.1.5. 修改元素

虽然我们可以用 get 方法通过指定位置来访问列表中的任何一项,但它只会 返回 列表里保存的值。 如果我们要修改指定位置存储的值,就不能使用 get()。 例如,输入 list.get(0) = 4; 将无法成功编译。 它不会允许我们把列表中存储的第一项从 -2 改成 4。 我们需要使用另一个 List 方法来修改已有条目的值。

list.set(1, 4);

调用 set() 方法时,我们必须指定两样东西。 第一,要修改的位置(它的索引或位置)。 在我们的例子中,我们要修改列表中的 第二 项,它位于索引 1。 这第一个实参总是一个数字。

我们希望把列表中第二项的值改为 4,所以 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,说明你正在访问列表中不存在的位置。

9.7. 语法练习 8b: 列表

9.8. 嵌套 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,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 循环。 这是一种极其常见的模式,尤其是在使用两个变量遍历二维坐标空间时,比如图像中像素构成的二维网格。

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

9.10. 编程练习 8a

9.11. 编程练习 8b

   «  8. 聚合、字符串与更多循环   ::   目录   ::   10. 列表、循环惯用法、泛型和 Null 关键字  »

关闭窗口