数据结构与算法之ACM Fellow-算法 1.2 数据抽象

数据结构与算法之ACM Fellow-算法 1.2 数据抽象

数据类型 指的是一组值和一组对这些值的操作的集合。目前,我们已经详细讨论过 Java 的 原始 数据类型:例如,原始数据类型 int 的取值范围是 ![-2^ /740932/image00851.gif) 到 ![2^-1/740932/image00852.gif) 之间的整数, int 的操作包括+、*、-、/、%、< 和 >。原则上所有程序都只需要使用原始数据类型即可,但在更高层次的抽象上编写程序会更加方便。在本节中,我们将重点学习定义和使用 数据类型,这个过程也被称为数据抽象(它是对 1.1 节所述的 函数抽象 风格的补充)。

Java 编程的基础主要是使用 class 关键字构造被称为 引用类型 的数据类型。这种编程风格也称为 面向对象编程,因为它的核心概念是 对象,即保存了某个数据类型的值的实体。如果只有 Java 的原始数据类型,我们的程序会在很大程度上被限制在算术计算上,但有了引用类型,我们就能编写操作字符串、图像、声音以及 Java 的标准库中或者本书的网站上的数百种抽象类型的程序。比各种库中预定义的数据类型更重要的是 Java 编程中的数据类型的种类是无限的,因为 你能够定义自己的数据类型来抽象任意对象

抽象数据类型(ADT)是一种能够对使用者隐藏数据表示的数据类型。用 Java 类来实现抽象数据类型和用一组静态方法实现一个函数库并没有什么不同。抽象数据类型的主要不同之处在于它将 数据 和函数的实现关联,并将数据的表示方式隐藏起来。在 使用 抽象数据类型时,我们的注意力集中在 API 描述的 操作 上而不会去关心数据的表示;在 实现 抽象数据类型时,我们的注意力集中在 数据 本身并将实现对该数据的各种操作。

抽象数据类型之所以重要是因为在程序设计上它们支持封装。在本书中,我们将通过它们:

  • 以适用于各种用途的 API 形式准确地定义问题;
  • 用 API 的实现描述算法和数据结构。

我们研究同一个问题的不同算法的主要原因在于它们的性能特点不同。抽象数据类型正适合于对算法的这种研究,因为它确保我们可以随时将算法性能的知识应用于实践中:可以在不修改任何用例代码的情况下用一种算法替换另一种算法并改进所有用例的性能。

1.2.1 使用抽象数据类型

附赠网盘下载地址

我用夸克网盘分享了「2025年AcWing在线课程VIP系列」,点击链接即可保存。
链接:https://pan.quark.cn/s/239188bc72b7

更多资源进+夸克网盘资源群 https://pan.quark.cn/g/4d94f2ef63

群满+新夸克共享群:https://link3.cc/cowcowit

要使用 一种数据类型并不一定非得知道它是如何实现的,所以我们首先来编写一个使用一种名为 Counter(计数器)的简单数据类型的程序。它的值是一个名称和一个非负整数,它的操作有 创建对象并初始化为 0、当前值 1 和 获取当前值。这个抽象对象在许多场景中都会用到。例如,这样一个数据类型可以用于电子记票软件,它能够保证投票者所能进行的唯一操作就是将他选择的候选人的计数器加一。我们也可以在分析算法性能时使用 Counter 来记录基本操作的调用次数。要使用 Counter 对象,首先需要了解应该如何定义数据类型的操作,以及在 Java 语言中应该如何创建和使用某个数据类型的对象。这些机制在现代编程中都非常重要,我们在全书中都会用到它们,因此请仔细学习我们的第一个例子。

1.2.1.1 抽象数据类型的 API

我们使用应用程序编程接口(API)来说明抽象数据类型的行为。它将列出所有 构造函数实例方法(即操作)并简要描述它们的功用,如表 1.2.1 中 Counter 的 API 所示。

尽管数据类型定义的基础是一组值的集合,但在 API 可见的仅是对它们的操作,而非它们的意义。因此,抽象数据类型的定义和静态方法库(请见 1.1.6.3 节)之间有许多共同之处:

  • 两者的实现均为 Java 类;
  • 实例方法可能接受 0 个或多个指定类型的参数,由括号括起并由逗号分隔;
  • 它们可能会返回一个指定类型的值,也可能不会(用 void 表示)。

当然,它们也有三个显著的不同。

  • API 中可能会出现若干个名称和类名相同且没有返回值的函数。这些特殊的函数被称为 构造函数。在本例中, Counter 对象有一个接受一个 String 参数的构造函数。
  • 实例方法不需要 static 关键字。它们 不是 静态方法——它们的目的就是操作该数据类型中的值。
  • 某些实例方法的存在是为了尊重 Java 的习惯——我们将此类方法称为 继承的方法 并在 API 中将它们显示为灰色。

表 1.2.1 计数器的 API

public class Counter``             Counter(String id)创建一个名为 id 的计数器      void   increment()将计数器的值加 1       int   tally()该对象创建之后计数器被加 1 的次数    String   toString()对象的字符串表示

和静态方法库的 API 一样,抽象数据类型的 API 也是和用例之间的一份契约,因此它是开发任何用例代码以及实现任意数据类型的起点。在本例中,这份 API 告诉我们可以通过构造函数 Counter()、实例方法 increment()tally(),以及继承的 toString() 方法使用 Counter 类型的对象。

1.2.1.2 继承的方法

根据 Java 的约定,任意数据类型都能通过在 API 中包含特定的方法从 Java 的内在机制中获益。例如,Java 中的所有数据类型都会继承 toString() 方法来返回用 String 表示的该类型的值。Java 会在用 + 运算符将任意数据类型的值和 String 值连接时调用该方法。该方法的默认实现并不实用(它会返回用字符串表示的该数据类型值的内存地址),因此我们常常会提供实现来重载默认实现,并在此时在 API 中加上 toString() 方法。此类方法的例子还包括 equals()compareTo()hashCode()(请见 1.2.5.5 节)。

1.2.1.3 用例代码

和基于静态方法的模块化编程一样,API 允许我们在不知道实现细节的情况下编写调用它的代码(以及在不知道任何用例代码的情况下编写实现代码)。1.1.7 节介绍的将程序组织为独立模块的机制可以应用于所有的 Java 类,因此它对基于抽象数据类型的模块化编程与对静态函数库一样有效。这样,只要抽象数据类型的源代码 .java 文件和我们的程序文件在同一个目录下,或是在标准 Java 库中,或是可以通过 import 语句访问,或是可以通过本书网站上介绍的 classpath 机制之一访问,该程序就能够使用这个抽象数据类型,模块化编程的所有优势就都能够继续发挥。通过将实现某种数据类型的全部代码封装在一个 Java 类中,我们可以将用例代码推向更高的抽象层次。在用例代码中,你需要 声明变量创建对象 来保存数据类型的值并 允许 通过实例方法来操作它们。尽管你也会注意到它们的一些相似之处,但这种方式和原始数据类型的使用方式非常不同。

1.2.1.4 对象

一般来说,可以声明一个变量 heads 并将它通过以下代码和 Counter 类型的数据关联起来:

Counter heads;

但如何为它赋值或是对它进行操作呢?这个问题的答案涉及数据抽象中的一个基础概念: 对象 是能够承载数据类型的值的实体。所有对象都有三大重要特性: 状态标识行为。对象的 状态 即数据类型中的值。对象的标识能够将一个对象区别于另一个对象。可以认为对象的标识就是它在内存中的位置。对象的 行为 就是数据类型的操作。数据类型的实现的唯一职责就是维护一个对象的身份,这样用例代码在使用数据类型时只需遵守描述对象行为的API 即可,而无需关注对象状态的表示方法。对象的状态可以为用例代码提供信息,或是产生某种副作用,或是被数据类型的操作所改变。但数据类型的值的表示细节和用例代码是无关的。 引用 是访问对象的一种方式。Java 使用术语 引用类型 以示和原始数据类型(变量和值相关联)的区别。不同的 Java 实现中引用的实现细节也各不相同,但可以认为引用就是内存地址,如图 1.2.1 所示(简洁起见,图中的内存地址为三位数)。

![/740932/image00853.gif)

图 1.2.1 对象的表示

1.2.1.5 创建对象

每种数据类型中的值都存储于一个对象中。要创建(或 实例化)一个对象,我们用关键字 new 并紧跟类名以及 ()(或在括号中指定一系列的参数,如果构造函数需要的话)来触发它的构造函数。构造函数没有返回值,因为它总是返回它的数据类型的对象的引用。每当用例调用了 new(),系统都会:

  • 为新的对象分配内存空间;
  • 调用构造函数初始化对象中的值;
  • 返回该对象的一个引用。

在用例代码中,我们一般都会在一条声明语句中创建一个对象并通过将它和一个变量关联来初始化该变量,和使用原始数据类型时一样。和原始数据类型不同的是,变量关联的是指向对象的引用而并非数据类型的值本身。我们可以用同一个类创建无数对象——每个对象都有自己的标识,且所存储的值和另一个相同类型的对象可以相同也可以不同。例如,以下代码创建了两个不同的 Counter 对象:

Counter heads = new Counter("heads");
Counter tails = new Counter("tails");

抽象数据类型向用例隐藏了值的表示细节。可以假定每个 Counter 对象中的值是一个 String 类型的名称和一个 int 计数器,但 不能编写依赖于任何特定表示方法的代码(即使知道假定是否正确——也许计数器是一个 long 值呢)。对象的创建过程如图 1.2.2 所示。

![/740932/image00854.gif)

图 1.2.2 创建对象

1.2.1.6 调用实例方法

实例方法的意义在于操作数据类型中的值,因此 Java 语言提供了一种特别的机制来触发实例方法,它突出了实例方法和对象之间的联系。具体来说,我们调用一个实例方法的方式是先写出对象的变量名,紧接着是一个句点,然后是实例方法的名称,之后是 0 个或多个在括号中并由逗号分隔的参数。实例方法 可能会改变 数据类型中的值,也可能只是 访问 数据类型中的值。实例方法拥有我们在 1.1.6.3 节讨论过的静态方法的所有性质——参数按值传递,方法名可以被重载,方法可以有返回值,它们也许还会产生一些副作用。但它们还有一个特别的性质: 方法的每次触发都是和一个对象相关的。例如,以下代码调用了实例方法 increment() 来操作 Counter 对象 heads(在这里该操作会将计数器的值加 1):

heads.increment();

而以下代码会调用实例方法 tally() 两次,第一次操作的是 Counter 对象 heads,第二次是 Counter 对象 tails(这里该操作会返回计数器的 int 值):

heads.tally() - tails.tally();

以上示例的调用过程见图 1.2.3。

![/740932/image00855.gif)

图 1.2.3 触发实例方法的各种方式

正如这些例子所示,在用例中实例方法和静态方法的调用方式完全相同——可以通过语句( void 方法)也可以通过表达式(有返回值的方法)。静态方法的主要作用是实现函数;非静态(实例)方法的主要作用是实现数据类型的操作。两者都可能出现在用例代码中,但很容易就可以区分它们,因为静态方法调用的开头是类名(按习惯为大写),而非静态方法调用的开头总是 对象 名(按习惯为小写)。表 1.2.2 总结了这些不同之处。

表 1.2.2 实例方法与静态方法

实例方法

静态方法

举例

heads.increment()

Math.sqrt(2.0)

调用方式

对象名

类名

参量

对象的引用和方法的参数

方法的参数

主要作用

访问或改变对象的值

计算返回值

1.2.1.7 使用对象

通过声明语句可以将变量名赋给对象,在代码中,我们不仅可以用该变量创建对象和调用实例方法,也可以像使用整数、浮点数和其他原始数据类型的变量一样使用它。要开发某种给定数据类型的用例,我们需要:

  • 声明该类型的变量,以用来引用对象;
  • 使用关键字 new 触发能够创建该类型的对象的一个构造函数;
  • 使用变量名在语句或表达式中调用实例方法。

例如,下面用例代码中的 Flips 类就使用了 Counter 类。它接受一个命令行参数 T 并模拟 T 次掷硬币(它还调用了 StdRandom 类)。除了这些直接用法外,我们可以和使用原始数据类型的变量一样使用和对象关联的变量:

  • 赋值语句;
  • 向方法传递对象或是从方法中返回对象;
  • 创建并使用对象的数组。
public class Flips
{
   public static void main(String[] args)
   {
      int T = Integer.parseInt(args[0]);
      Counter heads = new Counter("heads");
      Counter tails = new Counter("tails");
      for (int t = 0; t < T; t++)
         if (StdRandom.bernoulli(0.5))
              heads.increment();
         else tails.increment();
      StdOut.println(heads);
      StdOut.println(tails);
      int d = heads.tally() - tails.tally();
      StdOut.println("delta: " + Math.abs(d));
   }
}

Counter 类的用例,模拟 T 次掷硬币

% java Flips 10
5 heads
5 tails
delta: 0

% java Flips 10
8 heads
2 tails
delta: 6

% java Flips 1000000
499710 heads
500290 tails
delta: 580

接下来将逐个分析它们。你会发现,你需要从 引用 而非值的角度去考虑问题才能理解这些用法的行为。

1.2.1.8 赋值语句

使用引用类型的赋值语句将会创建该引用的一个副本。赋值语句不会创建新的对象,而只是创建另一个指向某个已经存在的对象的引用。这种情况被称为 别名:两个变量同时指向同一个对象。别名的效果可能会出乎你的意料,因为对于原始数据类型的变量,情况不同,你必须理解其中的差异。如果 xy 是原始数据类型的变量,那么赋值语句 x = y 会将 y 的值复制到 x 中。对于引用类型,复制的是 引用(而非实际的值)。在 Java 中,别名是 bug 的常见原因,如下例所示(图 1.2.4):

Counter c1 = new Counter("ones");
c1.increment();
Counter c2 = c1;
c2.increment();
StdOut.println(c1);

![/740932/image00856.gif)

图 1.2.4 别名

对于一般的 toString() 实现,这段代码将会打印出 "2 ones"。这可能并不是我们想要的,而且乍一看有些奇怪。这种问题经常出现在使用对象经验不足的人所编写的程序之中(可能就是你,所以请集中注意力!)。改变一个对象的状态将会影响到所有和该对象的别名有关的代码。我们习惯于认为两个不同的原始数据类型的变量是相互独立的,但这种感觉对于引用类型的变量并不适用。

1.2.1.9 将对象作为参数

可以将对象作为 参数 传递给方法,这一般都能简化用例代码。例如,当我们使用 Counter 对象作为参数时,本质上我们传递的是一个名称和一个计数器,但我们只需要指定一个变量。当我们调用一个需要参数的方法时,该动作在 Java 中的效果相当于每个参数值都出现在了一个赋值语句的右侧,而参数名则在该赋值语句的左侧。也就是说,Java 将参数值的一个副本从调用端传递给了方法,这种方式称为 按值传递(请见 1.1.6.3 节)。这种方式的一个重要后果是方法无法改变调用端变量的值。对于原始数据类型来说,这种策略正是我们所期望的(两个变量互相独立),但每当使用引用类型作为参数时我们创建的都是别名,所以就必须小心。换句话说,这种约定将会传递引用的值(复制引用),也就是传递对象的引用。例如,如果我们传递了一个指向 Counter 类型的对象的引用,那么方法虽然无法改变原始的引用(比如将它指向另一个 Counter 对象),但它 能够 改变该对象的值,比如通过该引用调用 increment() 方法。

1.2.1.10 将对象作为返回值

当然也能够将对象作为方法的 返回值。方法可以将它的参数对象返回,如下面的例子所示,也可以创建一个对象并返回它的引用。这种能力非常重要,因为 Java 中的方法只能有一个返回值——有了对象我们的代码实际上就能返回多个值。

% java FlipsMax 1000000
500281 tails wins
public class FlipsMax
{
   public static Counter max(Counter x, Counter y)
   {
      if (x.tally() > y.tally()) return x;
      else return y;
   }

   public static void main(String[] args)
   {
      int T = Integer.parseInt(args[0]);
      Counter heads = new Counter("heads");
      Counter tails = new Counter("tails");
      for (int t = 0; t < T; t++)
        if (StdRandom.bernoulli(0.5))
            heads.increment();
         else tails.increment();

     if (heads.tally() == tails.tally())
      StdOut.println("Tie");
     else StdOut.println(max(heads, tails) + " wins");
  }
}

一个接受对象作为参数并将对象作为返回值的静态方法的例子

1.2.1.11 数组也是对象

在 Java 中,所有非原始数据类型的值都是对象。也就是说,数组也是对象。和字符串一样,Java 语言对于数组的某些操作有特殊的支持:声明、初始化和索引。和其他对象一样,当我们将数组传递给一个方法或是将一个数组变量放在赋值语句的右侧时,我们都是在创建该数组引用的一个副本,而非数组的副本。对于一般情况,这种效果正合适,因为我们期望方法能够重新安排数组的条目并修改数组的内容,如 java.util.Array.sort() 或表 1.1.10 讨论的 shuffle() 方法。

1.2.1.12 对象的数组

我们已经看到,数组元素可以是任意类型的数据:我们实现的 main() 方法的 args[] 参数就是一个 String 对象的数组。创建一个对象的数组需要以下两个步骤:

  • 使用方括号语法调用数组的构造函数创建数组;
  • 对于每个数组元素调用它的构造函数创建相应的对象。

例如,下面这段代码模拟的是掷骰子。它使用了一个 Counter 对象的数组来记录每种可能的值的出现次数。在 Java 中,对象数组即是一个由对象的引用组成的数组,而非所有对象本身组成的数组。如果对象非常大,那么在移动它们时由于只需要操作引用而非对象本身,这就会大大提高效率;如果对象很小,每次获取信息时都需要通过引用反而会降低效率。

public class Rolls
{
   public static void main(String[] args)
   {
      int T = Integer.parseInt(args[0]);
      int SIDES = 6;
      Counter[] rolls = new Counter[SIDES+1];
      for (int i = 1; i <= SIDES; i++)
         rolls[i] = new Counter(i + "'s");

      for (int t = 0; t < T; t++)
      {
         int result = StdRandom.uniform(1, SIDES+1);
         rolls[result].increment();
      }
      for (int i = 1; i <= SIDES; i++)
         StdOut.println(rolls[i]);
   }
}

模拟 T 次掷骰子的 Counter 对象的用例

% java Rolls 1000000
167308 1's
166540 2's
166087 3's
167051 4's
166422 5's
166592 6's

有了这些对象的知识,运用数据抽象的思想编写代码(定义和使用数据类型,将数据类型的值封装在对象中)的方式称为 面向对象编程。刚才学习的基本概念是我们面向对象编程的起点,因此有必要对它们进行简单的总结。 数据类型 指的是一组值和一组对值的操作的集合。我们会将数据类型实现在独立的 Java 类模块中并编写它们的用例。 对象 是能够存储任意该数据类型的值的实体,或数据类型的 实例。对象有三大关键性质: 状态标识行为。一个数据类型的实现所支持的操作如下。

  • 创建对象(创造它的标识):使用 new 关键字触发构造函数并创建对象,初始化对象中的值并返回对它的引用。
  • 操作对象中的值(控制对象的行为,可能会改变对象的状态):使用和对象关联的变量调用实例方法来对对象中的值进行操作。
  • 操作多个对象:创建对象的数组,像原始数据类型的值一样将它们传递给方法或是从方法中返回,只是变量关联的是对象的引用而非对象本身。

这些能力是这种灵活且应用广泛的现代编程方式的基础,也是我们在本书中对算法研究的基础。

1.2.2 抽象数据类型举例

Java 语言内置了上千种抽象数据类型,我们也会为了辅助算法研究创建许多其他抽象数据类型。实际上,我们编写的每一个 Java 程序实现的都是某种数据类型(或是一个静态方法库)。为了控制复杂度,我们会明确地说明在本书中用到的所有抽象数据类型的 API(实际上并不多)。

在本节中,我们会举一些抽象数据类型的例子,以及它们的一些用例。在某些情况下,我们会节选一些含有数十个方法的 API 的一部分。我们将会用这些 API 展示一些实例以及在本书中会用到的一些方法,并用它们说明要使用一个抽象数据类型并不需要了解其实现细节。

作为参考,下页显示了我们在本书中将会用到或开发的所有数据类型。它们可以被分为以下几类。

  • java.lang.* 中的标准系统抽象数据类型,可以被任意 Java 程序调用。
  • Java 标准库中的抽象数据类型,如 java.swt、java.net 和 java.io,它们也可以被任意 Java 程序调用,但需要 import 语句。
  • I/O 处理类抽象数据类型,和 StdIn 和 StdOut 类似,允许我们处理多个输入输出流。
  • 面向数据类抽象数据类型,它们的主要作用是通过封装数据的表示简化数据的组织和处理。稍后在本节中我们将介绍在计算几何和信息处理中的几个实际应用的例子,并会在以后将它们作为抽象数据类型用例的范例。
  • 集合类抽象数据类型,它们的主要用途是简化对同一类型的一组数据的操作。我们将会在 1.3 节中介绍基本的 BagStackQueue 类,在第 2 章中介绍优先队列(PQ)及其相关的类,在第 3 章和第 5 章中分别介绍符号表( ST)和集合( SET)以及相关的类。
  • 面向操作的抽象数据类型,我们用它们分析各种算法,如 1.4 节和 1.5 节所述。
  • 图算法相关的抽象数据类型,它们包括一些用来封装各种图的表示的面向数据的抽象数据类型,和一些提供图的处理算法的面向操作的抽象数据类型。

这个列表中并没有包含我们将在练习中遇到的某些抽象数据类型,读者可以在本书的索引中找到它们。另外,如 1.2.4.1 节所述,我们常常通过描述性的前缀来区分各种抽象数据类型的多种实现。从整体上来说,我们使用的抽象数据类型说明组织并理解你所使用的数据结构是现代编程中的重要因素。

一般的应用程序可能只会使用这些抽象数据类型中的 5 ~ 10 个。在本书中,开发和组织抽象数据类型的主要目标是使程序员们在编写用例时能够轻易地利用它们的一小部分。

1.2.2.1 几何对象

面向对象编程的一个典型例子是为几何对象设计数据类型。例如,表 1.2.3 至表 1.2.5 中的 API 为三种常见的几何对象定义了相应的抽象数据类型: Point2D(平面上的点)、 Interval1D(直线上的间隔)、 Interval2D(平面上的二维间隔,即和数轴对齐的长方形)。和以前一样,这些 API 都是自文档化的,它们的用例十分容易理解,列在了表 1.2.5 的后面。这段代码从命令行读取一个 Interval2D 的边界和一个整数 ![T/740932/image00850.gif),在单位正方形内随机生成 ![T/740932/image00850.gif) 个点并统计落在间隔之内的点数(用来估计该长方形的面积)。为了表现效果,用例还画出了间隔和落在间隔之外的所有点。这种计算方法是一个模型,它将计算几何图形的面积和体积的问题转化为了判定一个点是否落在该图形中(稍稍简单,但仍然不那么容易)。我们当然也能为其他几何对象定义 API,比如线段、三角形、多边形、圆等,不过实现它们的相关操作可能十分有挑战性。本节末尾的练习会考察其中几个例子。

![/740932/image00857.jpeg)

本书中使用的部分抽象数据类型

表 1.2.3 平面上的点的 API

public class Point2D``              Point2D(double x, double y)创建一个点     double   x()![/740932/image00818.gif) 坐标     double   y()![/740932/image00820.gif) 坐标     double   r()极径(极坐标)     double   theta()极角(极坐标)     double   distTo(Point2D that)从该点到 that 的欧几里得距离       void   draw()StdDraw 绘出该点

表 1.2.4 直线上间隔的 API

public class Interval1D``              Interval1D(double lo, double hi)创建一个间隔      double  length()间隔长度     boolean  contains(double x)``x 是否在间隔中     boolean  intersect(Interval1D that)该间隔是否和间隔 that 相交        void  draw()StdDraw 绘出该间隔

表 1.2.5 平面上的二维间隔的 API

public class Interval2D``          Interval2D(Interval1D x, Interval1D y)创建一个二维间隔  double  area()二维间隔的面积 boolean  contains(Point2D p)``p 是否在二维间隔中 boolean  intersect(Interval2D that)该间隔是否和二维间隔 that 相交    void  draw()StdDraw 绘出该二维间隔

public static void main(String[] args)
{
   double xlo = Double.parseDouble(args[0]);
   double xhi = Double.parseDouble(args[1]);
   double ylo = Double.parseDouble(args[2]);
   double yhi = Double.parseDouble(args[3]);
   int T = Integer.parseInt(args[4]);

   Interval1D xinterval = new Interval1D(xlo, xhi);
   Interval1D yinterval = new Interval1D(ylo, yhi);
   Interval2D box = new Interval2D(xinterval, yinterval);
   box.draw();

   Counter c = new Counter("hits");
   for (int t = 0; t < T; t++)
   {
      double x = Math.random();
      double y = Math.random();
      Point2D p = new Point2D(x, y);
      if (box.contains(p)) c.increment();
      else                 p.draw();
   }

   StdOut.println(c);
   StdOut.println(box.area());
}

Interval2D 的测试用例

![/740932/image00858.gif)

处理几何对象的程序 在自然世界模型、科学计算、电子游戏、电影等许多应用的计算中有着广泛的应用。此类程序的研发已经发展成了 计算几何学 这门影响深远的研究学科。在贯穿全书的众多例子中你会看到,我们在本书中学习的许多算法在这个领域都有应用。在这里我们要说明的是直接表示几何对象的抽象数据类型的定义并不困难且在用例中的应用也十分简洁。本书网站和本节末尾的若干练习都证明了这一点。

1.2.2.2 信息处理

无论是需要处理数百万信用卡交易的银行,还是需要处理数十亿点击的网络分析公司,或是需要处理数百万实验观察结果的科学研究小组,无数应用的核心都是组织和处理信息。抽象数据类型是组织信息的一种自然方式。虽然没有给出细节,表 1.2.6 中的两份 API 也展示了商业应用程序中的一种典型做法。这里的主要思想是定义和真实世界中的物体相对应的对象。一个日期就是一个日、月和年的集合,一笔交易就是一个客户、日期和金额的集合。这只是两个例子,我们也可以为客户、时间、地点、商品、服务和其他任何东西定义对象以保存相关的信息。每种数据类型都包含能够创建对象的构造函数和用于访问其中数据的方法。为了简化用例的代码,我们为每个类型都提供了两个构造函数,一个接受适当类型的数据,另一个则能够解析字符串中的数据(细节请见练习 1.2.19)。和以前一样,用例并不需要知道数据的表示方法。用这种方式组织数据最常见的理由是将一个对象和它相关的数据变成一个整体:我们可以维护一个 Transaction 对象的数组,将 Date 值作为参数或是某个方法的返回值等。这些数据类型的重点在于封装数据,同时它们也可以确保用例的代码不依赖于数据的表示方法。我们不会深究这种组织信息的方式,需要注意的只是这种做法,以及实现继承的方法 toString()compareTo()equals()hashCode() 可以使我们的算法处理 任意类型的数据。我们会在 1.2.5.4 节中详细讨论继承的方法。例如,我们已经注意到,根据 Java 的习惯,在数据结构中包含一个 toString() 的实现可以帮助用例打印出由对象中的值组成的一个字符串。我们会在 1.3 节、2.5 节、3.4 节和 3.5 节中用 Date 类和 Transaction 类作为例子考察其他继承的方法所对应的习惯用法。1.3 节给出了有关数据类型和 Java 语言的 类型参数(泛型) 机制的几个经典例子,它们都遵循了这些习惯用法。第 2 章和第 3 章也都利用了泛型和继承的方法来实现可以处理任意数据类型的高效排序和查找算法。

表 1.2.6 商业应用程序中的示例 API(日期和交易)

public class   Date implements Comparable<Date>``               Date(int month, int day, int year)创建一个日期               Date(String date)创建一个日期(解析字符串的构造函数)          int  month()          int  day()          int  year()       String  toString()对象的字符串表示      boolean  equals(Object that)该日期和 that 是否相同          int  compareTo(Date that)将该日期和 that 比较          int  hashCode()散列值public class   Transaction implements Comparable<Transaction>``               Transaction(String who, Date when, double amount)

               Transaction(String transaction)创建一笔交易(解析字符串的构造函数)       String  who()客户名         Date  when()交易日期       double  amount()交易金额       String  toString()对象的字符串表示      boolean  equals(Object that)该笔交易和 that 是否相同          int  compareTo(Transaction that)将该笔交易和 that 比较          int  hashCode()散列值

每当遇到逻辑上相关的不同类型的数据时,你都应该考虑像刚才的例子那样定义一个抽象数据类型。这么做能够帮助我们组织数据并在一般应用程序中极大地简化使用者的代码。它是我们在通向数据抽象之路上迈出的重要一步。

1.2.2.3 字符串

Java 的 String 是一种重要而实用的抽象数据类型。一个 String 值是一串可以由索引访问的 char 值。 String 对象拥有许多实例方法,如表 1.2.7 所示。

表 1.2.7 Java 的字符串 API(部分)

Public class String``             String()创建一个空字符串        int  length()字符串长度        int  charAt(int i)i 个字符        int  indexOf(String p)``p 第一次出现的位置(如果没有则返回 -1        int  indexOf(String p, int i)``pi 个字符后第一次出现的位置(如果没有则返回 -1     String  concat(String t)t 附在该字符串末尾     String  substring(int i, int j)该字符串的子字符串(第 i 个字符到第 j-1 个字符)   String[]  split(String delim)使用 delim 分隔符切分字符串        int  compareTo(String t)比较字符串    boolean  equals(String t)该字符串的值和 t 的值是否相同        int  hashCode()散列值

String 值和字符数组类似,但两者是不同的。数组能够通过 Java 语言的内置语法访问每个字符, String 则为索引访问、字符串长度以及其他许多操作准备了实例方法。另一方面,Java 语言为 String 的初始化和连接提供了特别的支持:我们可以直接使用字符串字面量而非构造函数来创建并初始化一个字符串,还可以直接使用 + 运算符代替 concat() 方法。我们不需要了解实现的细节,但是在第 5 章中你会看到,了解某些方法的性能特点在开发字符串处理算法时是非常重要的。为什么不直接使用字符数组代替 String 值?对于任何抽象数据类型,这个问题的答案都是一样的: 为了使代码更加简洁清晰。有了 String 类型,我们可以写出清晰干净的用例代码而无需关心字符串的表示方式。先看一下右侧这段短小的列表,其中甚至含有一些需要我们在第 5 章才会学到的高级算法才能实现的强大操作。例如, split() 方法的参数可以是 正则表达式(请见 5.4 节),“典型的字符串处理代码”(显示在下页)中 split() 的参数是 "\\s+",它表示“一个或多个制表符、空格、换行符或回车”。

![/740932/image00859.gif)

字符串操作举例

任务

实现

判断字符串是否是一条回文

public static boolean isPalindrome(String s)
{
    int N = s.length();
    for (int i = 0; i < N/2; i++)
        if (s.charAt(i) != s.charAt(N-1-i))
            return false;
    return true;
}

从一个命令行参数中提取文件名和扩展名

String s = args[0];
int dot = s.indexOf(".");
String base = s.substring(0, dot);
String extension = s.substring(dot + 1, s.length());

打印出标准输入中所有含有通过命令行指定的字符串的行

String query = args[0];
while (!StdIn.isEmpty())
{
    String s = StdIn.readLine();
    if (s.contains(query)) StdOut.println(s);
}

以空白字符为分隔符从 StdIn 中创建一个字符串数组

String input = StdIn.readAll();
String[] words = input.split("\s+");

检查一个字符串数组中的元素是否已按照字母表顺序排列

public boolean isSorted(String[] a)
{
    for (int i = 1; i < a.length; i++)
    {
        if (a[i-1].compareTo(a[i]) > 0)
            return false;
    }
    return true;
}

典型的字符串处理代码

1.2.2.4 再谈输入输出

1.1 节中的 StdIn、StdOut 和 StdDraw 标准库的一个缺点是对于任意程序,我们只能接受一个输入文件、向一个文件输出或是产生一幅图像。有了面向对象编程,我们就能定义类似的机制来在一个程序中同时处理 多个 输入流、输出流和图像。具体来说,我们的标准库定义了数据类型 InOutDraw,它们的 API 如表 1.2.8 至表 1.2.10 所示。当使用一个 String 类型的参数调用它们的构造函数时, InOut 会首先尝试在当前目录下查找指定的文件。如果找不到,它会假设该参数是一个网站的名称并尝试连接到那个网站(如果该网站不存在,它会抛出一个运行时异常)。无论哪种情况,指定的文件或网站都会成为被创建的输入或输出流对象的来源或目标,所有 read*()print*() 方法都会指向那个文件或网站(如果你使用的是无参数的构造函数,对象将会使用标准的输入输出流)。这种机制使得单个程序能够处理多个文件和图像;你也能将这些对象赋给变量,将它们当做方法的参数、作为方法的返回值或是创建它们的数组,可以像操作任何类型的对象那样操作它们。下页所示的程序 Cat 就是一个 InOut 的用例,它使用了多个输入流来将多个输入文件归并到同一个输出文件中。 InOut 类也包括将仅含 intdoubleString 类型值的文件读取为一个数组的静态方法(请见 1.3.1.5 节和练习 1.2.15)。

public class Cat
{
   public static void main(String[] args)
   {  // 将所有输入文件复制到输出流(最后一个参数)中
      Out out = new Out(args[args.length-1]);
      for (int i = 0; i < args.length - 1; i++)
      {  // 将第i个输入文件复制到输出流中
         In in = new In(args[i]);
         String s = in.readAll();
         out.println(s);
         in.close();
      }
      out.close();
   }
}

InOut 的用例示例

% more in1.txt
This is

% more in2.txt
a tiny
test.

% java Cat in1.txt in2.txt out.txt

% more out.txt
This is
a tiny
test.

表 1.2.8 我们的输入流数据类型的 API

public class In``             In()从标准输入创建输入流             In(String name)从文件或网站创建输入流   boolean   isEmpty()如果输入流为空则返回 true,否则返回 false``       int   readInt()读取一个 int 类型的值    double   readDouble()读取一个 double 类型的值             ...``      void   close()关闭输入流

注: In 对象也支持 StdIn 所支持的所有操作。

表 1.2.9 我们的输出流数据类型的 API

public class Out``             Out()从标准输出创建输出流             Out(String name)从文件创建输出流      void   print(String s)s 添加到输出流中      void   println(String s)s 和一个换行符添加到输出流中      void   println()将一个换行符添加到输出流中      void   printf(String f, ...)格式化并打印到输出流中      void   close()关闭输出流

注: Out 对象也支持 StdOut 所支持的所有操作。

表 1.2.10 我们的绘图数据类型的 API

public class Draw``             Draw()``      void   line(double x0, double y0, double x1, double y1)``      void   point(double x, double y)``             ...

注: Draw 对象也支持 StdDraw 所支持的所有操作。

1.2.3 抽象数据类型的实现

和静态方法库一样,我们也需要使用 Java 的类( class)实现抽象数据类型并将所有代码放入一个和类名相同并带有 .java 扩展名的文件中。文件的第一部分语句会定义表示数据类型的值的 实例变量。它们之后是实现对数据类型的值的操作的 构造函数实例方法。实例方法可以是 公共的(在 API 中说明)或是 私有的(用于辅助计算,用例无法使用)。一个数据类型的定义中可能含有多个构造函数,而且也可能含有静态方法,特别是单元测试用例 main(),它通常在调试和测试中很实用。作为第一个例子,我们来学习 1.2.1.1 节定义的 Counter 抽象数据类型的实现。它的完整实现(带有注释)如图 1.2.5 所示,在对它的各个部分的讨论中,我们还将该图作为参考。本书后面开发的每个抽象数据类型的实现都会含有和这个简单例子相同的元素。

![/740932/image00860.gif)

抽象数据类型中的实例变量是私有的

![/740932/image00861.gif)

图 1.2.5 详解数据类型的定义类

1.2.3.1 实例变量

要定义数据类型的值(即每个对象的 状态),我们需要声明 实例变量,声明的方式和局部变量差不多。实例变量和你所熟悉的静态方法或是某个代码段中的局部变量最关键的区别在于:每一时刻每个局部变量只会有 一个 值,但每个实例变量则对应着 无数 值(数据类型的每个实例对象都会有一个)。这并不会产生二义性,因为我们在访问实例变量时都需要通过一个对象——我们访问的是这个对象的值。同样,每个实例变量的声明都需要一个 可见性修饰符。在抽象数据类型的实现中,我们会使用 private,也就是使用 Java 语言的机制来保证向使用者隐藏抽象数据类型中的数据表示,如下面的示例所示。如果该值在初始化之后不应该再被改变,我们也会使用 finalCounter 类型含有两个实例变量,一个 String 类型的值 name 和一个 int 类型的值 count。如果我们使用 public 修饰这些实例变量(在 Java 中是允许的),那么根据定义,这种数据类型就不再是抽象的了,因此我们不会这么做。

1.2.3.2 构造函数

每个 Java 类都至少含有一个 构造函数 以创建一个对象的 标识。构造函数类似于一个静态方法,但它能够直接访问实例变量且没有返回值。一般来说,构造函数的作用是初始化实例变量。每个构造函数都将创建一个对象并向调用者返回一个该对象的引用。构造函数的名称总是和类名相同。我们可以和重载方法一样重载这个名称并定义签名不同的多个构造函数。如果没有定义构造函数,类将会隐式定义一个默认情况下不接受任何参数的构造函数并将所有实例变量初始化为默认值。原始数字类型的实例变量默认值为 0,布尔类型变量为 false,引用类型变量为 null。我们可以在声明语句中初始化这些实例变量并改变这些默认值。当用例使用关键字 new 时,Java 会自动触发一个构造函数。重载构造函数一般用于将实例变量由默认值初始化为用例提供的值。例如, Counter 类型有个接受一个参数的构造函数,它将实例变量 name 初始化为由参数给定的值(实例变量 count 仍将被初始化为默认值 0)。构造函数解析如图 1.2.6 所示。

![/740932/image00862.gif)

图 1.2.6 详解构造函数

1.2.3.3 实例方法

初始化实例变量的代码实现数据类型的实例方法(即每个对象的 行为)的代码和 1.1 节中实现 静态方法(函数)的代码完全相同。每个实例方法都有一个返回值类型、一个签名(它指定了方法名、所有参数变量的类型和名称)和一个 主体(它由一系列语句组成,包括一个 返回 语句来将一个返回类型的值传递给调用者)。当调用者触发了一个方法时,方法的参数(如果有)均会被初始化为调用者所提供的值,方法的语句会被执行,直到得到一个返回值并且将该值返回给调用者。它的效果就好像调用者代码中的函数调用被替换为了这个返回值。实例方法的所有这些行为都和静态方法相同,只有一点关键的不同: 它们可以访问并操作实例变量。如何指定我们希望使用的对象的实例变量?只要稍加思考,就能够得到合理的答案:在一个实例方法中对变量的引用指的是 调用该方法的对象中的值。当我们调用 heads.increment() 时, increment() 方法中的代码访问的是 heads 中的实例变量。换句话说,面向对象编程为 Java 程序增加了另一种使用变量的重要方式。

  • 通过触发一个实例方法来操作该对象的值。

这与调用静态方法仅仅是语法上的区别(请见答疑),但在许多情况下它颠覆了现代程序员对程序开发的思维方式。你会看到,这种方式与算法和数据结构的研究非常契合。实例方法解析如图 1.2.7 所示。

![/740932/image00863.gif)

图 1.2.7 详解实例方法

1.2.3.4 作用域

总的来说,我们在实现实例方法的 Java 代码中使用了 三种 变量:

  • 参数变量;
  • 局部变量;
  • 实例变量

前两者的用法和静态方法中一样:方法的签名定义了参数变量,在方法被调用时参数变量会被初始化为调用者提供的值;局部变量的声明和初始化都在方法的主体中。参数变量的作用域是整个方法;局部变量的作用域是当前代码段中它的定义之后的所有语句。实例变量则完全不同(如右侧示例所示):它们为该类的对象保存了数据类型的值,它们的作用域是整个类(如果出现二义性,可以使用 this 前缀来区别实例变量)。理解实例方法中这三种变量的区别是理解面向对象编程的关键。

![/740932/image00864.gif)

实例方法中的实例变量和局部变量的作用范围

1.2.3.5 API、用例与实现

这些都是你要在 Java 中构造并使用抽象数据类型所需要理解的基本组件。我们将要学习的每个抽象数据类型的实现都会是一个含有若干私有实例变量、构造函数、实例方法和一个测试用例的 Java 类。要完全理解一个数据类型,我们需要它的 API、典型的用例和它的实现。 Counter 类型的总结请见表 1.2.11。为了强调用例和实现的分离,我们一般会将用例独立成为含有一个静态方法 main() 的类,并将数据类型定义中的 main() 方法预留为一个用于开发和最小单元测试的测试用例(至少调用每个实例方法一次)。我们开发的每种数据类型都会遵循相同的步骤。我们思考的不是应该采取什么行动来达成某个计算性的目的(如同我们第一次学习编程时那样),而是用例的需求。我们会按照下面三步走的方式用抽象数据类型满足它们。

  • 定义一份API:API 的作用是 将使用和实现分离,以实现模块化编程。我们制定一份API 的目标有二:第一,我们希望用例的代码清晰而正确,事实上,在最终确定API 之前就编写一些用例代码来确保所设计的数据类型操作正是用例所需要的是很好的主意;第二,我们希望能够实现这些操作,定义一些无法实现的操作是没有意义的。
  • 用一个 Java 类实现 API 的定义:首先我们选择适当的实例变量,然后再编写构造函数和实例方法。
  • 实现多个测试用例来验证前两步做出的设计决定。

表 1.2.11 一个简单计数器的抽象数据类型

API``public class Counter``             Counter(String id)创建一个名为 id 的计数器       void  increment()将计数器的值加 1        int  tally()计数器的值     String  toString()对象的字符串表示典型的用例

public class Flips
{
   public static void main(String[] args)
   {
      int T = Integer.parseInt(args[0]);
      Counter heads = new Counter("heads");
      Counter tails = new Counter("tails");
      for (int t = 0; t < T; t++)
         if (StdRandom.bernoulli(0.5))
              heads.increment();
         else tails.increment();
      StdOut.println(heads);
      StdOut.println(tails);
      int d = heads.tally() - tails.tally();
      StdOut.println("delta: " + Math.abs(d));
   }
}

数据类型的实现

public class Counter
{
   private final String name;
   private int count;
   public Counter(String id)
   { name = id; }
   public void increment()
   { count++; }
   public int tally()
   { return count; }
   public String toString()
   { return count + " " + name; }
}

使用方法

% java Flips 1000000
500172 heads
499828 tails
delta: 344

用例一般需要什么操作?数据类型的值应该是什么才能最好地支持这些操作?这些基本的判断是我们开发的每种实现的核心内容。

1.2.4 更多抽象数据类型的实现

和任何编程概念一样,理解抽象数据类型的威力和用法的最好办法就是仔细研究更多的例子和实现。本书中大量代码是通过抽象数据类型实现的,因此你的机会很多,但是一些更简单的例子能够帮助我们为研究抽象数据类型打好基础。

1.2.4.1 日期

表 1.2.12 是我们在表 1.2.6 中定义的 Date 抽象数据类型的两种实现。简单起见,我们省略了解析字符串的构造函数(请见练习 1.2.19)和继承的方法 equals()(请见 1.2.5.8 节)、 compareTo()(请见 2.1.1.4 节)和 hashCode()(请见练习 3.4.22)。表 1.2.12 中左侧的简单实现将日、月和年设为实例变量,这样实例方法就可以直接返回适当的值;右侧的实现更加节省空间,仅使用了一个 int 变量来表示一个日期。它将 d 日、 m 月和 y 年的一个日期表示为一个混合进制的整数 512y+32m+d。用例分辨这两种实现的区别的一种方法可能是打破我们对日期的隐式假设:第二种实现的正确性基于日的值在 0 到 31 之间,月的值在 0 到 15 之间,年的值为正(在实际应用中,两种实现都应该检查月份的值是否在 1 到 12 之间,日的值是否在 1 到 31 之间,以及例如 2009 年 6 月 31 日和 2 月 29 日这样的非法日期,尽管这么做要费些工夫)。这个例子的主要意思是说明我们在 API 中极少 完整 地指定对实现的要求(一般来说我们都会尽力而为,这里还可以做得更好)。用例要分辨出这两种实现的区别的另一种方法是 性能:右侧的实现中保存数据类型的值所需的空间较少,代价是在向用例按照约定的格式提供这些值时花费的时间更多(需要进行一两次算术运算)。这种交换是很常见的:某些用例可能偏爱其中一种实现,而另一些用例可能更喜欢另一种,因此我们两者都要满足。事实上,本书中反复出现的一个主题就是我们需要理解各种实现对空间和时间的需求以及它们对各种用例的适用性。在实现中使用数据抽象的一个关键优势是我们可以将一种实现替换为另一种而 无需改变用例的任何代码

表 1.2.12 一种封装日期的抽象数据类型以及它的两种实现

API``public class Date``             Date(int month, int day, int year)创建一个日期        int  day()        int  month()        int  year()     String  toString()对象的字符串表示测试用例

public static void main(String[] args)
{
   int m = Integer.parseInt(args[0]);
   int d = Integer.parseInt(args[1]);
   int y = Integer.parseInt(args[2]);
   Date date = new Date(m, d, y);
   StdOut.println(date);
}

使用方法

% java Date 12 31 1999
12/31/1999

数据类型的实现

public class Date
{
   private final int month;
   private final int day;
   private final int year;
   public Date(int m, int d, int y)
   {  month = m; day = d; year = y; }
   public int month()
   {  return month;  }
   public int day()
   {  return day;  }
   public int year()
   {  return year;  }
   public String toString()
   {  return month() + "/" + day()
                     + "/" + year();  }
}

数据类型的另一种实现

public class Date
{
   private final int value;
   public Date(int m, int d, int y)
   { value = y\*512 + m\*32 + d; }
   public int month()
   { return (value / 32) % 16; }
   public int day()
   { return value % 32; }
   public int year()
   { return value / 512; }

   public String toString()
   {  return month() + "/" + day()
                     + "/" + year();  }
}

1.2.4.2 维护多个实现

同一份API 的多个实现可能会产生维护和命名问题。在某些情况下,我们可能只是想将较老的实现替换为改进的实现。而在另一些情况下,我们可能需要维护两种实现,一种适用于某些用例,另一种适用于另一些用例。实际上,本书的一个主要目标就是深入讨论若干种基本抽象数据结构的实现并衡量它们的性能的不同。在本书中,我们经常会比较同一份API 的两种不同实现在同一个用例中的性能表现。为此,我们通常采用一种非正式的命名约定。

  • 通过前缀的描述性修饰符区别同一份 API 的不同实现。例如,我们可以将表 1.2.12 中的 Date 实现命名为 BasicDateSmallDate,我们可能还希望实现一种能够验证日期是否合法的 SmartDate
  • 维护一个没有前缀的参考实现,它应该适合于大多数用例的需求。在这里,大多数用例应该直接会使用 Date

在一个庞大的系统中,这种解决方案并不理想,因为它可能会需要修改用例的代码。例如,如果需要开发一个新的实现 ExtraSmallDate,那么我们只能修改用例的代码或是让它成为所有用例的参考实现。Java 有许多高级语言特性来保证在无需修改用例代码的情况下维护多个实现,但我们很少会使用它们,因为即使 Java 专家使用起它们来也十分困难(有时甚至是有争议的),尤其是同我们极为需要的其他高级语言特性(泛型和迭代器)一起使用时。这些问题很重要(例如,忽略它们会导致千禧年著名的 Y2K 问题,因为许多程序使用的都是它们自己对日期的抽象实现,且并没有考虑到年份的头两位数字),但是深究它们会使我们大大偏离对算法的研究。

1.2.4.3 累加器

表 1.2.13 中的 累加器 API 定义了一种能够为用例计算一组数据的实时平均值的抽象数据类型。例如,本书中经常会使用该数据类型来处理实验结果(请见 1.4 节)。它的实现很简单:它维护一个 int 类型的实例变量来记录已经处理过的数据值的数量,以及一个 double 类型的实例变量来记录所有数据值之和,将和除以数据数量即可得到平均值。请注意该实现并没有保存数据的值——它可以用于处理大规模的数据(甚至是在一个无法全部保存它们的设备上),而一个大型系统也可以大量使用累加器。这种性能特点很容易被忽视,所以也许应该在API 中注明,因为一种存储所有数据值的实现可能会使调用它的应用程序用光所有内存。

表 1.2.13 一种能够累加数据的抽象数据类型

API``public class Accumulator``             Accumulator()创建一个累加器       void  addDataValue(double val)添加一个新的数据值     double  mean()所有数据值的平均值     String  toString()对象的字符串表示典型的用例

public class TestAccumulator
{
   public static void main(String[] args)
   {
      int T = Integer.parseInt(args[0]);
      Accumulator a = new Accumulator();
      for (int t = 0; t < T; t++)
         a.addDataValue(StdRandom.random());
      StdOut.println(a);
   }
}

使用方法

% java TestAccumulator 1000
Mean (1000 values): 0.51829

% java TestAccumulator 1000000
Mean (1000000 values): 0.49948

% java TestAccumulator 1000000
Mean (1000000 values): 0.50014

数据类型的实现

public class Accumulator
{
   private double total;
   private int N;
   public void addDataValue(double val)
   {
       N++;
       total += val;
   }
   public double mean()
   {  return total/N;  }
   public String toString()
   { return "Mean (" + N + " values): "
                 + String.format("%7.5f", mean()); }
}

1.2.4.4 可视化的累加器

表 1.2.14 所示的 可视化累加器 的实现继承了 Accumulator 类并展示了一种实用的副作用:它用 StdDraw 画出了所有数据(灰色)和实时的平均值(红色),见图 1.2.8。完成这项任务最简单的办法是添加一个构造函数来指定需要绘出的点数和它们的最大值(用于调整图像的比例)。严格说来, VisualAccumulator 并不是 Accumulator 的 API 的实现(它的构造函数的签名不同且产生了一种不同的副作用)。一般来说,我们会仔细而完整地设计 API,并且一旦定型就不愿再对它做 任何 改动,因为这有可能会涉及修改无数用例(和实现)的代码。但添加一个构造函数来取得某些功能有时能够获得通过,因为它对用例的影响和改变类名所产生的变化相同。在本例中,如果已经开发了一个使用 Accumulator 的用例并大量调用了 addDataValue()mean(),只需改变用例的一行代码就能享受到 VisualAccumulator 的优势。

![/740932/image00865.jpeg)

图 1.2.8 可视化累加器图像

表 1.2.14 一种能够累加数据的抽象数据类型

API``public class VisualAccumulator``             VisualAccumulator(int trials, double max)``       void  addDataValue(double val)添加一个新的数据值     double  mean()所有数据的平均值     String  toString()对象的字符串表示典型的用例

public class TestVisualAccumulator
{
   public static void main(String[] args)
   {
      int T = Integer.parseInt(args[0]);
      VisualAccumulator a = new VisualAccumulator(T, 1.0);
      for (int t = 0; t < T; t++)
         a.addDataValue(StdRandom.random());
      StdOut.println(a);
   }
}

数据类型的实现

![/740932/image00866.gif)

1.2.5 数据类型的设计

抽象数据类型是一种向用例隐藏内部表示的数据类型。这种思想强有力地影响了现代编程。我们遇到过的众多例子为我们研究抽象数据类型的高级特性和它们的 Java 实现打下了基础。简单看来,下面的许多话题和算法的学习关系不大,因此你可以跳过本节,在今后实现抽象数据类型中遇到特定问题时再回过头来参考它。我们的目的是将关于设计数据类型的重要知识集中起来以供参考,并为本书中的所有抽象数据类型的实现做铺垫。

1.2.5.1 封装

面向对象编程的特征之一就是使用数据类型的实现 封装 数据,以简化实现和隔离用例开发。封装实现了模块化编程,它允许我们:

  • 独立开发用例和实现的代码;
  • 切换至改进的实现而不会影响用例的代码;
  • 支持尚未编写的程序(对于后续用例,API 能够起到指南的作用)。

封装同时也隔离了数据类型的操作,这使我们可以:

  • 限制潜在的错误;
  • 在实现中添加一致性检查等调试工具;
  • 确保用例代码更明晰。

一个封装的数据类型可以被任意用例使用,因此它扩展了 Java 语言。我们所提倡的编程风格是将大型程序分解为能够独立开发和调试的小型模块。这种方式将修改代码的影响限制在局部区域,改进了我们的软件质量。它也促进了代码复用,因为我们可以用某种数据类型的新实现代替老的实现来改进它的性能、准确度或是内存消耗。同样的思想也适用于许多其他领域。我们在使用系统库时常常从封装中受益。Java 系统的新实现往往更新了多种数据类型或静态方法库的实现, 但它们的 API 并没有变化。在算法和数据结构的学习中,我们总是希望开发出更好的算法,因为只需用抽象数据类型的改进实现替换老的实现即可在不改变 任何 用例代码的情况下改进所有用例的性能。模块化编程成功的关键在于保持模块之间的 独立性。我们坚持将 API 作为用例和实现之间唯一的依赖点来做到这一点。 并不需要知道一个数据类型是如何实现的才能使用它,实现数据类型时也应该假设使用者除了 API 什么也不知道。封装是获得所有这些优势的关键。

1.2.5.2 设计 API

构建现代软件最重要也最有挑战的一项任务就是设计 API。它需要经验、思考和反复的修改,但设计一份优秀的 API 所付出的所有时间都能从调试和代码复用所节省的时间中获得回报。为一个小程序给出一份 API 似乎有些多余,但你应该按照能够复用的方式编写 每个程序。理想情况下,一份 API 应该能够清楚地说明所有可能的输入和副作用,然后我们应该先写出检查实现是否与 API 相符的程序。但不幸的是,计算机科学理论中一个叫做 说明书问题(specification problem)的基础结论说明这个目标是 不可能 实现的。简单地说,这样一份说明书应该用一种类似于编程语言的形式语言编写。而从数学上可以证明,判定这样两个程序进行的计算是否相同是 不可能的。因此,我们的 API 将是与抽象数据类型相关联的值以及一系列构造函数和实例方法的目的和副作用的自然语言描述。为了验证我们的设计,我们会在 API 附近的正文中给出一些用例代码。但是,这些宏观概述之中也隐藏着每一份 API 设计都可能落入的无数陷阱。

  • API 可能会 难以实现:实现的开发非常困难,甚至不可能。
  • API 可能会 难以使用:用例代码甚至比没有 API 时更复杂。
  • API 的范围可能 太窄:缺少用例所需的方法。
  • API 的范围可能 太宽:包含许多不会被任何用例调用的方法。这种缺陷可能是最常见的,并且也是最难以避免的。API 的大小一般会随着时间而增长,因为向已有的API 中添加新方法很简单,但在不破坏已有用例程序的前提下从中删除方法却很困难。
  • API 可能会 太粗略:无法提供有效的抽象。
  • API 可能会 太详细:抽象过于细致或是发散而无法使用。
  • API 可能会 过于依赖某种特定的数据表示:用例代码可能会因此无法从数据表示的细节中解脱出来。要避免这种缺陷也是很困难的,因为数据表示显然是抽象数据类型实现的核心。

这些考虑有时又被总结为另一句格言: 只为用例提供它们所需要的,仅此而已

1.2.5.3 算法与抽象数据类型

数据抽象天生适合算法研究,因为它能够为我们提供一个框架,在其中能够准确地说明一个算法的目的以及其他程序应该如何使用该算法。在本书中,算法一般都是某个抽象数据类型的一个实例方法的实现。例如,本章开头的白名单例子就很自然地被实现为一个抽象数据类型的用例。它进行了以下操作:

  • 由一组给定的值构造了一个 SET(集合)对象;
  • 判定一个给定的值是否存在于该集合中。

这些操作封装在 StaticSETofInts 抽象数据类型中,和Whitelist 用例一起显示在表 1.2.15 中。 StaticSETofInts 是更一般也更有用的 符号表 抽象数据类型的一种特殊情况,符号表抽象数据类型将是第 3 章的重点。在我们研究过的所有算法中,二分查找是较为适合用于实现这些抽象数据类型的一种。和 1.1.10 节中的 BinarySearch 实现比较起来,这里的实现所产生的用例代码更加清晰和高效。例如, StaticSETofInts 强制要求数组在 rank() 方法被调用之前排序。有了抽象数据类型,我们可以将抽象数据类型的调用和实现区分开来,并确保任意遵守 API 的用例程序都能受益于二分查找算法(使用 BinarySearch 的程序在调用 rank() 之前必须能够将数组排序)。白名单应用是众多二分查找算法的用例之一。

应用

% java Whitelist largeW.txt <
largeT.txt
499569
984875
295754
207807
140925
161828
 ...

每个 Java 程序 都是一组静态方法和(或)一种数据类型的实现的集合。在本书中我们主要关注的是 抽象 数据类型的实现中的操作和向用例隐藏其中的数据表示,例如 StaticSETofInts。正如这个例子所示,数据抽象使我们能够:

  • 准确定义算法能为用例提供什么;
  • 隔离算法的实现和用例的代码;
  • 实现多层抽象,用已知算法实现其他算法。

表 1.2.15 将二分查找重写为一段面向对象的程序(用于在整数集合中进行查找的一种抽象数据类型)

API``public class StaticSETofInts``             StaticSETofInts(int[] a)根据 a[] 中的所有值创建一个集合    boolean  contains(int key)``key 是否存在于集合中典型的用例

public class Whitelist
{
   public static void main(String[] args)
   {
      int[] w = In.readInts(args[0]);
      StaticSETofInts set = new StaticSETofInts(w);
      while (!StdIn.isEmpty())
      {  // 读取键,如果不在白名单中则打印它
         int key = StdIn.readInt();
         if (!set.contains(key))
            StdOut.println(key);
      }
   }
}

数据类型的实现

import java.util.Arrays;
public class StaticSETofInts
{
   private int[] a;
   public StaticSETofInts(int[] keys)
   {
      a = new int[keys.length];
      for (int i = 0; i < keys.length; i++)
         a[i] = keys[i]; // 保护性复制
      Arrays.sort(a);
   }
   public boolean contains(int key)
   {  return rank(key) != -1;  }
   private int rank(int key)
   {  // 二分查找
      int lo  = 0;
      int hi = a.length - 1;
      while (lo <= hi)
      {  // 键要么存在于a[lo..hi] 中,要么不存在
         int mid = lo + (hi - lo) / 2;
         if      (key < a[mid]) hi = mid - 1;
         else if (key > a[mid]) lo = mid + 1;
         else                   return mid;
      }
      return -1;
   }
}

无论是使用自然语言还是伪代码描述算法,这些都是我们所希望拥有的性质。使用 Java 的类机制来支持数据的抽象将使我们收获良多:我们编写的代码将能够测试算法并比较各种用例程序的性能。

1.2.5.4 接口继承

Java 语言为定义对象之间的关系提供了支持,称为 接口。程序员广泛使用这些机制,如果上过软件工程的课程那么你可以详细地研究一下它们。我们学习的第一种继承机制叫做 子类型。它允许我们通过指定一个含有一组公共方法的 接口 为两个本来并没有关系的类建立一种联系,这两个类都必须实现这些方法。例如,如果不使用我们的非正式 API,也可以为 Date 声明一个接口:

public interface Datable
{
   int month();
   int day();
   int year();
}

并在我们的实现中引用该接口:

public class Date implements Datable
{
   // 实现代码(和以前一样)
}

这样,Java 编译器就会检查该实现是否和接口相符。为任意实现了 month()day()year() 的类添加 implements Datable 保证了所有用例都能用该类的对象调用这些方法。这种方式称为 接口继承——实现类继承的是接口。接口继承使得我们的程序能够通过调用接口中的方法操作实现该接口的 任意 类型的对象(甚至是还未被创建的类型)。我们可以在更多非正式的 API 中使用接口继承,但为了避免代码依赖于和理解算法无关的高级语言特性以及额外的接口文件,我们并没有这么做。在某些情况下 Java 的习惯用法鼓励我们使用接口:我们用它们进行 比较迭代,如表 1.2.16 所示。我们会在接触那些概念时再详细研究它们。

表 1.2.16 本书中所用到的 Java 接口

接口

方法

章节

比较

java.lang.Comparable

java.util.Comparator

compareTo()

compare()

2.1

2.5

迭代

java.lang.Iterable

java.util.Iterator

iterator()

hasNext()

next()

remove()

1.3

1.3

1.2.5.5 实现继承

Java 还支持另一种继承机制,被称为 子类。这种非常强大的技术使程序员不需要重写整个类就能改变它的行为或者为它添加新的功能。它的主要思想是定义一个新类( 子类,或称为 派生类)来继承另一个类( 父类,或称为 基类)的所有实例方法和实例变量。子类包含的方法比父类更多。另外,子类可以重新定义或者 重写 父类的方法。子类继承被系统程序员广泛用于编写所谓 可扩展 的库——任何一个程序员(包括你)都能为另一个程序员(或者也许是一个系统程序员团队)创建的库添加方法。这种方法能够有效地重用潜在的十分庞大的库中的代码。例如,这种方法被广泛用于图形用户界面的开发,因此实现用户所需要的各种控件(下拉菜单,剪切—粘贴,文件访问等)的大量代码都能够被重用。子类继承的使用在系统程序员和应用程序员之间是有争议的(它和接口继承之间的优劣还没有定论)。在本书中我们会避免使用它,因为它会破坏封装。但这种机制是 Java 的一部分,因此它的残余是无法避免的:具体来说,每个类都是 Java 的 Object 类的子类。这种结构意味着每个类都含有 getClass()toString()equals()hashCode()(见表 1.2.17)和另外几个我们不会在本书中用到的方法的实现。实际上,每个类都通过子类 继承Object 类中继承了这些方法,因此任何用例都可以在任意对象中调用这些方法。我们通常会重写新类的 toString()equals()hashCode() 方法,因为 Object 类的默认实现一般无法提供所需的行为。接下来我们将讨论 toString()equals(),在 3.4 节中讨论 hashCode()

表 1.2.17 本书中所使用的由 Object 类继承得到的方法

方法

作用

章节

  Class  getClass()

该对象的类是什么

1.2

 String  toString()

该对象的字符串表示

1.1

boolean  equals(Object that)

该对象是否和 that 相等

1.2

    int  hashCode()

该对象的散列值

3.4

1.2.5.6 字符串表示的习惯

按照习惯,每个 Java 类型都会从 Object 继承 toString() 方法,因此任何用例都能够调用任意对象的 toString() 方法。当连接运算符的一个操作数是字符串时,Java 会自动将另一个操作数也转换为字符串,这个约定是这种自动转换的基础。如果一个对象的数据类型没有实现 toString() 方法,那么转换会调用 Obejct 的默认实现。默认实现一般都没有多大实用价值,因为它只会返回一个含有该对象内存地址的字符串。因此我们通常会为我们的每个类实现并重写默认的 toString() 方法,如下面代码框的 Date 类中加粗的部分所示。由代码可以看到, toString() 方法的实现通常很简单,只需隐式调用(通过 +)每个实例变量的 toString() 方法即可。

1.2.5.7 封装类型

Java 提供了一些内置的引用类型,称为 封装类型。每种原始数据类型都有一个对应的封装类型: BooleanByteCharacterDoubleFloatIntegerLongShort 分别对应着 booleanbytechardoublefloatintlongshort。这些类主要由类似于 parseInt() 这样的静态方法组成,但它们也含有继承得到的实例方法 toString()compareTo()equals()hashCode()。在需要的时候 Java 会自动将原始数据类型转换为封装类型,如 1.3.1.1 节所述。例如,当一个 int 值需要和一个 String 连接时,它的类型会被转换为 Integer 并触发 toString() 方法。

1.2.5.8 等价性

两个对象相等意味着什么?如果我们用相同类型的两个引用变量 ab 进行等价性测试( a == b),我们检测的是它们的标识是否相同,即 引用 是否相同。一般用例希望能够检查 数据类型的值(对象的状态)是否相同或者实现某种针对该类型的规则。Java 为我们开了个头,为 IntegerDoubleString 等标准数据类型以及一些如 FileURL 的复杂数据类型提供了实现。在处理这些类型的数据时,可以直接使用内置的实现。例如,如果 xy 均为 String 类型的值,那么当且仅当 xy 的长度相同且每个位置的字符均相同时 x.equals(y) 的返回值为 true。当我们在定义自己的数据类型时,比如 DateTransaction,需要重载 equals() 方法。Java 约定 equals() 必须是一种 等价性 关系。它必须具有:

  • 自反性x.equals(x)true
  • 对称性,当且仅当 y.equals(x)true 时, x.equals(y) 返回 true
  • 传递性,如果 x.equals(y)y.equals(z) 均为 truex.equals(z) 也将为 true

另外,它必须接受一个 Object 为参数并满足以下性质:

  • 一致性,当两个对象均未被修改时,反复调用 x.equals(y) 总是会返回相同的值;
  • 非空性x.equals(null) 总是返回 false

这些定义都是自然合理的,但确保这些性质成立并遵守 Java 的约定,同时又避免在实现时做无用功却并不容易,如 Date 所示。它通过以下步骤做到了这一点。

  • 如果该对象的引用和参数对象的引用相同,返回 true。这项测试在成立时能够免去其他所有测试工作。
  • 如果参数为空( null),根据约定返回 false(还可以避免在下面的代码中使用空引用)。
  • 如果两个对象的类不同,返回 false。要得到一个对象的类,可以使用 getClass() 方法。请注意我们会使用 == 来判断 Class 类型的对象是否相等,因为同一种类型的所有对象的 getClass() 方法一定能够返回相同的引用。
  • 将参数对象的类型从 Object 转换到 Date(因为前一项测试已经通过,这种转换必然成功)。
  • 如果任意实例变量的值不相同,返回 false。对于其他类,等价性测试方法的定义可能不同。例如,我们只有在两个 Counter 对象的 count 变量相等时才会认为它们相等。

![/740932/image00867.gif)

在数据类型的定义中重写 toString()equals() 方法

你可以使用上面的实现作为实现任意数据类型的 equals() 方法的模板。只要实现一次 equals() 方法,下一次就不会那么困难了。

1.2.5.9 内存管理

我们可以为一个引用变量赋予一个新的值,因此一段程序可能会产生一个无法被引用的对象。例如,请看图 1.2.9 中所示的三行赋值语句。在第三行赋值语句之后,不仅 ab 会指向同一个 Date 对象(1/1/2011),而且不存在能够引用初始化变量 a 的那个 Date 对象的引用了。本来该对象的唯一引用就是变量 a,但是该引用被赋值语句覆盖了,这样的对象被称为孤儿。对象在离开作用域之后也会变成 孤儿。Java 程序经常会创建大量对象(以及许多保存原始数据类型值的变量),但在某个时刻程序只会需要它们之中的一小部分。因此,编程语言和系统需要某种机制来在必要时为数据类型的值 分配 内存,而在不需要时释放它们的内存(对于一个对象来说,有时是在它变成孤儿之后)。内存管理对于原始数据类型更容易,因为内存分配所需要的所有信息在编译阶段就能够获取。Java(以及大多数其他系统)会在声明变量时为它们预留内存空间,并会在它们离开作用域后释放这些空间。对象的内存管理更加复杂:系统会在创建一个对象时为它分配内存,但是程序在执行时的动态性决定了一个对象何时才会变为孤儿,系统并不能准确地知道应该何时释放一个对象的内存。在许多语言中(例如 C 和 C++),分配和释放内存是程序员的责任。众所周知,这种操作既繁琐又容易出错。Java 最重要的一个特性就是 自动 内存管理。它通过记录孤儿对象并将它们的内存释放到内存池中将程序员从管理内存的责任中解放出来。这种回收内存的方式叫做 垃圾回收。Java 的一个特点就是它不允许修改引用的策略。这种策略使 Java 能够高效自动地回收垃圾。程序员们至今仍在争论,为获得无需为内存管理操心的方便而付出的使用自动垃圾回收的代价是否值得。

Date a = new Date(12, 31, 1999);
Date b = new Date(1, 1, 2011);
a = b;

![/740932/image00868.jpeg)

图 1.2.9 孤儿对象

1.2.5.10 不可变性

不可变 数据类型,例如 Date,指的是该类型的对象中的值在创建之后就无法再被改变。与此相反, 可变 数据类型,例如 CounterAccumulator,能够操作并改变对象中的值。Java 语言通过 final 修饰符来强制保证不可变性。当你将一个变量声明为 final 时,也就保证了只会对它赋值一次,可以用赋值语句,也可以用构造函数。试图改变 final 变量的值的代码将会产生一个编译时错误。在我们的代码中,我们用 final 修饰值不会改变的实例变量。这种策略就像文档一样,说明了这个变量的值不会再发生改变,它能够预防意外修改,也能使程序的调试更加简单。像 Date 这样实例变量均为原始数据类型且被 final 修饰的数据类型(按照约定,在不使用子类继承的代码中)是不可变的。数据类型是否可变是一个重要的设计决策,它取决于当前的应用场景。对于类似于 Date 的数据类型,抽象的目的是封装不变的值,以便将其和原始数据类型一样用于赋值语句、作为函数的参数或返回值(而不必担心它们的值会被改变)。程序员在使用 Date 时可能会写出操作两个 Date 类型的变量的代码 d = d0,就像操作 double 或者 int 值一样。但如果 Date 类型是可变的且 d 的值在 d = d0 之后可以被改变,那么 d0 的值 也会 被改变(它们都是指向同一个对象的引用)!从另一方面来说,对于类似于 CounterAccumulator 的数据类型,抽象的目的是封装变化中的值。作为用例程序员,你在使用 Java 数组(可变)和 Java 的 String 类型(不可变)时就已经遇到了这种区别。将一个 String 传递给一个方法时,你不会担心该方法会改变字符串中的字符顺序,但当你把一个数组传递给一个方法时,方法可以自由改变数组的内容。 String 对象是不可变的,因为我们 一般都不希望 String 的值改变,而 Java 数组是可变的,因为我们一般的确希望改变数组中的值。但也存在我们希望使用可变字符串(这就是 Java 的 StringBuilder 类存在的目的)和不可变数组(这就是稍后讨论的 Vector 类存在的目的)的情况。一般来说,不可变的数据类型比可变的数据类型使用更容易,误用更困难,因为能够改变它们的值的方式要少得多。调试使用不可变类型的代码更简单,因为我们更容易确保用例代码中使用它们的变量的状态前后一致。在使用可变数据类型时,必须时刻关注它们的值会在何时何地发生变化。而不可变性的缺点在于 我们需要为每个值创建一个新对象。这种开销一般是可以接受的,因为 Java 的垃圾回收器通常都为此进行了优化。不可变性的另一个缺点在于, final 非常不幸地只能用来保证原始数据类型的实例变量的不可变性,而无法用于引用类型的变量。如果一个引用类型的实例变量含有修饰符 final,该实例变量的值(某个对象的引用)就永远无法改变了——它将永远指向同一个对象,但对象的值本身 仍然是 可变的。例如,这段代码并 没有 实现一个不可变的数据类型:

public class Vector
{
   private final double[] coords;

   public Vector(double[] a)
   {  coords = a; }
   ...
}

用例程序可以通过给定的数组创建一个 Vector 对象,并在构造函数执行之后(绕过 API)改变 Vector 中的元素的值:

double[] a = { 3.0, 4.0 };
Vector vector = new Vector(a);
a[0] = 0.0;  // 绕过了公有API

实例变量 coords[]privatefinal 的,但 Vector 是可变的,因为用例拥有指向数据的一个引用。任何数据类型的设计都需要考虑到不可变性,而且数据类型是否是不可变的则应该在 API 中说明,这样使用者才能知道该对象中的值是无法改变的。在本书中,我们对不可变性的主要兴趣在于用它保证我们的算法的正确性。例如,如果一个二分查找算法所使用的数据的类型是可变的,那么算法的用例就可能破坏我们对二分查找中的数组已经有序的假设。可变数据与不可变数据的示例见表 1.2.18。

表 1.2.18 可变与不可变数据类型举例

可变数据类型

不可变数据类型

Counter

Date

Java 数组

String

1.2.5.11 契约式设计

在最后,我们将简要讨论 Java 语言中能够在程序 运行时 检验程序状态的一些机制。为此我们将使用两种 Java 的语言特性:

  • 异常(Exception),一般用于处理不受我们控制的不可预见的错误;
  • 断言(Assertion),验证我们 在代码中 做出的一些假设。

大量使用异常和断言是很好的编程实践。为了节约版面我们在本书中极少使用它们,但你在本书网站上的所有代码中都会找到它们。这些代码中的每个和异常条件以及断言恒等式有关的算法周围都有大量的注释。

1.2.5.12 异常与错误

异常错误 都是在程序运行中出现的破坏性事件。Java 采取的行动称为 抛出异常 或是 抛出错误。我们已经在学习 Java 的基本特性的过程中遇到过 Java 系统方法抛出的异常: StackOverflowErrorArithmeticExceptionArrayIndexOutOfBoundsExceptionOutOfMemoryErrorNullPointerException 都是典型的例子。你也可以创建自己的异常,最简单的一种是 RuntimeException,它会中断程序的执行并打印出一条出错信息:

throw new RuntimeException("Error message here.");

一种叫做 快速出错 的常规编程实践提倡,一旦出错就立刻抛出异常,使定位出错位置更容易(这和忽略错误并将异常推迟到以后处理的方式相反)。

1.2.5.13 断言

断言 是一条需要在程序的某处确认为 true 的布尔表达式。如果表达式的值为 false,程序将会终止并报告一条出错信息。我们使用断言来确定程序的正确性并记录我们的意图。例如,假设你计算得到一个值并可以将它作为索引访问一个数组。如果该值为负数,稍后它将会产生一条 ArrayIndexOutOfBoundsException 异常。但如果代码中有一句 assert index >= 0;,你就能找到出错的位置。还可以选择性地加上一条详细的消息来辅助定位 bug,例如:

assert index >= 0 : "Negative index in method X";

默认设置没有启用断言,可以在命令行下使用 -enableassertions 标志(简写为 -ea)启用断言。断言的作用是调试:程序在正常操作中不应该依赖断言,因为它们可能会被禁用。系统编程课程会学习使用断言来保证代码 永远不会 被系统错误终止或是进入死循环。一种叫做 契约式设计 的编程模型采用的就是这种思想。数据类型的设计者需要说明 前提条件(用例在调用某个方法前必须满足的条件)、 后置条件(实现在方法返回时必须达到的要求)和 副作用(方法可能对对象状态产生的任何其他变更)。在开发过程中,这些条件可以用断言进行测试。

如果您想了解更多技术资源,课件对应视频地址。欢迎点击这里1查看

如果您想了解更多技术资源,欢迎点击这里2查看

本文由博客一文多发平台 OpenWrite 发布!

posted @ 2025-04-11 16:43  牛牛cowcow  阅读(43)  评论(0)    收藏  举报