MIT-6-005-软件构建讲义-全-

MIT 6.005 软件构建讲义(全)

阅读 1:静态检查

今天课程的目标

今天的课程有两个主题:

  • 静态类型检查

  • 优秀软件的三个关键属性

冰雹序列

许多阅读包括来自 MITx 版本 6.005 的可选视频。

有关视频的更多信息

▶ 播放 MITx 视频

作为一个运行的示例,我们将探讨冰雹序列,其定义如下。从数字n开始,序列中的下一个数字如果n是偶数,则为n/2,如果n是奇数,则为3n+1。当序列达到 1 时结束。以下是一些示例:

2, 1
3, 10, 5, 16, 8, 4, 2, 1
4, 2, 1
2n, 2n-1 , ... , 4, 2, 1
5, 16, 8, 4, 2, 1
7, 22, 11, 34, 17, 52, 26, 13, 40, ...? (where does this stop?)

由于奇数规则的存在,序列在减少到 1 之前可能会上下跳动。有人推测所有冰雹最终都会落到地面上——即,所有起始为n的冰雹序列最终会达到 1,但这仍然是一个待解决的问题。为什么称它为冰雹序列?因为冰雹在云中通过上下跳动形成,直到最终积累足够的重量落到地面上。

计算冰雹数

这是一些用于计算和打印一些起始为n的冰雹序列的代码。我们将 Java 和 Python 并排写出来进行比较:

|

// Java
int n = 3;
while (n != 1) {
    System.out.println(n);
    if (n % 2 == 0) {
        n = n / 2;
    } else {
        n = 3 * n + 1;
    }
}
System.out.println(n);

|

# Python
n = 3
while n != 1:
    print(n)
    if n % 2 == 0:
        n = n / 2
    else:
        n = 3 * n + 1

print(n)

|

这里有几点值得注意:

  • Java 中的表达式和语句的基本语义与 Python 非常相似:例如,whileif的行为相同。

  • Java 要求在语句的末尾加上分号。额外的标点符号可能会让人头疼,但它也使你在组织代码时拥有更多的自由——你可以将一个语句分成多行以增加可读性。

  • Java 要求在ifwhile的条件周围加上括号。

  • Java 要求在块周围加上大括号,而不是缩进。你应该始终缩进代码块,即使 Java 不会关注你多出的空格。编程是一种交流形式,你不仅在向编译器传达信息,也在向人类传达信息。人类需要那个缩进。我们稍后会回来讨论这个问题。

类型

▶ 播放 MITx 视频

上面 Python 和 Java 代码之间最重要的语义差异是变量n的声明,指定了它的类型:int

类型是一组值,以及可以对这些值执行的操作。

Java 有几种基本类型,其中包括:

  • int(用于像 5 和-200 这样的整数,但限制在±2³¹范围内,大约±20 亿)

  • long(用于更大的整数,范围为±2⁶³)

  • boolean(表示真或假)

  • double(用于浮点数,代表实数的一个子集)

  • char(用于像'A''$'这样的单个字符)

Java 还有对象类型,例如:

  • String表示字符序列,类似于 Python 的字符串。

  • BigInteger 表示任意大小的整数,因此它的行为类似于 Python 的整数。

根据 Java 的约定,基本类型是小写的,而对象类型以大写字母开头。

操作 是接受输入并产生输出(有时还会更改值本身)的函数。操作的语法各不相同,但无论它们如何编写,我们仍然将它们视为函数。以下是 Python 或 Java 中一个操作的三种不同语法:

  • 作为中缀、前缀或后缀运算符。 例如,a + b 调用操作 + : int × int → int

  • 作为对象的方法。 例如,bigint1.add(bigint2) 调用操作 add: BigInteger × BigInteger → BigInteger

  • 作为一个函数。 例如,Math.sin(theta) 调用操作 sin: double → double。这里,Math 不是一个对象。它是包含 sin 函数的类。

对比 Java 的 str.length() 和 Python 的 len(str)。在两种语言中,它执行的是相同的操作 - 一个函数,接受一个字符串并返回其长度 - 但它只是使用了不同的语法。

一些操作是重载的,即相同的操作名称用于不同的类型。在 Java 中,算术运算符 +-*/ 在数字原始类型中被大量重载。方法也可以重载。大多数编程语言都具有某种程度的重载。

静态类型

▶ 播放 MITx 视频

Java 是一种静态类型语言。所有变量的类型在编译时(程序运行之前)就已知,因此编译器也可以推断所有表达式的类型。如果 ab 被声明为 int,那么编译器会得出 a+b 也是一个 int 的结论。实际上,Eclipse 环境在您输入代码时就会这样做,因此您会在打字时就发现许多错误。

在像 Python 这样的动态类型语言中,这种检查被推迟到运行时(程序正在运行时)。

静态类型是一种特定类型的静态检查,它在编译时检查错误。错误是编程的祸根。本课程中的许多思想都旨在消除代码中的错误,而静态检查是我们看到的第一个旨在实现这一目标的思想。静态类型可以防止程序感染一大类由于将操作应用于错误类型的参数而引起的错误。如果您编写了一行破损的代码,例如:

 "5" * "6"

如果尝试将两个字符串相乘,则在编程过程中静态类型将捕捉到此错误,而不是等到执行期间到达该行时。

静态检查,动态检查,无检查

思考语言可以提供的三种自动检查方式是很有用的:

  • 静态检查:在程序运行之前自动发现错误。

  • 动态检查:在执行代码时自动发现错误。

  • 无检查:语言不会帮助您找到错误。您必须自己注意,否则会得到错误的答案。

不用说,静态捕获错误总比动态捕获好,而动态捕获总比不捕获好。

这里有一些经验法则,指出你可以在每个时间点预期会被捕获的错误。

静态检查可以捕获:

  • 语法错误,例如额外的标点符号或多余的单词。即使是像 Python 这样的动态类型语言也会进行这种静态检查。如果您的 Python 程序存在缩进错误,您将在程序开始运行之前就会发现这个问题。

  • 错误的名称,例如Math.sine(2)。(正确的名称是 sin。)

  • 错误的参数数量,例如Math.sin(30, 20)

  • 错误的参数类型,例如Math.sin("30")

  • 错误的返回类型,例如从声明为返回 int 的函数返回"30"

动态检查可以捕获:

  • 非法的参数值。例如,整数表达式 x/y 仅在 y 实际上为零时才出错;否则它可以运行。因此,在这个表达式中,除零错误不是静态错误,而是动态错误。

  • 不能表示的返回值,即特定返回值无法在类型中表示。

  • 超出范围的索引,例如在字符串上使用负值或太大的索引。

  • 在空对象引用上调用方法(null类似于 Python 的 None)。

静态检查倾向于关于类型的错误,这些错误与变量具体的值无关。类型是一组值。静态类型保证变量将具有该集合中的某些值,但直到运行时我们才知道它实际上具有哪个值。因此,如果错误仅由某些值引起,例如除以零或索引超出范围,则编译器不会提出静态错误。

相反,动态检查倾向于关于特定值引起的错误。

惊喜:原始类型并非真正的数字

Java 中的一个陷阱——以及许多其他编程语言——是它的原始数值类型有一些边界情况,这些情况的行为与我们习惯的整数和实数不同。因此,一些真正应该被动态检查的错误根本不会被检查。以下是陷阱:

  • 整数除法5/2 不会返回分数,它返回一个截断的整数。因此,这是一个例子,我们本来希望它是一个动态错误的地方(因为分数不能表示为整数),经常产生错误的答案。

  • 整数溢出intlong 类型实际上是有限的整数集,具有最大和最小值。当你进行一个计算,其答案太正或太负而无法适应该有限范围时会发生什么?计算会静默地溢出(环绕),并返回一个整数,该整数位于合法范围内但不是正确的答案。

  • 浮点类型中的特殊值。像double这样的浮点类型有几个不是实数的特殊值:NaN(代表“不是数字”)、POSITIVE_INFINITYNEGATIVE_INFINITY。因此,当你对double应用某些你期望产生动态错误的操作,比如除以零或取负数的平方根时,你会得到这些特殊值之一。如果你继续计算,最终会得到一个错误的最终答案。

阅读练习

让我们尝试一些有错误的代码示例,并看看它们在 Java 中的行为如何。这些错误是静态地、动态地捕捉到的,还是根本没有捕捉到?

1

int n = 5;
if (n) {
  n = n + 1;
}

(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

2

int big = 200000; // 200,000
big = big * big;  // big should be 4 billion now

(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

3

double probability = 1/5;

(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

4

int sum = 0;
int n = 0;
int average = sum/n;

(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

5

double sum = 7;
double n = 0;
double average = sum/n;

(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

数组和集合

▶ 播放 MITx 视频

让我们修改我们的哈尔斯通计算,使其将序列存储在数据结构中,而不仅仅是打印出来。Java 有两种我们可以使用的类似列表的类型:数组和列表。

数组是另一种类型 T 的固定长度序列。例如,下面是如何声明数组变量并构造数组值以赋给它的:

int[] a = new int[100];

int[]数组类型包含所有可能的数组值,但特定的数组值一旦创建,就永远不能改变其长度。数组类型的操作包括:

  • 索引:a[2]

  • 赋值:a[2]=0

  • 长度:a.length(注意这与String.length()的语法不同 - a.length不是一个方法调用,所以你不需要在它后面加括号)

这是使用数组的哈尔斯通代码的一个示例。我们首先构造数组,然后使用索引变量i来遍历数组,将我们生成的序列值存储起来。

int[] a = new int[100];  // <==== DANGER WILL ROBINSON
int i = 0;
int n = 3;
while (n != 1) {
    a[i] = n;
    i++;  // very common shorthand for i=i+1
    if (n % 2 == 0) {
        n = n / 2;
    } else {
        n = 3 * n + 1;
    }
}
a[i] = n;
i++;

这种方法应该立即感觉有问题。那个魔术数字 100 是什么意思?如果我们尝试一个具有非常长的哈尔斯通序列的 n 会发生什么?它不会适应长度为 100 的数组。我们有一个错误。Java 是否会静态地、动态地或根本不会捕捉到这个错误?顺便说一句,这样的错误 - 溢出固定长度数组,这些错误通常在不像 C 和 C++这样不进行数组访问的自动运行时检查的不太安全的语言中使用 - 已经导致了大量的网络安全漏洞和互联网蠕虫。

让我们不使用固定长度数组,而是使用List类型。列表是另一种类型T的可变长度序列。下面是声明List变量并生成列表值的方法:

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

以下是一些它的操作:

  • 索引:list.get(2)

  • 赋值:list.set(2, 0)

  • 长度:list.size()

请注意,List 是一个接口,一个不能直接用 new 构造的类型,而是指定了一个 List 必须提供的操作。我们将在关于抽象数据类型的未来课程中讨论这个概念。ArrayList 是一个类,一个提供了这些操作的具体类型的实现。ArrayList 不是 List 类型的唯一实现,尽管它是最常用的一个。LinkedList 是另一个。在 Java API 文档中查找它们,可以通过在网上搜索“Java 8 API”找到。了解 Java API 文档,它们是你的朋友。(“API” 意味着“应用程序编程接口”,通常被用作“库”的同义词。)

还要注意,我们写的是 List<Integer> 而不是 List<int>。不幸的是,我们无法像 int[] 的直接类比那样写 List<int>。列表只知道如何处理对象类型,而不是原始类型。在 Java 中,每个原始类型(以小写字母书写并经常缩写,如 int)都有一个对应的对象类型(以大写字母书写,并完整拼写,如 Integer)。当我们使用尖括号参数化类型时,Java 要求我们使用这些对象类型的等价物。但在其他情况下,Java 会自动在 intInteger 之间转换,所以我们可以毫无类型错误地写 Integer i = 5

这里是用列表编写的雹石代码:

List<Integer> list = new ArrayList<Integer>();
int n = 3;
while (n != 1) {
    list.add(n);
    if (n % 2 == 0) {
        n = n / 2;
    } else {
        n = 3 * n + 1;
    }
}
list.add(n);

不仅更简单,而且更安全,因为列表会自动扩展自身以适应添加的数字(当然,直到内存耗尽)。

迭代

for 循环遍历数组或列表的元素,就像在 Python 中一样,尽管语法看起来有点不同。例如:

// find the maximum point of a hailstone sequence stored in list
int max = 0;
for (int x : list) {
    max = Math.max(x, max);
}

你可以遍历数组以及列表。如果列表被数组替换,相同的代码也会起作用。

Math.max() 是来自 Java API 的一个方便的函数。Math 类中充满了像这样的有用函数 - 在网上搜索“java 8 Math”以找到其文档。

方法

▶ 播放 MITx 视频

在 Java 中,语句通常必须在方法内部,每个方法都必须在一个类中,所以编写我们的雹石程序的最简单方法看起来像这样:

public class Hailstone {
    /**
     * Compute a hailstone sequence.
     * @param n  Starting number for sequence.  Assumes n > 0.
     * @return hailstone sequence starting with n and ending with 1.
     */
    public static List<Integer> hailstoneSequence(int n) {
        List<Integer> list = new ArrayList<Integer>();
        while (n != 1) {
            list.add(n);
            if (n % 2 == 0) {
                n = n / 2;
            } else {
                n = 3 * n + 1;
            }
        }
        list.add(n);
        return list;
    }
}

让我们解释一下这里的一些新东西。

public 表示程序中的任何代码都可以引用该类或方法。其他访问修饰符,如 private,用于在程序中获得更多的安全性,并保证不可变类型的不可变性。我们将在即将到来的课程中更多地讨论它们。

static 表示该方法不接受 self 参数 - 在 Java 中这是隐式的,你永远不会看到它作为方法参数。静态方法不能在对象上调用。与之相反,List add() 方法或 String length() 方法,例如,需要首先一个对象。调用静态方法的正确方式是使用类名而不是对象引用:

Hailstone.hailstoneSequence(83)

还要注意方法前面的注释,因为它非常重要。这个注释是对方法的规范,描述了操作的输入和输出。规范应该简洁、清晰且准确。这个注释提供了从方法类型中并不清晰的信息。例如,它并没有说 n 是一个整数,因为下面的 int n 声明已经说了。但它确实说 n 必须是正数,这对调用者来说是非常重要的。

我们将在未来几节课中详细讨论如何编写良好的规范,但你必须立即开始阅读并使用它们。

改变值 vs. 重新分配变量

下一篇阅读将介绍 快照图,为我们提供一种可视化区分改变变量和改变值的方法。当你给一个变量赋值时,你正在改变变量的箭头指向。你可以将其指向一个不同的值。

当你给可变值(如数组或列表)分配内容时,你正在改变该值内部的引用。

变化是一个必要的恶。好的程序员避免变化,因为它们可能会出乎意料地改变。

不可变性(免于变化)是本课程的一个主要设计原则。不可变类型是一旦创建就永远不会改变其值的类型。(至少对外部世界来说不会改变——在这方面还有一些微妙之处,我们将在未来关于不可变性的课程中更多地讨论。)我们到目前为止讨论过的类型中,哪些是不可变的,哪些是可变的?

Java 也给了我们不可变的引用:只赋值一次且不会重新赋值的变量。要使引用不可变,用关键字 final 声明它:

final int n = 5;

如果 Java 编译器不确信你的 final 变量在运行时只会被赋值一次,那么它会产生编译错误。因此,final 为不可变引用提供了静态检查。

使用 final 声明方法的参数和尽可能多的局部变量是一种良好的做法。与变量的类型一样,这些声明是重要的文档,对代码的读者有用,并且被编译器静态地检查。

在我们的 hailstoneSequence 方法中有两个变量:我们可以把它们声明为 final 吗,还是不行?

public static List<Integer> hailstoneSequence(final int n) { 
    final List<Integer> list = new ArrayList<Integer>();

文档化假设

▶ 播放 MITx 视频

写下变量的类型是对它的一个假设的文档化:比如,这个变量将永远引用一个整数。Java 实际上会在编译时检查这个假设,并保证在你的程序中没有违反这个假设的地方。

声明一个变量为 final 也是一种文档化的形式,它声明这个变量在初始赋值后永远不会改变。Java 也会静态地检查这一点。

我们记录了 Java(不幸地)不能自动检查的另一个假设:n 必须是正数。

为什么我们需要写下我们的假设?因为编程中充满了假设,如果我们不把它们写下来,我们就不会记住它们,以后需要阅读或更改我们的程序的其他人也不会知道它们。他们将不得不猜测。

编写程序时必须牢记两个目标:

  • 与计算机进行通信。首先说服编译器,使你的程序合理——语法正确且类型正确。然后确保逻辑正确,以便在运行时产生正确的结果。

  • 与其他人进行通信。使程序易于理解,这样当有人需要在未来修复、改进或适应它时,他们可以这样做。

黑客 vs. 工程

我们在这门课上写了一些黑客式的代码。黑客通常表现出无限的乐观:

  • 不好:在测试之前写很多代码

  • 不好:把所有细节都记在脑子里,假设你永远记得它们,而不是写在你的代码中

  • 不好:假设错误将不存在,否则很容易找到和修复

但软件工程不是黑客行为。工程师是悲观主义者:

  • 好:一次写一点,边写边测试。在以后的课程中,我们会谈论测试优先编程。

  • 好:记录你的代码依赖的假设

  • 好:保护你的代码免受愚蠢之人的影响——尤其是你自己!静态检查有助于这一点。

6.005 的目标

▶ 播放 MITx 视频

我们在这门课程中的主要目标是学习如何生产以下软件:

  • 防止错误。正确性(当前正确的行为)和防御性(未来正确的行为)。

  • 易于理解。必须传达给未来的程序员,他们需要理解它并对其进行更改(修复错误或添加新功能)。那个未来的程序员可能是你,几个月或几年后。如果你不把它写下来,你会惊讶地发现你忘记了多少,而且拥有良好的设计对你自己的未来有多么有帮助。

  • 准备好改变。软件总是在变化。有些设计使得改变变得容易;另一些则需要抛弃并重新编写大量代码。

软件还有其他重要的属性(如性能、可用性、安全性),它们可能与这三个属性相互权衡。但这些是我们在 6.005 中关心的三个最重要的属性,也是软件开发者在构建软件时通常最重视的属性。值得考虑我们在这门课程中学习的每一种语言特性、每一种编程实践、每一种设计模式,以及它们如何与这三个重要属性相关。

为什么我们在这门课上使用 Java

既然你已经学过 6.01,我们假设你对 Python 很熟悉。那么为什么我们在这门课上不使用 Python?为什么我们在 6.005 中使用 Java?

安全性是第一个原因。Java 具有静态检查(主要是类型检查,但还有其他类型的静态检查,比如你的代码是否从声明为返回值的方法返回值)。我们在这门课程中学习软件工程,防止错误是该方法的一个关键原则。Java 将安全性提升到了极致,这使得它成为学习良好软件工程实践的好语言。在动态语言(如 Python)中编写安全代码当然是可能的,但是如果你学习在一个安全的、静态检查的语言中做什么,你会更容易理解你需要做什么。

无处不在是另一个原因。Java 在研究、教育和工业领域广泛使用。Java 可以在许多平台上运行,不仅仅是 Windows/Mac/Linux。Java 可以用于 Web 编程(服务器端和客户端),原生 Android 编程是用 Java 完成的。尽管其他编程语言更适合教授编程(Scheme 和 ML 是我想到的),遗憾的是这些语言在现实世界中并不如 Java 广泛使用。在你的简历上写上 Java 将被认为是一种有市场价值的技能。但是不要误解我们:你从这门课程中获得的真正技能并不是特定于 Java 的,而是适用于你可能使用的任何语言。这门课程的最重要的教训将会在语言潮流中生存下来:安全性、清晰度、抽象化、工程直觉。

无论如何,一个好的程序员必须是多语言的。编程语言是工具,你必须使用正确的工具来完成工作。在你甚至还没有完成 MIT 的职业生涯之前,你肯定会学习其他编程语言(JavaScript,C/C++,Scheme 或 Ruby 或 ML 或 Haskell),所以我们现在开始学习第二种语言。

由于其无处不在,Java 拥有各种有趣且有用的(既包括其庞大的内置库,也包括网络上的其他库),以及出色的免费开发工具(如 Eclipse 等 IDE,编辑器,编译器,测试框架,性能分析工具,代码覆盖率工具,样式检查器)。即使 Python 在生态系统的丰富程度上仍落后于 Java。

有一些原因让人后悔使用 Java。它很冗长,这使得在白板上编写示例变得困难。它很庞大,多年来积累了许多功能。它内部不一致(例如,final关键字在不同的上下文中具有不同的含义,在 Java 中,static关键字与静态检查无关)。它负载了像 C/C++这样的旧语言的包袱(原始类型和switch语句是很好的例子)。它没有像 Python 那样的解释器,你可以通过玩弄一些小段代码来学习。

总的来说,目前选择学习编写安全、易于理解且易于变更的代码的语言,Java 是一个合理的选择。这就是我们的目标。

摘要

我们今天介绍的主要概念是静态检查。以下是这个概念与课程目标的关系:

  • 免受错误困扰。 静态检查通过在运行时之前捕获类型错误和其他错误来帮助确保安全性。

  • 易于理解。 它有助于理解,因为类型在代码中明确声明。

  • 准备好改变。 静态检查使得通过识别需要同时更改的其他位置,更容易改变您的代码。例如,当您更改变量的名称或类型时,编译器立即在所有使用该变量的地方显示错误,提醒您也要更新它们。

阅读 2:基本 Java

上课前晚完成:你必须在 9 月 8 日星期四晚上 10:00 之前完成本次阅读中的阅读练习。 阅读练习仅根据完成情况评分,永远不会根据正确性评分,详情请参阅课程概况。

获取阅读练习学分:右侧有一个大大的红色登录按钮。只有在做练习时登录才能获得阅读练习的学分。

上课前完成:你必须在 9 月 9 日星期五下午 1:00 之前完成问题集 0 部分 I。

周日前完成:你必须在 9 月 11 日星期日晚上 10:00 之前完成 Java 导师中的基本 Java练习。

可选,截止到周一:完成 Java 导师练习的前三个级别来赚取问题集 0 的一个免费 slack 日。

目标

  • 学习基本的 Java 语法和语义

  • 从编写 Python 转向编写 Java

在 6.005 中的软件

免受错误干扰 易于理解 准备好应对变化
今天正确,未来也正确。 与未来的程序员清晰沟通,包括未来的你。 设计以适应未来的变化而不需要重写。

开始使用 Java 教程

接下来的几节将链接到Java 教程网站,以帮助您快速掌握基础知识,并提供阅读练习以检查您的理解。

阅读教程页面并尝试这些阅读练习后,使用 6.005 的Java 导师在 Eclipse 中练习您所学的内容。

本阅读和其他资源将经常引导您查阅Java API 文档,该文档描述了 Java 内置的所有类。

语言基础

阅读语言基础

你应该能够回答关于四个语言基础主题的问题与练习页面上的问题。

请注意,每个问题与练习页面底部都有指向解决方案的链接。

还通过回答一些关于 Java 基础与 Python 基础相比较的问题来检查您的理解:

阅读练习

语言基础

假设我们正在编辑 Java 中一个函数的主体,声明和使用局部变量。

int a = 5;     // (1)
if (a > 10) {  // (2)
    int b = 2; // (3)
} else {       // (4)
    int b = 4; // (5)
}              // (6)
b *= 3;        // (7)

(缺少答案)

(缺少解释)

修复错误(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

你是谁?(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

数字和字符串

阅读数字和字符串

如果你觉得Number包装类令人困惑,不要担心。它们确实如此。

你应该能够回答问题和练习页面上的所有问题。

阅读练习

数字和字符串

fahrenheit = 212.0
celsius = (fahrenheit - 32) * 5/9

(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

双重射击(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

适合打印(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

类和对象

阅读类和对象

你应该能够回答前两个问题和练习页面上的问题。

如果你现在不理解嵌套类枚举类型中的一切,不要担心。当我们在课堂上看到它们时,你可以在本学期后期回顾这些构造。

阅读练习

类和对象

class Tortoise:
    def __init__(self):
        self.position = 0

    def forward(self):
        self.position += 1

pokey = Tortoise()
pokey.forward()
print pokey.position

(缺少答案)

(缺少解释)

正在建设中

在 Python 中,我们声明一个__init__函数来初始化新对象。

(缺少答案)

(缺少解释)

(缺少答案)

(缺失解释)

方法论

在 Java 上声明Tortoise对象上的forward方法:

public void forward() {
    // self.position += 1 (Python)
}

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

准备好

在 Python 中,我们使用self.position = 0Tortoise对象一个从零开始的position

在 Java 中,我们可以一行完成这个操作:

public class Tortoise {

    private int position = 0;      // (1)
    static int position = 0;       // (2)

    public Tortoise() {
        int position = 0;          // (3)
        int self.position = 0;     // (4)
        int this.position = 0;     // (5)
        int Tortoise.position = 0; // (6)
    }
    // ...
}

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案) … 或者结合几行:

public class Tortoise {

    private int position;          // (1)
    static int position;           // (2)

    public Tortoise() {
        self.position = 0;         // (3)
        this.position = 0;         // (4)
        Tortoise.position = 0;     // (5)
    }
    // ...
}

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案) (缺失解释)

你好,世界!

阅读Hello World!

你应该能够创建一个新的HelloWorldApp.java文件,输入来自该教程页面的代码,然后编译和运行程序,在控制台上看到Hello World!


快照图

许多阅读包括来自 6.005 版本的 MITx 的可选视频。

有关视频的更多信息

注意:此视频使用不同版本的文本。

▶ 播放 MITx 视频

对我们有用的是,在运行时绘制发生的情况的图片,以便理解微妙的问题。快照图表示程序在运行时的内部状态 - 其堆栈(正在进行的方法及其局部变量)和其堆(当前存在的对象)。

以下是我们在 6.005 中使用快照图的原因:

  • 通过图片互相交流(在课堂上和团队会议中)

  • 用于说明概念,如原始类型 vs. 对象类型,不可变值 vs. 不可变引用,指针别名,堆栈 vs. 堆,抽象 vs. 具体表示。

  • 帮助解释你的团队项目的设计(与团队和 TA 互相之间)。

  • 为后续课程中更丰富的设计符号铺平道路。例如,快照图在 6.170 中概括成对象模型。

尽管本课程中的图表使用 Java 的示例,但这种符号可以应用于任何现代编程语言,例如 Python、Javascript、C++、Ruby。

原始值

快照图中的原始值

原始值由裸露的常量表示。传入的箭头是来自变量或对象字段的值的引用。

对象值

快照图中的对象值

对象值是一个以其类型标记的圆圈。当我们想要显示更多细节时,我们将字段名称写在里面,并用箭头指向它们的值。为了更多细节,字段可以包括它们声明的类型。有些人更喜欢写x:int而不是int x,但两者都可以。

变异值 vs. 重新分配变量

快照图为我们提供了一种可视化的方式来区分改变变量和改变值之间的区别:

  • 当你将一个变量或字段分配给一个值时,你正在改变变量的箭头指向的位置。你可以将它指向一个不同的值。

  • 当你分配给可变值的内容时(比如数组或列表),你正在改变该值内部的引用。

重新分配和不可变值

重新分配变量

例如,如果我们有一个String变量s,我们可以将其从值"a"重新分配为"ab"

String s = "a";
s = s + "b";

String不可变类型的示例,一旦创建后其值就永远不会改变。不可变性(免疫于变化)是本课程的一个重要设计原则,在未来的阅读中我们将更多地讨论它。

不可变对象(由其设计者设计为始终表示相同值)在快照图中用双边框表示,就像我们图中的String对象一样。

可变值

改变对象的状态

相比之下,StringBuilder(另一个内置 Java 类)是一个可变对象,表示一串字符,并且它有改变对象值的方法:

StringBuilder sb = new StringBuilder("a");
sb.append("b");

这两个快照图看起来非常不同,这很好:可变性和不可变性之间的差异将在使我们的代码免受错误方面发挥重要作用。

不可变引用

Java 还为我们提供了不可变引用:只分配一次并且不再重新分配的变量。要使引用不可变,使用关键字final声明它:

final int n = 5;

最终参考是双箭头

如果 Java 编译器不确信你的final变量在运行时只会被分配一次,那么它将产生编译错误。所以final为不可变引用提供了静态检查。

在快照图中,不可变引用(final)用双箭头表示。这里有一个对象,它的id永远不会改变(它不能被重新分配到一个不同的数字),但age可以改变。

请注意,我们可以有一个对可变值不可变引用(例如:final StringBuilder sb),尽管我们指向同一个对象,其值也可以改变。

我们也可以有一个对不可变值可变引用(比如String s),其中变量的值可以改变,因为它可以被重新指向一个不同的对象。


Java 集合

第一个语言基础教程讨论了数组,它们是一个包含一系列对象或基本值的固定长度容器。Java 提供了一些更强大和灵活的工具来管理对象的集合Java 集合框架

列表、集合和映射

Java List 类似于 Python 列表 一个 List 包含一个有序的零个或多个对象的集合,其中同一个对象可能多次出现。 我们可以向 List 添加和删除项目,它将根据其内容增长和缩小。

示例 List 操作:

Java 描述 Python
int count = lst.size(); 计算元素数量 count = len(lst)
lst.add(e); 将元素追加到末尾 lst.append(e)
if (lst.isEmpty()) ... 测试列表是否为空 if not lst: ...

在快照图中,我们将 List 表示为具有索引绘制为字段的对象:

这个 cities 列表可能代表从波士顿到波哥大再到巴塞罗那的旅程。

Set 是一个无序的零个或多个唯一对象的集合。 像数学 集合Python set —— 与 List 不同 —— 对象不能多次出现在集合中。 它要么在里面,要么在外面。

示例 Set 操作:

Java 描述 Python
s1.contains(e) 测试集合是否包含元素 e in s1
s1.containsAll(s2) 测试 s1 ⊇ s2 s1.issuperset(s2) s1 >= s2
s1.removeAll(s2) s1 中删除 s2 s1.difference_update(s2) s1 -= s2

在快照图中,我们将 Set 表示为没有名称字段的对象:

这里我们有一组整数,顺序不限:42、1024 和 -7。

Map 类似于 Python 字典 在 Python 中,地图的 必须是 可散列的。 Java 有类似的要求,我们将在面对 Java 对象之间的相等性时讨论。

示例 Map 操作:

Java 描述 Python
map.put(key, val) 添加映射 key → val map[key] = val
map.get(key) 获取键的值 map[key]
map.containsKey(key) 测试地图是否有键 key in map
map.remove(key) 删除映射 del map[key]

在快照图中,我们将 Map 表示为包含键/值对的对象:

这个 turtles 地图包含分配给 String 键的 Turtle 对象:Bob,Buckminster 和 Buster。

字面值

Python 提供了方便的语法来创建列表:

lst = [ "a", "b", "c" ]

和地图:

map = { "apple": 5, "banana": 7 }

Java 不提供。 它确实提供了一个数组的文字语法:

String[] arr = { "a", "b", "c" };

但这会创建一个 数组,而不是 List。 我们可以使用 实用函数 Arrays.asList 从数组创建 List

Arrays.asList(new String[] { "a", "b", "c" })

… 或直接从参数中:

Arrays.asList("a", "b", "c")

使用 Arrays.asList 创建的 List 来自带一个限制:它的长度是固定的。

泛型:声明 List、Set 和 Map 变量

与 Python 集合类型不同,使用 Java 集合,我们可以限制集合中包含的对象的类型。当我们添加一个项目时,编译器可以执行静态检查以确保我们只添加适当类型的项目。然后,当我们取出一个项目时,我们可以确保它的类型是我们期望的。

这是声明一些变量以容纳集合的语法:

List<String> cities;        // a List of Strings
Set<Integer> numbers;       // a Set of Integers
Map<String,Turtle> turtles; // a Map with String keys and Turtle values

由于泛型的工作方式,我们无法创建原始类型的集合。例如,Set<int>起作用的。然而,正如我们之前看到的,int 有一个我们可以使用的Integer包装器(例如 Set<Integer> numbers)。

为了更容易使用这些包装类型的集合,Java 进行了一些自动转换。如果我们声明了 List<Integer> sequence,这段代码可以工作:

sequence.add(5);              // add 5 to the sequence
int second = sequence.get(1); // get the second element

ArrayLists 和 LinkedLists:创建 List

正如我们很快就会看到的那样,Java 帮助我们区分类型的规范——它是做什么的?——和实现——代码是什么?

ListSetMap 都是接口:它们定义了这些相应类型的工作方式,但它们不提供实现代码。有几个优点,但一个潜在的优点是我们,这些类型的用户,可以在不同的情况下选择不同的实现。

这是如何创建一些实际的 List

List<String> firstNames = new ArrayList<String>();
List<String> lastNames = new LinkedList<String>();

如果左右的泛型类型参数相同,Java 可以推断出正在发生的情况,并为我们节省一些输入:

List<String> firstNames = new ArrayList<>();
List<String> lastNames = new LinkedList<>();

ArrayListLinkedListList 的两种实现。两者都提供了 List 的所有操作,并且这些操作必须按照 List 的文档中描述的方式工作。在这个例子中,firstNameslastNames 将表现相同;如果我们交换了哪一个使用了 ArrayList vs. LinkedList,我们的代码不会出错。

不幸的是,这种选择的能力也是一种负担:我们不关心 Python 列表的工作方式,为什么我们要关心我们的 Java 列表是 ArrayLists 还是 LinkedLists?由于唯一的区别是性能,对于 6.005 我们不关心。

如果不确定,使用 ArrayList

HashSet 和 HashMaps:创建 Set 和 Map

HashSet 是我们默认选择的 Set

Set<Integer> numbers = new HashSet<>();

Java 还提供了有序集合TreeSet 实现。

对于 Map,默认选择是HashMap

Map<String,Turtle> turtles = new HashMap<>();

迭代

所以也许我们有:

List<String> cities        = new ArrayList<>();
Set<Integer> numbers       = new HashSet<>();
Map<String,Turtle> turtles = new HashMap<>();

遍历我们的城市/数字/乌龟等是一项非常常见的任务。

在 Python 中:

for city in cities:
    print city

for num in numbers:
    print num

for key in turtles:
    print "%s: %s" % (key, turtles[key])

Java 为遍历ListSet中的项目提供了类似的语法。

这是 Java 的代码:

for (String city : cities) {
    System.out.println(city);
}

for (int num : numbers) {
    System.out.println(num);
}

我们无法用这种方式遍历Map本身,但我们可以像在 Python 中那样遍历键:

for (String key : turtles.keySet()) {
    System.out.println(key + ": " + turtles.get(key));
}

这种for循环底层使用Iterator,这是我们在课程中稍后会看到的一种设计模式。

使用索引进行迭代

如果你愿意,Java 提供了不同的for循环,我们可以用来使用其索引迭代列表:

for (int ii = 0; ii < cities.size(); ii++) {
    System.out.println(cities.get(ii));
}

除非我们实际上需要索引值ii,否则这段代码冗长且有更多隐藏 bug 的地方。避免。

阅读练习

集合

List重写这些变量声明,而不是使用数组。我们只声明变量,而不给它们赋任何值。

(缺失答案)

(缺失解释)

(缺失答案)

(缺失解释)

(缺失答案)

(缺失解释)

X 标记着地点

Java 的Map工作原理类似于 Python 的字典。

当我们运行此代码后:

Map<String, Double> treasures = new HashMap<>();
String x = "palm";
treasures.put("beach", 25.);
treasures.put("palm", 50.);
treasures.put("cove", 75.);
treasures.put("x", 100.);
treasures.put("palm", treasures.get("palm") + treasures.size());
treasures.remove("beach");
double found = 0;
for (double treasure : treasures.values()) {
    found += treasure;
}

…的值是多少…

(缺失答案)(缺失答案)(缺失答案)

Java API 文档

前面的部分有许多链接指向Java 平台 API中的类的文档。

API 代表应用程序编程接口。如果你想编写一个与 Facebook 交互的应用程序,Facebook 发布了一个 API(实际上不止一个,对于不同的语言和框架有不同的 API),你可以针对其进行编程。Java API 是一个大型的通用工具集,几乎可以用于编程的任何事情。

  • java.lang.StringString的全名。我们可以通过使用“双引号”来创建String类型的对象。

  • java.lang.Integer和其他原始包装类。Java 在大多数情况下自动在原始类型和包装(或“装箱”)类型之间转换。

  • java.util.List就像 Python 中的列表,但在 Python 中,列表是语言的一部分。在 Java 中,List是用 Java 实现的!

  • java.util.Map就像一个 Python 字典。

  • java.io.File表示磁盘上的文件。看看File提供的方法:我们可以测试文件是否可读,删除文件,查看上次修改时间…

  • java.io.FileReader让我们可以读取文本文件。

  • java.io.BufferedReader让我们可以高效地读取文本,并且还提供了一个非常有用的功能:一次读取一整行。

让我们更仔细地查看BufferedReader的文档。这里有很多与我们尚未讨论的 Java 特性相关的东西!保持头脑清醒,专注于下面加粗的内容

页面顶部是BufferedReader类层次结构已实现接口的列表。BufferedReader对象具有所有这些类型的方法(加上自己的方法),可供使用。

接下来我们看到直接子类,对于一个接口来说,是实现类。这可以帮助我们找到,例如,HashMapMap的实现。

接下来是:类的描述。有时这些描述有点晦涩,但这是你应该去了解一个类的第一个地方

如果你想要创建一个新的BufferedReader构造方法概要是第一个需要查看的地方。构造方法并不是在 Java 中获取新对象的唯一方式,但它们是最常见的。

接下来:方法概要列出了我们可以调用的所有方法BufferedReader对象上。

摘要下面是每个方法和构造函数的详细描述。点击构造函数或方法以查看详细描述。这是了解方法功能的第一个地方。

每个详细描述包括:

  • 方法签名:我们看到返回类型、方法名和参数。我们还看到异常。目前,这些通常意味着方法可能遇到的错误。

  • 完整的描述

  • 参数:方法参数的描述。

  • 以及方法的返回描述

规格

这些详细描述是规格。它们使我们能够使用像StringMapBufferedReader这样的工具,而不必阅读或理解实现它们的代码。

阅读、编写、理解和分析规格将是我们在 6.005 中的首要任务之一,从几节课开始。

阅读练习

阅读 Javadocs

使用 Java API 文档来回答……

假设我们有一个类TreasureChest。在我们运行此代码后:

Map<String, TreasureChest> treasures = new HashMap<>();
treasures.put("beach", new TreasureChest(25));
TreasureChest result = treasures.putIfAbsent("beach", new TreasureChest(75));

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

阿斯特!

在我们运行此代码后,其中???是适当的类型:

Map<String, String> translations = new HashMap<>();
translations.put("green", "verde");
??? result = translations.replace("green", "verde", "ahdar");

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)


阅读练习

到目前为止,你应该已经完成了以上所有的阅读练习。

要检查你的阅读练习状态,请参阅Omnivore 上的 classes/02-basic-java

完成阅读练习可以为每次课程开始时的纳米测验做准备,并且提交练习是每晚 10 点之前的必须要求。

第三章阅读:测试

阅读练习需在课前一晚完成。您必须在 9 月 11 日星期日晚上 10:00 前完成本阅读的阅读练习。

基础 Java 在线教程的练习也需在 9 月 11 日星期日晚上 10:00 前完成。

可选地,在 9 月 12 日星期一晚上 10:00 前完成 Java 在线教程的前三个级别的练习,以赢得问题集 0 的一个免费的 Slack 天。

在 6.005 中的软件

免于错误 易于理解 可变更
今天正确且在未知的未来也正确。 与未来的程序员(包括未来的你)清晰沟通。 设计以适应变化而无需重写。

今天课程的目标

在今天的课程之后,您应该:

  • 理解测试的价值,并知道测试优先编程的过程;

  • 能够通过划分输入和输出空间并选择良好的测试用例为方法设计测试套件;

  • 能够通过测量其代码覆盖率来判断测试套件;和

  • 理解并知道何时使用黑盒测试与白盒测试,单元测试与集成测试,以及自动化回归测试。

验证

许多阅读包括来自 MITx 版本 6.005 的可选视频。

有关视频的更多信息

▶ 播放 MITx 视频

测试是称为验证的更一般的过程的一个例子。验证的目的是发现程序中的问题,从而增加对程序正确性的信心。验证包括:

  • 对程序进行形式推理,通常称为验证。验证构建一个正式的证明,证明程序是正确的。手工进行验证是很繁琐的,验证的自动化工具支持仍然是一个活跃的研究领域。尽管如此,程序的小而关键的部分可能会被正式验证,例如操作系统中的调度程序,虚拟机中的字节码解释器,或操作系统中的文件系统

  • 代码审查。让其他人仔细阅读您的代码,并对其进行非正式推理,可以发现错误的好方法。这很像让别人校对你写的文章。我们将在下一次阅读中更多地讨论代码审查。

  • 测试。在精心选择的输入上运行程序并检查结果。

即使进行了最佳的验证,要在软件中达到完美的质量也是非常困难的。以下是一些典型的剩余缺陷率(软件发货后剩余的错误)每 kloc(一千行源代码):

  • 1 - 10 缺陷/千行代码:典型的行业软件。

  • 0.1 - 1 缺陷/千行代码:高质量的验证。Java 库可能达到这个正确性水平。

  • 0.01 - 0.1 每千行代码的缺陷:最佳的、安全关键的验证。NASA 和像 Praxis 这样的公司可以达到这个水平。

对于大型系统而言,这可能是令人泄气的。例如,如果你已经交付了 100 万行典型行业源代码(每千行代码 1 个缺陷),这意味着你错过了 1000 个错误!

软件测试为何如此困难

以下是一些在软件世界中不起作用的方法。

穷举测试 是不可行的。可能的测试用例空间通常太大而无法穷尽。想象一下穷举测试 32 位浮点乘法操作a*b。有 2⁶⁴ 个测试用例!

随意测试(“只是试一试,看看是否有效”)不太可能发现错误,除非程序非常有错误,随意选择的输入更有可能失败而不是成功。它也不会增加我们对程序正确性的信心。

随机或统计测试 在软件领域效果不佳。其他工程学科可以测试小的随机样本(例如制造的硬盘的 1%),并推断整个生产批次的缺陷率。物理系统可以使用许多技巧来加快时间,例如在 24 小时内打开冰箱 1000 次,而不是 10 年。这些技巧给出了已知的故障率(例如硬盘的平均寿命),但它们假设缺陷空间上的连续性或均匀性。这对物理制品是成立的。

但对于软件来说并非如此。软件行为在可能的输入空间中以不连续和离散的方式变化。系统可能在广泛的输入范围内看起来正常工作,然后在单个边界点突然失败。著名的奔腾除法错误 影响了大约 90 亿次除法中的 1 次。栈溢出、内存不足错误和数值溢出错误往往会突然发生,并且总是以相同的方式发生,而不是以概率变化的方式。这与物理系统不同,物理系统中通常有可见的证据表明系统正在接近故障点(桥梁上的裂缝),或者故障在接近故障点附近概率分布(因此统计测试将观察到一些故障甚至在到达点之前)。

相反,测试用例必须被仔细而系统地选择,这就是我们接下来要看的内容。

阅读练习

测试基础知识

在 1990 年代,为欧洲空间局设计和建造的阿里安 5 号运载火箭,在首次发射后的 37 秒内自我销毁。

原因是一个控制软件的 bug 未被检测到。阿丽亚娜 5 号的导航软件是从速度较慢的阿丽亚娜 4 号重用的。当速度计算从 64 位浮点数(Java 术语中的 double,尽管此软件并非用 Java 编写)转换为 16 位有符号整数(short)时,它溢出了小整数并导致异常被抛出。异常处理程序已被禁用以提高效率,因此导航软件崩溃了。没有导航,火箭也崩溃了。这次失败的成本是 10 亿美元。

这个故事表明了哪些想法?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

戴上你的测试帽

▶ 播放 MITx 视频

测试需要正确的态度。当你编码时,你的目标是使程序工作,但作为一个测试人员,你想要让它失败

这是一个微妙但重要的区别。很容易把刚写的代码当作一个珍贵的东西,一个脆弱的蛋壳,并且轻微地测试它以确保它可以工作。

相反,你必须毫不留情。一个好的测试人员持有一把大锤,无处不击中程序可能脆弱的地方,以便消除这些脆弱性。

测试驱动编程

早期并且经常进行测试。不要等到最后才进行测试,那时你会有一大堆未经验证的代码。将测试留到最后只会使调试变得更长,更痛苦,因为 bug 可能存在于代码的任何位置。在开发代码时进行测试会更加愉快。

在测试驱动编程中,你甚至在编写任何代码之前就编写测试。单个函数的开发按照以下顺序进行:

  1. 为函数编写规范。

  2. 编写测试以测试规范。

  3. 编写实际代码。一旦你的代码通过了你编写的测试,你就完成了。

规范描述了函数的输入和输出行为。它给出了参数的类型以及对它们的任何附加约束(例如,sqrt 的参数必须是非负的)。它还给出了返回值的类型以及返回值与输入之间的关系。在这门课程的问题集中,你已经看到并使用了规范。在代码中,规范由方法签名和它上面的注释组成,描述了它的作用。几节课后,我们将对规范有更多的话要说。

首先编写测试是理解规范的好方法。规范也可能存在 bug —— 不正确、不完整、含糊不清、缺少边界情况。尝试编写测试可以在你浪费时间编写有问题的规范的实现之前及早发现这些问题。

通过划分选择测试用例

创建一个好的测试套件是一个具有挑战性和有趣的设计问题。我们希望选择一组测试用例,它足够小以便快速运行,但又足够大以验证程序。

partitioning a function's input space

为了做到这一点,我们将输入空间划分为 子域,每个子域由一组输入组成。这些子域一起完全覆盖了输入空间,以便每个输入至少位于一个子域中。然后我们从每个子域中选择一个测试用例,这就是我们的测试套件。

子域背后的思想是将输入空间划分为具有相似行为的类似输入的集合。然后我们使用每个集合的一个代表。通过选择不同的测试用例,这种方法可以最大程度地利用有限的测试资源,并迫使测试探索随机测试可能无法达到的输入空间的部分。

如果需要确保我们的测试将探索输出空间的不同部分,我们还可以将输出空间划分为子域(程序在其中具有相似行为的相似输出)。大多数情况下,划分输入空间就足够了。

例子:BigInteger.multiply()

让我们来看一个例子。BigInteger 是 Java 库中内置的一个类,可以表示任意大小的整数,不像原始类型 intlong 只有有限的范围。BigInteger 有一个 multiply 方法,用于将两个 BigInteger 值相乘:

/**
 * @param val another BigIntger
 * @return a BigInteger whose value is (this * val).
 */
public BigInteger multiply(BigInteger val)

例如,下面是它可能被使用的方式:

BigInteger a = ...;
BigInteger b = ...;
BigInteger ab = a.multiply(b);

这个例子显示了即使在方法的声明中只显式显示一个参数,multiply 实际上是一个 两个 参数的函数:你调用方法的对象(上面的例子中的 a),以及你在括号中传递的参数(在这个例子中是 b)。在 Python 中,接收方法调用的对象将在方法声明中明确命名为一个名为 self 的参数。在 Java 中,你不在参数中提及接收对象,而是称之为 this 而不是 self

所以我们应该将 multiply 视为一个接受两个输入的函数,每个输入类型为 BigInteger,并生成一个类型为 BigInteger 的输出:

multiply : BigInteger × BigInteger → BigInteger

所以我们有一个二维输入空间,由所有整数对 (a,b) 组成。现在让我们将其分割。考虑乘法的工作原理,我们可能从这些分区开始:

  • a 和 b 都是正数

  • a 和 b 都是负数

  • a 是正数,b 是负数

  • a 是负数,b 是正数

还有一些乘法的特殊情况需要检查:0、1 和 -1。

  • a 或 b 是 0、1 或 -1

最后,作为一个怀疑的测试人员试图找到错误,我们可能怀疑 BigInteger 的实现者可能会尝试通过在可能的情况下内部使用intlong来加快速度,并且只有在值太大时才会退回到昂贵的一般表示(比如数字列表)。因此,我们还应该尝试非常大的整数,比最大的long还要大。

  • a 或 b 很小

  • a 或 b 的绝对值大于Long.MAX_VALUE,即 Java 中最大可能的原始整数,大约为 2⁶³。

让我们将所有这些观察结果整合到整个(a,b)空间的简单划分中。我们将独立选择ab,从以下选取:

划分 multiply()

  • 0

  • 1

  • -1

  • 小正整数

  • 小负整数

  • 极大正整数

  • 极大负整数

因此,这将产生 7 × 7 = 49 个完全覆盖整数对空间的划分。

为了生成测试套件,我们将从网格的每个方格中选择任意一对(a,b),例如:

  • (a,b) = (-3, 25) 以覆盖 (小负数, 小正数)

  • (a,b) = (0, 30) 以覆盖 (0, 小正数)

  • (a,b) = (2¹⁰⁰, 1) 以覆盖 (大正数, 1)

  • 等等。

右侧的图表显示了如何通过这种划分将二维的(a,b)空间划分开来,而点是我们可能选择的测试用例,以完全覆盖划分。

例如:max()

让我们看看 Java 库中的另一个例子:整数max()函数,位于Math类中。

/**
 * @param a  an argument
 * @param b  another argument
 * @return the larger of a and b.
 */
public static int max(int a, int b)

从数学上讲,这种方法是以下类型的函数:

max : int × int → int

划分最大值

根据规范,将此函数划分为以下部分是有意义的:

  • a < b

  • a = b

  • a > b

我们的测试套件可能是:

  • (a, b) = (1, 2) 以覆盖 a < b

  • (a, b) = (9, 9) 以覆盖 a = b

  • (a, b) = (-5, -6) 以覆盖 a > b

在划分中包含边界

错误经常发生在子域之间的边界。一些例子:

  • 0 是正数和负数之间的边界

  • 数值类型的最大值和最小值,比如intdouble

  • 对于集合类型的空值(空字符串,空列表,空数组)

  • 集合的第一个和最后一个元素

为什么错误经常发生在边界?一个原因是程序员经常犯差一错误(比如写<=而不是<,或者将计数器初始化为 0 而不是 1)。另一个原因是一些边界可能需要在代码中作为特殊情况处理。另一个原因是边界可能是代码行为不连续的地方。例如,当一个int变量增长超过其最大正值时,它会突然变成一个负数。

在划分中包含边界作为子域是很重要的,这样你就选择了一个边界输入。

让我们重新做一下max : int × int → int

划分为:

  • a 和 b 之间的关系

    • a < b

    • a = b

    • a > b

  • a 的值

    • a = 0

    • a < 0

    • a > 0

    • a = 最小整数

    • a = 最大整数

  • b 的值

    • b = 0

    • b < 0

    • b > 0

    • b = 最小整数

    • b = 最大整数

现在让我们选择覆盖所有这些类的测试值:

  • (1,2)覆盖了 a < b,a > 0,b > 0

  • (-1,-3)覆盖了 a > b,a < 0,b < 0

  • (0,0)覆盖了 a = b,a = 0,b = 0

  • (Integer.MIN_VALUE,Integer.MAX_VALUE)覆盖了 a < b,a = minint,b = maxint

  • (Integer.MAX_VALUE,Integer.MIN_VALUE)覆盖了 a > b,a = maxint,b = minint

覆盖分区的两个极端

在划分输入空间之后,我们可以选择测试套件的详尽程度:

  • 完整笛卡尔积

    每个分区维度的每个合法组合都由一个测试用例覆盖。这就是我们为multiply示例所做的,它给我们提供了 7 × 7 = 49 个测试用例。对于包含边界的max示例,它有三个维度,分别为 3 部分,5 部分和 5 部分,这意味着最多有 3 × 5 × 5 = 75 个测试用例。然而,在实践中,并非所有这些组合都是可能的。例如,无法覆盖 a < b,a = 0,b = 0 的组合,因为a不能同时小于零且等于零。

  • 覆盖每个部分。

    每个维度的每个部分都至少由一个测试用例覆盖,但不一定是每个组合。采用这种方法,如果精心选择,max的测试套件可能只有 5 个测试用例。这就是我们上面采取的方法,它让我们选择了 5 个测试用例。

通常我们在这两个极端之间做出一些妥协,基于人类的判断和谨慎,并受到白盒测试和代码覆盖工具的影响,接下来我们将看到。

阅读练习

分区

考虑以下规范:

/**
 * Reverses the end of a string.
 *
 *                          012345                     012345
 * For example: reverseEnd("Hello, world", 5) returns "Hellodlrow ,"
 *                               <----->                    <----->
 *
 * With start == 0, reverses the entire text.
 * With start == text.length(), reverses nothing.
 *
 * @param text    non-null String that will have its end reversed
 * @param start   the index at which the remainder of the input is reversed,
 *                requires 0 <= start <= text.length()
 * @return input text with the substring from start to the end of the string reversed
 */
public static String reverseEnd(String text, int start)

以下哪些是start参数的合理分区?

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

对字符串进行分区

以下哪些是text参数的合理分区?

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

黑盒测试和白盒测试

▶ 播放 MITx 视频

从上面回顾,规范是函数行为的描述 - 参数类型,返回值类型以及它们之间的约束和关系。

黑盒测试意味着仅从规范中选择测试用例,而不是从函数的实现中选择。这是我们迄今为止在示例中所做的。我们对multiplymax进行了分区并寻找了边界,而没有查看这些函数的实际代码。

白盒测试(也称为玻璃盒测试)意味着选择测试用例时要了解函数的实际实现方式。例如,如果实现根据输入选择不同的算法,则应根据这些领域进行划分。如果实现保留内部缓存以记住先前输入的答案,则应测试重复输入。

在进行白盒测试时,必须注意你的测试用例不应要求特定的实现行为,而这些行为在规范中并没有明确要求。例如,如果规范说“如果输入格式不良,则抛出异常”,那么你的测试不应该特别检查NullPointerException,只是因为当前实现是这样做的。在这种情况下,规范允许抛出任何异常,因此你的测试用例也应该是通用的,以保留实现者的自由。我们在规范课上会有更多讨论。

阅读练习

黑盒和白盒测试

考虑以下函数:

/**
 * Sort a list of integers in nondecreasing order.  Modifies the list so that 
 * values.get(i) <= values.get(i+1) for all 0<=i<values.length()-1
 */
public static void sort(List<Integer> values) {
    // choose a good algorithm for the size of the list
    if (values.length() < 10) {
        radixSort(values);
    } else if (values.length() < 1000*1000*1000) {
        quickSort(values);
    } else {
        mergeSort(values);
    }
}

以下哪些测试用例可能是白盒测试产生的边界值?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

记录您的测试策略

▶ 播放 MITx 视频

对于左侧的示例函数,在右侧是我们如何记录我们在上面的划分练习中工作的测试策略。该策略还解决了我们之前没有考虑的一些边界值。

|

/**
 * Reverses the end of a string.
 *
 * For example:
 *   reverseEnd("Hello, world", 5)
 *   returns "Hellodlrow ,"
 *
 * With start == 0, reverses the entire text.
 * With start == text.length(), reverses nothing.
 *
 * @param text    non-null String that will have
 *                its end reversed
 * @param start   the index at which the
 *                remainder of the input is
 *                reversed, requires 0 <=
 *                start <= text.length()
 * @return input text with the substring from
 *               start to the end of the string
 *               reversed
 */
static String reverseEnd(String text, int start)

| 在测试类的顶部记录策略:

/*
 * Testing strategy
 *
 * Partition the inputs as follows:
 * text.length(): 0, 1, > 1
 * start:         0, 1, 1 < start < text.length(),
 *                text.length() - 1, text.length()
 * text.length()-start: 0, 1, even > 1, odd > 1
 *
 * Include even- and odd-length reversals because
 * only odd has a middle element that doesn't move.
 *
 * Exhaustive Cartesian coverage of partitions.
 */

记录每个测试用例是如何选择的,包括白盒测试:

// covers test.length() = 0,
//        start = 0 = text.length(),
//        text.length()-start = 0
@Test public void testEmpty() {
    assertEquals("", reverseEnd("", 0));
}

// ... other test cases ...

|

覆盖

▶ 播放 MITx 视频

评估测试套件的一种方法是询问它对程序进行了多么彻底的测试。这个概念称为覆盖。这里有三种常见的覆盖类型:

  • 语句覆盖:每个语句是否都被某个测试用例执行?

  • 分支覆盖:程序中每个ifwhile语句是否都被某个测试用例执行了真和假两个方向?

  • 路径覆盖:程序中每个分支的每个可能组合——程序中的每个路径是否都被某个测试用例执行?

分支覆盖比语句覆盖更强(需要更多的测试来实现),路径覆盖比分支覆盖更强。在工业界,100%语句覆盖是一个常见目标,但由于无法到达的防御性代码(如“不应该到达这里”的断言),即使很少实现。100%分支覆盖是非常理想的,安全关键行业代码甚至有更艰巨的标准(例如,“MCDC”,修改的决策/条件覆盖)。不幸的是,100%路径覆盖是不可行的,需要指数大小的测试套件才能实现。

测试的标准方法是添加测试,直到测试套件达到足够的语句覆盖率:即程序中的每个可达语句都至少被一个测试用例执行。在实践中,语句覆盖率通常由代码覆盖率工具测量,该工具计算测试套件运行每个语句的次数。使用这样的工具,白盒测试很容易;您只需测量黑盒测试的覆盖率,并添加更多测试用例,直到所有重要语句都被记录为已执行。

Eclipse 的 EclEmma 代码覆盖工具

Eclipse 的一个很好的代码覆盖率工具是EclEmma,如右侧所示。

测试套件执行的行将显示为绿色,尚未覆盖的行将显示为红色。如果您从覆盖率工具中看到这个结果,您的下一步将是想出一个测试用例,使 while 循环体执行,并将其添加到测试套件中,以便红色行变为绿色。

阅读练习

使用覆盖率工具

在 Eclipse 上安装 EclEmma。请使用您的笔记本电脑,因为您将需要它来测试课堂练习。

然后创建一个名为Hailstone.java的新的 Java 类(你可以为它创建一个新项目,或者只是将其放在第 2 课练习的项目中),包含以下代码:

public class Hailstone {
  public static void main(String[] args) {
    int n = 3;
    while (n != 1) {
        if (n % 2 == 0) {
            n = n / 2;
        } else {
            n = 3 * n + 1;
        }
    }
  }
}

通过选择 Run → Coverage As → Java Application 来运行此类,并打开 EclEmma 代码覆盖高亮显示。

通过更改n的初始值,您可以观察 EclEmma 如何以不同的方式突出显示不同的代码行。

(缺少答案)

(缺少解释)

(缺少答案)

(缺少解释)

(缺少答案)

(缺少解释)

单元测试和存根

▶ 播放 MITx 视频

一个经过良好测试的程序将对其包含的每个单独模块(其中模块是一个方法或一个类)进行测试。测试一个单独模块,如果可能的话是独立的,被称为单元测试。独立测试模块会导致更容易的调试。当一个模块的单元测试失败时,您可以更有信心地认为错误在于该模块,而不是程序中的任何地方。

单元测试的反义词是集成测试,它测试模块的组合,甚至是整个程序。如果你只有集成测试,那么当测试失败时,你就必须搜索错误。错误可能出现在程序的任何地方。集成测试仍然很重要,因为程序可能在模块之间的连接处失败。例如,一个模块可能期望从另一个模块接收到与实际接收到的输入不同的输入。但如果你有一套详尽的单元测试,可以让你相信各个模块的正确性,那么你将会减少寻找错误的时间。

假设你正在构建一个网络搜索引擎。你的两个模块可能是getWebPage(),用于下载网页,以及extractWords(),用于将页面拆分为组成单词:

/** @return the contents of the web page downloaded from url 
 */
public static String getWebPage(URL url) {...}

/** @return the words in string s, in the order they appear, 
 *          where a word is a contiguous sequence of 
 *          non-whitespace and non-punctuation characters 
 */
public static List<String> extractWords(String s) { ... }

这些方法可能会被另一个模块makeIndex()使用,作为构建搜索引擎索引的网络爬虫的一部分:

/** @return an index mapping a word to the set of URLs 
 *          containing that word, for all webpages in the input set 
 */
public static Map<String, Set<URL>> makeIndex(Set<URL> urls) { 
    ...
    for (URL url : urls) {
        String page = getWebPage(url);
        List<String> words = extractWords(page);
        ...
    }
    ...
} 

在我们的测试套件中,我们希望:

  • 仅针对getWebPage()的单元测试,以在不同的 URL 上测试它

  • 仅针对extractWords()的单元测试,以在不同的字符串上测试它

  • 针对makeIndex()的单元测试,以在不同的 URL 集合上测试它

程序员有时会犯的一个错误是以一种依赖于getWebPage()正确性的方式编写extractWords()的测试用例。更好的方法是独立思考和测试extractWords(),并对其进行分区。使用涉及网页内容的测试分区可能是合理的,因为这就是extractWords()在程序中实际使用的方式。但不要在测试用例中实际调用getWebPage(),因为getWebPage()可能有错误!而是将网页内容存储为字面字符串,并直接传递给extractWords()。这样你就编写了一个独立的单元测试,如果它失败了,你就可以更有信心地认为错误出现在实际测试的模块extractWords()中。

注意,无法轻松地隔离makeIndex()的单元测试。当测试用例调用makeIndex()时,它不仅测试了makeIndex()内部代码的正确性,还测试了makeIndex()调用的所有方法的正确性。如果测试失败,错误可能在任何一个方法中。这就是为什么我们希望为getWebPage()extractWords()编写单独的测试,以增加我们对这些模块的信心,并将问题定位到将它们连接在一起的makeIndex()代码。

如果我们编写了makeIndex()调用的模块的存根版本,就可以隔离像makeIndex()这样的高级模块。例如,getWebPage()的一个存根根本不会访问互联网,而是无论传递什么 URL 都会返回模拟的网页内容。类的存根通常称为模拟对象。在构建大型系统时,存根是一种重要的技术,但我们通常不会在 6.005 中使用它们。

自动化测试和回归测试

▶ 播放 MITx 视频

没有什么比完全自动化更容易运行测试,也更可能运行测试了。自动化测试意味着自动运行测试并检查其结果。测试驱动程序不应该是一个需要提示输入并打印结果供您手动检查的交互式程序。相反,测试驱动程序应该在固定的测试用例上调用模块本身,并自动检查结果是否正确。测试驱动程序的结果应该是“所有测试 OK”或“这些测试失败:…”一个良好的测试框架,比如 JUnit,可以帮助您构建自动化测试套件。

请注意,像 JUnit 这样的自动化测试框架使得运行测试变得容易,但您仍然必须自己设计好测试用例。自动化测试生成是一个困难的问题,仍然是活跃的计算机科学研究领域。

一旦您进行了测试自动化,当您修改代码时重新运行测试非常重要。这可以防止您的程序退化——在修复新 bug 或添加新功能时引入其他 bug。在每次更改后运行所有测试称为回归测试

每当您找到并修复一个 bug 时,请取得引发 bug 的输入,并将其添加到您的自动化测试套件中作为一个测试用例。这种类型的测试用例称为回归测试。这有助于填充您的测试套件,并保存良好的测试用例。请记住,如果一个测试引发了一个 bug,那么每个回归测试在您代码的一个版本中都曾这样做!保存回归测试也可以防止重新引入 bug 的倒退。由于已经发生过一次,因此 bug 可能是一个容易犯的错误。

这个想法也导致了先测试后调试。当出现 bug 时,立即为它编写一个引发它的测试用例,并立即将其添加到您的测试套件中。一旦找到并修复了 bug,所有的测试用例都将通过,您就完成了调试工作,并且获得了针对该 bug 的回归测试。

在实践中,自动化测试和回归测试这两个概念几乎总是结合使用。

只有在测试可以经常自动运行时,回归测试才是切实可行的。相反,如果您的项目已经有了自动化测试,那么您可能会用它来防止回归。因此,自动化回归测试是现代软件工程的最佳实践。

阅读练习

回归测试

以下哪个最好地定义了回归测试?

(缺少答案)(缺少答案)(缺少答案)(缺少答案)运行自动化测试

哪些情况下重新运行所有 JUnit 测试是好的时机?

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

测试技术

在测试优先编程中,在编写任何代码之前,哪些技术对于选择测试用例是有用的?

  • (缺失的答案)(缺失的答案)(缺失的答案)(缺失的答案)(缺失的答案)(缺失的答案)(缺失的答案)

  • (缺失的解释)

- 摘要

  • 在本阅读中,我们看到了这些想法:
    • 先测试编程。在编写代码之前先编写测试。
    • 分区和边界以系统地选择测试用例。
    • 白盒测试和语句覆盖用于填充测试套件。
    • 尽可能孤立地对每个模块进行单元测试。
    • 自动化回归测试以防止错误再次出现。
  • 今天阅读的主题与我们的三个关键优秀软件特性相关如下:
    • 免受错误影响。测试的目的是发现代码中的错误,而先测试编程则是尽早地发现它们,就在你引入它们后立即发现。
    • 易于理解。测试并不能像代码审查那样帮助到这一点。
    • 为变更做好准备。通过编写仅依赖于规范中行为的测试来考虑变更准备性。我们还谈到了自动化回归测试,它有助于在对代码进行更改时防止错误再次出现。

- 一个给读者的练习

  • 到目前为止,你应该完成了上面的所有阅读练习。

  • 完成阅读练习可以为每节课开始时的微型测验做好准备,提交练习要求在课前晚上 10 点之前完成。

阅读 4:代码审查

您必须在课前晚上 10:00 之前完成本次阅读中的阅读练习

在 6.005 中的软件

无 Bug 易于理解 可随时更改
今天正确且未来也正确。 与未来的程序员清晰沟通,包括未来的您。 设计以适应更改而无需重写。

今天课程的目标

在今天的课程中,我们将练习:

  • 代码审查:阅读并讨论其他人编写的代码

  • 良好编码的一般原则:不论编程语言或程序目的如何,您都可以在每次代码审查中查找的内容

代码审查

▶︎ 播放 MITx 视频

代码审查是由非代码原作者进行的源代码的仔细、系统的研究。这类似于校对一篇学期论文。

代码审查真正有两个目的:

  • 改进代码。 查找错误,预测可能的错误,检查代码的清晰度,并检查与项目的样式标准的一致性。

  • 改进程序员。 代码审查是程序员学习和互相教导的重要方式,包括新语言特性、项目设计的变化或其编码标准以及新技术。在开源项目中,尤其是,很多对话都发生在代码审查的背景下。

代码审查在像 Apache 和Mozilla这样的开源项目中得到了广泛的实践。在工业界也得到了广泛的实践。在 Google,您不能将任何代码推送到主存储库中,直到另一位工程师在代码审查中签字。

在 6.005 中,我们将根据课程网站上的代码审查文档对问题集进行代码审查。

样式标准

大多数公司和大型项目都有编码样式标准(例如,Google Java 样式)。这些可以变得非常详细,甚至到指定空格(缩进多深)以及大括号和括号应该放在哪里的程度。这些问题往往会引发圣战,因为它们最终成为品味和风格的问题。

对于 Java,有一个通用的样式指南(不幸的是,没有更新到最新版本的 Java)。其中一些建议非常具体:

  • 开放的花括号应位于开始复合语句的行的末尾;闭合的花括号应该从一行开始,并缩进到复合语句的开始处。

在 6.005 中,我们没有这样的官方风格指南。我们不会告诉你在哪里放置花括号。这是每个程序员都应该做出的个人决定。但是保持自一致很重要,遵循你正在处理的项目的约定也非常重要。如果你是那种每次接触的模块都会按照个人风格重新格式化的程序员,你的队友会讨厌你,而且理所当然。要成为团队的一员。

但是有一些规则是相当合理的,而且比放置花括号更有针对性,针对我们的三个主要属性。本文的其余部分将讨论这些规则,至少是在课程的这一阶段,我们主要讨论编写基本 Java 时是相关的。当你审查其他学生的代码时,以及当你查看自己的代码以进行改进时,这些都是你应该开始寻找的东西。但是不要认为这是代码风格指南的详尽列表。在学期结束时,我们将讨论更多的内容——规格、具有表示不变量的抽象数据类型、并发和线程安全——这些将成为代码审查的素材。

有臭味的示例#1

程序员经常将糟糕的代码描述为具有需要删除的“坏味道”。“代码卫生”是另一个词。让我们从一些有臭味的代码开始。

public static int dayOfYear(int month, int dayOfMonth, int year) {
    if (month == 2) {
        dayOfMonth += 31;
    } else if (month == 3) {
        dayOfMonth += 59;
    } else if (month == 4) {
        dayOfMonth += 90;
    } else if (month == 5) {
        dayOfMonth += 31 + 28 + 31 + 30;
    } else if (month == 6) {
        dayOfMonth += 31 + 28 + 31 + 30 + 31;
    } else if (month == 7) {
        dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30;
    } else if (month == 8) {
        dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30 + 31;
    } else if (month == 9) {
        dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30 + 31 + 31;
    } else if (month == 10) {
        dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30;
    } else if (month == 11) {
        dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30 + 31;
    } else if (month == 12) {
        dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30 + 31 + 31;
    }
    return dayOfMonth;
}

接下来的几节和练习将挑出这个代码示例中的特定问题。

不要重复自己

重复的代码对安全构成风险。如果两个地方有相同或非常相似的代码,那么根本风险就在于两个副本中都存在错误,某个维护者修复了一个地方的错误,但没有修复另一个地方的错误。

避免像你避免过马路不看一样重复。复制粘贴是一种极具诱惑力的编程工具,每次使用它时你应该感受到一丝危险的冲击。你复制的代码块越长,风险就越大。

不要重复自己,或简称为 DRY,已成为程序员的口头禅。

dayOfYear()示例中充满了相同的代码。你会如何消除重复?

阅读练习

不要重复自己

dayOfYear()中一些重复是重复的值。四月份的天数在dayOfYear()中写了多少次?

(缺少答案)

(缺少解释)

不要重复自己

重复代码之所以不好的一个原因是因为重复代码中的问题必须在许多地方进行修复,而不仅仅是一个地方。假设我们的日历变了,所以二月实际上有 30 天而不是 28 天。在这段代码中有多少个数字必须更改?

(缺少答案)

(缺少解释)

不要重复自己

代码中的另一种重复是dayOfMonth+=。假设你有一个数组:

int[] monthLengths = new int[] { 31, 28, 31, 30, ..., 31}

以下哪种代码框架可以使代码足够简洁,以至于 dayOfMonth+= 只出现一次?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

需要注释的地方

▶︎ 播放 MITx 视频

一个关于注释的快速概述。优秀的软件开发人员在他们的代码中写注释,并且要审慎地做到这一点。好的注释应该使代码更容易理解,更安全免受错误(因为重要的假设已经被记录下来),并且方便修改。

一种至关重要的注释是规范,它出现在方法或类的上方,并记录了方法或类的行为。在 Java 中,这通常被写成 Javadoc 注释,意味着以 /** 开头,并包含 @-语法,比如方法的 @param@return。这是一个规范的例子:

/**
 * Compute the hailstone sequence.
 * See http://en.wikipedia.org/wiki/Collatz_conjecture#Statement_of_the_problem
 * @param n starting number of sequence; requires n > 0.
 * @return the hailstone sequence starting at n and ending with 1.
 *         For example, hailstone(3)=[3,10,5,16,8,4,2,1].
 */
public static List<Integer> hailstoneSequence(int n) {
    ...
}

规范文件记录了假设。我们已经多次提到规范,并且在未来的阅读中还会有更多内容。

另一个至关重要的注释是指定代码来源或源自其他地方的代码的注释。这对于实践软件开发人员至关重要,并且在你从网上找到的代码进行修改时,6.005 合作政策要求这样做。这里是一个例子:

// read a web page into a string
// see http://stackoverflow.com/questions/4328711/read-url-to-string-in-few-lines-of-java-code
String mitHomepage = new Scanner(new URL("http://www.mit.edu").openStream(), "UTF-8").useDelimiter("\\A").next();

记录来源的一个原因是为了避免侵犯版权。来自 Stack Overflow 的小代码片段通常属于公共领域,但从其他来源复制的代码可能是专有的或受其他类型的开源许可证保护,这些许可证更具限制性。记录来源的另一个原因是代码可能会过时;这段代码来自的Stack Overflow 回答在回答之后的几年里发生了显著变化。

有些注释是糟糕且不必要的。例如,直译代码成英语对理解没有任何帮助,因为你应该假设你的读者至少了解 Java:

while (n != 1) { // test whether n is 1   (don't write comments like this!)
   ++i; // increment i
   l.add(n); // add n to l
}

但是晦涩的代码应该加上注释:

sendMessage("as you wish"); // this basically says "I love you"

dayOfYear 代码 需要一些注释 — 你会把它们放在哪里?例如,你会在哪里记录 month 是从 0 到 11 还是从 1 到 12?

阅读练习

需要注释的地方

哪些注释对代码是有用的补充?独立考虑每个注释,就好像其他注释不存在一样。

/** @param month month of the year, where January=1 and December=12  [C1] */
public static int dayOfYear(int month, int dayOfMonth, int year) {
    if (month == 2) {      // we're in February  [C2]
        dayOfMonth += 31;  // add in the days of January that already passed  [C3]
    } else if (month == 3) {
        dayOfMonth += 59;  // month is 3 here  [C4]
    } else if (month == 4) {
        dayOfMonth += 90;
    }
    ...
    } else if (month == 12) {
        dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30 + 31 + 31;
    }
    return dayOfMonth; // the answer  [C5]
}

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

快速失败

▶︎ 播放 MITx 视频

快速失败意味着代码应该尽早显示其错误。问题越早被观察到(接近其原因),找到并修复就越容易。正如我们在第一次阅读中看到的,静态检查比动态检查失败得更快,动态检查比产生可能会破坏后续计算的错误答案更快。

dayOfYear函数不会快速失败 —— 如果你传递的参数顺序不对,它将悄悄地返回错误答案。事实上,dayOfYear 的设计方式,非常有可能一个非美国人会传递错误的参数顺序!它需要更多的检查 —— 要么是静态检查,要么是动态检查。

阅读练习

快速失败

public static int dayOfYear(int month, int dayOfMonth, int year) {
    if (month == 2) {
        dayOfMonth += 31;
    } else if (month == 3) {
        dayOfMonth += 59;
    } else if (month == 4) {
        dayOfMonth += 90;
    } else if (month == 5) {
        dayOfMonth += 31 + 28 + 31 + 30;
    } else if (month == 6) {
        dayOfMonth += 31 + 28 + 31 + 30 + 31;
    } else if (month == 7) {
        dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30;
    } else if (month == 8) {
        dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30 + 31;
    } else if (month == 9) {
        dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30 + 31 + 31;
    } else if (month == 10) {
        dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30;
    } else if (month == 11) {
        dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30 + 31;
    } else if (month == 12) {
        dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30 + 31 + 31;
    }
    return dayOfMonth;
}

假设日期是 2019 年 2 月 9 日。这个日期的dayOfYear()正确结果是 40,因为它是一年中的第 40 天。

以下哪些是程序员可能(错误地)调用dayOfYear()的合理方式?对于每一种方式,它是否会导致静态错误、动态错误或错误答案?

dayOfYear(2, 9, 2019)

(缺失答案)

(缺失解释)

dayOfYear(1, 9, 2019)

(缺失答案)

(缺失解释)

dayOfYear(9, 2, 2019)

(缺失答案)

(缺失解释)

dayOfYear("February", 9, 2019)

(缺失答案)

(缺失解释)

dayOfYear(2019, 2, 9)

(缺失答案)

(缺失解释)

dayOfYear(2, 2019, 9)

(缺少答案)

(缺少解释)

更快失败

如果按错误的顺序调用带参数的代码,下列哪些变化(分开考虑)会使代码更快失败?

public static int dayOfYear(String month, int dayOfMonth, int year) { 
    ... 
}

(缺少答案)

(缺少解释)

public static int dayOfYear(int month, int dayOfMonth, int year) {
    if (month < 1 || month > 12) {
        return -1;
    }
    ...
}

(缺少答案)

(缺少解释)

public static int dayOfYear(int month, int dayOfMonth, int year) {
    if (month < 1 || month > 12) {
        throw new IllegalArgumentException();
    }
    ...
}

(缺少答案)

(缺少解释)

public enum Month { JANUARY, FEBRUARY, MARCH, ..., DECEMBER };
public static int dayOfYear(Month month, int dayOfMonth, int year) {
    ...
}

(缺少答案)

(缺少解释)

public static int dayOfYear(int month, int dayOfMonth, int year) {
    if (month == 1) {
        ...
    } else if (month == 2) {
        ...
    }
    ...
    } else if (month == 12) {
        ...
    } else {
        throw new IllegalArgumentException("month out of range");
    }
}

(缺少答案)

(缺少解释)

避免神奇数字

▶︎ 播放 MITx 视频

计算机科学家真正承认的只有两个常数是自身有效的:0,1,也许还有 2(好吧,是三个常数)。

所有其他常数都被称为神奇,因为它们似乎是无缘无故出现的,没有解释。

解释数字的一种方法是通过注释,但更好的方法是将数字声明为具有良好、清晰名称的命名常量。

dayOfYear充满了神奇数字:

  • 月份 2 至 12 可以更清晰地写为FEBRUARYDECEMBER

  • 月份的天数 30、31、28 如果在数组、列表或映射等数据结构中,例如MONTH_LENGTH[month],将更易读(并消除重复代码)。

  • 神秘的数字 59 和 90 是特别恶劣的神奇数字示例。它们不仅没有被注释和记录,而且实际上是程序员手工计算的结果。不要将手工计算的常数硬编码。Java 在算术方面比你强。明确的计算,如31 + 28,使这些神秘数字的来源更加清晰。MONTH_LENGTH[JANUARY] + MONTH_LENGTH[FEBRUARY]会更清晰。

阅读练习

避免神奇数字

在代码中:

if (month == 2) { ... }

一个合理的程序员可能对魔法数字 2 的含义做出什么样的假设?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

当你假设时会发生什么

假设你正在阅读一些使用你不太了解的乌龟图形库的代码,你看到了这段代码:

turtle.rotate(3);

以下哪些是你可能对魔法数字 3 的含义做出的合理假设?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

使用名称而不是数字

考虑以下代码:

for (int i = 0; i < 5; ++i) {
    turtle.forward(36);
    turtle.turn(72);
}

这段代码中的魔法数字导致它在代码质量的三个度量标准上都失败:不安全(SFB),难以理解(ETU)和不适应变更(RFC)。

对于以下每个重写,判断它是否改进了 SFB、ETU 和/或 RFC,或者都没有改进。

final int five = 5;
final int thirtySix = 36;
final int seventyTwo = 72;
for (int i = 0; i < five; ++i) {
    turtle.forward(thirtySix);
    turtle.turn(seventyTwo);
}

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

int[] numbers = new int[] { 5, 36, 72 }; 
for (int i = 0; i < numbers[0]; ++i) {
    turtle.forward(numbers[1]);
    turtle.turn(numbers[2]);
}

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

int x = 5;
for (int i = 0; i < x; ++i) {
    turtle.forward(36);
    turtle.turn(360.0 / x);
}

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

final double fullCircleDegrees = 360.0;
final int numSides = 5;
final int sideLength = 36;
for (int i = 0; i < numSides; ++i) {
    turtle.forward(sideLength);
    turtle.turn(fullCircleDegrees / numSides);
}

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

每个变量只有一个目的

▶︎ 播放 MITx 视频

dayOfYear示例中,参数dayOfMonth被重复使用来计算一个非常不同的值——函数的返回值,这不是月份的日期。

不要重复使用参数,也不要重复使用变量。在编程中,变量不是一种稀缺资源。自由引入它们,给它们起个好名字,当你不再需要它们时就停止使用。如果一个变量曾经代表一件事情,突然在几行代码后开始代表另一件事情,会让读者感到困惑。

这不仅是一个易于理解的问题,也是一个防止错误和为变更做好准备的问题。

方法参数,特别是,通常应该保持不被修改。(这对于做好变更准备很重要——将来,方法的其他部分可能想知道方法的原始参数是什么,所以在计算时不应该将它们清除。)最好使用final关键字来声明方法参数,以及尽可能多的其他变量。final关键字表示该变量永远不会被重新赋值,并且 Java 编译器会在静态检查时检查它。例如:

public static int dayOfYear(final int month, final int dayOfMonth, final int year) {
    ...
}

有臭味的示例 #2

dayOfYear中存在一个潜在的错误。它根本没有处理闰年。作为修复的一部分,假设我们编写了一个闰年方法。

public static boolean leap(int y) {
    String tmp = String.valueOf(y);
    if (tmp.charAt(2) == '1' || tmp.charAt(2) == '3' || tmp.charAt(2) == 5 || tmp.charAt(2) == '7' || tmp.charAt(2) == '9') {
        if (tmp.charAt(3)=='2'||tmp.charAt(3)=='6') return true; /*R1*/
        else
            return false; /*R2*/
    }else{
        if (tmp.charAt(2) == '0' && tmp.charAt(3) == '0') {
            return false; /*R3*/
        }
        if (tmp.charAt(3)=='0'||tmp.charAt(3)=='4'||tmp.charAt(3)=='8')return true; /*R4*/
    }
    return false; /*R5*/
}

这段代码中隐藏了哪些错误?以及我们已经讨论过的哪些风格问题?

阅读练习

心理执行 2016

当你调用时会发生什么:

leap(2016)

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

心理执行 2017

当你调用时会发生什么:

leap(2017)

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)Mental execution 2050

当你调用时会发生什么:

leap(2050)

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

Mental execution 10016

当你调用时会发生什么:

leap(10016)

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

Mental execution 916

当你调用时会发生什么:

leap(916)

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

魔法数字

这段代码中有多少个魔法数字?如果某些数字出现多次,请计算每个出现次数。

(缺失答案)

(缺失解释)

DRYing out

假设你编写了辅助函数:

public static boolean isDivisibleBy(int number, int factor) { return number % factor == 0; }

如果leap()被重写为使用isDivisibleBy(year, ...),并正确遵循闰年算法,那么代码中会有多少个魔法数字?

(缺失答案)

(缺失解释)

使用好的名称

▶︎ 播放 MITx 视频

良好的方法和变量名称应该长而自我描述性。通过使代码本身更易读,使用更好的名称来描述方法和变量,通常可以完全避免注释。

例如,你可以重写

int tmp = 86400;  // tmp is the number of seconds in a day (don't do this!) 

如:

int secondsPerDay = 86400; 

一般来说,像tmptempdata这样的变量名很糟糕,是极度懒惰的程序员的症状。每个局部变量都是临时的,每个变量都是数据,所以这些名称通常没有意义。最好使用一个更长、更描述性的名称,这样你的代码就能清晰地阅读。

遵循语言的词汇命名惯例。在 Python 中,类通常大写,变量小写,单词用下划线分隔。在 Java 中:

  • methodsAreNamedWithCamelCaseLikeThis

  • variablesAreAlsoCamelCase

  • CONSTANTS_ARE_IN_ALL_CAPS_WITH_UNDERSCORES

  • 类名首字母大写

  • packages.are.lowercase.and.separated.by.dots

方法名称通常是动词短语,如getDateisUpperCase,而变量和类名称通常是名词短语。选择简短的词汇,并简洁,但避免缩写。例如,messagemsg更清晰,wordwd更好。请记住,你班级和现实世界中的许多队友都不是以英语为母语,缩写对于非母语人士可能更难理解。

ALL_CAPS_WITH_UNDERSCORES 用于static final常量。方法内声明的所有变量,包括final变量,都使用 camelCaseNames。

leap方法的命名不好:方法名称本身和局部变量名称。你会给它们起什么名字呢?

阅读练习

更好的方法名

public static boolean leap(int y) {
    String tmp = String.valueOf(y);
    if (tmp.charAt(2) == '1' || tmp.charAt(2) == '3' || tmp.charAt(2) == 5 || tmp.charAt(2) == '7' || tmp.charAt(2) == '9') {
        if (tmp.charAt(3)=='2'||tmp.charAt(3)=='6') return true;
        else
            return false;
    }else{
        if (tmp.charAt(2) == '0' && tmp.charAt(3) == '0') {
            return false;
        }
        if (tmp.charAt(3)=='0'||tmp.charAt(3)=='4'||tmp.charAt(3)=='8')return true;
    }
    return false;
}

以下哪些是leap()方法的好名字?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

更好的变量名

以下哪些是leap()函数内tmp变量的良好命名?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

使用空格帮助读者

▶︎ 播放 MITx 视频

使用一致的缩进。leap示例在这方面做得很糟糕。dayOfYear示例要好得多。事实上,dayOfYear很好地将所有数字对齐成列,使人类读者可以轻松比较和检查。这是空格的一个很好的运用。

在代码行内部放置空格,使其易于阅读。leap示例中有一些紧密排列的行——加入一些空格。

缩进时永远不要使用制表符,只能使用空格字符。请注意我们说的是字符,而不是键。我们并不是说你永远不应该按 Tab 键,只是你的编辑器不应该在你按 Tab 键时向源文件中插入制表符。这条规则的原因是不同的工具对制表符的处理方式不同——有时将它们扩展为 4 个空格,有时为 2 个空格,有时为 8 个空格。如果在命令行上运行“git diff”,或者在不同的编辑器中查看源代码,那么缩进可能会完全混乱。只使用空格。始终将编程编辑器设置为在按 Tab 键时插入空格字符。

有问题的示例#3

这是第三个例子,展示了本阅读剩余部分的要点。

public static int LONG_WORD_LENGTH = 5;
public static String longestWord;

public static void countLongWords(List<String> words) {
   int n = 0;
   longestWord = "";
   for (String word: words) {
       if (word.length() > LONG_WORD_LENGTH) ++n;
       if (word.length() > longestWord.length()) longestWord = word;
   }
   System.out.println(n);
}

不要使用全局变量

▶︎ 播放 MITx 视频

避免使用全局变量。让我们来解释一下全局变量的含义。全局变量是:

  • 一个变量,其含义可以更改的名称

  • 全局的,可以从程序的任何地方访问和更改的变量。

为什么全局变量是不好的缓存版本)列出了全局变量的危险性。

在 Java 中,全局变量声明为public staticpublic修饰符使其可以在任何地方访问,而static表示变量只有一个实例。

通常情况下,将全局变量转换为参数和返回值,或将它们放在你正在调用方法的对象内部。我们将在未来的阅读中看到许多实现这一点的技术。

阅读练习

识别全局变量

这段代码中,哪些是全局变量?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

final 的效果

通过添加final关键字将变量转换为常量可以消除全局变量的风险。当添加final关键字时,每个变量会发生什么变化?

n

(缺失答案)

(缺失解释)

LONG_WORD_LENGTH

(缺失答案)

(缺失解释)

longestWord

(缺失答案)

(缺失解释)

word

(缺失答案)

(缺失解释)

words

(缺失答案)

(缺失解释)

方法应该返回结果,而不是打印它们

countLongWords还没有做好变化的准备。它将一些结果发送到控制台System.out。这意味着,如果您想在另一个上下文中使用它——在该上下文中,该数字需要用于其他目的,例如计算而不是人眼——它将不得不被重新编写。

通常情况下,程序的最高级部分应该与人类用户或控制台进行交互。较低级别的部分应该将其输入作为参数并将其输出作为结果返回。这里唯一的例外是调试输出,当然可以将其打印到控制台上。但是,那种输出不应该成为你设计的一部分,只应该成为你调试设计的一部分。

摘要

代码审查是一种通过人工检查来提高软件质量的广泛使用的技术。代码审查可以检测代码中许多种类的问题,但作为一个入门者,本文讨论了以下关于良好代码的一般原则:

  • 不要重复自己(DRY)

  • 需要注释的地方

  • 快速失败

  • 避免魔法数字

  • 每个变量只有一个目的

  • 使用良好的名称

  • 无全局变量

  • 返回结果,不要打印它们

  • 使用空白以提高可读性

今天阅读的主题与我们的三个关键软件特性相关如下:

  • 免受错误侵扰。 通常,代码审查使用人类审阅员来发现错误。DRY 代码使您可以在一个地方修复错误,而不必担心它已在其他地方蔓延。清楚地注释您的假设使得其他程序员引入错误的可能性更小。Fail Fast 原则尽可能早地检测到错误。避免使用全局变量使得更容易局部化与变量值相关的错误,因为非全局变量只能在代码中的有限位置更改。

  • 易于理解。 代码审查实际上是找到晦涩或令人困惑的代码的唯一方法,因为其他人在阅读它并试图理解它。适度使用注释,避免使用神奇数字,使每个变量只有一个目的,使用良好的命名以及合理使用空白都可以提高代码的可理解性。

  • 准备变革。 当由有经验的软件开发人员进行代码审查时,他们可以预见可能发生的变化并提出防范措施。DRY(Don't Repeat Yourself)的代码更容易应对变化,因为只需在一个地方进行更改。返回结果而不是将其打印出来使得将代码适应新目的更容易。


记住这些练习

目前为止,你应该已经完成了上述所有的阅读练习。

完成阅读练习可以为每次课堂会议开始时的微小测验做准备,并且要求在课前晚上10pm前提交练习。

阅读 5:版本控制

6.005 中的软件

免受错误影响 易于理解 为变化做好准备
今天正确且未来未知时也正确。 与未来的程序员清晰沟通,包括未来的自己。 设计以适应变化而无需重写。

目标

  • 了解版本控制是什么以及为什么我们使用它

  • 了解 Git 如何将版本历史存储为图形

  • 练习阅读、创建和使用版本历史

介绍

版本控制系统 是软件工程世界中必不可少的工具。几乎每个项目 — 无论是严肃的还是业余的,开源的还是专有的 — 都使用版本控制。没有版本控制,协调一组程序员同时编辑同一项目代码将达到让人抓狂的程度。

你已经使用过的版本控制系统

项目报告 项目报告 v2 项目报告 v3 项目报告 最终版 项目报告 最终版-v2 项目报告 最终版-v2-修复部分 5

发明版本控制

假设 爱丽丝 正在独自解决一个问题。

爱丽丝 版本 1 你好.java

她从她的作业中的一个文件 你好.java 开始,她在上面工作了几天。

在她需要交作业给老师评分的最后一刻,她意识到自己做出的更改导致一切都错了。要是她能回到过去并检索以前的版本就好了!

简单的保存备份文件的纪律就能完成任务。

爱丽丝 版本 1 你好.1.java 版本 2 你好.2.java 版本 3 你好.java 主分支

爱丽丝根据自己的判断力决定何时达到了值得保存代码的里程碑。她将 你好.java 的版本保存为 你好.1.java你好.2.java你好.java。她遵循最近版本就是 你好.java 的约定,以避免混淆 Eclipse。我们将最近版本称为 主分支

现在当爱丽丝意识到版本 3 有致命缺陷时,她只需将版本 2 复制回当前代码的位置。灾难避免了!但如果版本 3 包含一些好的和一些坏的更改怎么办?爱丽丝可以手动比较文件以找到更改,并将其分类为好的和坏的更改。然后她可以将好的更改复制到版本 2 中。

这是很多工作,而且人眼很容易忽略变化。幸运的是,有标准的软件工具用于比较文本;在 UNIX 世界中,其中一个工具是diff。一个更好的版本控制系统将使生成差异变得容易。

版本 1 Hello.1.java 版本 2 Hello.2.java 版本 3 Hello.java
艾丽丝 版本 1 Hello.1.java 版本 2 Hello.2.java 版本 3 Hello.java

艾丽丝还想要做好准备,以防她的笔记本电脑被公交车碾过,所以她在云端保存了她的工作备份,在她对其内容满意时上传她的工作目录的内容。

如果她的笔记本电脑被丢进了查尔斯河,艾丽丝可以检索备份,并在一台新机器上恢复作业,保留随时返回旧版本的能力。

此外,她可以在多台机器上开发她的作业,使用云服务提供商作为公共交换点。艾丽丝在她的笔记本电脑上做了一些更改,并将它们上传到云端。然后她在家里的台式机上下载,做了更多的工作,并将改进的代码(包括旧文件版本)上传回云端。

版本 5L Hello.java 艾丽丝在笔记本电脑上

然而,如果艾丽丝不小心,她可能会遇到麻烦。想象一下,她开始在她的笔记本电脑上编辑Hello.java以创建“版本 5”。然后她分心了,忘记了她的改变。后来,她开始在她的台式机上工作一个新的“版本 5”,包括不同的改进。我们将这些版本称为“5L”和“5D”,分别代表“笔记本电脑”和“台式机”。

当到了上传变更到云端的时候,就有可能出现问题!艾丽丝可能会将她所有的本地文件复制到云端,导致其中只包含版本 5D。后来,艾丽丝从云端同步到她的笔记本电脑,潜在地覆盖版本 5L,丢失了有价值的更改。艾丽丝真正想要的是一个合并,以基于两个版本 5 创建一个新版本。

到目前为止,仅考虑一个程序员独自工作的情况,我们已经有了一个应该由版本控制方案支持的操作列表:

  • 恢复到以前的版本

  • 比较两个不同版本

  • 推送完整版本历史到另一个位置

  • 从那个位置拉取历史

  • 合并来自同一早期版本的分支的版本

多个开发者

现在让我们把鲍勃也加入到图片中,另一个开发者。这个情况与我们刚刚考虑的情况并没有太大不同。

版本 5A Hello.java 版本 5A Greet.java

这里的 Alice 和 Bob 就像在不同计算机上工作的两个 Alices。他们不再共享一个大脑,这使得在向共享云服务器推送和拉取时遵循严格的纪律变得更加重要。这两位程序员必须协调一个用于生成版本号的方案。理想情况下,该方案允许我们为整套文件分配清晰的名称,而不仅仅是单个文件。 (文件依赖于其他文件,因此孤立地考虑它们可能会导致不一致。)

仅仅上传新的源文件并不是向他人传达一组变更的高层想法的好方法。因此,让我们添加一个记录每个版本编写的,何时完成的,以及哪些变更的日志,以简短的人工编写消息的形式。

| 日志: 1: Alice, 晚上 7 点, ...

...

4: Bob, 晚上 8 点, ...

5A: Alice, 晚上 9 点, ... | Ver. 5A Hello.java | Ver. 5A Greet.java | Alice | | Bob | Ver. 5B Hello.java | Ver. 5B Greet.java | 日志: 1: Alice, 晚上 7 点, ...

...

4: Bob, 晚上 8 点, ...

5B: Bob, 晚上 9 点, ... |

现在推送另一个版本变得有点复杂,因为我们需要合并日志。这比 Java 文件更容易做,因为日志具有更简单的结构 - 但是没有工具支持,Alice 和 Bob 将需要手动完成!我们还希望在日志和实际可用文件集之间强制保持一致性:对于每个日志条目,应该很容易提取在该条目制作时当前的完整文件集。

但是有了日志,就可以实现各种有用的操作。我们可以查看特定文件的日志:限制为涉及修改某些文件的更改的日志视图。我们还可以使用日志来确定每行代码的贡献者,甚至更好的是,确定每行代码的贡献者,这样当代码不起作用时我们就知道要向谁抱怨。这种操作手动执行将会很繁琐;版本控制系统中的自动操作称为annotate(或者不幸地称为blame)。

多个分支

对于开发人员的子集来说,有时候他们会分支出去工作,即一个并行的代码宇宙,用于尝试新功能。其他开发人员不希望在新功能完成之前拉取新功能,即使在此期间创建了几个协调的版本。即使是单个开发人员也可能会发现创建分支很有用,原因与最初使用云服务器的 Alice 相同。

一般来说,有许多共享位置用于交换项目状态将是有用的。可能会有多个分支位置同时存在,每个位置由几位程序员共享。通过正确的设置,任何程序员都可以从任何位置拉取或推送,从而在合作模式中创造出严肃的灵活性。

令人震惊的结论

当然,事实证明我们在这里并没有发明任何东西:Git为您完成所有这些工作,许多其他版本控制系统也是如此。

分布式与集中式

卡罗尔
爱丽丝 鲍勃

像 CVS 和Subversion这样的传统集中式版本控制系统只做了我们上面想象的一部分。它们支持协作图谱 - 谁与谁共享了什么变化 - 有一个主服务器和只与主服务器通信的副本。

在集中式系统中,每个人都必须与主仓库进行工作共享。如果更改存储在版本控制中,那么它们就是在主仓库中安全存储的,因为那是唯一的仓库。

卡罗尔
爱丽丝 鲍勃

相比之下,像GitMercurial这样的分布式版本控制系统允许各种不同的协作图谱,团队和团队的子集可以轻松尝试代码和历史的替代版本,并在确定是个好主意时将版本合并在一起。

在分布式系统中,所有仓库都是平等创建的,用户可以为它们分配不同的角色。不同的用户可能与不同的仓库共享工作,并且团队必须决定什么样的更改才算是在版本控制中。如果更改仅存储在单个程序员的仓库中,他们是否仍然需要与指定的合作者或特定服务器共享它,以便团队的其他成员将其视为官方更改?

阅读练习

更加平等(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

版本控制术语

  • 仓库:我们项目中版本的本地或远程存储

  • 工作副本:我们可以在其上进行工作的本地可编辑副本项目

  • 文件:我们项目中的单个文件

  • 版本修订:项目内容在某个时间点的记录

  • 变更差异:两个版本之间的差异

  • :当前版本

版本控制系统的特点

  • 可靠性:在我们需要的时间内保留版本;允许备份

  • 多个文件:跟踪项目的版本,而不是单个文件

  • 有意义的版本:有何更改,为何进行更改?

  • 恢复:恢复旧版本,全部或部分

  • 比较版本

  • 查看历史记录:针对整个项目或个别文件

  • 不仅仅用于代码:散文,图片,...

它应该允许多人一起工作

  • 合并:合并自共同之前版本分叉出的版本

  • 跟踪责任:谁做出了那个更改,谁碰了那行代码?

  • 并行工作:允许一个程序员一段时间内独立工作(不放弃版本控制)

  • 工作进行中:允许多个程序员共享未完成的工作(不会干扰其他人,也不会放弃版本控制)

Git

我们在 6.005 中将使用的版本控制系统是Git。它很强大,值得学习。但 Git 的用户界面可能会让人非常沮丧。Git 的用户界面是什么?

  • 在 6.005 中,我们将在命令行上使用 Git。命令行是生活中的一个事实,因为它非常强大。

  • 命令行可能会使您很难看到存储库中发生的情况。你可能会发现SourceTree(右侧显示)对 Mac 和 Windows 很有用。在任何平台上,gitk可以为您提供基本的 Git GUI。询问 Google 获取其他建议。

关于 Git 工具的重要说明:

  • Eclipse 内置支持 Git。如果你按照问题集说明操作,Eclipse 将知道你的项目在 Git 中,并会显示有用的图标��我们不建议使用 Eclipse Git UI 进行更改、提交等操作,课程工作人员可能无法帮助您解决问题。

  • GitHub为 Mac 和 Windows 制作了桌面应用程序。由于 GitHub 应用程序改变了一些 Git 操作的工作方式,如果你使用 GitHub 应用程序,课程工作人员将无法帮助你。

开始使用 Git

Git网站上,你可以找到两个特别有用的资源:

  • Pro Git记录了关于 Git 的所有你可能需要了解的内容。

  • Git 命令参考可以帮助理解 Git 命令的语法。

你已经完成了PS0入门 Git 介绍

Git 对象图

阅读:Pro Git 1.3: Git Basics

该阅读介绍了 Git 存储库的三个部分:.git目录、工作目录和暂存区。

我们使用 Git 进行的所有操作——克隆、添加、提交、推送、日志、合并等——都是对存储项目中所有文件版本和描述这些更改的所有日志条目的图形数据结构的操作。Git 对象图存储在本地存储库的.git目录中。例如,PS0 的图形的另一个副本存储在 Athena 中:

/mit/6.005/git/fa16/psets/ps0/[your username].git

使用git clone复制对象图

如何将 Athena 上的对象图复制到本地计算机以开始解决问题集?git clone复制图形。

假设你的用户名是bitdiddle

git clone ssh://.../psets/ps0/bitdiddle.git ps0

悬停或点击每个步骤以更新下面的图表:

  1. 创建一个空的本地目录ps0ps0/.git

  2. 将对象图从ssh://.../psets/ps0/bitdiddle.git复制到ps0/.git

  3. 查看当前版本的master分支

突出显示步骤的图表:

我们仍然没有解释对象图中有什么。但在我们这样做之前,让我们了解一下git clone的第三步:检出master分支的当前版本。

对象图以方便且高效的结构存储在磁盘上,用于执行 Git 操作,但不是我们可以轻松使用的格式。在爱丽丝发明的版本控制方案中,Hello.java的当前版本只是称为Hello.java,因为她需要能够正常编辑它。在 Git 中,我们通过检出它们从对象图中获取我们文件的正常副本。这些是我们在 Eclipse 中看到和编辑的文件。

我们还决定可能支持版本历史中的多个分支可能很有用。对于长期项目的大团队来说,多个分支是必不可少的。为了在 6.005 中保持简单,我们不会使用分支,也不建议你创建任何分支。每个 Git 仓库都带有一个名为master的默认分支,我们所有的工作都将在master分支上进行。

git clone 的第二步给我们一个对象图,第三步给我们一个充满文件的工作目录,我们可以从项目的当前版本开始进行编辑。

让我们最终深入研究一下对象图!

克隆一个示例仓库:https://github.com/mit6005/fa16-ex05-hello-git.git

使用入门指南中的命令或Pro Git 2.3:查看提交历史中的命令,或使用像 SourceTree 这样的工具,向自己解释一下这个小项目的历史。

这是这个示例仓库的 git lol 的输出:

* b0b54b3 (HEAD, origin/master, origin/HEAD, master) Greeting in Java
*   3e62e60 Merge
|\  
| * 6400936 Greeting in Scheme
* | 82e049e Greeting in Ruby
|/  
* 1255f4e Change the greeting
* 41c4b8f Initial commit

一个 Git 项目的历史是一个有向无环图(DAG)。历史图是存储在.git中的完整对象图的骨架,所以让我们专注一分钟。

历史图中的每个节点都是项目的提交,也称为版本,也称为修订版:项目在那个时间点的所有文件的完整快照。你可能还记得我们之前的阅读中提到,每个提交都由一个唯一的标识符标识,显示为十六进制数。

除了初始提交之外,每个提交都有一个指向其父提交的指针。例如,提交1255f4e的父提交是41c4b8f:这意味着41c4b8f先发生,然后是1255f4e

一些提交具有相同的父提交:它们是从共同的先前版本分叉出来的版本。而一些提交具有两个父提交:它们是将分歧的历史重新连接在一起的版本。

一个分支 —— 记住现在只有master分支 —— 只是一个指向提交的名称。

最后,HEAD 指向我们当前的提交 — 几乎。我们还需要记住我们正在工作的分支。因此 HEAD 指向当前分支,当前分支指向当前提交。

检查你的理解…

阅读练习

HEAD 计数(缺失答案)

(缺失解释)

(缺失答案)

(缺失解释)

(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

第一印象(缺失答案)

(缺失解释)

图形

选择所有正确的答案。

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

循环往复(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

还有什么在对象图中?

历史图是完整对象图的主干。还有什么在里面?

每个提交都是我们整个项目的快照,Git 用一个 节点表示。对于任何合理大小的项目,大多数文件 不会 在任何给定的修订中更改。存储文件的冗余副本将是浪费的,因此 Git 不会这样做。

相反,Git 对象图仅一次存储每个文件的每个版本,并允许多个提交 共享 该一份副本。左侧是我们示例的 Git 对象图的更完整的渲染。

记住这个画面,因为它是不可变数据类型所启用的共享的精彩例子,我们将在几个类之后讨论。

每个提交还有日志数据 —— 谁、何时、简短的日志消息等 —— 在图表中未显示。

git commit 添加到对象图中

我们如何将新的提交添加到历史图中?git commit 创建一个新的提交。

在某个替代宇宙中,git commit 可能会根据你的工作目录中当前内容创建一个新的提交。所以如果你编辑了 Hello.java 然后执行 git commit,那么快照将包括你的更改。

我们不在那个宇宙中;在我们的宇宙中,Git 使用存储库的第三个和最后一个部分:暂存区(又称为索引,这只是一个有用的名称,因为有时它会出现在文档中)。

暂存区就像是一个原型提交,一个正在进行中的提交。这是我们如何使用暂存区和 git add 来构建一个新的快照,然后使用 git commit 来确定它的方式:

修改 hello.txtgit add hello.txtgit commit

将鼠标悬停或轻触每个步骤以更新图表,并查看每个步骤中 git status 的输出:

  1. 如果我们还没有做出任何更改,那么工作目录、暂存区和 HEAD 提交都是相同的。

  2. 修改文件。例如,让我们编辑 hello.txt

    其他更改可能包括创建一个新文件,或删除一个文件。

  3. 阶段 这些更改使用 git add

  4. 使用 git commit 创建一个包含所有已暂存更改的新提交。

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.

nothing to commit, working directory clean

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.

Changes not staged for commit:
  (use "git add ..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

 modified:   hello.txt

no changes added to commit (use "git add" and/or "git commit -a")</file> 
$ git add hello.txt 
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.

Changes to be committed:
  (use "git reset HEAD ..." to unstage)

 modified:   hello.txt 
$ git commit
[master 8a8858a] Update the greeting again
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git status
On branch master
Your branch is ahead of 'origin/master' by 1 commit.
  (use "git push" to publish your local commits)

nothing to commit, working directory clean

经常使用git status来跟踪你是否没有更改、未暂存的更改或已暂存的更改;以及你的本地仓库中是否有尚未推送的新提交。

阅读练习

优雅

Java 编译器将.java文件编译成.class文件。

(缺少答案)(缺少答案)

(缺少解释)

登台(缺少答案)(缺少答案)

(缺少解释)

上台

假设我们有一个仓库,并且有准备提交的更改

我们运行git commit(没有花哨的参数)。

(缺少答案)(缺少答案)

(缺少解释)

(缺少答案)(缺少答案)

(缺少解释)

轻描淡写

假设我们从项目的版本 A 开始。

在版本 B 中,我们做了一些更改。

然后在版本 C 中,我们做了与版本 B 中相反的更改。

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

序列、树和图形

当你独立工作时,在单台机器上,你的版本历史的 DAG 通常看起来像一个序列:提交 1 是提交 2 的父提交,提交 2 是提交 3 的父提交…

我们示例仓库的历史中涉及三位程序员。其中两位 – Alyssa 和 Ben – “同时”做出了更改。在这种情况下,“同时”并不是指完全同时。相反,它意味着他们基于相同的之前版本制作了两个不同的版本,就像 Alice 在她的笔记本电脑和台式机上制作了版本 5L 和 5D 一样。

当多个提交共享相同的父提交时,我们的历史 DAG 从序列变为树:它分支开来。请注意,项目历史中的一个分支并不需要任何人创建新的 Git 分支,只是我们从相同的提交开始,并在仓库的不同副本上并行工作:

⋮
*   commit 82e049e248c63289b8a935ce71b130a74dc04152
|   Author: Ben Bitdiddle <ben.bitdiddle@example.com>
|   Greeting in Ruby
|     
| * commit 64009369c5ab93492931ad07962ee81bda921ded
|/  Author: Alyssa P. Hacker <alyssa.p.hacker@example.com>
|   Greeting in Scheme
|  
* commit 1255f4e4a5836501c022deb337fda3f8800b02e4
| Author: Max Goldman <maxg@mit.edu>
| Change the greeting
⋮

最后,当分支更改合并在一起时,历史 DAG 从树形变为图形:

⋮
*   commit 3e62e60a7b4a0c262cd8eb4308ac3e5a1e94d839
|\  Author: Max Goldman <maxg@mit.edu>
| | Merge
| |   
* | commit 82e049e248c63289b8a935ce71b130a74dc04152
| | Author: Ben Bitdiddle <ben.bitdiddle@example.com>
| | Greeting in Ruby
| |   
| * commit 64009369c5ab93492931ad07962ee81bda921ded
|/  Author: Alyssa P. Hacker <alyssa.p.hacker@example.com>
|   Greeting in Scheme
|  
* commit 1255f4e4a5836501c022deb337fda3f8800b02e4
| Author: Max Goldman <maxg@mit.edu>
| Change the greeting
⋮

更改是如何合并在一起的?首先,我们需要了解如何在不同用户和仓库之间共享历史。

使用git pushgit pull发送和接收对象图形

我们可以使用git push将新提交发送到远程仓库:

git push origin master

悬停或点击每个步骤以更新图表:

  1. 当我们克隆一个仓库时,我们获得了历史图的副本。

    Git 记住了我们克隆的位置作为名为origin远程仓库

  2. 使用git commit,我们将新提交添加到master分支的本地历史中。

  3. 要将这些更改发送回origin远程仓库,请使用git push origin master

我们使用git pull接收新提交。请注意,git pull除了获取对象图的新部分外,还通过检出最新版本来更新工作副本(就像git clone一开��检出工作副本一样)。

合并

现在,让我们来看看当更改并行发生时会发生什么:

并行创建和提交 hello.scmhello.rb

悬停或点击每个步骤以更新图表:

  1. Alyssa 和 Ben 都克隆了包含两个提交(41c4b8f1255f4e)的仓库。

  2. Alyssa 创建了 hello.scm 并将她的更改提交为 6400936

  3. 与此同时,Ben 创建了 hello.rb 并将他的更改提交为 82e049e

    此时,他们两人的更改仅存在于各自的本地仓库中。在每个仓库中,master 现在指向一个不同的提交。

  4. 假设 Alyssa 是第一个把她的更改推送到 Athena 的人。

  5. 如果 Ben 现在尝试推送会发生什么?推送将被拒绝:如果服务器将 master 更新为 Ben 的提交,则 Alyssa 的提交将从项目历史中消失!

  6. Ben 必须与 Alyssa 合并他的更改。

    为了执行合并,他从 Athena 拉取她的提交,这样做了两件事:

    (a)下载新提交到 Ben 的仓库对象图中

  7. (b)将 Ben 的历史与 Alyssa 的合并,创建一个新的提交(3e62e60),将不同的历史连接在一起。这个提交像任何其他提交一样是一个快照:一个应用了他们两人更改的仓库的快照。

  8. 现在 Ben 可以 git push,因为当他这样做时不会丢失任何历史记录。

  9. Alyssa 可以使用 git pull 获取 Ben 的工作。

在这个例子中,Git 能够自动合并 Alyssa 和 Ben 的更改,因为他们分别修改了不同的文件。如果他们两个都编辑了相同文件的相同部分,Git 将报告合并冲突。在提交合并之前,Ben 必须手动将他们的更改编织在一起。所有这些都在入门部分的合并、合并和合并冲突中讨论过。

阅读练习

合并

Alice 和 Bob 都从相同的 Java 文件开始:

public class Hello {
    public static void greet(String name) {
        System.out.println(greeting() + ", " + name);
    }
    public static String greeting() {
        return "Hello";
    }
}

| Alice 更改 greet(..)

public static void greet(String name) {
    System.out.println(greeting() +
                       ", " + name + "!");
}

| Bob 更改 greeting()

public static String greeting() {
    return "Ciao";
}

|

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

危险的合并前景

相同的起始程序:

public class Hello {
    public static void greet(String name) {
        System.out.println(greeting() + ", " + name);
    }
    public static String greeting() {
        return "Hello";
    }
}

| Alice 更改 greeting()

public static String greeting() {
    return "Ciao";
}

| Bob 更改逗号的位置:

public static void greet(String name) {
    System.out.println(greeting() + name);
}
public static String greeting() {
    return "Hello, ";
}

|

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

继续合并

相同的起始程序:

public class Hello {
    public static void greet(String name) {
        System.out.println(greeting() + ", " + name);
    }
    public static String greeting() {
        return "Hello";
    }
}

Alice 将 greet(..) 改为返回而不是打印:

 public static String greet(String name) {
        return greeting() + ", " + name;
    }

Bob 创建了一个新文件,Main.java

public class Main {
    public static void main(String[] args) {
        // print a greeting to Eve
        Hello.greet("Eve");
    }
}

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

为什么提交看起来像差异?

我们把一个提交定义为我们整个项目的快照,但如果你问 Git,它似乎并不这样看待事情:

$ git show 1255f4e
commit 1255f4e4a5836501c022deb337fda3f8800b02e4
Author: Max Goldman <maxg@mit.edu>
Date:   Mon Sep 14 14:58:40 2015 -0400

    Change the greeting

diff --git a/hello.txt b/hello.txt
index c1106ab..3462165 100644
--- a/hello.txt
+++ b/hello.txt
@@ -1 +1 @@
-Hello, version control!
+Hello again, version control!

Git 假设我们项目的大部分在任何给定的提交中都不会改变,因此只显示差异将更有用。几乎所有时候,这是正确的。

但我们可以要求 Git 显示给我们特定提交时存储库中的内容:

$ git show 3e62e60:
tree 3e62e60:

hello.rb
hello.scm
hello.txt

是的,添加一个 : 完全改变了该命令的含义。

我们还可以查看该提交中特定文件的内容:

$ git show 3e62e60:hello.scm
(display "Hello, version control!")

这是你可以使用 Git 从灾难中恢复的最简单的方法之一:要求它 git show 你在文件出现问题之前的某个早期版本时文件是正常的时候的内容。

我们将在课堂上练习一些灾难恢复命令。

版本控制与三大重要思想

版本控制与 6.005 的三个重要思想有何关系?

免受错误的影响

找到什么时候以及在哪里出了问题

寻找其他类似的错误

获得代码没有意外更改的信心

易于理解

为什么做出了改变?

在同一时间还改变了什么?

我可以问谁有关于这段代码的问题?

为变化做好准备

管理和组织变更的全部内容

接受并集成其他开发人员的变更

分支上的 speculative 工作隔离开来

阅读 6:规范

6.005 中的软件

免受错误的影响 易于理解 为变化做好准备
今天正确,未知的未来也正确。 与未来的程序员清晰沟通,包括未来的你。 设计以适应变化而无需重写。

目标

  • 了解方法规范中的先决条件和后置条件,并能够编写正确的规范

  • 能够根据规范编写测试

  • 了解 Java 中已检查和未检查异常的区别

  • 了解如何使用异常获取特殊结果

介绍

规范是团队合作的关键。没有规范,无法委派实现方法的责任。规范充当合同:实施者负责遵守合同,而使用该方法的客户可以依赖合同。事实上,我们会发现,就像真实的法律合同一样,规范对双方都提出了要求:当规范有先决条件时,客户也有责任。

在本次阅读中,我们将研究方法规范的作用。我们将讨论先决条件和后置条件是什么,以及它们对方法的实施者和客户的意义。我们还将讨论如何使用异常,这是 Java、Python 和许多其他现代语言中的一个重要语言特性,它使我们能够使方法的接口更安全,更易于理解。

第 1 部分:规范

第 2 部分:异常

总结

在我们结束之前,用一个最后的例子检查一下你的理解:

阅读练习

Scrabble 1

假设我们正在处理下面的方法…

// Requires: tiles has length 7 & contains only uppercase letters.
//           crossings contains only uppercase letters, without duplicates.
// Effects: Returns a list of words where each word can be made by taking
//          letters from tiles and at most 1 letter from crossings.
public static List<String> scrabble(String tiles, String crossings) {
    if (tiles.length() != 7) { throw new RuntimeException(); }
    return new ArrayList<>();
}

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

Scrabble 2(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

规范充当程序实现者和其客户之间的重要防火墙。它使分离开发成为可能:客户可以自由编写使用该过程的代码,而无需看到其源代码,并且实施者可以自由编写实施该过程的代码,而不知道它将如何使用。

让我们回顾一下规范如何帮助实现这门课程的主要目标:

  • 免受错误的影响。一个好的规范清楚地记录了客户和实施者依赖的共同假设。错误通常来自接口的分歧,而规范的存在可以减少这种情况。在规范中使用机器检查的语言特性,如静态类型和异常,而不仅仅是人类可读的注释,可以进一步减少错误。

  • 易于理解。简短、简单的规范比实现本身更容易理解,并且节省了其他人阅读代码的时间。

  • 准备好改变。规范建立了代码中不同部分之间的契约,使得这些部分可以独立变化,只要它们继续满足契约的要求。

阅读 7:设计规范

6.005 中的软件

免受错误困扰 易于理解 为变化做好准备
今天正确,未来也正确。 与未来的程序员清晰沟通,包括未来的你。 设计以适应变化而无需重写。

目标

  • 了解未决定规范,并能够识别和评估非确定性

  • 了解声明性与操作性规范,并能够编写声明性规范

  • 了解前置条件、后置条件和规范的强度,并能够比较规范的强度

  • 能够编写连贯、有用的适当强度的规范

介绍

▶ 播放 MITx 视频

在这篇阅读中,我们将看看类似行为的不同规范,并讨论它们之间的权衡。我们将从三个维度来比较规范:

  • 它有多少确定性。规范是否仅为给定输入定义了单一可能的输出,还是允许实现者从一组合法输出中选择?

  • 它有多么声明性。规范是否只是描述输出应该是什么,还是明确说明了如何计算输出?

  • 它有多么。规范是否有一个小的合法实现集合,还是一个大的集合?

并非我们可能为一个模块选择的所有规范都同样有用,我们将探讨什么使一些规范比其他规范更好。

确定性与未决定性规范

回顾我们在上一篇阅读中开始的find的两个示例实现:

static int findFirst {
    for (int i = 0; i < arr.length; i++) {
        if (arr[i] == val) return i;
    }
    return arr.length;
} 
static int findLast {
    for (int i = arr.length - 1 ; i >= 0; i--) {
        if (arr[i] == val) return i;
    }
    return -1;
} 

下标[First][Last]不是实际的 Java 语法。我们在这里使用它们是为了区分两种实现以便讨论。在实际的代码中,这两种实现都应该是名为find的 Java 方法。

这是find的一种可能的规范:

static int findExactlyOne(int[] arr, int val)
  *requires*: val occurs exactly once in arr
  *effects*:  returns index i such that arr[i] = val

这个规范是确定性的:当提供满足前置条件的状态时,结果是完全确定的。只有一个返回值和一个最终状态是可能的。没有有效输入会导致多个有效输出。

find[First]find[Last]都满足规范,因此如果这是客户所依赖的规范,那么这两个实现是等效的,并可以相互替代。

这是一个略有不同的规范:

static int findOneOrMore,AnyIndex(int[] arr, int val)
  requires: val occurs in arr
  effects:  returns index i such that arr[i] = val

这个规范不是确定性的。它没有说明如果val出现多次则返回哪个索引。它只是说,如果你查找由返回值给出的索引的条目,你会找到val。这个规范允许同一输入有多个有效输出。

请注意,这与通常意义上的不确定性是不同的。不确定性代码有时表现一种方式,有时表现另一种方式,即使在同一程序中使用相同的输入调用。例如,当代码的行为依赖于随机数或依赖于并发进程的时间时,就会发生这种情况。但是,一个不确定的规范并不一定要有一个非确定性的实现。它可以由一个完全确定性的实现满足。

为了避免混淆,我们将不确定的规范称为未决定性

这个find规范的未决定性(underdetermined)版本同样被find[First]find[Last]所满足,每个都以自己的方式(完全确定的方式)解决了未决定性。如果val出现多次,那么find[OneOrMore,AnyIndex]规范的客户端不能依赖于返回哪个索引。该规范也可以被一个非确定性的实现所满足,例如,一个根据抛硬币来决定是从数组的开始还是末尾开始搜索的实现。但在我们遇到的几乎所有情况下,规范中的未决定性都提供了一个由实现者在实现时进行选择的选项。未决定性规范通常由完全确定性的实现来实现。

阅读练习

突出的

使用与上述相同的两个实现:

static int findFirst {
    for (int i = 0; i < arr.length; i++) {
        if (arr[i] == val) return i;
    }
    return arr.length;
} 
static int findLast {
    for (int i = arr.length - 1 ; i >= 0; i--) {
        if (arr[i] == val) return i;
    }
    return -1;
} 

考虑这个规范:

static int find(int[] arr, int val)
  *effects*: returns largest index i such that
             arr[i] = val, or -1 if no such i

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

过度/不足

之前我们看到了find规范的这个未决定版本:

static int findOneOrMore,AnyIndex(int[] arr, int val)
  *requires*: val occurs in arr
  *effects*:  returns index i such that arr[i] = val

这个规范允许对于具有重复值的数组有多个可能的返回值。

对于下面的每个规范,说它是否比find[OneOrMore,AnyIndex]未决定,确定,还是确定的。

static int find(int[] arr, int val)
  *requires*: val occurs exactly once in arr
  *effects*:  returns index i such that arr[i] = val

(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

static int find(int[] arr, int val)
  effects: returns largest index i such that
             arr[i] = val, or -1 if no such i

(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

static int find(int[] arr, int val)
  *requires*: val occurs in arr
  *effects*: returns largest index i such that arr[i] = val

(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

声明式与操作性规范

▶ 播放 MITx 视频

大致来说,有两种规范。操作性规范给出了方法执行的一系列步骤;伪代码描述是操作性的。声明式规范不提供中间步骤的细节。相反,它们只给出最终结果的属性,以及它与初始状态的关系。

几乎总是,声明性规范更可取。它们通常更短,更容易理解,而且最重要的是,它们不会无意中暴露客户可能依赖的实现细节(然后发现在更改实现时不再有效)。例如,如果我们想要允许 find 的任一实现,我们就不会希望在规范中说方法“沿着数组找到 val”,因为除了非常模糊外,这个规范暗示着搜索是从较低索引到较高索引进行的,并且会返回最低值,这也许不是规范者的意图。

程序员有时候会陷入操作规范,是因为他们在规范注释中为维护者解释实现方式。不要这样做。当有必要时,在方法体内使用注释,而不是在规范注释中。

对于给定的规范,可能有多种方式来声明它:

static boolean startsWith(String str, String prefix)
 *effects*: returns true if and only if there exists String suffix
            such that prefix + suffix = str

static boolean startsWith(String str, String prefix)
 *effects*: returns true if and only if there exists integer i
            such that str.substring(0, i) = prefix

static boolean startsWith(String str, String prefix)
 *effects*: returns true if the first prefix.length() characters of str
            are the characters of prefix, false otherwise

选择对客户和代码维护者最清晰的规范是我们的责任。

阅读练习

联合声明

给定这个规范:

static String join(String delimiter, String[] elements)
  *effects*: append together the strings in elements, but at each step,
             if there are more elements left, insert delimiter

(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

更强 vs. 更弱的规范

▶ 播放 MITx 视频

假设你想要改变一个方法——无论是其实现的行为还是规范本身。已经有客户端依赖于方法的当前规范。如何比较两个规范的行为,以决定是否安全地用新规范替换旧规范?

如果规范 S2 强于或等于规范 S1,那么

  • S2 的前置条件比 S1 的要弱或者相等,

  • 对于满足 S1 的前置条件的状态,S2 的后置条件比 S1 的要强或者相等。

如果是这样的话,那么一个满足 S2 的实现也可以用来满足 S1,并且可以安全地用 S2 替换 S1 在你的程序中。

这两条规则包含了几个观点。它们告诉你,你总是可以弱化前置条件;对客户提出较少要求永远不会让他们感到不满。而且你总是可以加强后置条件,这意味着可以做更多承诺。

比如,对于 find 的这个规范:

static int findExactlyOne(int[] a, int val)
  *requires*: val occurs exactly once in a
  *effects*:  returns index i such that a[i] = val

可以被替换为:

static int findOneOrMore,AnyIndex(int[] a, int val)
  *requires*: val occurs at least once in a
  *effects*:  returns index i such that a[i] = val

具有更弱前置条件。这反过来又可以被替换为:

static int findOneOrMore,FirstIndex(int[] a, int val)
  *requires*: val occurs at least once in a
  *effects*:  returns lowest index i such that a[i] = val

具有更强后置条件。

关于这个规范,有什么想法:

static int findCanBeMissing(int[] a, int val)
  *requires*: nothing
  *effects*:  returns index i such that a[i] = val,
              or -1 if no such i

我们将在练习中回到 find[CanBeMissing]

绘制规范图表

想象(非常抽象地)所有可能的 Java 方法的空间。

这个空间中的每个点代表一个方法实现。

首先我们将绘制在上面定义的 find[First]find[Last]。回顾代码,看到 find[First]find[Last] 不是规范。它们是实现,具有实现其实际行为的方法体。因此我们将它们表示为空间中的点。

一个规范在所有可能实现的空间中定义了一个区域。给定的实现要么按照规范行为,满足前置条件蕴含后置条件的契约(它在区域内),要么不满足(在区域外)。

find[First]find[Last] 都满足 find[OneOrMore,AnyIndex],因此它们位于该规范定义的区域内。

我们可以想象客户端在这个空间中观察:规范就像一个防火墙。

  • 实现者可以在规范内部自由移动,改变他们的代码而不必担心影响客户。这对于实现者能够改进算法性能、代码清晰度或在发现错误时改变方法等方面至关重要。

  • 客户端不知道他们将得到哪种实现。他们必须尊重规范,但也有自由更改他们使用实现的方式,而不必担心它会突然出现问题。

类似的规范如何相互关联?假设我们从规范 S1 开始,并使用它创建一个新规范 S2。

如果 S2 比 S1 更强,这些规范在我们的图表中会如何呈现?

  • 让我们从增强后置条件开始。如果 S2 的后置条件现在比 S1 的后置条件更强,那么 S2 就是更强的规范。

    想想增强后置条件对于实现者意味着什么:这意味着他们的自由度更小,对于他们输出的要求更强。也许他们之前通过返回任何索引 i 来满足 find[OneOrMore,AnyIndex],但现在规范要求返回最低索引 i。因此现在有实现位于 find[OneOrMore,AnyIndex] 但位于 find[OneOrMore,FirstIndex] 之外。

    是否可能有实现位于 find[OneOrMore,FirstIndex] 内部但位于 find[OneOrMore,AnyIndex] 外部?不可能。所有这些实现都满足比 find[OneOrMore,AnyIndex] 要求更强的后置条件。

  • 想一想如果减弱前置条件会发生什么,这将再次使 S2 成为更强的规范。实现者将不得不处理先前由规范排除的新输入。如果它们之前在这些输入上表现不佳,我们可能没有注意到,但现在它们的不良行为暴露出来了。

我们看到当 S2 比 S1 更强时,在这个图表中它定义了一个更小的区域;一个较弱的规范定义了一个更大的区域。

在我们的图中,由于 find[Last] 从数组 arr 的末尾开始迭代,它不满足 find[OneOrMore,FirstIndex],并且位于该区域之外。

另一个规范 S3 既不比 S1 强也不比 S1 弱,可能会重叠(存在仅满足 S1、仅满足 S3 和同时满足 S1 和 S3 的实现)或者可能是不相交的。在这两种情况下,S1 和 S3 是不可比较的。

阅读练习

增加(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

力量是真实的(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

寻找 findExactlyOne

这里再次是find的规范:

static int findExactlyOne(int[] a, int val)
  *requires:* val occurs exactly once in a
  *effects:*  returns index i such that a[i] = val

 static int findOneOrMore,AnyIndex(int[] a, int val)
  *requires:* val occurs at least once in a
  *effects:*  returns index i such that a[i] = val

 static int findOneOrMore,FirstIndex(int[] a, int val)
  *requires:* val occurs at least once in a
  *effects:*  returns lowest index i such that a[i] = val

static int findCanBeMissing(int[] a, int val)
  *requires:* nothing
  *effects:*  returns index i such that a[i] = val,
              or -1 if no such i

我们已经知道find[OneOrMore,FirstIndex]find[OneOrMore,AnyIndex]强,后者比find[ExactlyOne]强。

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

寻找 findCanBeMissing

让我们确定图表上find[CanBeMissing]的位置。

(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

找到(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

设计良好的规范

▶ 播放 MITx 视频

什么是一个好的方法?设计一个方法主要意味着编写规范。

关于规范的形式:显然应该简洁、清晰和结构良好,以便阅读。

然而,规范的内容更难规定。没有绝对的规则,但有一些有用的指导原则。

规范应该是连贯的

规范不应该有很多不同的情况。长参数列表、深度嵌套的 if 语句和布尔标志都是麻烦的迹象。考虑这个规范:

static int sumFind(int[] a, int[] b, int val)
  *effects*: returns the sum of all indices in arrays a and b at which
             val appears

这是一个设计良好的过程吗?可能不是:它是不连贯的,因为它做了几件事情(在两个数组中查找并求索引的总和)这些事情并不真正相关。最好使用两个单独的过程,一个用于查找索引,另一个用于求和。

这里是另一个例子,来自Code ReviewcountLongWords方法:

public static int LONG_WORD_LENGTH = 5;
public static String longestWord;

/**
 * Update longestWord to be the longest element of words, and print
 * the number of elements with length > LONG_WORD_LENGTH to the console.
 * @param words list to search for long words
 */
public static void countLongWords(List<String> words)

除了糟糕使用全局变量和打印而不是返回之外,规范不连贯——它做了两件不同的事情,计算单词数和找到最长的单词。

将这两个责任分开成两个不同的方法将使它们更简单(易于理解)并且在其他情境中更有用(为变更做好准备)。

调用的结果应该是有信息的

考虑一个将值放入映射中的方法的规范,其中键是某种类型K,值是某种类型V

static V put (Map<K,V> map, K key, V val)
  *requires*: val may be null, and map may contain null values
  *effects*:  inserts (key, val) into the mapping,
              overriding any existing mapping for key, and
              returns old value for key, unless none,
              in which case it returns null

请注意,前置条件并不排除null值,因此映射可以存储null。但后置条件将null用作缺失键的特殊返回值。这意味着如果返回null,你无法确定先前是否未绑定键,还是实际上绑定到了null。这不是一个很好的设计,因为返回值是无用的,除非你确定没有插入null

规范应该足够强大。

当然,在一般情况下,规范应该为客户端提供足够强大的保证 - 它需要满足他们的基本要求。在指定特殊情况时,我们必须特别小心,以确保它们不会破坏否则将是一个有用方法的内容。

例如,对于一个坏参数抛出异常但允许任意变异是没有意义的,因为客户端将无法确定实际进行了哪些变异。这里有一个说明这个缺陷的规范(也以不恰当的操作风格编写):

static void addAll(List<T> list1, List<T> list2)
  *effects*: adds the elements of list2 to list1,
             unless it encounters a null element,
             at which point it throws a NullPointerException

如果抛出NullPointerException,客户端将被迫自行确定哪些元素实际上传递到了list1

规范也应该足够弱。

考虑这样一个打开文件的方法的规范:

static File open(String filename)
  *effects*: opens a file named filename

这是一个糟糕的规范。它缺乏重要细节:文件是用于读取还是写入?它已经存在还是被创建?而且它太强大了,因为它无法保证打开文件。运行它的过程可能缺乏打开文件的权限,或者可能存在程序无法控制的文件系统问题。相反,规范应该说得更弱:它尝试打开一个文件,如果成功,文件具有某些属性。

规范应尽可能使用抽象类型

我们早在 Java 基础部分的基本 Java中看到,我们可以区分更抽象的概念,如ListSet和特定实现,如ArrayListHashSet

使用抽象类型编写我们的规范为客户端和实现者提供了更多自由。在 Java 中,这通常意味着使用接口类型,如MapReader,而不是具体的实现类型,如HashMapFileReader。考虑这个规范:

static ArrayList<T> reverse(ArrayList<T> list)
  *effects*: returns a new list which is the reversal of list, i.e.
             newList[i] == list[n-i-1]
             for all 0 <= i < n, where n = list.size()

这迫使客户端传入ArrayList,并迫使实现者返回ArrayList,即使可能有他们更愿意使用的其他List实现。由于规范的行为与ArrayList的特定内容无关,最好以更抽象的List形式编写此规范。

前置条件还是后置条件?

另一个设计问题是是否使用先决条件,以及如果使用,则方法代码是否应该尝试确保已满足先决条件才继续进行。实际上,先决条件最常见的用法是要求一个属性,因为对于方法来说,检查它可能很难或很昂贵。

如上所述,非平凡的先决条件会让客户感到不便,因为他们必须确保不调用处于不良状态(违反先决条件)的方法;如果他们这样做,就没有可预测的方法来从错误中恢复。方法的用户不喜欢先决条件。这就是为什么 Java API 类,例如,倾向于指定(作为后置条件),当参数不合适时抛出未检查的异常。这种方法使得更容易找到在调用者代码中导致传递错误参数的错误或不正确假设。一般来说,更好的做法是尽早失败,尽可能靠近 bug 的地方,而不是让错误值在远离原始原因的程序中传播。

有时,在不使方法变得不可接受地缓慢的情况下,不可避免地需要检查一个条件,而在这种情况下,先决条件是必要的。如果我们想要使用二分查找来实现find方法,我们将不得不要求数组已经排序。强制方法实际上检查数组是否已排序将完全背离二分查找的目的:以对数而不是线性时间获取结果。

是否使用先决条件的决定是一种工程判断。关键因素是检查的成本(编写和执行代码)以及方法的范围。如果方法只在类中本地调用,那么先决条件可以通过仔细检查调用方法的所有地点来释放。但是,如果方法是 public 的,并且被其他开发人员使用,那么使用先决条件就不太明智。相反,像 Java API 类一样,你应该抛出一个异常。

关于访问控制

阅读:(7 页)在 Java 教程中。

阅读:控制访问(1 页)在 Java 教程中。

我们几乎所有的方法都使用public,而没有真正考虑过。将方法设置为 public 或 private 实际上是关于类的契约的决定。公共方法可以自由地被程序的其他部分访问。将方法设置为 public 就是在宣传它是你的类愿意提供的一项服务。如果你将所有的方法都设置为 public,包括只是用于类内部本地使用的辅助方法,那么程序的其他部分可能会依赖于它们,这将使得将来更难以改变类的内部实现。你的代码就不会做好变更准备

将内部辅助方法公开也会使你的类提供的可见接口变得混乱。将内部事物保持私有可以使你的类的公共接口更小更连贯(意味着它做一件事情并且做得很好)。你的代码将更易于理解

当我们开始编写具有持久内部状态的类时,在接下来的几个课程中,我们将会有更多理由使用private。保护这个状态将有助于使程序免受错误

关于静态 vs. 实例方法

阅读:static 关键字 在 CodeGuru 上。

我们几乎所有的方法都使用了static,同样没有太多讨论。静态方法不与类的任何特定实例相关联,而实例方法(声明时没有使用 static 关键字)必须在特定对象上调用。

实例方法的规范的编写方式与静态方法的规范完全相同,但它们通常会引用调用它们的实例的属性。

例如,到目前为止,我们非常熟悉这个规范:

static int find(int[] arr, int val)
  *requires*: val occurs in arr
  *effects*:  returns index i such that arr[i] = val

如果我们有一个设计用于存储整数数组的类IntArray,而不是使用int[]会怎样?IntArray 类可能提供一个具有如下规范的实例方法:

int find(int val)
  *requires*: val occurs in this array
  *effects*:  returns index i such that **the value at index i in this array**
              is val 

我们在未来的课程中将会对实例方法的规范有更多的讨论!

阅读练习

给我看一个标志(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

那是一个奇怪的看法

public static int secondToLastIndexOf(int[] arr, int val)
  *requires*: val appears in arr an odd number of times
  *effects*:  returns the 2nd-largest i such that arr[i] == val

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

行为怪异

public static int secondToLastIndexOf(int[] arr, int val)
  *requires*: val appears in arr an odd number of times
  *effects*:  returns the 2nd-largest i such that arr[i] == val

考虑secondToLastIndexOf的以下测试用例:

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

奇怪的文档

public static int secondToLastIndexOf(int[] arr, int val)
  *requires*: val appears in arr an odd number of times
  *effects*:  returns the 2nd-largest i such that arr[i] == val

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

总结

规范充当实施者和客户之间的关键防火墙 - 不管是人与人之间(或同一个人在不同的时间点)还是代码之间。就像我们上次看到的那样,它使分开开发成为可能:客户可以自由地编写使用模块的代码,而不必看到其源代码,实施者可以自由地编写实现代码,而不必知道它将如何使用。

在实践中,声明性规范是最有用的。前置条件(弱化规范)让客户的生活更加困难,但是如果明智地应用,它们是软件设计师工具库中的重要工具,允许实施者做出必要的假设。

如往常一样,我们的目标是设计使我们的软件:

  • 免受错误的影响。没有规范,甚至我们程序的任何部分的微小变化都可能是倒下的倒下的骨牌。良好结构化、连贯的规范最小化了误解,并最大化了我们通过静态检查、仔细推理、测试和代码审查编写正确代码的能力。

  • 易于理解。一个写得很好的声明性规范意味着客户不必阅读或理解代码。你可能从来没有阅读过,比如 Python dict.update 的代码,而这样做对 Python 程序员来说并不像 阅读声明性规范 那样有用。

  • 愿意改变。一个适度弱化的规范给了实施者自由,而一个适度强化的规范给了客户自由。我们甚至可以改变规范本身,而不必回顾每个使用它们的地方,只要我们只是加强它们:弱化前置条件和加强后置条件。

阅读 8:避免调试

6.005 中的软件

免受错误侵扰 易于理解 为变更做好准备
今天正确,未来未知也正确。 与未来程序员清晰沟通,包括未来的自己。 设计以适应变更而无需重写。

目标

今天课堂的主题是调试 - 或者更确切地说,如何完全避免调试,或者在必须进行调试时保持简单。

第一道防线:使错误不可能发生

▶︎ 播放 MITx 视频

防止错误的最佳方法是通过设计使其不可能发生。

我们已经讨论过的一种方式是静态检查。静态检查通过在编译时捕获错误来消除许多错误。

我们在之前的课堂会议中还看到了一些动态检查的例子。例如,Java 通过动态捕获使数组溢出错误变得不可能。如果尝试使用超出数组或列表边界的索引,Java 会自动产生错误。而旧的语言如 C 和 C++会默默允许错误访问,导致错误和安全漏洞

不可变性(免于改变)是另一个防止错误的设计原则。不可变类型是一种一旦创建就永远不会改变其值的类型。

String 是一种不可变类型。没有任何方法可以调用 String 上的内容来改变它所代表的字符序列。字符串可以在代码之间传递和共享,而不必担心它们会被其他代码修改。

Java 还为我们提供了不可变引用:使用关键字final声明的变量,可以赋值一次但永远不会重新赋值。在声明方法的参数和尽可能多的局部变量时使用final是一个良好的实践。与变量的类型一样,这些声明是重要的文档,对代码的读者有用,并且被编译器静态检查。

考虑这个例子:

final char[] vowels = new char[] { 'a', 'e', 'i', 'o', 'u' };

vowels变量被声明为 final,但它真的是不可变的吗?以下哪些语句将是非法的(被编译器静态捕获),哪些将被允许?

vowels = new char[] { 'x', 'y', 'z' }; 
vowels[0] = 'z';

你将在下面的练习中找到答案。要小心final的含义!它只会使引用不可变,而不一定是引用指向的对象

阅读练习

最终引用,不可变对象

考虑以下代码,按顺序执行:

char vowel0 = 'a';
final char vowel1 = vowel0;

String vowel2 = vowel1 + "eiou";
final String vowel3 = vowel2;

char[] vowel4 = new char[] { vowel0, 'e', 'i', 'o', 'u' };
final char[] vowel5 = vowel4;

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

之后

在上一个练习答案中的所有合法语句执行后,每个变量的结果值是什么?只写出变量值中的字母序列,不带标点或空格。例如:

vowel0

y

(缺少解释)

vowel1

(缺少答案)

(缺少解释)

vowel2

(缺少答案)

(缺少解释)

vowel3

(缺少答案)

(缺少解释)

vowel4

(缺少答案)

(缺少解释)

vowel5

(缺少答案)

(缺少解释)

第二防御:本地化 Bug

▶︎ 播放 MITx 视频

如果我们不能防止 bug,我们可以尝试将它们本地化到程序的一小部分,这样我们就不必费太大劲找到 bug 的原因。当本地化到单个方法或小模块时,可以通过简单地研究程序文本来找到 bug。

我们已经谈过尽早失败:问题被观察到的越早(距离其原因越近),修复起来就越容易。

让我们从一个简单的例子开始:

/**
 * @param x  requires x >= 0
 * @return approximation to square root of x
 */
public double sqrt(double x) { ... }

现在假设有人用负参数调用了sqrtsqrt的最佳行为是什么?由于调用者未能满足x应该是非负的要求,所以sqrt不再受其合同条款的约束,因此它在技术上可以自由做任何它想做的事情:返回任意值,或者进入无限循环,或者熔断 CPU。然而,由于错误的调用表明调用者存在 bug,因此最有用的行为是尽早指出 bug。我们通过插入一个运行时断言来测试前提条件的方式之一,来实现这一点。这是我们可能编写断言的一种方式:

/**
 * @param x  requires x >= 0
 * @return approximation to square root of x
 */
public double sqrt(double x) { 
    if (! (x >= 0)) throw new AssertionError();
    ...
}

当前提条件不满足时,此代码通过抛出AssertionError异常终止程序。调用者 bug 的影响被阻止传播。

检查前提条件是防御性编程的一个例子。真实的程序很少是无 bug 的。防御性编程提供了一种减轻 bug 影响的方式,即使你不知道它们在哪里。

断言

通常的做法是为这些防御性检查定义一个过程,通常称为assert

assert (x >= 0);

这种方法抽象出了当断言失败时到底发生了什么。失败的断言可能会退出;它可能会在日志文件中记录一个事件;它可能会向维护者发送一份报告。

断言还具有一个额外的好处,即对程序在该点的状态作出假设的记录。对于阅读你的代码的人来说,assert (x >= 0)表示“在这一点上,x >= 0 应该始终为真。”然而,与注释不同,断言是可执行的代码,它在运行时强制执行假设。

在 Java 中,运行时断言是语言的一个内置特性。assert 语句的最简单形式接受一个布尔表达式,与上面完全相同,如果布尔表达式评估为 false,则抛出AssertionError

assert x >= 0;

断言语句还可以包括描述表达式,通常是一个字符串,但也可以是一个基本类型或对象的引用。当断言失败时,描述将打印在错误消息中,因此可以用于向程序员提供有关失败原因的额外细节。描述跟在断言表达式后面,用冒号分隔。例如:

assert (x >= 0) : "x is " + x;

如果 x == -1,则此断言将失败,并显示错误消息。

x 是 -1

还会显示一个堆栈跟踪,告诉您在代码中找到断言语句的位置以及带来程序到达该点的调用序列。这些信息通常足以开始查找错误。

Java 断言的一个严重问题是,默认情况下断言是关闭的。

如果您像平常一样运行程序,则不会检查任何断言!Java 的设计者之所以这样做,是因为检查断言有时会对性能造成一定的代价。例如,使用二分搜索搜索数组的过程要求数组已排序。断言此要求需要扫描整个数组,将应该在对数时间内运行的操作转换为线性时间。在测试期间,您应该愿意(渴望!)支付这个成本,因为这样可以更容易地进行调试,但在程序发布给用户后则不应该。然而,对于大多数应用程序来说,与代码的其余部分相比,断言并不昂贵,它们在检查错误方面提供的好处值得在性能上付出的这点小成本。

因此,您必须通过向 Java 虚拟机传递 -ea(表示启用断言)来显式启用断言。在 Eclipse 中,您可以通过转到 Run → Run Configurations → Arguments,并将 -ea 放入 VM arguments 框中来启用断言。实际上,最好通过转到 Preferences → Java → Installed JREs → Edit → Default VM Arguments 来默认启用它们,就像您在入门指南中所做的那样。

在运行 JUnit 测试时,始终将断言打开是一个好主意。您可以使用以下测试用例确保已启用断言:

@Test(expected=AssertionError.class)
public void testAssertionsEnabled() {
    assert false;
}

如果断言已按预期打开,则assert false会抛出AssertionError。测试中的注解(expected=AssertionError.class)期望并要求抛出此错误,因此测试通过。但是,如果关闭了断言,则测试的主体将不执行任何操作,未能抛出预期的异常,JUnit 将标记测试为失败。

注意,Java 的 assert 语句与 JUnit 的 assertTrue()assertEquals() 等方法是不同的机制。它们都断言关于你的代码的谓词,但设计用于不同的上下文中。assert 语句应该在实现代码中使用,用于实现内部的防御性检查。JUnit 的 assert...() 方法应该在 JUnit 测试中使用,用于检查测试的结果。assert 语句如果没有 -ea 参数不会运行,但是 JUnit 的 assert...() 方法总是会运行。

断言什么

以下是一些你应该断言的事情:

方法参数要求,就像我们在 sqrt 中看到的那样。

方法返回值要求。这种断言有时被称为自检。例如,sqrt 方法可能会将其结果平方以检查它是否与 x 相近:

public double sqrt(double x) {
    assert x >= 0;
    double r;
    ... // compute result r
    assert Math.abs(r*r - x) < .0001;
    return r;
}

覆盖所有情况。如果条件语句或 switch 没有覆盖所有可能的情况,使用断言来阻止非法情况是一个好的做法:

switch (vowel) {
  case 'a':
  case 'e':
  case 'i':
  case 'o':
  case 'u': return "A";
  default: assert false;
}

默认子句中的断言效果是断言vowel必须是五个元音字母中的一个。

什么时候应该写运行时断言?在编写代码时,而不是事后。当你编写代码时,你心中有不变量。如果你推迟编写断言,你就不太可能这样做,而且你可能会忽略一些重要的不变量。

不要断言什么

运行时断言并不是免费的。它们可能会使代码变得混乱,因此必须谨慎使用。避免无关紧要的断言,就像你会避免无信息的注释一样。例如:

// don't do this:
x = y + 1;
assert x == y+1;

这个断言并不会找出你代码中的 bug。它会找出编译器或 Java 虚拟机中的 bug,这些都是你应该信任的组件,直到你有充分的理由怀疑它们。如果一个断言在其局部上下文中是显而易见的,请省略它。

永远不要使用断言来测试与程序外部条件有关的条件,比如文件的存在、网络的可用性或人类用户输入的正确性。断言测试程序的内部状态,以确保它在其规范的范围内。当一个断言失败时,它表示程序在某种意义上已经偏离了轨道,进入了一个它没有设计为正确运行的状态。因此,断言失败表示存在 bug。外部失败不是 bug,你无法提前对程序进行任何更改以防止它们发生。应该使用异常来处理外部失败。

许多断言机制设计成仅在测试和调试期间执行断言,并在程序发布给用户时关闭。Java 的 assert 语句就是这样。由于断言可能被禁用,程序的正确性不应该依赖于断言表达式是否被执行。特别是,断言表达式不应该有副作用。例如,如果你想要断言从列表中移除的元素实际上在列表中被找到,不要这样写:

// don't do this:
assert list.remove(x);

如果禁用了断言,则整个表达式将被跳过,x 将永远不会从列表中移除。改为像这样写:

boolean found = list.remove(x);
assert found;

对于 6.005,你需要始终打开断言。确保你在 Eclipse 中按照入门手册中的说明进行了此操作。如果你没有打开断言,你会感到难过,而工作人员也不会对此有太多同情。

阅读练习

断言

考虑这个(不完整的)函数:

/**
 * Solves quadratic equation ax² + bx + c = 0.
 * 
 * @param a quadratic coefficient, requires a != 0
 * @param b linear coefficient
 * @param c constant term
 * @return a list of the real roots of the equation
 */
public static List<Double> quadraticRoots(final int a, final int b, final int c) {
    List<Double> roots = new ArrayList<Double>();
    // A
    ... // compute roots 
    // B
    return roots;
}

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

增量开发

▶︎ 播放 MITx 视频

将错误局限于程序的一小部分的一个很好的方法是增量开发。每次只构建程序的一部分,并在继续之前彻底测试该部分。这样,当你发现一个错误时,它更可能出现在你刚刚编写的部分,而不是在大量代码的任何地方。

我们关于测试的课程谈到了两种有助于此的技术:

  • 单元测试:当你单独测试一个模块时,你可以确信找到的任何错误都在该单元内 - 或者可能在测试用例本身。

  • 回归测试:当你向一个大型系统添加新功能时,尽可能频繁地运行回归测试套件。如果一个测试失败,那么错误很可能在你刚刚修改的代码中。

模块化与封装

你也可以通过更好的软件设计来定位错误。

模块化。 模块化意味着将系统划分为组件或模块,每个模块都可以与系统的其余部分分开设计、实现、测试、理解和重复使用。模块化系统的相反是单片系统 - 大而且所有部分都混在一起并且彼此依赖。

由一个非常长的 main() 函数组成的程序是单片化的 - 更难理解,更难隔离错误。相比之下,将程序分解为小函数和类更具模块化。

封装。 封装意味着在模块周围建立墙壁(一个坚硬的外壳或胶囊),使得模块对其自身的内部行为负责,系统其他部分的错误不会损害其完整性。

一种封装是访问控制,使用publicprivate来控制变量和方法的可见性和可访问性。公共变量或方法可以被任何代码访问(假设包含该变量或方法的类也是公共的)。私有变量或方法只能被同一类中的代码访问。尽可能保持私有,特别是对于变量,提供了封装,因为它限��了可能无意中引起 bug 的代码。

另一种封装来自于变量作用域。变量的作用域是指变量定义的程序文本部分,表达式和语句可以引用该变量。方法参数的作用域是方法的主体。局部变量的作用域从其声明到下一个闭合花括号。保持变量作用域尽可能小,使得更容易推断程序中可能存在 bug 的位置。例如,假设你有一个像这样的循环:

for (i = 0; i < 100; ++i) {
    ...
    doSomeThings();
    ...
}

…你发现这个循环永远运行下去——i永远不会达到 100。某处,有人在改变i。但是在哪里呢?如果i被声明为一个全局变量,像这样:

public static int i;
...
for (i = 0; i < 100; ++i) {
    ...
    doSomeThings();
    ...
}

…那么它的作用域是整个程序。它可能在程序的任何地方被改变:通过doSomeThings(),通过doSomeThings()调用的其他方法,通过运行一些完全不同代码的并发线程。但是如果i被声明为一个具有狭窄作用域的局部变量,像这样:

for (int i = 0; i < 100; ++i) {
    ...
    doSomeThings();
    ...
}

…那么i可以被改变的唯一地方就是在 for 语句内——事实上,只在我们省略的部分中。你甚至不必考虑doSomeThings(),因为doSomeThings()无法访问这个局部变量。

最小化变量的作用域 是一种用于定位 bug 的强大实践。以下是一些对 Java 有益的规则:

  • 总是在 for 循环初始化器中声明循环变量。 因此,而不是在循环之前声明它:

    int i;
    for (i = 0; i < 100; ++i) {
    

    这使得变量的作用域延伸到包含此代码的外部花括号块的其余部分,你应该这样做:

    for (int i = 0; i < 100; ++i) {
    

    这使得i的作用域仅限于 for 循环。

  • 只有在首次需要时才声明变量,并且在您可以的最内层大括号块中。 在 Java 中,变量范围是大括号块,因此将变量声明放在包含所有需要使用该变量的表达式的最内层块中。不要在函数开头声明所有变量——这会使它们的范围不必要地大。但请注意,在没有静态类型声明的语言中,比如 Python 和 Javascript,变量的范围通常是整个函数,因此你无法用大括号限制变量的范围,唉。

  • 避免全局变量。 非常糟糕的想法,特别是当程序变得很大时。全局变量通常被用作为程序的几个部分提供参数的捷径。最好将参数传递到需要它的代码中,而不是将其放在全局空间中,这样可以无意中重新分配。

阅读练习

变量范围

考虑以下代码(其中缺少一些变量声明):

1  class Apartment {
2      Apartment(String newAddress) {
3          this.address = newAddress;
4          this.roommates = new HashSet<Person>();
5      }
6      
7      String getAddress() {
8          return address;
9      }
10     
11     void addRoommate(Person newRoommate) {
12         roommates.add(newRoommate);
13         if (roommates.size() > MAXIMUM_OCCUPANCY) {
14             roommates.remove(newRoommate);
15             throw new TooManyPeopleException();
16         }
17     }
18     
19     int getMaximumOccupancy() {
20         return MAXIMUM_OCCUPANCY;
21     }
22 }

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

摘要

在这篇阅读中,我们看了一些减少调试成本的方法:

  • 避免调试

    • 使用静态类型、自动动态检查和不可变类型和引用等技术使错误变得不可能
  • 保持错误局限

    • 使用断言快速失败可以防止错误的影响扩散

    • 增量开发和单元测试将错误限制在您最近的代码中

    • 范围最小化减少了您必须搜索的程序量

考虑我们对代码质量的三个主要指标:

  • 免受错误之苦。 我们正在尝试预防它们并摆脱它们。

  • 易于理解。 类似静态类型、最终声明和断言的技术是你代码中假设的额外文档。变量范围的最小化使读者更容易理解变量的使用方式,因为要查看的代码更少。

  • 为变化做好准备。 断言和静态类型以一种可自动检查的方式记录了假设,因此当将来的程序员更改代码时,会检测到对这些假设的意外违反。

阅读 9:可变性与不可变性

6.005 中的软件

- 无 bug 易于理解 可以应对变化
- 改正今天,也适用于未知的未来。 与未来的程序员清晰沟通,包括未来的你。 设计用于在不重写的情况下容纳变化。

目标

  • 理解可变性和可变对象

  • 确定别名和理解可变性的危险

  • 利用不可变性来提高正确性、清晰度和可变性

可变性

▶ 播放 MITx 视频

Basic Java 中我们讨论快照图表时,可以回想起,某些对象是不可变的:一旦创建,它们始终代表相同的值。其他对象是可变的:它们具有可以改变对象值的方法。

String 是不可变类型的一个例子。String 对象始终表示相同的字符串。StringBuilder 是可变类型的一个例子。它具有删除字符串的部分、插入或替换字符等方法。

重新分配变量

由于 String 是不可变的,一旦创建,String 对象始终具有相同的值。要在字符串末尾添加内容,必须创建一个新的 String 对象:

String s = "a";
s = s.concat("b"); // s+="b" and s=s+"b" also mean the same thing

改变对象的可变性

相反,StringBuilder 对象是可变的。这个类有改变对象值的方法,而不仅仅是返回新值:

StringBuilder sb = new StringBuilder("a");
sb.append("b");

StringBuilder 还有其他方法,用于删除字符串的部分、在中间插入或更改单个字符。

那么呢?在两种情况下,最终你都会得到 ssb 引用字符串 "ab"。当只有一个引用指向对象时,可变性和不可变性之间的差异并不重要。但是当对象存在其他引用时,它们的行为就有很大的区别。例如,当另一个变量 t 指向与 s 相同的 String 对象,另一个变量 tb 指向与 sb 相同的 StringBuilder 对象时,不可变对象和可变对象之间的差异变得更加明显:

String 和 StringBuilder 的不同行为

String t = s;
t = t + "c";

StringBuilder tb = sb;
tb.append("c");

这表明改变ts没有影响,但改变tb也会影响sb —— 这可能让程序员感到意外。这就是我们将在本次阅读中探讨的问题的实质。

既然我们已经有了不可变的 String 类,为什么我们在编程中还需要可变的 StringBuilder?它的一个常见用途是将大量的字符串连接在一起。考虑以下代码:

String s = "";
for (int i = 0; i < n; ++i) {
    s = s + n;
}

使用不可变字符串,这会产生很多临时副本 —— 字符串的第一个数字("0")实际上在构建最终字符串的过程中被复制了 n 次,第二个数字被复制了 n-1 次,依此类推。即使我们只是连接了 n 个元素,做所有这些复制实际上需要 O(n²) 的时间。

StringBuilder 被设计为最小化这种复制。它使用一种简单但巧妙的内部数据结构,在你调用 toString() 方法请求最终的 String 时完全避免了任何复制:

StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; ++i) {
  sb.append(String.valueOf(i));
}
String s = sb.toString();

获得良好性能是我们使用可变对象的另一个原因。另一个原因是方便共享:程序的两个部分可以通过共享一个共同的可变数据结构更方便地进行通信。

阅读练习

跟我来

(答案缺失)(答案缺失)(答案缺失)(答案缺失)

(解释缺失)

(答案缺失)(答案缺失)(答案缺失)(答案缺失)

(解释缺失)

选择最佳答案。

(答案缺失)(答案缺失)(答案缺失)(答案缺失)(答案缺失)(答案缺失)

(解释缺失)

变异风险

▶ 播放 MITx 视频

可变类型似乎比不可变类型更强大。如果你在 Datatype 超市购物,并且必须在一个无聊的不可变 String 和一个超级强大的可变 StringBuilder 中选择,为什么你会选择不可变的?StringBuilder 应该能够做任何 String 能做的事情,而且还可以 set()append() 等等。

答案是不可变类型更安全,更容易理解,并且更容易应对变化。可变性使得理解程序在做什么变得更加困难,并且更加难以强制执行合同。以下是两个说明原因的示例。

风险示例 #1:传递可变值

让我们从一个简单的方法开始,它对列表中的整数求和:

/** @return the sum of the numbers in the list */
public static int sum(List<Integer> list) {
    int sum = 0;
    for (int x : list)
        sum += x;
    return sum;
}

假设我们还需要一个求绝对值之和的方法。遵循良好的 DRY 实践(不要重复自己),实施者编写了一个使用 sum() 的方法:

/** @return the sum of the absolute values of the numbers in the list */
public static int sumAbsolute(List<Integer> list) {
    // let's reuse sum(), because DRY, so first we take absolute values
    for (int i = 0; i < list.size(); ++i)
        list.set(i, Math.abs(list.get(i)));
    return sum(list);
}

注意,这个方法通过直接改变列表来完成其工作。对于实施者来说,这似乎是合理的,因为重用现有列表更高效。如果列表有数百万个项目,那么你就节省了生成新的百万项目的绝对值列表的时间和内存。因此,实施者对这种设计有两个非常好的理由:DRY 和性能。

但是结果行为对于任何使用它的人都会非常令人惊讶!例如:

// meanwhile, somewhere else in the code...
public static void main(String[] args) {
    // ...
    List<Integer> myData = Arrays.asList(-5, -3, -2);
    System.out.println(sumAbsolute(myData));
    System.out.println(sum(myData));
}

这段代码会打印什么?是10后面跟着-10吗?还是其他什么?

阅读练习

风险 #1(答案缺失)(答案缺失)

(解释缺失)

让我们思考一下这里的要点:

  • 免于错误? 在这个例子中,很容易责怪sum­Absolute()的实现者超出了其规范允许的范围。但实际上,传递可变对象是一个潜在的错误。它只是在等待一些程序员无意中改变那个列表,通常出于重用或性能的很好意图,但导致一个可能非常难以追踪的错误。

  • 易于理解? 当阅读main()时,你会认为sum()sum­Absolute()会对myData做出什么样的改变?读者清楚地看到myData被它们中的一个改变了吗?

风险示例 #2:返回可变值

▶ 播放 MITx 视频

我们刚刚看到一个例子,其中将一个可变对象传递给函数导致了问题。那么返回一个可变对象会发生什么呢?

让我们考虑一下Date,其中之一内置的 Java 类。 Date碰巧是一个可变类型。假设我们编写一个确定春天第一天的方法:

/** @return the first day of spring this year */
public static Date startOfSpring() {
    return askGroundhog();
}

在这里,我们使用了计算春天开始时间的著名土拨鼠算法(哈罗德·拉米斯,比尔·默里等人,《土拨鼠之日》,1993 年)。

客户开始使用这个方法,例如计划他们的大型聚会:

// somewhere else in the code...
public static void partyPlanning() {
    Date partyDate = startOfSpring();
    // ...
}

所有的代码都能够正常运行,人们也都很满意。现在,独立地发生了两件事情。首先,startOfSpring()的实现者意识到,地鼠因为被不断询问春天何时开始而开始感到恼火。于是代码被重写,最多只询问一次地鼠,然后将地鼠的答案缓存起来供将来使用:

/** @return the first day of spring this year */
public static Date startOfSpring() {
    if (groundhogAnswer == null) groundhogAnswer = askGroundhog();
    return groundhogAnswer;
}
private static Date groundhogAnswer = null;

(另外:请注意为缓存答案使用了一个私有静态变量。你会认为这是一个全局变量吗?还是不是?)

其次,startOfSpring()的一个客户决定实际的春天第一天太冷了,所以聚会将会推迟一个月:

// somewhere else in the code...
public static void partyPlanning() {
    // let's have a party one month after spring starts!
    Date partyDate = startOfSpring();
    partyDate.setMonth(partyDate.getMonth() + 1);
    // ... uh-oh. what just happened?
}

(另外:这段代码在增加一个月的方式上也存在潜在的错误。为什么?它隐含地假设了春天开始的时间?)

当这两个决定相互作用时会发生什么?更糟糕的是,想象一下谁会首先发现这个错误——是startOfSpring()吗?是partyPlanning()吗?还是一些完全无辜的第三方代码,也会调用startOfSpring()吗?

阅读练习

风险 #2

我们不知道Date是如何存储月份的,所以我们将用Date的想象中的month字段中的抽象值...march......april...来表示。

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

理解风险示例 #2

partyPlanning无意中改变了春天的开始,因为partyDategroundhogAnswer碰巧指向同一个可变的Date对象。

更糟糕的是,这个错误可能不会立即在partyPlanning()startOfSpring()中被发现。相反,它将是一些无辜的代码片段,随后调用startOfSpring(),得到错误的日期返回,并继续计算自己的错误答案。

(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

第二个错误

代码在如何增加月份方面还存在另一个潜在的错误。

看一看 Java API 文档中的Date.setMonth

`(缺少答案)

(缺少解释)

NoSuchMonthException

Date.setMonth的文档说month: 介于 0-11 之间的月份值

根据那个陈述和你到目前为止所读到的内容…

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

SuchTerribleSpecificationsException

Date文档的其他地方,它说:“给方法传递的参数可以不在指定的范围内;例如,一个日期可以被指定为 1 月 32 日,解释为 2 月 1 日”。

看起来像是前提条件的… 不是!

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

关键点:

  • 免受错误困扰?再次出现了一个潜在的错误。

  • 准备好变化?显然,日期对象的变异是一种变化,但这不是我们谈论“准备好变化”时所指的那种变化。相反,问题是程序的代码是否可以在不大量重写或引入错误的情况下轻松更改。在这里,我们有两个显然独立的更改,由不同的程序员完成,它们相互作用产生了一个严重的错误。

在这两个例子中——List<Integer>Date——如果列表和日期是不可变类型,问题就完全可以避免。这些错误本来是设计上不可能出现的。

实际上,你不应该使用Date!使用java.time包中的类之一:LocalDateTimeInstant等。它们在规范中保证它们是不可变的。

这个例子也说明了为什么使用可变对象实际上对性能可能是有害的。避免更改任何规范或方法签名的最简单解决方案是,startOfSpring()始终返回土拨鼠的答案的副本

 return new Date(groundhogAnswer.getTime());

这种模式是防御性拷贝,当我们谈论抽象数据类型时,我们会看到更多这种情况。防御性拷贝意味着partyPlanning()可以自由地修改返回的日期,而不会影响startOfSpring()缓存的日期。但是防御性拷贝会迫使startOfSpring()做额外的工作,并使用额外的空间来为每个客户端做拷贝 — 即使 99%的客户端从不改变它返回的日期。我们可能会在内存中有很多复制的春天第一天。如果我们使用不可变类型,那么程序的不同部分可以安全地共享内存中的相同值,因此需要更少的拷贝和更少的内存空间。不可变性可能比可变性更有效,因为不可变类型永远不需要进行防御性拷贝。

别名是使可变类型风险的原因

▶ 播放 MITx 视频

实际上,如果你在一个方法中完全本地使用可变对象,并且只有一个对对象的引用,那么使用可变对象是完全可以的。刚刚我们看到的两个例子中��现问题的原因是有多个引用,也称为别名,指向同一个可变对象。

通过快照图示例来解释这一点,但这里是概述:

  • List的例子中,同一个列表被list(在sumsumAbsolute中)和myData(在main中)指向。一个程序员(sumAbsolute的)认为修改列表是可以的;另一个程序员(main的)希望列表保持不变。由于别名的存在,main的程序员失败了。

  • Date的例子中,有两个变量名指向Date对象,groundhogAnswerpartyDate。这些别名位于代码的完全不同部分,由不同的程序员控制,他们可能不知道对方在做什么。

首先在纸上绘制快照图示,但你真正的目标应该是在脑海中开发快照图示,这样你就可以可视化代码中发生的情况。

变异方法的规范

在这一点上,应该清楚,当一个方法执行变异时,将该变异包含在方法的规范中是至关重要的,使用我们在上一篇阅读中讨论的结构。

(现在我们已经看到,即使一个特定的方法改变一个对象,该对象的可变性仍然可能是错误的来源。)

这里有一个改变方法的例子:

static void sort(List<String> lst)
  *requires*: nothing
  *effects*:  puts lst in sorted order, i.e. lst[i] <= lst[j]
              for all 0 <= i < j < lst.size()

一个不改变其参数的方法的例子:

static List<String> toLowerCase(List<String> lst)
  *requires*: nothing
  *effects*:  returns a new list t where t[i] = lst[i].toLowerCase()

如果效果没有明确说明输入可以被改变,那么在 6.005 中,我们假设输入的变异是被隐式禁止的。几乎所有程序员都会假设相同的事情。意外的变异会导致可怕的错误。

遍历数组和列表

▶ 播放 MITx 视频

我们将要看的下一个可变对象是一个迭代器 —— 一个逐步遍历元素集合并逐个返回元素的对象。在 Java 中,当你使用 for (... : ...) 循环 遍历 List 或数组时,底层实际上使用了迭代器。这段代码:

List<String> lst = ...;
**for (String str : lst) {**
    System.out.println(str);
**}** 

被编译器重写为类似于这样的内容:

List<String> lst = ...;
**Iterator iter = lst.iterator();**
**while (iter.hasNext()) {**
    **String str = iter.next();**
    System.out.println(str);
**}** 

一个迭代器有两个方法:

  • next() 返回集合中的下一个元素。

  • hasNext() 用于测试迭代器是否已经到达集合的末尾。

请注意,next() 方法是一个变异方法,不仅返回一个元素,还推进迭代器,以便随后调用 next() 将返回不同的元素。

你还可以查看 Java API 中 Iterator 的定义

在我们进一步讨论之前:

你应该已经阅读了:类和对象 在 Java 教程中。

阅读:在 CodeGuru 上的 final 关键字

MyIterator

为了更好地理解迭代器的工作原理,这里有一个简单的 ArrayList<String> 迭代器的实现:

/**
 * A MyIterator is a mutable object that iterates over
 * the elements of an ArrayList<String>, from first to last.
 * This is just an example to show how an iterator works.
 * In practice, you should use the ArrayList's own iterator
 * object, returned by its iterator() method.
 */
public class MyIterator {

    private final ArrayList<String> list;
    private int index;
    // list[index] is the next element that will be returned
    //   by next()
    // index == list.size() means no more elements to return

    /**
     * Make an iterator.
     * @param list list to iterate over
     */
    public MyIterator(ArrayList<String> list) {
        this.list = list;
        this.index = 0;
    }

    /**
     * Test whether the iterator has more elements to return.
     * @return true if next() will return another element,
     *         false if all elements have been returned
     */
    public boolean hasNext() {
        return index < list.size();
    }

    /**
     * Get the next element of the list.
     * Requires: hasNext() returns true.
     * Modifies: this iterator to advance it to the element 
     *           following the returned element.
     * @return next element of the list
     */
    public String next() {
        final String element = list.get(index);
        ++index;
        return element;
    }
}

MyIterator 使用了一些与我们迄今为止编写的类不同的 Java 语言特性。确保你已经阅读了链接的 Java 教程部分,以便你理解它们:

实例变量,在 Java 中也称为字段。实例变量与方法参数和局部变量不同;实例变量存储在对象实例中,并且在方法调用之后持续存在。My­Iterator 的实例变量是什么?

一个构造函数,用于创建一个新的对象实例并初始化其实例变量。My­Iterator 的构造函数在哪里?

My­Iterator 的方法缺少了 static 关键字,这意味着它们是实例方法,必须在对象实例上调用,例如 iter.next()

在一个地方使用了this 关键字 来引用实例对象,特别是用来引用实例变量(this.list)。这是为了消除两个不同命名的变量 list(一个是实例变量,一个是构造函数参数)之间的歧义。My­Iterator 的大部分代码都引用了没有显式 this 的实例变量,但这只是 Java 支持的一种方便的简写 —— 例如,index 实际上是 this.index

private 用于对象的内部状态和内部辅助方法,而 public 表示供类的客户端使用的方法和构造函数 (访问控制)。

final 用于指示对象的哪些内部变量可以重新分配,哪些不能。index 允许更改(next() 在遍历列表时更新它),但 list 不能(迭代器必须始终指向相同的列表 —— 如果要遍历另一个列表,应该创建另一个迭代器对象)。

这是一个显示 MyIterator 对象在操作中的典型状态的快照图:

注意,我们用双线从 list 画箭头,表示它是 final 的。这意味着一旦画出箭头,它就不能改变。但是它所指向的 ArrayList 对象是可变的 —— 元素可以在其中改变 —— 并且将 list 声明为 final 对此没有影响。

迭代器为什么存在?有许多种类型的集合数据结构(链表、映射、哈希表)具有不同种类的内部表示。迭代器概念允许以单一统一的方式访问它们,因此客户端代码更简单,集合实现可以更改而不必更改遍历它的客户端代码。大多数现代语言(包括 Python、C#和 Ruby)使用迭代器的概念。这是一个有效的 设计模式(对常见设计问题的经过测试的解决方案)。随着我们课程的进行,我们会看到许多其他设计模式。

阅读练习

MyIterator.next 签名

这个例子是我们见过的第一个使用 实例方法 的例子。实例方法在类的实例上操作,带有一个隐式的 this 参数(类似于 Python 中的显式 self 参数),并且可以访问 实例字段

让我们检查 MyIteratornext 方法:

public class MyIterator {

    private final ArrayList<String> list;
    private int index;

    ...

    /**
     * Get the next element of the list.
     * Requires: hasNext() returns true.
     * Modifies: this iterator to advance it to the element 
     *           following the returned element.
     * @return next element of the list
     */
    public String next() {
        final String element = list.get(index);
        ++index;
        return element;
    }
}

next 视为 Static Checking: Types 中定义的 操作

(缺失答案)

(缺失解释)

(缺失答案)

(缺失解释)

MyIterator.next 前提条件

next 具有前提条件 requires: hasNext() 返回 true。

(缺失答案)

(缺失解释)

当前提条件不满足时,实现可以执行任何操作。

(缺失答案)

(缺失解释)

MyIterator.next 后置条件

next 的后置条件的一部分是:@return 列表的下一个元素

(缺失答案)

(缺失解释)

next的后置条件的另一部分是modifies: this iterator to advance it to the element following the returned element.

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

突变损害迭代器

▶ 播放 MITx 视频

让我们尝试使用我们的迭代器来做一个简单的任务。假设我们有一个表示 MIT 课程的字符串列表,如 ["6.005", "8.03", "9.00"]。我们希望有一个名为dropCourse6的方法,它将从列表中删除课程 6,留下其他课程。遵循良好的实践,我们首先编写规范:

/**
 * Drop all subjects that are from Course 6\. 
 * Modifies subjects list by removing subjects that start with "6."
 * 
 * @param subjects list of MIT subject numbers
 */
public static void dropCourse6(ArrayList<String> subjects)

注意,dropCourse6在其合同中有一个框架条件(modifies条款),警告客户端其列表参数将被改变。

接下来,遵循测试驱动的编程方式,我们设计一个测试策略,将输入空间划分为分区,并选择测试用例来覆盖该分区:

// Testing strategy:
//   subjects.size: 0, 1, n
//   contents: no 6.xx, one 6.xx, all 6.xx
//   position: 6.xx at start, 6.xx in middle, 6.xx at end

// Test cases:
//   [] => []
//   ["8.03"] => ["8.03"]
//   ["14.03", "9.00", "21L.005"] => ["14.03", "9.00", "21L.005"]
//   ["2.001", "6.01", "18.03"] => ["2.001", "18.03"]
//   ["6.045", "6.005", "6.813"] => [] 

最后,我们来实现它:

public static void dropCourse6(ArrayList<String> subjects) {
    MyIterator iter = new MyIterator(subjects);
    while (iter.hasNext()) {
        String subject = iter.next();
        if (subject.startsWith("6.")) {
            subjects.remove(subject);
        }
    }
}

现在我们运行我们的测试用例,它们可以工作了!...几乎。最后一个测试用例失败了:

// dropCourse6(["6.045", "6.005", "6.813"])
//   expected [], actual ["6.005"] 

我们得到了错误的答案:dropCourse6在列表中留下了一门课程!为什么?追踪发生了什么。在你分析代码时使用快照图,展示MyIterator对象和ArrayList对象,并在工作过程中更新它。

阅读练习

绘制一个快照图

绘制一个快照图来说明这个错误。

(随意使用这个空间和一个魔法笔)

(缺失答案)

(缺失解释)

请注意,这不仅仅是我们的MyIterator中的一个错误。ArrayList中的内置迭代器也遇到了同样的问题,for循环是它的语法糖。问题只是有不同的症状。如果您使用了这段代码:

for (String subject : subjects) {
    if (subject.startsWith("6.")) {
        subjects.remove(subject);
    }
}

然后你会得到一个Concurrent­Modification­Exception。内置迭代器检测到您在其脚下更改列表,并大声疾呼。(你认为它是怎么做到的?)

你怎么解决这个问题?一种方法是使用Iteratorremove()方法,这样迭代器就会适当地调整其索引:

Iterator iter = subjects.iterator();
while (iter.hasNext()) {
    String subject = iter.next();
    if (subject.startsWith("6.")) {
        **iter.remove();**
    }
} 

实际上,这也更有效率,因为iter.remove()已经知道应该删除的元素在哪里,而subjects.remove()需要重新搜索。

但是这并不能解决整个问题。如果当前有其他Iterator在相同的列表上活动呢?它们都不会被通知!

阅读练习

选择一个快照图(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

突变和契约

▶ 播放 MITx 视频

可变对象可能使简单的合同变得非常复杂

这是可变数据结构的一个基本问题。对同一可变对象的多个引用(也称为对象的别名)可能意味着程序中的多个地方——可能相距很远——都依赖于该对象保持一致。

以规范的术语来说,契约不能再仅仅在一个地方强制执行了,例如在类的客户端和类的实现者之间。涉及可变对象的契约现在取决于每个持有对可变对象引用的人的良好行为。

作为这种非本地契约现象的一种症状,考虑一下 Java 集合类,这些类通常在类的客户端和实现者之间具有非常明确的契约。试图找到它在哪里记录了我们刚刚发现的关键要求——在迭代集合时不能修改集合。谁负责?IteratorListCollection?你能找到吗?

需要推理全局属性像这样使得理解和对可变数据结构的程序的正确性更加困难和自信。我们仍然必须这样做——为了性能和方便——但我们为此付出了巨大的 bug 安全成本。

可变对象减少了可变性

可变对象使客户端和实现者之间的契约变得更加复杂,并减少了客户端和实现者的自由变动。换句话说,使用允许更改的对象使代码更难更改。下面是一个例子来说明这一点。

我们示例的关键将是该方法的规范,该方法查找 MIT 数据库中的用户名并返回用户的 9 位标识符:

/**
 * @param username username of person to look up
 * @return the 9-digit MIT identifier for username.
 * @throws NoSuchUserException if nobody with username is in MIT's database
 */
public static char[] getMitId(String username) throws NoSuchUserException {        
    // ... look up username in MIT's database and return the 9-digit ID
}

一个合理的规范。现在假设我们有一个客户端使用此方法来打印用户的标识符:

char[] id = getMitId("bitdiddle");
System.out.println(id);

现在客户端和实现者分别决定进行更改。 客户端担心用户的隐私,决定遮蔽 id 的前 5 位:

char[] id = getMitId("bitdiddle");
for (int i = 0; i < 5; ++i) {
    id[i] = '*';
}
System.out.println(id);

实现者担心数据库的速度和负载,所以引入了一个记住已查找过的用户名的缓存:

private static Map<String, char[]> cache = new HashMap<String, char[]>();

public static char[] getMitId(String username) throws NoSuchUserException {        
    // see if it's in the cache already
    if (cache.containsKey(username)) {
        return cache.get(username);
    }

    // ... look up username in MIT's database ...

    // store it in the cache for future lookups
    cache.put(username, id);
    return id;
}

这两个变化引入了一个微妙的错误。当客户端查找"bitdiddle"并返回一个字符数组时,现在客户端和实现者的缓存都指向相同的字符数组。该数组是别名。这意味着客户端的遮蔽代码实际上正在覆盖缓存中的标识符,因此对getMidId("bitdiddle")的未来调用将不会返回完整的 9 位数,例如“928432033”,而是遮蔽版本“*****2033”。

共享可变对象会使合同复杂化。如果这个合同的失败上升到软件工程法庭,将会引发争议。谁在这里应该负责?客户是否有义务不修改返回的对象?实现者是否有义务不保留返回的对象?

以下是我们可以澄清规范的一种方式:

public static char[] getMitId(String username) throws NoSuchUserException 
  *requires*: nothing
  *effects*: returns an array containing the 9-digit MIT identifier of username,
             or throws NoSuchUserException if nobody with username is in MIT’s
             database. **Caller may never modify the returned array.**

这是一个不好的做法。这种方法的问题在于,这意味着合同必须在整个程序的剩余生命周期内有效。这是一个终身合同!我们编写的其他合同范围更窄;你可以在调用之前仔细考虑前置条件,调用之后仔细考虑后置条件,而不必考虑将来的一切会发生什么。

以下是一个类似问题的规范:

public static char[] getMitId(String username) throws NoSuchUserException 
  *requires*: nothing
  *effects*: returns **a new array** containing the 9-digit MIT identifier of username,
             or throws NoSuchUserException if nobody with username is in MIT’s
             database.

这也并不能完全解决问题。这个规范至少说数组必须是新的。但这能阻止实现者保留对该新数组的别名吗?它能阻止实现者改变该数组或将其来用于将来的其他用途吗?

以下是一个更好的规范:

public static **String** getMitId(String username) throws NoSuchUserException 
  *requires*: nothing
  *effects*: returns the 9-digit MIT identifier of username, or throws
             NoSuchUserException if nobody with username is in MIT’s database.

不可变的 String 返回值提供了一个保证,即客户和实现者不会像使用 char 数组那样相互干扰。它不依赖于程序员仔细阅读规范注释。String 是不可变的。不仅如此,这种方法(与之前的方法不同)还赋予了实现者引入缓存的自由——一种性能改进。

有用的不可变类型

由于不可变类型避免了许多陷阱,让我们列举一些在 Java API 中常用的不可变类型:

  • 原始类型和原始包装类型都是不可变的。如果需要处理大数,BigIntegerBigDecimal 是不可变的。

  • 不要使用可变的 Date,而是根据您需要的时间粒度使用 java.time 中的适当不可变类型。

  • Java 集合类型的通常实现——ListSetMap——都是可变的:ArrayListHashMap 等。Collections 实用类有用于获取这些可变集合的不可修改视图的方法:

    您可以将不可修改的视图视为封装在底层列表/集合/映射周围的包装器。拥有对该包装器的引用并尝试执行突变操作 —— addremoveput等 —— 将触发一个Unsupported­Operation­Exception

    在将可变集合传递给程序的另一部分之前,我们可以将其包装在不可修改的包装器中。在那一点上,我们应该小心地忘记对可变集合的引用,以免意外突变它。(一个方法是让它超出作用域。)就像一个final引用后面的可变对象一样可以被突变一样,不可修改包装器中的可变集合仍然可以被拥有对其的引用的人修改,从而破坏包装器。

  • Collections还提供了获取不可变空集合的方法:Collections.emptyList,等等。最糟糕的是发现您绝对是空的列表突然变得绝对不是空的

阅读练习

不可变性(missing answer)(missing answer)(missing answer)(missing answer)(missing answer)

(missing explanation)

总结

在本阅读中,我们看到可变性对性能和方便是有用的,但它也通过要求使用对象的代码在全局层面上表现良好而产生了 bug 的风险,这极大地复杂化了我们需要进行的推理和测试,以确保其正确性。

确保你理解不可变对象(如String)和不可变引用(如final变量)之间的区别。快照图可以帮助理解这一点。对象是值,用快照图中的圆圈表示,不可变对象具有双重边框,表示它永远不会改变其值。引用是指向对象的指针,用快照图中的箭头表示,不可变引用是一条带有双线的箭头,表示该箭头不能移动指向不同的对象。

这里的关键设计原则是不可变性:尽可能使用不可变对象和不可变引用。让我们回顾一下不可变性如何帮助达到本课程的主要目标:

  • 免于 bug。不可变对象不容易受到别名的影响而产生 bug。不可变引用始终指向相同的对象。

  • 易于理解。因为不可变对象或引用始终意味着相同的事物,所以对于代码的读者来说更容易推理 —— 他们不必追踪所有代码以找到对象或引用可能被更改的所有位置,因为它无法更改。

  • 为变化做好准备。如果一个对象或引用在运行时不能被更改,那么依赖于该对象或引用的代码在程序更改时就不必进行修订。

阅读 10:调试

软件在 6.005

免受 bug 的影响 易于理解 为变化做好准备
今天正确,未来也正确。 与未来的程序员清晰沟通,包括未来的你。 设计以适应变化而无需重写。

目标

今天课堂的主题是系统化调试。

有时你别无选择,只能进行调试 - 特别是当 bug 只在将整个系统连接在一起时才发现,或者在系统部署后由用户报告时,这种情况下很难将其定位到特定模块。对于这些情况,我们可以建议一种更有效的调试系统策略。

重现 Bug

▶ 播放 MITx 视频

首先找到一个小的、可重复的测试用例,产生故障。如果 bug 是通过回归测试发现的,那么你很幸运;你的测试套件中已经有一个失败的测试用例。如果 bug 是由用户报告的,可能需要一些努力来重现 bug。对于图形用户界面和多线程程序,如果 bug 取决于事件的时间或线程执行,可能很难一致地重现 bug。

然而,无论你付出多少努力使测试用例变小且可重复,都会得到回报,因为在搜索 bug 并开发修复方案时,你将不得不一遍又一遍地运行它。此外,在成功修复 bug 后,你会希望将测试用例添加到回归测试套件中,以便 bug 不再出现。一旦你有了 bug 的测试用例,使这个测试用例工作成为你的目标。

这里有一个例子。假设你写了这个函数:

/**
 * Find the most common word in a string.
 * @param text string containing zero or more words, where a word
 *     is a string of alphanumeric characters bounded by nonalphanumerics.
 * @return a word that occurs maximally often in text, ignoring alphabetic case.
 */
public static String mostCommonWord(String text) {
    ...
}

一个用户将整个莎士比亚剧本的文本传递给你的方法,类似于 mostCommonWord(allShakespearesPlaysConcatenated),发现该方法返回的不是可预测的常见英文单词,如 "the""a",而是一些意外的东西,也许是 "e"

莎士比亚的剧本有 10 万行,包含超过 80 万字,因此通过常规方法进行调试,如打印调试和断点调试,将会非常痛苦。如果你首先努力减少有 bug 的输入的大小,使其变得可管理,同时仍然展示相同(或非常相似)的 bug,调试将会更容易:

  • 第一半莎士比亚的作品是否显示相同的 bug?(二分查找!这总是一个很好的技巧。更多关于这个下面。)

  • 一个剧本是否有相同的 bug?

  • 一个演讲是否有相同的 bug?

一旦找到一个小的测试用例,使用该较小的测试用例找到并修复 bug,然后回到原始有 bug 的输入,并确认你修复了相同的 bug。

阅读练习

将 bug 缩小到一个测试用例

假设一个用户报告说 mostCommonWord("chicken chicken chicken beef") 返回 "beef" 而不是 "chicken"

(缺少答案)

(缺少解释)

(缺少答案)

(缺少解释)

(缺少答案)

(缺少解释)

(缺少答案)

(缺少解释)

(缺少答案)

(缺少解释)

给出所有有意义的答案,而不仅仅是最简单的一个(因为最简单的有时候不再表现出错误!)

回归测试

假设你将"chicken chicken chicken beef"输入缩减为"c c b",这也存在问题。你找到一个错误,修复它,并观察到现在"c c b""chicken chicken chicken beef"都返回了正确的答案。

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

理解错误的位置和原因

▶ 播放 MITx 视频

要定位错误及其原因,可以使用科学方法:

  1. 研究数据。 查看导致错误的测试输入,以及由此产生的不正确结果、失败的断言和堆栈跟踪。

  2. 假设。 提出一个假设,与所有数据一致,关于错误可能在哪里,或者它不可能在哪里。最好一开始将这个假设概括。

  3. 实验。 设计一个测试你假设的实验。最好一开始将实验作为观察 - 一种收集信息但尽可能少干扰系统的探针。

  4. 重复。 将你从实验中收集到的数据添加到之前所知的内容中,并提出一个新的假设。希望你已经排除了一些可能性,并缩小了可能的错误位置和原因集。

让我们在mostCommonWord()示例的背景下看看这些步骤,再加上三个辅助方法进行详细说明:

/**
 * Find the most common word in a string.
 * @param text string containing zero or more words, 
 *     where a word is a string of alphanumeric 
 *     characters bounded by nonalphanumerics.
 * @return a word that occurs maximally often in text, 
 *         ignoring alphabetic case.
 */
public static String mostCommonWord(String text) {
    ... words = splitIntoWords(text); ...
    ... frequencies = countOccurrences(words); ...
    ... winner = findMostCommon(frequencies); ...
    ... return winner;
}

/** Split a string into words ... */
private static List<String> splitIntoWords(String text) {
    ...
}

/** Count how many times each word appears ... */
private static Map<String,Integer> countOccurrences(List<String> words) {
    ...
}

/** Find the word with the highest frequency count ... */
private static String findMostCommon(Map<String,Integer> frequencies) {
    ...
}

1. 研究数据

一种重要的数据形式是异常的堆栈跟踪。练习阅读你得到的堆栈跟踪,因为它们将为你提供关于错误可能在哪里以及是什么的大量信息。

隔离一个小测试案例的过程也可能给你之前没有的数据。你甚至可能有两个相关的测试案例,夹击了错误,即一个成功一个失败。例如,也许mostCommonWords("c c, b")有问题,但mostCommonWords("c c b")没问题。

2. 假设

数据流经程序模块

将程序视为模块或算法中的步骤有助于思考,并尝试一次性排除程序的整个部分。

mostCommonWord()中的数据流如右图所示。如果错误的症状是在countOccurrences()中出现异常,那么你可以排除所有下游内容,特别是findMostFrequent()

然后你会选择一个试图进一步定位 bug 的假设。你可能会假设 bug 在splitIntoWords()中,损坏了其结果,然后导致countOccurrences()中的异常。然后你会使用一个实验来测试这个假设。如果假设成立,那么你就排除了countOccurrences()作为问题的来源。如果它是错误的,那么你就排除了splitIntoWords()

3. 实验

一个好的实验是对系统进行轻微观察而不会过多干扰它。可能是:

  • 运行一个不同的测试用例。上面讨论的测试用例缩减过程使用测试用例作为实验。

  • 在运行的程序中插入一个打印语句断言,以检查其内部状态的某些内容。

  • 使用调试器设置一个断点,然后逐步执行代码并查看变量和对象的值。

试图插入修复以猜测的 bug,而不是仅仅是探针,这很诱人,但几乎总是错误的。首先,这会导致一种临时猜测和测试编程,产生糟糕、复杂、难以理解的代码。其次,你的修复可能只是掩盖了真正的 bug,而没有真正解决它。

例如,如果你得到一个ArrayOutOfBoundsException,先尝试了解发生了什么。不要只是添加避免或捕获异常的代码,而不解决真正的问题。

其他提示

二分法进行 bug 定位。调试是一个搜索过程,有时你可以使用二分法来加速这个过程。例如,在mostCommonWords中,数据通过三个辅助方法流动。要进行二分搜索,你会把这个工作流程分成两半,也许猜测 bug 在第一个辅助方法调用和第二个之间,然后在那里插入探针(比如断点、打印语句或断言)来检查结果。从那个实验的答案中,你会进一步分成两半。

优先考虑你的假设。在提出假设时,你可能想记住系统的不同部分有不同的故障可能性。例如,经过老旧测试的代码可能比最近添加的代码更可靠。Java 库代码可能比你的更可靠。Java 编译器和运行时、操作系统平台和硬件越来越可靠,因为它们经过了更多的尝试和测试。在找到充分理由之前,你应该信任这些较低的级别。

交换组件。如果你有另一个满足相同接口的模块实现,并且你怀疑这个模块,那么你可以尝试一下替换另一个实现。例如,如果你怀疑你的 binarySearch()实现,那么尝试用一个更简单的 linearSearch()代替。如果你怀疑 java.util.ArrayList,你可以用 java.util.LinkedList 代替。如果你怀疑 Java 运行时,尝试用不同版本的 Java。如果你怀疑操作系统,尝试在不同的操作系统上运行程序。如果你怀疑硬件,尝试在不同的机器上运行。然而,除非你有充分的理由怀疑一个组件,否则不要浪费时间替换无故失败的组件。

确保你的源代码和目标代码是最新的。从存储库中拉取最新版本,并删除所有的二进制文件然后重新编译所有东西(在 Eclipse 中,通过 Project → Clean 完成)。

寻求帮助。向别人解释你的问题通常会有所帮助,即使你对方不知道你在说什么。实验室助理和其他 6.005 学生通常知道你在说什么,所以他们更好。

先睡一觉。如果你太累了,你就不会成为一个有效的调试者。把延迟换成效率。

修复 Bug

一旦你找到了错误并理解了它的原因,第三步就是为其设计一个修复方案。避免急于贴上补丁然后继续。问问自己,这个 bug 是编码错误,比如拼写错误的变量或者交换的方法参数,还是设计错误,比如未详细说明或不足的接口。设计错误可能意味着你需要退后一步重新审视你的设计,或者至少考虑一下失败接口的其他所有客户端是否也受到了这个 bug 的影响。

还要考虑一下这个 bug 是否有任何类似的。如果我刚刚在这里发现了一个除以零的错误,我还在代码中其他地方这样做了吗?尝试使代码免受未来类似 bug 的影响。还要考虑你的修复会产生什么影响。它会破坏任何其他代码吗?

最后,在应用了修复方案之后,将 bug 的测试用例添加到回归测试套件中,并运行所有测试以确保(a)bug 已修复,(b)未引入新的 bug。

阅读练习

调试策略

假设你正在调试quadraticRoots函数,它似乎有时会产生错误的答案。

/**
 * Solves quadratic equation ax² + bx + c = 0.
 * 
 * @param a quadratic coefficient, requires a != 0
 * @param b linear coefficient
 * @param c constant term
 * @return a list of the real roots of the equation
 */
public static List<Double> quadraticRoots(int a, int b, int c) { ... }

将以下项目按照你应该尝试它们的顺序放入: 1, 2, 3, ... 对于无意义的陈述,说“wat”。

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

总结

在这篇阅读中,我们看了如何系统地调试:

  • 将 bug 重现为测试用例,并将其放入回归测试套件中

  • 使用科学方法找到 bug

  • 深思熟虑地修复 bug,而不是匆忙行事

思考我们的三个主要代码质量指标:

  • 免受 bug 之害。 我们正试图阻止它们并摆脱它们。

  • 易于理解。 静态类型、最终声明和断言等技术是你的代码中假设的额外文档。变量范围的最小化使读者更容易理解变量的使用方式,因为需要查看的代码更少。

  • 为变化做好准备。 断言和静态类型将假设以一种可自动检查的方式记录下来,因此当未来的程序员更改代码时,可以检测到对这些假设的意外违反。

读书 11:抽象数据类型

6.005 中的软件

免受错误影响 易于理解 为变更做好准备
今天正确且未来不变。 与未来的程序员清晰交流,包括未来的你。 设计以适应变化而无需重写。

目标

今天的课程介绍了两个概念:

  • 抽象数据类型

  • 表示独立性

在这篇阅读中,我们看到了一个强大的概念,抽象数据类型,它使我们能够将程序中使用数据结构的方式与数据结构本身的特定形式分离开来。

抽象数据类型解决了一个特别危险的问题:客户端对类型的内部表示做出假设。我们将看到为什么这是危险的,以及如何避免这种情况。我们还将讨论操作的分类,以及抽象数据类型的良好设计原则。

Java 中的访问控制

你应该已经阅读了:控制对类成员的访问在 Java 教程中。

阅读练习

以下问题使用下面的代码。首先研究它,然后回答问题。

 class Wallet {
        private int amount;

        public void loanTo(Wallet that) {
            // put all of this wallet's money into that wallet
/*A*/       that.amount += this.amount;
/*B*/       amount = 0;
        }

        public static void main(String[] args) {
/*C*/       Wallet w = new Wallet();
/*D*/       w.amount = 100;
/*E*/       w.loanTo(w);
        }
    }

    class Person {
        private Wallet w;

        public int getNetWorth() {
/*F*/       return w.amount;
        }

        public boolean isBroke() {
/*G*/       return Wallet.amount == 0;
        }
    }

访问控制 A

that.amount += this.amount;

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

访问控制 B

amount = 0;

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

访问控制 C

Wallet w = new Wallet();

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

访问控制 D

w.amount = 100;

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

访问控制 E

w.loanTo(w);

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

访问控制 F

return w.amount;

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

访问控制 G

return Wallet.amount == 0;

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

抽象的含义是什么

▶︎ 播放 MITx 视频

抽象数据类型是软件工程中的一个通用原则的一个实例,它有许多不同的名称,意思略有不同。以下是这个想法的一些名称:

  • 抽象。省略或隐藏低级细节,采用更简单、更高级的思想。

  • 模块化。将系统分成组件或模块,每个组件可以单独设计、实现、测试、推理和重复使用,与系统的其余部分分开。

  • 封装。在模块周围构建墙壁(硬壳或胶囊),使得模块负责其自身的内部行为,系统其他部分的错误不能损害其完整性。

  • 信息隐藏。 将模块实现的细节隐藏在系统的其余部分之外,以便稍后可以更改这些细节而不更改系统的其余部分。

  • 关注点分离。 将一个特性(或“关注点”)的责任交给单个模块,而不是分散在多个模块中。

作为软件工程师,您应该了解这些术语,因为您经常会遇到它们。所有这些想法的根本目的是帮助实现我们在 6.005 中关心的三个重要属性:防止错误、易于理解和易于更改。

用户定义类型

在计算机的早期,编程语言带有内置类型(如整数、布尔值、字符串等)和内置过程,例如输入和输出。用户可以定义自己的过程:这就是构建大型程序的方式。

软件开发的一个重大进步是抽象类型的概念:可以设计一种编程语言以允许用户定义的类型。这个想法源自许多研究人员的工作,特别是达尔(Simula 语言的发明者)、霍尔(开发了我们现在用来推理抽象类型的许多技术)、帕纳斯(创造了信息隐藏术语并首次阐述了围绕其封装的秘密组织程序模块的想法),以及麻省理工学院的巴巴拉·利斯科夫和约翰·古塔格,在抽象类型规范和支持编程语言方面做出了开创性工作——并开发了原始的 6.170,6.005 的前身。巴巴拉·利斯科夫因其在抽象类型上的工作而获得了图灵奖,这是计算机科学的诺贝尔奖。

数据抽象的关键思想是类型由您可以对其执行的操作来表征。数字是可以相加和相乘的东西;字符串是可以连接和取子字符串的东西;布尔值是可以否定的东西,等等。在某种意义上,用户在早期编程语言中已经可以定义自己的类型:例如,您可以创建一个名为 date 的记录类型,其中包含用于日、月和年的整数字段。但是,使抽象类型新颖和不同的是对操作的关注:类型的用户不需要担心其值实际存储方式,就像程序员可以忽略编译器实际如何存储整数一样。重要的是操作。

在 Java 中,与许多现代编程语言一样,内置类型和用户定义类型之间的分隔有些模糊。java.lang 中的类,如 Integer 和 Boolean 是内置的;你是否将 java.util 中的所有集合视为内置的不太清楚(而且也不太重要)。Java 通过具有不是对象的原始类型使问题变得复杂。这些类型的集合,如 int 和 boolean,用户无法扩展。

阅读练习

抽象数据类型

考虑一个抽象数据类型 Bool。该类型具有以下操作:

true : Bool

false : Bool

and : Bool × Bool → Bool

or : Bool × Bool → Bool

not : Bool → Bool

… 其中前两个操作构造了类型的两个值,而最后三个操作在这些值上具有逻辑、逻辑和逻辑的通常含义。

以下哪些是 Bool 可能被实现的方式,并且仍然能够满足操作的规范?选择所有适用的选项。

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

类型和操作的分类

▶︎ 播放 MITx 视频

无论是内置的还是用户定义的类型,都可以被分类为可变不可变。可变类型的对象可以被改变:也就是说,它们提供的操作在执行时会导致对同一对象的其他操作产生不同的结果。因此,Date 是可变的,因为你可以调用 setMonth 并观察到 getMonth 操作的变化。但是 String 是不可变的,因为它的操作会创建新的 String 对象而不是改变现有的对象。有时一个类型会以两种形式提供,一个是可变的,一个是不可变的。例如,StringBuilderString 的可变版本(尽管两者肯定不是相同的 Java 类型,也不能互换)。

抽象类型的操作被分类如下:

  • Creators 创建类型的新对象。创建者可能会接受一个对象作为参数,但不会接受正在构造的类型的对象。

  • Producers 从类型的旧对象创建新对象。例如,Stringconcat 方法是一个 producer:它接受两个字符串并产生一个表示它们连接的新字符串。

  • Observers 接受抽象类型的对象并返回不同类型的对象。例如,Listsize 方法返回一个 int

  • Mutators 改变对象。例如,Listadd 方法通过向列表末尾添加元素来改变列表。

我们可以用如下方式概括这些区别(后续解释):

  • creator : t* → T

  • producer : T+, t* → T

  • observer : T+, t* → t

  • mutator : T+, t* → void | t | T

这些非正式地展示了各个类中操作签名的形状。每个 T 是抽象类型本身;每个 t 是其他某种类型。+ 标记表示该类型在签名的那部分可能出现一次或多次,* 标记表示它可能出现零次或多次。| 表示或。例如,一个 producer 可能接受两个抽象类型 T 的值,就像 String.concat() 一样:

  • concat : String × String → String

一些观察者不需要其他类型 t 的参数:

  • size : 列表 → 整数

...和其他需要多个参数的:

  • regionMatches : 字符串 × 布尔 × 整数 × 字符串 × 整数 × 整数 → 布尔

创建者操作通常实现为 构造函数,比如 new ArrayList()。但是创建者也可以简单地是一个静态方法,比如 Arrays.asList()。作为静态方法实现的创建者通常被称为工厂方法。Java 中的各种 String.valueOf 方法是工厂方法实现的其他示例。

修改器通常由 void 返回类型表示。返回 void 的方法 必须 用于某种副作用,因为它否则不返回任何内容。但并非所有修改器都返回 void。例如,Set.add() 返回一个布尔值,指示集合是否实际上已更改。在 Java 的图形用户界面工具包中,Component.add() 返回对象本身,以便多个 add() 调用可以被 链接在一起

抽象数据类型示例

这里有一些抽象数据类型的示例,以及它们的一些操作,按类型分组。

int 是 Java 的基本整数类型。int 是不可变的,因此没有修改器。

  • creators: 数字文字 0, 1, 2, …

  • producers: 算术运算符 +, -, *, /

  • observers: 比较运算符 ==, !=, <, >

  • 修改器: 无(它是不可变的)

List 是 Java 的列表类型。List 是可变的。List 也是一个接口,这意味着其他类提供了数据类型的实际实现。这些类包括 ArrayListLinkedList

String 是 Java 的字符串类型。String 是不可变的。

  • creators: String 构造函数

  • producers: concat, substring, toUpperCase

  • observers: length, charAt

  • 修改器: 无(它是不可变的)

这种分类提供了一些有用的术语,但并不完美。在复杂的数据类型中,可能存在既是生产者又是变更者的操作,例如。有些人仅将术语生产者保留给不进行变更的操作。

阅读练习

操作

下面的每个方法都是来自 Java 库的抽象数据类型的操作。点击链接查看其文档。思考操作的类型签名。然后对操作进行分类。

提示:注意类型本身是否出现为参数或返回值。记住,实例方法(缺少static关键字)有一个隐式参数。

data:text/html,点击方法名称查看其 Javadoc。

Integer.valueOf() (缺少答案)

(缺少解释)

BigInteger.mod() (缺少答案)

(缺少解释)

List.addAll() (缺少答案)

(缺少解释)

String.toUpperCase() (缺少答案)

(缺少解释)

Set.contains() (缺少答案)

(缺少解释)

Collections.unmodifiableList() (缺少答案)

(缺少解释)

BufferedReader.readLine() (缺少答案)

(缺少解释)

设计抽象类型

▶︎ 播放 MITx 视频

设计抽象类型涉及选择良好的操作并确定它们应该如何行为。以下是一些经验法则。

最好拥有少量简单的操作,可以以强大的方式组合,而不是大量复杂的操作。

每个操作应该有明确定义的目的,并且应该具有连贯的行为,而不是一系列特例。例如,我们可能不应该向List添加一个sum操作。这可能有助于使用整数列表的客户端,但是对于字符串列表呢?或者嵌套列表呢?所有这些特例会使sum成为一个难以理解和使用的操作。

操作集应该是足够的,意味着必须有足够的操作来执行客户端可能想要执行的计算。一个好的测试是检查类型对象的每个属性是否可以被提取。例如,如果没有get操作,我们将无法找出列表的元素是什么。基本信息不应该过于难以获得。例如,对于列表来说,size方法并不是绝对必要的,因为我们可以在递增的索引上应用get直到出现错误,但这是低效且不方便的。

类型可以是通用的:例如列表或集合,或者是特定领域的:例如街道地图,员工数据库,电话簿等。但是不应该混合通用和特定领域的特性。用于表示一系列扑克牌的Deck类型不应该有一个接受任意对象(如整数或字符串)的通用add方法。反之,将特定领域的方法如dealCards放入通用类型List中也没有意义。

表示独立性

关键是,一个良好的抽象数据类型应该是表示独立的。这意味着抽象类型的使用与其表示(用于实现它的实际数据结构或数据字段)无关,因此表示的更改对抽象类型本身之外的代码没有影响。例如,List 提供的操作与列表是作为链接列表还是作为数组表示是独立的。

除非操作的前提条件和后置条件完全指定,以便客户知道依赖于什么,您知道可以安全更改,否则您将无法更改 ADT 的表示。

示例:字符串的不同表示

让我们看一个简单的抽象数据类型,看看表示独立意味着什么,以及为什么它很有用。下面的MyString类型比真正的 Java String 拥有更少的操作,并且它们的规格略有不同,但这仍然具有说明性。以下是 ADT 的规格:

/** MyString represents an immutable sequence of characters. */
public class MyString { 

    //////////////////// Example of a creator operation ///////////////
    /** @param b a boolean value
     *  @return string representation of b, either "true" or "false" */
    public static MyString valueOf(boolean b) { ... }

    //////////////////// Examples of observer operations ///////////////
    /** @return number of characters in this string */
    public int length() { ... }

    /** @param i character position (requires 0 <= i < string length)
     *  @return character at position i */
    public char charAt(int i) { ... }

    //////////////////// Example of a producer operation /////////////// 
    /** Get the substring between start (inclusive) and end (exclusive).
     *  @param start starting index
     *  @param end ending index.  Requires 0 <= start <= end <= string length.
     *  @return string consisting of charAt(start)...charAt(end-1) */
    public MyString substring(int start, int end) { ... }
}

这些公共操作及其规格是此数据类型的客户端允许知道的唯一信息。实际上,按照测试先编程的范式,我们应该创建的第一个客户端是根据其规格执行这些操作的测试套件。但是,目前,直接在MyString对象上使用assertEquals编写测试用例将不起作用,因为我们没有在MyString上定义相等操作。我们将在稍后的阅读中谨慎讨论如何实现相等性。目前,我们唯一可以执行的操作是我们上面定义的操作:valueOflengthcharAtsubstring。我们的测试必须限制自己在这些操作上。例如,这是一个valueOf操作的测试:

MyString s = MyString.valueOf(true);
assertEquals(4, s.length());
assertEquals('t', s.charAt(0));
assertEquals('r', s.charAt(1));
assertEquals('u', s.charAt(2));
assertEquals('e', s.charAt(3));

我们将在本文末尾回到测试 ADT 的问题。

现在,让我们看一个简单的MyString表示:只是一个字符数组,长度正好与字符串相同,末尾没有额外的空间。以下是如何声明该内部表示的,作为类中的一个实例变量:

private char[] a;

在选择了该表示后,操作将以直接的方式实现:

public static MyString valueOf(boolean b) {
    MyString s = new MyString();
    s.a = b ? new char[] { 't', 'r', 'u', 'e' } 
            : new char[] { 'f', 'a', 'l', 's', 'e' };
    return s;
}

public int length() {
    return a.length;
}

public char charAt(int i) {
    return a[i];
}

public MyString substring(int start, int end) {
    MyString that = new MyString();
    that.a = new char[end - start];
    System.arraycopy(this.a, start, that.a, 0, end - start);
    return that;
}

valueOf中的?:语法称为三元条件运算符,它是 if-else 语句的简写。请参见Java 教程中的条件运算符页面。)

考虑问题:为什么charAtsubstring不必检查其参数是否在有效范围内?如果客户端使用非法输入调用这些实现,您认为会发生什么?

这种实现的一个问题是,它错过了性能改进的机会。因为这种数据类型是不可变的,substring 操作实际上不需要将字符复制到新数组中。它可以直接指向原始 MyString 对象的字符数组,并跟踪新子字符串对象表示的起始和结束位置。在某些版本的 Java 中,String 实现就是这样做的。

要实现这种优化,我们可以将该类的内部表示更改为:

private char[] a;
private int start;
private int end;

使用这种新表示,操作现在是这样实现的:

public static MyString valueOf(boolean b) {
    MyString s = new MyString();
    s.a = b ? new char[] { 't', 'r', 'u', 'e' } 
            : new char[] { 'f', 'a', 'l', 's', 'e' };
    s.start = 0;
    s.end = s.a.length;
    return s;
}

public int length() {
    return end - start;
}

public char charAt(int i) {
  return a[start + i];
}

public MyString substring(int start, int end) {
    MyString that = new MyString();
    that.a = this.a;
    that.start = this.start + start;
    that.end = this.start + end;
    return that;
}

因为 MyString 现有的客户端仅依赖于其公共方法的规范,而不依赖于其私有字段,所以我们可以进行这种更改,而无需检查和更改所有客户端代码。这就是表示独立性的力量。

阅读练习

表示 1

考虑以下抽象数据类型。

/**
 * Represents a family that lives in a household together.
 * A family always has at least one person in it.
 * Families are mutable.
 */
class Family {
    // the people in the family, sorted from oldest to youngest, with no duplicates.
    public List<Person> people;

    /**
     * @return a list containing all the members of the family, with no duplicates.
     */
    public List<Person> getMembers() {
        return people;
    }
}

这是这个抽象数据类型的一个客户端:

void client1(Family f) {
    // get youngest person in the family
    Person baby = f.people.get(f.people.size()-1);
    ...
}

假设所有这些代码都能正确运行(包括 Familyclient1)并通过所有测试。

现在 Family 的表示已从 List 更改为 Set,如下所示:

/**
 * Represents a family that lives in a household together.
 * A family always has at least one person in it.
 * Families are mutable.
 */
class Family {
    // the people in the family
    public Set<Person> people;

    /**
     * @return a list containing all the members of the family, with no duplicates.
     */
    public List<Person> getMembers() {
        return new ArrayList<>(people);
    }
}

假设在更改后 Family 能正确编译。

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

表示 2

| 原始版本:

/**
 * Represents a family that lives in a
 * household together. A family always
 * has at least one person in it.
 * Families are mutable. */
class Family {
    // the people in the family,
    // sorted from oldest to youngest,
    // with no duplicates.
    public List<Person> people;

    /** @return a list containing all
     *  the members of the family,
     *  with no duplicates. */
    public List<Person> getMembers() {
        return people;
    }
}

| 更改后的版本:

/**
 * Represents a family that lives in a
 * household together. A family always
 * has at least one person in it.
 * Families are mutable. */
class Family {
    // the people in the family
    public Set<Person> people;

    /**
     * @return a list containing all
     * the members of the family,
     * with no duplicates. */
    public List<Person> getMembers() {
        return new ArrayList<>(people);
    }
}

|

现在考虑 client2

void client2(Family f) {
    // get size of the family
    int familySize = f.people.size();
    ...
}

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

表示 3

| 原始版本:

/**
 * Represents a family that lives in a
 * household together. A family always
 * has at least one person in it.
 * Families are mutable. */
class Family {
    // the people in the family,
    // sorted from oldest to youngest,
    // with no duplicates.
    public List<Person> people;

    /** @return a list containing all
     *  the members of the family,
     *  with no duplicates. */
    public List<Person> getMembers() {
        return people;
    }
}

| 更改后的版本:

/**
 * Represents a family that lives in a
 * household together. A family always
 * has at least one person in it.
 * Families are mutable. */
class Family {
    // the people in the family
    public Set<Person> people;

    /**
     * @return a list containing all
     * the members of the family,
     * with no duplicates. */
    public List<Person> getMembers() {
        return new ArrayList<>(people);
    }
}

|

现在考虑 client3

void client3(Family f) {
    // get any person in the family
    Person anybody = f.getMembers().get(0);
    ...
}

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

表示 4

对于下面展示的 Family 数据类型代码的每个部分,它是 ADT 的规范、表示还是实现的一部分?

1 (缺少答案)
/**
 * Represents a family that lives in a household together.
 * A family always has at least one person in it.
 * Families are mutable.
 */

|

2 (缺少答案)
public class Family {

|

3 (缺少答案)
 // the people in the family, sorted from oldest to youngest, with no duplicates.

|

4 (缺少答案)
 private List<Person> people;

|

5 (缺少答案)
 /**
     * @return a list containing all the members of the family, with no duplicates.
     */

|

6 (缺少答案)
 public List<Person> getMembers() {

|

7 (缺少答案)
 return people;

|

 }
}

|

(缺少解释)

在 Java 中实现 ADT 概念

▶︎ 播放 MITx 视频

让我们总结一下我们在本次阅读中讨论过的一些一般性观念,这些观念通常适用于任何编程语言,并使用 Java 语言特性进行具体实现。关键是有几种方法可以做到这一点,重要的是既要理解大观念,如创建操作,也要理解在实践中实现该观念的不同方式。

ADT 概念 在 Java 中的实现方式 示例
抽象数据类型 String
接口 + 类 ListArrayList 注 1
枚举 DayOfWeek 注 2
创建操作 构造函数 ArrayList()
静态(工厂)方法 Collections.<wbr>singletonList(), Arrays.asList()
常量 BigInteger.ZERO 注 3
观察者操作 实例方法 List.get()
静态方法 Collections.max()
生成操作 实例方法 String.trim()
静态方法 Collections.<wbr>unmodifiableList()
改变器操作 实例方法 List.add()
静态方法 Collections.copy()
表示 private 字段

这张表中有三项尚未在本次阅读中讨论:

  1. 使用接口定义抽象数据类型。我们已经以ListArrayList为例,我们将在未来的阅读中讨论接口。

  2. 使用枚举(enum)定义抽象数据类型。我们在基本 Java中提到了枚举类型,并且您可以在Java 教程中阅读有关它们的深奥细节。枚举类型非常适合具有一组固定值的 ADT。

  3. 使用常量对象作为创建操作。这种模式通常在不可变类型中看到,其中类型的最简单或最空的值只是一个公共常量,而生产者用于从中构建更复杂的值。

测试抽象数据类型

我们通过为其每个操作创建测试来为抽象数据类型构建测试套件。这些测试不可避免地相互交互。测试创建者、生产者和变更者的唯一方法是在结果对象上调用观察者,同样,测试观察者的唯一方法是为其创建对象以供观察。

这是我们如何可能将MyString类型的四个操作的输入空间进行分区的方式:

// testing strategy for each operation of MyString:
//
// valueOf():
//    true, false
// length(): 
//    string len = 0, 1, n
//    string = produced by valueOf(), produced by substring()
// charAt(): 
//    string len = 1, n
//    i = 0, middle, len-1
//    string = produced by valueOf(), produced by substring()
// substring():
//    string len = 0, 1, n
//    start = 0, middle, len
//    end = 0, middle, len
//    end-start = 0, n
//    string = produced by valueOf(), produced by substring()

然后,一个涵盖所有这些分区的紧凑测试套件可能如下所示:

@Test public void testValueOfTrue() {
    MyString s = MyString.valueOf(true);
    assertEquals(4, s.length());
    assertEquals('t', s.charAt(0));
    assertEquals('r', s.charAt(1));
    assertEquals('u', s.charAt(2));
    assertEquals('e', s.charAt(3));
}

@Test public void testValueOfFalse() {
    MyString s = MyString.valueOf(false);
    assertEquals(5, s.length());
    assertEquals('f', s.charAt(0));
    assertEquals('a', s.charAt(1));
    assertEquals('l', s.charAt(2));
    assertEquals('s', s.charAt(3));
    assertEquals('e', s.charAt(4));
}

@Test public void testEndSubstring() {
    MyString s = MyString.valueOf(true).substring(2, 4);
    assertEquals(2, s.length());
    assertEquals('u', s.charAt(0));
    assertEquals('e', s.charAt(1));
}

@Test public void testMiddleSubstring() {
    MyString s = MyString.valueOf(false).substring(1, 2);
    assertEquals(1, s.length());
    assertEquals('a', s.charAt(0));
}

@Test public void testSubstringIsWholeString() {
    MyString s = MyString.valueOf(false).substring(0, 5);
    assertEquals(5, s.length());
    assertEquals('f', s.charAt(0));
    assertEquals('a', s.charAt(1));
    assertEquals('l', s.charAt(2));
    assertEquals('s', s.charAt(3));
    assertEquals('e', s.charAt(4));
}

@Test public void testSubstringOfEmptySubstring() {
    MyString s = MyString.valueOf(false).substring(1, 1).substring(0, 0);
    assertEquals(0, s.length());
}

尝试将每个测试用例与其覆盖的分区匹配。

请注意,每个测试用例通常调用一些创建修改类型对象的操作(创建者、生产者、变更者)以及一些检查类型对象的操作(观察者)。因此,每个测试用例涵盖了几个操作的部分。

阅读练习

这些问题使用以下数据类型:

/** Immutable datatype representing a student's progress through school. */
class Student {

    /** make a freshman */
    public Student() { ... }

    /** @return a student promoted to the next year, i.e.
           freshman returns a sophomore, 
           sophomore returns a junior,
           junior returns a senior,
           senior returns an alum,
           alum stays an alum and can't be promoted further. */   
    public Student promote() { ... }

    /** @return number of years of school completed, i.e.
           0 for a freshman, 4 for an alum */
    public int getYears() { ... }

}

对 ADT 操作进行分区(缺少答案)

(缺少解释)

(缺少答案)

(缺少解释)

(缺少答案)

(缺少解释)

选择 ADT 测试用例

以下每个方法调用序列都是一个单独的测试用例。请注意,我们省略了每个方法调用的对象以及关于其返回值的断言的细节。

这些测试用例中,哪一个单独就可以覆盖上述所有分区?有多个答案可行。选择所有正确答案。

(缺少答案)

(缺少解释)

(缺少答案)

(缺少解释)

(缺少答案)

(缺少解释)

(缺少答案)

(缺少解释)

(缺少答案)

(缺少解释)

摘要

  • 抽象数据类型的特征在于其操作。

  • 操作可以分为创建者、生产者、观察者和变更者。

  • ADT 的规范是其操作集合及其规范。

  • 一个良好的 ADT 是简单、连贯、充分和与表示无关的。

  • 一个 ADT 通过为其每个操作生成测试来进行测试,但在相同的测试中同时使用创建者、生产者、变异器和观察者。

这些想法与我们对好软件的三个关键属性相连接:

  • 免受错误的影响。 一个好的 ADT 为数据类型提供了一个明确定义的契约,使客户端知道可以从数据类型中期望什么,而实现者则有明确定义的自由来变化。

  • 易于理解。 一个好的 ADT 将其实现隐藏在一组简单的操作后面,这样使用 ADT 的程序员只需要理解这些操作,而不需要了解实现的细节。

  • 为变化做好准备。 表示独立性允许抽象数据类型的实现发生变化,而不需要其客户端进行更改。

阅读 12:抽象函数和表示不变性

6.005 中的软件

免于错误 易于理解 准备好变化
今天正确,未来也正确。 与未来的程序员清晰沟通,包括未来的你。 设计以适应变化而无需重写。

目标

今天的阅读介绍了几个想法:

  • 不变性

  • 表示曝光

  • 抽象函数

  • 表示不变性

在这篇阅读中,我们研究了一个更正式的数学概念,即类如何实现 ADT,通过抽象函数表示不变性的概念。 这些数学概念在软件设计中非常实用。 抽象函数将为我们提供一种清晰地定义抽象数据类型上的相等操作的方式(我们将在未来的课程中更深入地讨论)。 表示不变性将使我们更容易捕捉由损坏的数据结构引起的错误。

不变性

▶ 播放 MITx 视频

继续我们关于什么构成良好的抽象数据类型的讨论,一个良好的抽象数据类型的最终,也许是最重要的特性是它保持自身的不变性不变性 是程序的一个属性,对于程序的每一个可能的运行时状态,它总是成立的。 不变性是我们已经遇到的一个至关重要的不变性:一旦创建,一个不可变对象应始终表示相同的值,直到其整个生命周期。说 ADT 保持自身的不变性 意味着 ADT 负责确保其自身的不变性保持不变。 它不依赖于其客户端的良好行为。

当一个 ADT 保持自身的不变性时,对代码进行推理就变得更容易。 如果你可以确信字符串永远不会改变,那么当你调试使用字符串的代码时,或者当你试图为使用字符串的另一个 ADT 建立不变性时,你就可以排除这种可能性。 与一个只在其客户端承诺不更改它时才保证它是不可变的字符串类型相比。 然后你就必须检查代码中可能使用字符串的所有地方。

不变性

在这篇阅读的后面,我们将看到许多有趣的不变性。 现在让我们专注于不变性。 这里有一个具体的例子:

/**
 * This immutable data type represents a tweet from Twitter.
 */
public class Tweet {

    public String author;
    public String text;
    public Date timestamp;

    /**
     * Make a Tweet.
     * @param author    Twitter user who wrote the tweet
     * @param text      text of the tweet
     * @param timestamp date/time when the tweet was sent
     */
    public Tweet(String author, String text, Date timestamp) {
        this.author = author;
        this.text = text;
        this.timestamp = timestamp;
    }
}

我们如何确保这些 Tweet 对象是不可变的——即,一旦创建了一条推文,其作者、消息和日期就永远不能更改?

不变性的第一个威胁来自于客户端可以——事实上必须——直接访问其字段的事实。 因此,没有什么能阻止我们编写这样的代码:

Tweet t = new Tweet("justinbieber", 
                    "Thanks to all those beliebers out there inspiring me every day", 
                    new Date());
t.author = "rbmllr";

这是表示暴露的一个微不足道的例子,意味着类外部的代码可以直接修改表示。这种 rep 暴露不仅威胁不变性,还威胁表示独立性。我们无法更改 Tweet 的实现而不影响直接访问这些字段的所有客户端。

幸运的是,Java 为我们提供了处理这种 rep 暴露的语言机制:

public class Tweet {

    private final String author;
    private final String text;
    private final Date timestamp;

    public Tweet(String author, String text, Date timestamp) {
        this.author = author;
        this.text = text;
        this.timestamp = timestamp;
    }

    /** @return Twitter user who wrote the tweet */
    public String getAuthor() {
        return author;
    }

    /** @return text of the tweet */
    public String getText() {
        return text;
    }

    /** @return date/time when the tweet was sent */
    public Date getTimestamp() {
        return timestamp;
    }

}

privatepublic关键字指示哪些字段和方法仅在类内部可访问,哪些可以从类外部访问。final关键字还有助于保证这种不可变类型的字段在对象构造后不会被重新分配。

retweetLater 破坏了 Tweet 的不可变性

但这还不是故事的结局:rep 仍然暴露!考虑这段使用Tweet的完全合理的客户端代码:

/** @return a tweet that retweets t, one hour later*/
public static Tweet retweetLater(Tweet t) {
    Date d = t.getTimestamp();
    d.setHours(d.getHours()+1);
    return new Tweet("rbmllr", t.getText(), d);
}

retweetLater接受一个 tweet,并应该返回另一个消息相同(称为转推)但晚一个小时发送的 tweet。retweetLater方法可能是一个自动回声 Twitter 名人说的有趣事情的系统的一部分。

这里的问题是什么?getTimestamp调用返回了一个对 tweet t引用的相同Date对象的引用。t.timestampd是指向同一可变对象的别名。因此,当该日期对象被d.setHours()改变时,这也会影响到t中的日期,如快照图中所示。

Tweet的不可变性不变式已经被破坏。问题在于Tweet泄漏了对一个可变对象的引用,其不可变性依赖于此。我们暴露了 rep,以至于Tweet无法再保证其对象是不可变的。完全合理的客户端代码造成了一个微妙的错误。

我们可以通过使用防御性复制来修补这种 rep 暴露:复制可变对象以避免泄漏对 rep 的引用。以下是代码:

public Date getTimestamp() {
    return new Date(timestamp.getTime());
}

可变类型通常有一个复制构造函数,允许您创建一个新实例,复制现有实例的值。在这种情况下,Date的复制构造函数使用自 1970 年 1 月 1 日以来以毫秒为单位测量的时间戳值。另一个例子,StringBuilder的复制构造函数接受一个String。复制可变对象的另一种方法是clone(),它受到一些类型支持,但并非所有类型都支持。在 Java 中,clone()的工作方式存在一些不幸的问题。更多信息,请参见 Josh Bloch 的Effective Java,第 11 项。

tweetEveryHourToday 破坏了 Tweet 的不可变性

因此,在getTimestamp的返回值中进行了一些防御性复制。但我们还没有完成!仍然存在 rep 暴露。考虑这段(再次完全合理的)客户端代码:

/** @return a list of 24 inspiring tweets, one per hour today */
public static List<Tweet> tweetEveryHourToday () {
    List<Tweet> list = new ArrayList<Tweet>(); 
    Date date = new Date();
    for (int i = 0; i < 24; i++) {
        date.setHours(i);
        list.add(new Tweet("rbmllr", "keep it up! you can do it", date));
    } 
    return list;
}

这段代码旨在通过一天的 24 小时推进单个Date对象,为每个小时创建一条推文。但请注意,Tweet 的构造函数保存了传入的引用,因此所有 24 个 Tweet 对象最终都具有相同的时间,如此快照图所示。

再次,Tweet 的不可变性已经被违反。我们也可以通过谨慎的防御性复制来解决这个问题,这次在构造函数中:

public Tweet(String author, String text, Date timestamp) {
    this.author = author;
    this.text = text;
    this.timestamp = new Date(timestamp.getTime());
}

通常,你应该仔细检查所有 ADT 操作的参数类型和返回类型。如果任何类型是可变的,请确保你的实现不返回对其表示的直接引用。这样做会暴露 rep。

你可能会认为这样做很浪费。为什么要复制所有这些日期?为什么我们不能通过一个精心编写的规范来解决这个问题,比如这样?

/**
 * Make a Tweet.
 * @param author    Twitter user who wrote the tweet
 * @param text      text of the tweet
 * @param timestamp date/time when the tweet was sent. Caller must never 
 *                   mutate this Date object again!
 */
public Tweet(String author, String text, Date timestamp) {

当没有其他合理的选择时,有时会采用这种方法——例如,当可变对象太大而无法有效地复制时。但是,这样做的代价是你对程序的推理能力和避免错误的能力是巨大的。在没有令人信服的反对意见的情况下,一个抽象数据类型几乎总是值得保证其自己的不变量,防止 rep 暴露对此至关重要。

更好的解决方案是更喜欢不可变类型。如果——正如可变性与不可变性中的《土拨鼠日》示例建议的那样——我们使用了不可变的日期对象,比如java.time.ZonedDateTime,而不是可变的java.util.Date,那么在讨论完publicprivate后,我们会结束这一节。没有进一步的 rep 暴露会发生。

不可变包装器围绕可变数据类型

Java 集合类提供了一个有趣的折衷方案:不可变包装器。

Collections.unmodifiableList()接受一个(可变的)List并用看起来像一个List的对象包装它,但其 mutator 被禁用——set()add()remove()等会抛出异常。因此,你可以使用 mutator 构造一个列表,然后将其封装在一个不可修改的包装器中(并且丢弃对原始可变列表的引用,如可变性与不可变性中所讨论的),并获得一个不可变的列表。

这里的缺点是你在运行时获得了不可变性,但在编译时却没有。如果你尝试对这个不可修改的列表进行sort(),Java 不会在编译时警告你。你只会在运行时得到一个异常。但这仍然比没有好,因此使用不可修改的列表、映射和集合可以是减少错误风险的一种非常好的方式。

读取练习

Rep 暴露

考虑以下问题数据类型:

 /** Represents an immutable right triangle. */
      class RightTriangle {
/*A*/     private double[] sides;

          // sides[0] and sides[1] are the two legs,
          // and sides[2] is the hypotenuse, so declare it to avoid having a
          // magic number in the code:
/*B*/     public static final int HYPOTENUSE = 2;

          /** Make a right triangle.
           * @param legA, legB  the two legs of the triangle
           * @param hypotenuse    the hypotenuse of the triangle.
 *C*       *        Requires hypotenuse² = legA² + legB² 
           *           (within the error tolerance of double arithmetic)
           */
          public RightTriangle(double legA, double legB, double hypotenuse) {
/*D*/         this.sides = new double[] { legA, legB, hypotenuse };
          }

          /** Get all the sides of the triangle.
           *  @return three-element array with the triangle's side lengths
           */
          public double[] getAllSides() {
/*E*/         return sides;
          }

          /** @return length of the triangle's hypotenuse */ 
          public double getHypotenuse() {
              return sides[HYPOTENUSE];
          }

          /** @param factor to multiply the sides by
           *  @return a triangle made from this triangle by 
           *  multiplying all side lengths by factor.
           */
          public RightTriangle scale(double factor) {
              return new RightTriangle(sides[0]*factor, sides[1]*factor, sides[2]*factor);
          }

          /** @return a regular triangle made from this triangle.
           *  A regular right triangle is one in which
           *  both legs have the same length.
           */
          public RightTriangle regularize() {
              double bigLeg = Math.max(side[0], side[1]);
              return new RightTriangle (bigLeg, bigLeg, side[2]);
          }

      }

(缺少答案)

(缺少解释)

(缺少答案)

(缺少解释)

(缺少答案)

(缺少解释)

(缺少答案)

(缺少解释)

(缺少答案)

(缺少解释)

Rep 不变式和抽象函数

▶ 播放 MITx 视频

现在让我们深入研究抽象数据类型的理论基础。这个理论不仅本身优雅而且有趣;它还直接应用于抽象类型的设计和实现。如果你深刻理解了这个理论,你将能够构建更好的抽象类型,并且不太可能陷入微妙的陷阱。

在考虑抽象类型时,考虑两个值空间之间的关系会有所帮助。

表示值空间(或简称为 rep 值)由实际实现实体的值组成。在简单的情况下,抽象类型将被实现为单个对象,但更常见的是需要一个小型对象网络,因此此值实际上通常是相当复杂的。不过,就目前而言,将其简单地视为数学值就足够了。

抽象值空间由类型设计支持的值组成。这些是我们想象的东西。它们是不存在的柏拉图实体,但它们是我们想要以抽象类型的客户端的方式来看待抽象类型元素的方式。例如,用于无界整数的抽象类型可能将数学整数作为其抽象值空间;它可能被实现为原始(有界)整数的数组,例如,这一事实与类型的用户无关。

当然,抽象类型的实现者必须对表示值感兴趣,因为实现者的工作是使用 rep 值空间实现抽象值空间的错觉。

例如,假设我们选择使用字符串来表示一组字符:

public class CharSet {
    private String s;
    ...
}

CharSet 的抽象空间和表示空间

然后表示空间 R 包含字符串,抽象空间 A 是数学字符集。我们可以用图形方式显示两个值空间,从 rep 值到它表示的抽象值的弧。关于这张图片有几点需要注意:

  • 每个抽象值都有其对应的表示值。实现抽象类型的目的是支持对抽象值进行操作。因此,我们可能需要能够创建和操作所有可能的抽象值,因此它们必须是可表示的。

  • 有些抽象值被多个表示值映射到。这是因为表示不是紧密编码的。有多种方法将无序字符集表示为字符串。

  • 并非所有的 rep 值都被映射。在这种情况下,字符串“abbc”没有被映射。在这种情况下,我们决定字符串不应该包含重复项。这将使我们能够在遇到特定字符的第一个实例时终止删除方法,因为我们知道最多只能有一个。

在实践中,我们只能说明两个空间及其关系的一些元素;整个图是无限的。因此,我们通过提供两个内容来描述它:

1. 将 rep 值映射到它们表示的抽象值的抽象函数

AF:R → A

图中的弧线显示了抽象函数。在函数术语中,我们上面讨论的属性可以通过说函数是满射(也称为)、不一定是单射(一对一)以及因此不一定是双射,通常是部分的方式来表达。

2. 将 rep 值映射到布尔值的rep 不变量

RI:R → 布尔值

对于 rep 值 rRI(r) 当且仅当 rAF 映射时为真。换句话说,RI 告诉我们给定的 rep 值是否合法。或者,你可以将 RI 看作一个集合:它是 AF 定义的 rep 值的子集。

使用 NoRepeatsRep 的 CharSet 的抽象空间和 rep 空间

在代码中,不变量和抽象函数应该与 rep 的声明紧邻:

public class CharSet {
    private String s;
    // Rep invariant:
    //   s contains no repeated characters
    // Abstraction Function:
    //   represents the set of characters found in s
    ...
}

关于抽象函数和 rep 不变量的一个常见误解是它们由 rep 和抽象值空间的选择,甚至仅由抽象值空间确定。如果是这样,它们将毫无用处,因为它们将重复地表达一些已经在其他地方可用的内容。

单靠抽象值空间无法确定 AF 或 RI:相同抽象类型可能有多种表示方法。一组字符可以等同地表示为一个字符串,如上所示,也可以表示为一个位向量,每个可能的字符对应一个位。显然,我们需要两个不同的抽象函数来映射这两个不同的 rep 值空间。

选择两个空间都无法确定 AF 和 RI 不太明显。关键点在于,为 rep 定义类型,从而选择 rep 值空间的值,并不确定哪些 rep 值将被视为合法,以及其中合法的值将如何解释。与我们上面所做的决定字符串没有重复不同,我们可以允许重复,但同时要求字符按非递减顺序排序。这样可以让我们在字符串上执行二分搜索,从而以对数时间而不是线性时间检查成员资格。相同的 rep 值空间 - 不同的 rep 不变量:

使用 SortedRep 的 CharSet 的抽象空间和 rep 空间

public class CharSet {
    private String s;
    // Rep invariant:
    //   s[0] <= s[1] <= ... <= s[s.length()-1]
    // Abstraction Function:
    //   represents the set of characters found in s
    ...
}

在这个表示法中,哪些 rep 值映射到抽象值 {a,b,c}

即使对于表示值空间和相同的表示不变式 RI 使用相同类型,我们仍然可能以不同的方式解释表示,使用不同的抽象函数 AF。假设 RI 允许任意字符串。那么我们可以定义 AF,如上所述,将数组的元素解释为集合的元素。但是没有 先验 理由让表示决定解释。也许我们将连续的字符对解释为子范围,这样字符串表示 "acgg" 就被解释为两个范围对,[a-c] 和 [g-g],因此表示了集合 {a,b,c,g}。以下是该表示的 AF 和 RI 的样子:

使用 SortedRangeRep 的 CharSet 的抽象空间和表示空间

public class CharSet {
    private String s;
    // Rep invariant:
    //   s.length is even
    //   s[0] <= s[1] <= ... <= s[s.length()-1]
    // Abstraction Function:
    //   represents the union of the ranges
    //   {s[i]...s[i+1]} for each adjacent pair of characters 
    //   in s
    ...
}

在这个表示法中,rep 值映射到抽象值 {a,c} 的是什么?

关键点在于,设计抽象类型意味着不仅选择两个空间 - 用于规范的抽象值空间和用于实现的表示值空间 - 还要决定使用什么表示值以及如何解释它们

在你的代码中写下这些假设是至关重要的,就像我们上面做的那样,这样未来的程序员(以及你未来的自己)就会意识到表示实际上意味着什么。为什么?如果不同的实现者对表示的含义有不同的意见会发生什么?

您可以在 GitHub 上找到 三种不同的 CharSet 实现的示例代码

阅读练习

谁知道什么?(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

表示不变式的部分

假设 C 是一个抽象数据类型,其表示具有两个 String 字段:

class C {
    private String s;
    private String t;
    ...
}

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

尝试在没有 AF/RI 的情况下实现

假设路易斯·理查德创建了具有以下表示的 CharSet

public class CharSet {
    private String s;
    ...
}

但是,路易斯不幸地忘记了写下抽象函数(AF)和表示不变式(RI)。以下是路易斯可能考虑过的四对可能的 AF/RI,这些也都在上面的阅读中提到过。

SortedRep:

// AF: represents the set of characters found in s
// RI: s[0] < s[1] < ... < s[s.length()-1]

SortedRangeRep:

// AF: represents the union of the ranges {s[i]...s[i+1]} for each adjacent pair of characters in s
// RI: s.length is even, and s[0] < s[1] < ... < s[s.length()-1]

NoRepeatsRep:

// AF: represents the set of characters found in s
// RI: s contains no character more than once

AnyRep:

// AF: represents the set of characters found in s
// RI: true

路易斯有三名队友帮助他实现 CharSet,每人负责不同的操作:add()remove()contains()。他们的实现如下。哪种可能的 AF/RI 对与每个程序员的实现一致?

public void add(char c) {
    s = s + c;
}

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

尝试在没有 AF/RI 的情况下实现 #2

public void remove(char c) {
    int position = s.indexOf(c);
    if (position >= 0) {
        s = s.substring(0, position) + s.substring(position+1, s.length());
    }
}

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

尝试在没有 AF/RI 的情况下实现 #3

public boolean contains(char c) {
    for (int i = 0; i < s.length(); i += 2) {
        char low = s.charAt(i);
        char high = s.charAt(i+1);
        if (low <= c && c <= high) {
            return true;
        }
    }
    return false;
}

(缺失回答)(缺失回答)(缺失回答)(缺失回答)

(缺失解释)

示例:有理数

这是一个有理数的抽象数据类型的示例。仔细看看它的表示不变性和抽象函数。

public class RatNum {

    private final int numer;
    private final int denom;

    // Rep invariant:
    //   denom > 0
    //   numer/denom is in reduced form

    // Abstraction Function:
    //   represents the rational number numer / denom

    /** Make a new RatNum == n.
     *  @param n value */
    public RatNum(int n) {
        numer = n;
        denom = 1;
        checkRep();
    }

    /** Make a new RatNum == (n / d).
     *  @param n numerator
     *  @param d denominator
     *  @throws ArithmeticException if d == 0 */
    public RatNum(int n, int d) throws ArithmeticException {
        // reduce ratio to lowest terms
        int g = gcd(n, d);
        n = n / g;
        d = d / g;

        // make denominator positive
        if (d < 0) {
            numer = -n;
            denom = -d;
        } else {
            numer = n;
            denom = d;
        }
        checkRep();
    }
}

RatNum 的抽象函数和表示不变性

这是这段代码的抽象函数和表示不变性的图片。RI 要求分子/分母对必须处于简化形式(即最低项),因此像(2,4)和(18,12)这样的对应关系应该绘制在 RI 之外。

完全可以设计另一个实现这个相同 ADT 的实现,其 RI 更为宽松。通过这样的变化,一些操作可能会变得更加昂贵,而另一些则更便宜。

检查表示不变性

▶ 播放 MITx 视频

表示不变性不仅仅是一个巧妙的数学想法。如果你的实现在运行时断言表示不变性,那么你可以及早捕获错误。这里有一个用于RatNum的方法,用于测试其表示不变性:

// Check that the rep invariant is true
// *** Warning: this does nothing unless you turn on assertion checking
// by passing -enableassertions to Java
private void checkRep() {
    assert denom > 0;
    assert gcd(numer, denom) == 1;
}

你应该在每个创建或改变表示的操作结束时调用checkRep()来断言表示不变性 - 换句话说,创建者,生产者和改变者。回顾上面的RatNum代码,你会看到它在两个构造函数的末尾都调用了checkRep()

观察者方法通常不需要调用checkRep(),但无论如何这样做都是良好的防御性做法。为什么?在每个方法中调用checkRep(),包括观察者,意味着你更有可能捕获由于表示曝光导致的表示不变性违规。

为什么checkRep是私有的?谁应该负责检查和强制表示不变性 - 客户端,还是实现本身?

表示中没有空值

规格阅读中回想起,空值是麻烦的和不安全的,以至于我们尽量完全将其从我们的编程中移除。在 6.005 中,我们方法的前提条件和后置条件隐含地要求对象和数组是非空的。

我们将这种禁止扩展到抽象数据类型的表示。默认情况下,在 6.005 中,RI 隐含地包括对于表示中的每个具有对象类型的引用x != null。因此,如果你的表示是:

class CharSet {
    String s;
}

那么它的表示不变性自动包含s != null,你不需要在表示不变性注释中说明它。

当到了在checkRep()方法中实现 rep invariant 的时候,你仍然必须实现s != null的检查,并确保当snull时,你的checkRep()能够正确地失败。通常这个检查在 Java 中是免费的,因为检查 rep invariant 的其他部分会在s为 null 时抛出异常。例如,如果你的checkRep()看起来像这样:

private void checkRep() {
    assert s.length() % 2 == 0;
    ...
}

那么你就不需要assert s!= null,因为对null引用调用s.length()将同样有效地失败。但是如果s没有被你的表示不变量(rep invariant)检查,那么就要明确地断言assert s != null

阅读练习

检查 rep invariant(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

文档化 AF、RI 和免于 Rep 暴露的安全性

▶ 播放 MITx 视频

在类中使用注释记录抽象函数和 rep invariant 是一个好的做法,就在 rep 的私有字段声明的地方。我们之前一直在做这个。

当你描述 rep invariant 和抽象函数时,你必须要准确:

  • RI 不能仅仅是一个类似“所有字段都有效”的通用陈述。rep invariant 的工作是准确解释字段值是什么使其有效或无效。

  • AF 不能仅仅提供一个类似“表示一组字符”的通用解释是不够的。抽象函数的工作是精确定义具体字段值如何被解释。作为一个函数,如果我们采用文档化的 AF 并替换实际(合法)的字段值,我们应该得到一个完整描述它们所代表的单个抽象值。

6.005 要求你写的另一部分文档是rep 暴露安全性参数。这是一个注释,检查 rep 的每个部分,查看处理该 rep 部分的代码(特别是关于客户端参数和返回值的代码,因为这是 rep 暴露发生的地方),并说明为什么代码不会暴露 rep。

这里有一个Tweet的例子,其 rep invariant、抽象函数和免于 rep 暴露的安全性完全被记录:

// Immutable type representing a tweet.
public class Tweet {

    private final String author;
    private final String text;
    private final Date timestamp;

    // Rep invariant:
    //   author is a Twitter username (a nonempty string of letters, digits, underscores)
    //   text.length <= 140
    // Abstraction Function:
    //   represents a tweet posted by author, with content text, at time timestamp 
    // Safety from rep exposure:
    //   All fields are private;
    //   author and text are Strings, so are guaranteed immutable;
    //   timestamp is a mutable Date, so Tweet() constructor and getTimestamp() 
    //        make defensive copies to avoid sharing the rep's Date object with clients.

    // Operations (specs and method bodies omitted to save space)
    public Tweet(String author, String text, Date timestamp) { ... }
    public String getAuthor() { ... }
    public String getText() { ... }
    public Date getTimestamp() { ... }
}

注意我们对timestamp没有任何明确的 rep invariant 条件(除了传统的假设timestamp!=null,对所有对象引用都适用)。但是我们仍然需要在 rep 暴露安全性参数中包括timestamp,因为整个类型的不可变性属性取决于所有字段保持不变。

将上述参数与涉及可变Date对象的破碎参数的示例进行比较。

这里是RatNum的参数。

// Immutable type representing a rational number.
public class RatNum {
    private final int numer;
    private final int denom;

    // Rep invariant:
    //   denom > 0
    //   numer/denom is in reduced form, i.e. gcd(|numer|,denom) = 1
    // Abstraction Function:
    //   represents the rational number numer / denom
    // Safety from rep exposure:
    //   All fields are private, and all types in the rep are immutable.

    // Operations (specs and method bodies omitted to save space)
    public RatNum(int n) { ... }
    public RatNum(int n, int d) throws ArithmeticException { ... }
    ...
}

注意,不可变的 rep 特别容易证明免于 rep 暴露的安全性。

您可以在 GitHub 上找到RatNum的完整代码

阅读练习

反对表示暴露的论点

考虑以下 ADT:

// Mutable type representing Twitter users' followers.
public class FollowGraph {
    private final Map<String,Set<String>> followersOf;

    // Rep invariant:
    //    all Strings in followersOf are Twitter usernames
    //           (i.e., nonempty strings of letters, digits, underscores)
    //    no user follows themselves, i.e. x is not in followersOf.get(x)
    // Abstraction function:
    //    represents the follower graph where Twitter user x is followed by user y
    //       if and only if followersOf.get(x).contains(y)
    // Safety from rep exposure:
    //    All fields are private, and ..???..

    // Operations (specs and method bodies omitted to save space)
    public FollowGraph() { ... }
    public void addFollower(String user, String follower) { ... }
    public void removeFollower(String user, String follower) { ... }
    public Set<String> getFollowers(String user) { ... }
}

对于下面的每个语句:假设省略的方法体与语句一致,我们能否使用语句来替换..???..以进行有说服力的安全性防范暴露评论?

1. “字符串是不可变的。”

(缺少答案)(缺少答案)

(缺少解释)

2.followersOf 是一个包含可变Set对象的可变Map,但 getFollowers() 对其返回的Set进行了防御性复制,而所有其他参数和返回值均为不可变的Stringvoid。”

(缺少答案)(缺少答案)

(缺少解释)

3. “这个类是可变的,所以表示暴露不是问题。”

(缺少答案)(缺少答案)

(缺少解释)

4.followersOf 是可变的 Map,但从未从操作中传递或返回。”

(缺少答案)(缺少答案)

(缺少解释)

5.FollowGraph()不暴露表示;addFollower()不暴露表示;removeFollower()不暴露表示;getFollowers()不暴露表示。”

(缺少答案)(缺少答案)

(缺少解释)

6.String 是不可变的,而 Set 对象在表示中通过不可修改的包装器变为不可变。Map 类型是可变的,但从未从操作中传递或返回该类型。”

(缺少答案)(缺少答案)

(缺少解释)

如何建立不变式

▶ 播放 MITx 视频

不变式是程序整体都成立的属性——对于关于对象的不变式来说,这归结为对象的整个生命周期。

要使不变式成立,我们需要:

  • 使对象的初始状态中的不变式为真;和

  • 确保对对象的所有更改保持不变式为真。

将其翻译为 ADT 操作的类型,则意味着:

  • 创建者和制作者必须为新对象实例建立不变式;和

  • 变更器和观察者必须保持不变式。

表示暴露的风险使情况变得更加复杂。如果表示被暴露,那么对象可能在程序中的任何位置被更改,而不仅仅在 ADT 的操作中,我们无法保证在这些任意更改之后不变式仍然成立。因此,证明不变式的完整规则是:

结构归纳。如果抽象数据类型的不变式为真

  1. 由创建者和制作者建立;

  2. 被变更器和观察者保留;和

  3. 没有表示暴露发生,

那么不变式就对抽象数据类型的所有实例都成立。

阅读练习

结构归纳

回想一下在本次阅读中的第一个练习中的此数据类型:

/** Represents an immutable right triangle. */
class RightTriangle {
    private double[] sides;
    // RI: ???
    // AF: ???

    // sides[0] and sides[1] are the two legs,
    // and sides[2] is the hypotenuse, so declare it to avoid having a
    // magic number in the code:
    public static final int HYPOTENUSE = 2;

    /** Make a right triangle.
     * @param legA, legB  the two legs of the triangle
     * @param hypotenuse    the hypotenuse of the triangle.
     *        Requires hypotenuse² = legA² + legB² 
     *           (within the error tolerance of double arithmetic)
     */
    public RightTriangle(double legA, double legB, double hypotenuse) {
        this.sides = new double[] { legA, legB, hypotenuse };
    }

    /** Get all the sides of the triangle.
     *  @return three-element array with the triangle's side lengths
     */
    public double[] getAllSides() {
        return sides;
    }

    /** @return length of the triangle's hypotenuse */ 
    public double getHypotenuse() {
        return sides[HYPOTENUSE];
    }

    /** @param factor to multiply the sides by
     *  @return a triangle made from this triangle by 
     *  multiplying all side lengths by factor.
     */
    public RightTriangle scale(double factor) {
        return new RightTriangle (sides[0]*factor, sides[1]*factor, sides[2]*factor);
    }

    /** @return a regular triangle made from this triangle.
     *  A regular right triangle is one in which
     *  both legs have the same length.
     */
    public RightTriangle regularize() {
        double bigLeg = Math.max(side[0], side[1]);
        return new RightTriangle (bigLeg, bigLeg, side[2]);
    }

}

此数据类型有一个重要的不变量:根据毕达哥拉斯定理所述的腿和斜边之间的关系。

(缺失答案)

(缺失解释)

(缺失答案)

(缺失解释)

(缺失答案)

(缺失解释)

(缺失答案)

(缺失解释)

(缺失答案)

(缺失解释)

ADT 不变量取代前置条件

▶ 播放 MITx 视频

现在让我们把许多片段组合在一起。 设计良好的抽象数据类型的一个巨大优势是它封装并强制执行我们否则必须在前置条件中规定的属性。 例如,与其具有复杂的前置条件的规范,如下所示:

/** 
 * @param set1 is a sorted set of characters with no repeats
 * @param set2 is likewise
 * @return characters that appear in one set but not the other,
 *  in sorted order with no repeats 
 */
static String exclusiveOr(String set1, String set2);

相反,我们可以使用捕捉所需属性的 ADT:

/** @return characters that appear in one set but not the other */
static SortedSet<Character> exclusiveOr(SortedSet<Character>  set1, SortedSet<Character> set2);

这更容易理解,因为 ADT 的名称传达了程序员需要了解的所有信息。 它也更安全免受错误的影响,因为 Java 静态检查会发挥作用,并且所需的条件(排序且无重复)可以在一个地方——SortedSet 类型中强制执行。

在问题集中我们使用前置条件的许多地方本可以受益于使用自定义 ADT。

阅读练习

将前置条件封装在 ADT 中

考虑这种方法:

/**
 * Find tweets written by a particular user.
 * 
 * @param tweets a list of tweets with distinct timestamps, not modified by this method.
 * @param username Twitter username (a nonempty sequence of letters, digits, and underscore)
 * @return all and only the tweets in the list whose author is username,
 *         in the same order as in the input list.
 */
public static List<Tweet> writtenBy(List<Tweet> tweets, String username) { ... }

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

摘要

  • 不变量是一个在 ADT 对象实例的生命周期内始终为真的属性。

  • 一个良好的 ADT 保留了自己的不变量。 不变量必须由创建者和生产者建立,并由观察者和变异器保持。

  • rep 不变量指定了表示的合法值,并且应该使用 checkRep() 在运行时进行检查。

  • 抽象函数将具体表示映射到它表示的抽象值。

  • 表示暴露威胁表示独立性和不变性的保留。

今天阅读的主题与我们的三个好软件属性相关如下:

  • 免受错误的影响。 一个良好的 ADT 保留了自己的不变量,因此这些不变量在 ADT 的客户端中更不容易受到错误的影响,并且在 ADT 自身的实现中更容易地隔离违反不变量。 明确陈述 rep 不变量,并使用 checkRep() 在运行时进行检查,可以更早地捕获误解和错误,而不是继续使用已损坏的数据结构。

  • 易于理解。 rep 不变量和抽象函数阐明了数据类型表示的含义,以及它与抽象的关系。

  • 易于变更。 抽象数据类型将抽象与具体表示分离,这使得可以在不必更改客户端代码的情况下更改表示。

阅读 13:接口

6.005 中的软件

免于错误 易于理解 为变化做好准备
今天正确的,未来也正确。 与未来的程序员清晰沟通,包括未来的你。 设计以适应变化而无需重写。

目标

今天课程的主题是接口:将抽象数据类型的接口与其实现分离,并使用 Java 的 interface 类型来强制执行该分离。

今天的课程结束后,你应该能够使用接口定义 ADTs,并编写实现接口的类。

接口

▶︎ 播放 MITx 视频

Java 的 interface 是一种表达抽象数据类型的有用语言机制。在 Java 中,接口是方法签名的列表,但没有方法体。如果类在其 implements 子句中声明了接口,并为接口的所有方法提供了方法体,那么该类就 实现 了该接口。因此,在 Java 中定义抽象数据类型的一种方式是将其作为一个接口,其实现作为实现该接口的类。

这种方法的一个优点是接口为客户端指定了合同,而不再有其他。接口是客户端程序员需要阅读以了解 ADT 的全部内容。客户端不能对 ADT 的 rep 产生无意的依赖,因为实例变量根本不能放在接口中。实现被完全保持分离,完全在另一个类中。

另一个优点是,抽象数据类型的多种不同表示可以在同一程序中共存,作为实现接口的不同类。当抽象数据类型仅表示为单个类时,没有接口,很难拥有多种表示。在 Abstract Data TypesMyString 示例 中,MyString 是一个单独的类。我们探索了 MyString 的两种不同表示,但不能在同一程序中同时使用 ADT 的这两种表示。

Java 的静态类型检查允许编译器捕获许多在实现 ADT 合同时的错误。例如,省略一个必需的方法或给出错误的返回类型都会导致编译时错误。不幸的是,编译器不会检查代码是否符合文档注释中编写的这些方法的规范。

要了解如何在 Java 中定义接口的详细信息,请参阅 Java 教程中关于接口的部分

阅读练习

Java 接口

考虑这个 Java 接口和 Java 类,它们旨在实现不可变的集合数据类型:

 /** Represents an immutable set of elements of type E. */
    public interface Set<E> {
        /** make an empty set */
A       public Set();
        /** @return true if this set contains e as a member */
        public boolean contains(E e);
        /** @return a set which is the union of this and that */
B       public ArraySet<E> union(Set<E> that);    
    }

    /** Implementation of Set<E>. */
    public class ArraySet<E> implements Set<E> {
        /** make an empty set */
        public ArraySet() { ... }
        /** @return a set which is the union of this and that */
        public ArraySet<E> union(Set<E> that) { ... }
        /** add e to this set */
        public void add(E e) { ... }
    }

标记为 A 的这行是一个问题,因为 Java 接口不能有构造函数。

(缺少答案)(缺少答案)

(缺少解释)

标记为B的线是一个问题,因为Set提到了ArraySet,但ArraySet也提到了Set,这是循环的。

(缺少答案)(缺少答案)

(缺少解释)

标记为B的线是一个问题,因为它不是表示独立的。

(缺少答案)(缺少答案)

(缺少解释)

ArraySet未正确实现Set,因为缺少contains()方法。

(缺少答案)(缺少答案)

(缺少解释)

ArraySet未正确实现Set,因为它包含一个Set没有的方法。

(缺少答案)(缺少答案)

(缺少解释)

ArraySet未正确实现Set,因为ArraySet是可变的,而Set是不可变的。

(缺少答案)(缺少答案)

(缺少解释)

子类型

▶︎ 播放 MITx 视频

请记住,类型是一组值。Java List 类型由一个接口定义。如果我们考虑所有可能的List值,那么它们都不是List对象:我们无法创建接口的实例。相反,这些值都是ArrayList对象,或LinkedList对象,或实现List的另一个类的对象。子类型只是超类型的子集:ArrayListLinkedListList的子类型。

“B 是 A 的子类型”意味着“每个 B 都是 A。”就规范而言:“每个 B 都满足 A 的规范。”

这意味着只有当 B 的规范至少与 A 的规范一样强大时,B 才是 A 的子类型。当我们声明一个类来实现一个接口时,Java 编译器会自动强制执行此要求的一部分:例如,它确保 A 中的每个方法都以兼容的类型签名出现在 B 中。类 B 不能实现接口 A 而不实现 A 中声明的所有方法。

但是编译器无法检查我们是否以其他方式削弱了规范:增强某些输入方法的前置条件,减弱后置条件,减弱接口抽象类型向客户端广告的保证。如果你在 Java 中声明一个子类型 - 实现接口是我们当前的重点 - 那么你必须确保子类型的规范至少与超类型的规范一样强大。

阅读练习

不可变形状

让我们为矩形定义一个接口:

/** An immutable rectangle. */
public interface ImmutableRectangle {
    /** @return the width of this rectangle */
    public int getWidth();
    /** @return the height of this rectangle */
    public int getHeight();
}

这意味着每个正方形都是一个矩形:

/** An immutable square. */
public class ImmutableSquare {
    private final int side;
    /** Make a new side x side square. */
    public ImmutableSquare(int side) { this.side = side; }
    /** @return the width of this square */
    public int getWidth() { return side; }
    /** @return the height of this square */
    public int getHeight() { return side; }
}

ImmutableSquare.getWidth()是否满足ImmutableRectangle.getWidth()的规范?

(缺少答案)(缺少答案)

ImmutableSquare.getHeight()是否满足ImmutableRectangle.getHeight()的规范?

(缺少答案)(缺少答案)

整个ImmutableSquare规范是否满足ImmutableRectangle规范?

(缺少答案)(缺少答案)

(缺少解释)

可变形状

/** A mutable rectangle. */
public interface MutableRectangle {
    // ... same methods as above ...
    /** Set this rectangle's dimensions to width x height. */
    public void setSize(int width, int height);
}

当然,每个正方形仍然是一个矩形吗?

/** A mutable square. */
public class MutableSquare {
    private final int side;
    // ... same constructor and methods as above ...
    // TODO implement setSize(..)
}

对于下面的每个可能的MutableSquare.setSize(..)实现,它是否是一个有效的实现?

/** Set this square's dimensions to width x height.
 *  Requires width = height. */
public void setSize(int width, int height) { ... }

(缺失答案)

(缺失解释)

/** Set this square's dimensions to width x height.
 *  @throw BadSizeException if width != height */
public void setSize(int width, int height) throws BadSizeException { ... }

(缺失答案)

(缺失解释)

/** If width = height, set this square's dimensions to width x height.
 *  Otherwise, new dimensions are unspecified. */
public void setSize(int width, int height) { ... }

(缺失答案)

(缺失解释)

/** Set this square's dimensions to side x side. */
public void setSize(int side) { ... }

(缺失答案)

(缺失解释)

例如:MyString

▶︎ 播放 MITx 视频

让我们重新审视MyString。使用抽象数据类型的接口而不是类,我们可以支持多种实现:

/** MyString represents an immutable sequence of characters. */
public interface MyString { 

    // We'll skip this creator operation for now
    // /** @param b a boolean value
    //  *  @return string representation of b, either "true" or "false" */
    // public static MyString valueOf(boolean b) { ... }

    /** @return number of characters in this string */
    public int length();

    /** @param i character position (requires 0 <= i < string length)
     *  @return character at position i */
    public char charAt(int i);

    /** Get the substring between start (inclusive) and end (exclusive).
     *  @param start starting index
     *  @param end ending index.  Requires 0 <= start <= end <= string length.
     *  @return string consisting of charAt(start)...charAt(end-1) */
    public MyString substring(int start, int end);
}

我们将跳过静态的 valueOf 方法,一会儿再回来。而是,让我们使用 Java 中抽象数据类型概念工具箱中的不同技术:构造函数。

这是我们的第一个实现:

public class SimpleMyString implements MyString {

    private char[] a;

    /* Create an uninitialized SimpleMyString. */
    private SimpleMyString() {}

    /** Create a string representation of b, either "true" or "false".
     *  @param b a boolean value */
    public SimpleMyString(boolean b) {
        a = b ? new char[] { 't', 'r', 'u', 'e' } 
              : new char[] { 'f', 'a', 'l', 's', 'e' };
    }

    @Override public int length() { return a.length; }

    @Override public char charAt(int i) { return a[i]; }

    @Override public MyString substring(int start, int end) {
        SimpleMyString that = new SimpleMyString();
        that.a = new char[end - start];
        System.arraycopy(this.a, start, that.a, 0, end - start);
        return that;
    }
}

这是优化后的实现:

public class FastMyString implements MyString {

    private char[] a;
    private int start;
    private int end;

    /* Create an uninitialized FastMyString. */
    private FastMyString() {}

    /** Create a string representation of b, either "true" or "false".
     *  @param b a boolean value */
    public FastMyString(boolean b) {
        a = b ? new char[] { 't', 'r', 'u', 'e' } 
              : new char[] { 'f', 'a', 'l', 's', 'e' };
        start = 0;
        end = a.length;
    }

    @Override public int length() { return end - start; }

    @Override public char charAt(int i) { return a[start + i]; }

    @Override public MyString substring(int start, int end) {
        FastMyString that = new FastMyString();
        that.a = this.a;
        that.start = this.start + start;
        that.end = this.start + end;
        return that;
    }
}
  • 将这些类与抽象数据类型MyString 的实现进行比较。注意以前出现在静态 valueOf 方法中的代码现在出现在构造函数中,稍作更改以引用 this 的表示。

  • 还要注意使用@Override。此注解通知编译器,该方法必须与我们正在实现的接口中的某个方法具有相同的签名。但由于编译器已经检查我们已实现了所有接口方法,因此在这里 @Override 的主要价值是给代码的读者:它告诉我们在接口中查找该方法的规范。重复规范不符合 DRY 原则,但完全不说则使代码难以理解。

  • 注意我们在substring(..)中使用的私有空构造函数,之后我们填充它们的表示数据。以前我们不需要编写这些空构造函数,因为当我们没有声明其他构造函数时,Java 会默认提供它们。添加带有boolean b的构造函数意味着我们必须显式声明空构造函数。

    现在我们知道良好的 ADT 严谨地保持它们自己的不变式,这些无所作为的构造函数是一个模式:它们不为 rep 分配任何值,当然也不建立任何不变式。我们应该认真考虑修改实现。由于MyString是不可变的,一个起点是使所有字段都是final的。

客户端将如何使用此 ADT?这里是一个例子:

MyString s = new FastMyString(true);
System.out.println("The first character is: " + s.charAt(0));

这段代码看起来与我们编写用于使用 Java 集合类的代码非常相似:

List<String> s = new ArrayList<String>();
...

不幸的是,这种模式破坏了我们努力构建的抽象屏障,即抽象类型与其具体表示之间的抽象屏障。客户端必须知道具体表示类的名称。因为 Java 中的接口不能包含构造函数,所以它们必须直接调用其中一个具体类的构造函数。该构造函数的规范不会出现在接口的任何地方,因此没有静态保证不同的实现甚至会提供相同的构造函数。

幸运的是,(截至 Java 8)接口 允许 包含静态方法,因此我们可以在接口MyString中将创建操作valueOf实现为静态工厂方法:

public interface MyString { 

    /** @param b a boolean value
     *  @return string representation of b, either "true" or "false" */
    public static MyString valueOf(boolean b) {
        return new FastMyString(true);
    }

    // ...

现在客户端可以在不破坏抽象屏障的情况下使用 ADT:

MyString s = MyString.valueOf(true);
System.out.println("The first character is: " + s.charAt(0));

阅读练习

代码审查

让我们回顾一下FastMyString的代码。哪些是有用的批评:

我希望抽象函数被记录下来

(缺失答案)(缺失答案)

(缺失解释)

我希望表示不变式被记录下来

(缺失答案)(缺失答案)

(缺失解释)

我希望 rep 字段是final的,这样它们就不能被重新赋值

(缺失答案)(缺失答案)

(缺失解释)

我希望私有构造函数是公共的,这样客户端就可以使用它来构造空字符串

(缺失答案)(缺失答案)

(缺失解释)

我希望charAt规范不暴露 rep 包含单个字符的事实

(缺失答案)(缺失答案)

(缺失解释)

我希望charAt的实现在i大于字符串长度时表现得更有帮助

(缺失答案)(缺失答案)

(缺失解释)

示例:泛型Set<E>

Java 的集合类提供了一个很好的示例,即将接口和实现分离的思想。

让我们以 Java 集合库中的一个 ADT 为例,SetSet是某种其他类型E的元素的有限集的 ADT。这是Set接口的简化版本:

/** A mutable set.
 *  @param <E> type of elements in the set */
public interface Set<E> {

Set泛型类型 的一个示例:其规范是以稍后填充的占位符类型来定义的类型。我们设计并实现了一个 Set<E>,而不是为 Set<String>Set<Integer> 等分别编写规范和实现。

我们可以将 Java 接口与我们对 ADT 操作的分类相匹配,从创建者开始:

 // example creator operation
    /** Make an empty set.
     *  @param <E> type of elements in the set
     *  @return a new set instance, initially empty */
    public static <E> Set<E> make() { ... } 

make 操作被实现为静态工厂方法。客户端将编写如下代码:

Set<String> strings = Set.make();

编译器会理解新的 SetString 对象的集合。(我们在此签名的前面写 <E>,因为 make 是一个静态方法。它需要自己的泛型类型参数,与我们在实例方法规范中使用的 E 不同。)

 // example observer operations

    /** Get size of the set.
     *  @return the number of elements in this set */
    public int size();

    /** Test for membership.
     *  @param e an element
     *  @return true iff this set contains e */
    public boolean contains(E e);

接下来我们有两个观察者方法。注意规范是以我们抽象的集合概念为基础的;提及特定实现集合的任何细节以及特定私有字段将是格式不正确的。这些规范应适用于集合 ADT 的任何有效实现。

 // example mutator operations

    /** Modifies this set by adding e to the set.
     *  @param e element to add */
    public void add(E e);

    /** Modifies this set by removing e, if found.
     *  If e is not found in the set, has no effect.
     *  @param e element to remove */
    public void remove(E e);

对于这些修改器的情况基本与观察者的情况相同。我们仍然在集合的抽象模型级别编写规范。

在 Java 教程中,阅读以下页面:

阅读练习

集合接口和实现

假设以下代码行按顺序运行,并且任何不能编译的代码行都被简单地注释掉,以便其余代码可以编译。

代码使用了 Collections 的两个方法,因此您可能需要查阅其文档。

对每个问题选择最具体的答案。

Set<String> set = new HashSet<String>();

set 现在指向:

(缺少答案)

(缺少解释)

set = [Collections.unmodifiableSet](http://docs.oracle.com/javase/8/docs/api/java/util/Collections.html#unmodifiableSet-java.util.Set-)(set);

set 现在指向:

(缺少答案)

(缺少解释)

set = [Collections.singleton](http://docs.oracle.com/javase/8/docs/api/java/util/Collections.html#singleton-T-)("glorp");

set 现在指向:

(缺少答案)

(缺少解释)

set = new Set<String>();

set 现在指向:

(缺少答案)

(缺少解释)

List<String> list = set;

set 现在指向:

(缺少答案)

(缺少解释)

点击方法名称以在此处查看其 Javadoc

实现泛型接口

假设我们想要实现上面的泛型 Set<E> 接口。我们可以编写一个非泛型实现,将 E 替换为特定类型,或者编写一个保留占位符的泛型实现。

泛型接口,非泛型实现。 让我们为 特定 类型 E 实现 Set<E>

抽象函数与表示不变量 中,我们看到了 CharSet,它表示字符集。 CharSet 的示例代码 包括一个泛型 Set 接口,以及每个实现 CharSet1/2/3 声明:

public class CharSet implements Set<Character>

当接口提到占位符类型 E 时,CharSet 实现会将 E 替换为 Character。例如:

|

public interface Set<E> {

    // ...

    /**
     * Test for membership.
     * @param e an element
     * @return true iff this set contains e
     */
    public boolean contains(E e);

    /**
     * Modifies this set by adding e to the set.
     * @param e element to add
     */
    public void add(E e);

    // ...
}

|

public class CharSet1 implements Set<Character> {

    private String s = "";

    // ...

    @Override
    public boolean contains(Character e) {
        checkRep();
        return s.indexOf(e) != -1;
    }

    @Override
    public void add(Character e) {
        if (!contains(e)) s += e;
        checkRep();
    }
    // ...
}

|

CharSet1/2/3 使用的表示法不适合表示任意类型元素的集合。例如,String 表示法不能表示 Set<Integer>,除非仔细定义新的表示不变量和处理多位数字的抽象函数。

泛型接口,泛型实现。 我们还可以实现泛型 Set<E> 接口,而不选择 E 的类型。在这种情况下,我们的代码是盲目的,无法知道客户端将为 E 选择的实际类型。Java 的 HashSet 就是这样做的。它的声明看起来像这样:

|

public interface Set<E> {

    // ...

|

public class HashSet<E> implements Set<E> {

    // ...

|

泛型实现只能依赖于包含在接口规范中的占位符类型的细节。在未来的阅读中,我们会看到 HashSet 依赖于 Java 中每种类型都必须实现的方法 —— 仅仅是这些方法,因为它不能依赖于任何特定类型声明的方法。

为什么要使用接口?

▶︎ 播放 MITx 视频

接口在真实的 Java 代码中被广泛使用。并非每个类都与接口相关,但有几个很好的理由将接口引入其中。

  • 为编译器和人类编写的文档。接口不仅帮助编译器捕捉 ADT 实现错误,而且对于人类阅读比具体实现的代码更有用。这样的实现将 ADT 级别的类型和规范与实现细节交替排列。

  • 允许性能权衡。ADT 的不同实现可以提供具有非常不同性能特征的方法。不同的应用程序可能与不同的选择更好地配合,但我们希望以表示无关的方式编写这些应用程序。从正确性的角度来看,应该可以通过简单的局部代码更改插入任何新的 ADT 关键实现。

  • 可选方法。Java 标准库中的List将所有变异方法标记为可选的。通过构建不支持这些方法的实现,我们可以提供不可变列表。一些操作很难在不可变列表上实现足够好的性能,因此我们也希望有可变实现。不调用变异器的代码可以自动适用于任一种列表。

  • 故意未确定规范的方法。有限集合的 ADT 可能会在将其转换为列表时未指定元素顺序。一些实现可能使用较慢的方法实现,以保持集合表示在某种排序顺序中,从而实现快速转换为排序列表。其他实现可能通过不支持转换为排序列表来使许多方法更快。

  • 一个类的多个视图。一个 Java 类可以实现多个接口。例如,显示下拉列表的用户界面小部件自然可以视为小部件和列表。这个小部件的类可以实现两个接口。换句话说,我们不是因为选择不同的数据结构而多次实现 ADT;我们可能会进行多次实现,因为许多不同类型的对象也可以被视为 ADT 的特殊情况,以及其他有用的视角。

  • 更可信和不太可信的实现。实现接口多次的另一个原因可能是很容易构建一个简单的实现,你相信它是正确的,而你可以更努力地构建一个更复杂的版本,更有可能包含错误。你可以根据受到错误影响的严重程度选择应用的实现。

在 Java 中实现 ADT 概念

我们已经完成了我们从第一个 ADTs 阅读中获得的 Java ADT 概念工具箱:

ADT 概念 在 Java 中实现的方式 示例
抽象数据类型 单个类 String
接口 + 类(es) ListArrayList
枚举 DayOfWeek
创建者操作 构造函数 ArrayList()
静态(工厂)方法 Collections.<wbr>singletonList(), Arrays.asList()
常量 BigInteger.ZERO
观察者操作 实例方法 List.get()
静态方法 Collections.max()
生产者操作 实例方法 String.trim()
静态方法 Collections.<wbr>unmodifiableList()
修改器操作 实例方法 List.add()
静态方法 Collections.copy()
表示 private 字段

阅读练习

假设你有一个类似于我们在抽象函数和表示不变性中讨论的的有理数的抽象数据类型,它当前被表示为一个 Java 类:

public class RatNum {
    ...
}

你决定将 RatNum 改为一个 Java 接口,同时还有一个名为 IntFraction 的实现类:

public interface RatNum {
    ...
}

public class IntFraction implements RatNum {
    ...
}

对于下面旧的 RatNum 类中的每一段代码,请确认它的身份,并决定它应该放在新的接口加实现类设计中的何处。

接口 + 实现 1

private int numer;
private int denom;
此代码片段是:(选中所有适用项)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案) 应该放在:(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

接口 + 实现 2

//   denom > 0
//   numer/denom is in reduced form
此代码片段是:(选中所有适用项)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案) 应该放在:(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

接口 + 实现 3

//   represents the rational number numer / denom
这段代码是:(选择所有适用项)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案) 它应该放在:(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

接口 + 实现 4

 /**
     * @param that another RatNum
     * @return a RatNum equal to (this / that)
     */
这段代码是:(选择所有适用项)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案) 它应该放在:(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

接口 + 实现 5

 public boolean isZero()
这段代码是:(选择所有适用项)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案) 它应该放在:(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

接口 + 实现 6

 return numer == 0;
这段代码是:(选择所有适用项)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案) 它应该放在:(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

概要

Java 接口帮助我们将抽象数据类型的概念正式化为一组必须由类型支持的操作。

这有助于使我们的代码……

  • 安全免受错误。 ADT 由其操作定义,接口正是如此。当客户端使用接口类型时,静态检查确保他们仅使用接口定义的方法。如果实现类暴露其他方法 — 或者更糟糕的是,有可见的表示 — 客户端无法意外看到或依赖它们。当我们有多个数据类型的实现时,接口提供方法签名的静态检查。

  • 易于理解。 客户端和维护者确切地知道在哪里查找 ADT 的规范。由于接口不包含实例字段或实例方法的实现,因此更容易将实现的细节排除在规范之外。

  • 准备好变化。 我们可以通过添加实现接口的类来轻松添加类型的新实现。如果我们避免构造函数而使用静态工厂方法,客户端将只看到接口。这意味着我们可以在不改变客户端代码的情况下切换客户端使用的实现类。

阅读 14:递归

在 6.005 中的软件

免受错误影响 易于理解 准备好改变
今天和未来未知时期的正确性。 与未来的程序员清晰沟通,包括未来的你。 设计以适应更改而不需要重写。

目标

今天的课结束后,你应该:

  • 能够将递归问题分解为递归步骤和基本情况

  • 知道何时以及如何在递归中使用辅助方法

  • 理解递归与迭代的优缺点

递归

在今天的课上,我们将讨论如何在已有规范的情况下实现方法。我们将重点放在一种特定的技术上,即递归。递归并不适用于每个问题,但它是你软件开发工具箱中的重要工具,很多人会对此感到困惑。我们希望你对递归感到舒适并且能够胜任,因为你将会反复遇到它。(这是个笑话,但也是真的。)

既然你已经学过 6.01,递归对你来说并不完全陌生,你之前已经见过并编写过递归函数,比如阶乘和斐波那契数列。今天的课程将比你之前接触的递归更深入地探讨。对递归实现的熟悉将是接下来课程的必要条件。

递归函数是根据基本情况递归步骤定义的。

  • 在基本情况下,我们立即计算给定函数调用的结果。

  • 在递归步骤中,我们通过一次或多次对这个相同函数的递归调用来计算结果,但输入的大小或复杂性会减小,接近基本情况。

考虑编写一个计算阶乘的函数。我们可以用两种不同的方式定义阶乘:

乘积 递推关系

|  (其中空积等于

乘法恒等式 1) | |

这导致两种不同的实现:

迭代 递归

|

public static long factorial(int n) {
  long fact = 1;
  for (int i = 1; i <= n; i++) {
    fact = fact * i;
  }
  return fact;
}

|

public static long factorial(int n) {
  if (n == 0) {
    return 1;
  } else {
    return n * factorial(n-1);
  }
}

|

在右侧的递归实现中,基本情况是 n = 0,其中我们立即计算并返回结果:0! 被定义为 1。递归步骤是 n > 0,其中我们通过递归调用来获得 (n-1)! 的结果,然后通过乘以 n 完成计算。

要可视化递归函数的执行,将当前执行函数的 调用堆栈 作为计算进行时的图表是有帮助的。

让我们在一个主方法中运行 factorial 的递归实现:

public static void main(String[] args) {
    long x = factorial(3);
}

在每一步中,时间从左到右移动:

main 中开始 调用 factorial(3) 调用 factorial(2) 调用 factorial(1) 调用 factorial(0) 返回到 factorial(1) 返回到 factorial(2) 返回到 factorial(3) 返回到 main

| main | factorialn = 3  mainx | factorialn = 2  factorialn = 3

mainx | factorialn = 1  factorialn = 2

factorialn = 3

mainx | factorialn = 0 返回 1factorialn = 1

factorialn = 2

factorialn = 3

mainx | factorialn = 1 返回 1factorialn = 2

factorialn = 3

mainx | factorialn = 2 返回 2factorialn = 3

mainx | factorialn = 3 返回 6mainx | mainx = 6 |

在图表中,我们可以看到堆栈如何增长,当main调用factorialfactorial然后调用自身,直到factorial(0)不再进行递归调用。然后调用堆栈展开,每次调用factorial将其答案返回给调用者,直到factorial(3)返回给main

这是一个交互式可视化的factorial。你可以逐步执行计算,观察递归的过程。在这个可视化中,新的堆栈帧向下增长而不是向上增长。

你可能以前见过阶乘,因为它是递归函数的常见示例。另一个常见示例是斐波那契数列:

/**
 * @param n >= 0
 * @return the nth Fibonacci number 
 */
public static int fibonacci(int n) {
    if (n == 0 || n == 1) {
        return 1; // base cases
    } else {
        return fibonacci(n-1) + fibonacci(n-2); // recursive step
    }
}

斐波那契很有趣,因为它有多个基本情况:n=0 和 n=1。你可以看看一个交互式斐波那契可视化。注意,阶乘的堆栈逐渐增长到最大深度,然后缩小到答案,而斐波那契的堆栈在计算过程中重复增长和缩小。

阅读练习

递归阶乘

考虑这个递归实现的阶乘函数。

public static int factorial(int n) {
    if (n == 0) {
        return 1; // this is called the base case
    } else {
        return n * factorial(n-1); // this is the recursive step
    }
}

对于factorial(3),基本情况return 1将执行多少次?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

递归斐波那契

考虑这个递归实现的斐波那契序列。

public static int fibonacci(int n) {
    if (n == 0 || n == 1) {
        return 1; // base cases
    } else {
        return fibonacci(n-1) + fibonacci(n-2); // recursive step
    }
}

对于fibonacci(3),基本情况return 1将执行多少次?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

选择正确的问题分解方式

找到正确的方法来分解问题,例如方法的实现,是很重要的。良好的分解方法简单、简短、易于理解、安全免受错误影响,并且可以轻松应对变化。

递归对于某些问题来说是一种优雅而简单的分解方法。假设我们想要实现这个规范:

/**
 * @param word consisting only of letters A-Z or a-z
 * @return all subsequences of word, separated by commas,
 * where a subsequence is a string of letters found in word 
 * in the same order that they appear in word.
 */
public static String subsequences(String word)

例如,subsequences("abc")可能返回"abc,ab,bc,ac,a,b,c,"。注意空子序列之前的尾随逗号,这也是一个有效的子序列。

这个问题很适合进行优雅的递归分解。取词的第一个字母。我们可以形成一个包含该字母的子序列集合,以及一个不包含该字母的子序列集合,这两个集合完全覆盖了可能的子序列集合。

 1 public static String subsequences(String word) {
 2     if (word.isEmpty()) {
 3         return ""; // base case
 4     } else {
 5         char firstLetter = word.charAt(0);
 6         String restOfWord = word.substring(1);
 7         
 8         String subsequencesOfRest = subsequences(restOfWord);
 9         
10         String result = "";
11         for (String subsequence : subsequencesOfRest.split(",", -1)) {
12             result += "," + subsequence;
13             result += "," + firstLetter + subsequence;
14         }
15         result = result.substring(1); // remove extra leading comma
16         return result;
17     }
18 }

阅读练习

subsequences("c")(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

subsequences("gc")(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

递归实现的结构

递归实现总是有两个部分:

  • 基本情形,即问题的最简单、最小的实例,不能进一步分解。基本情形通常对应于空集 – 空字符串、空列表、空集合、空树、零等。

  • 递归步骤,它将问题的较大实例分解为一个或多个更简单或较小的实例,可以通过递归调用解决,然后将这些子问题的结果重新组合以产生原始问题的解决方案。

递归步骤将问题实例转换为更小的东西非常重要,否则递归可能永远不会结束。如果每个递归步骤都将问题缩小,并且基本情形位于底部,则递归保证是有限的。

递归实现可能有多个基本情形,或多个递归步骤。例如,斐波那契函数有两个基本情形,n=0 和 n=1。

阅读练习

递归结构

递归方法有一个基本情形和一个递归步骤。计算机科学中还有哪些概念具有(等价的)基本情形和递归步骤?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

辅助方法

我们刚刚看到的subsequences()的递归实现是问题的一种可能的递归分解。我们拿到了一个子问题的解决方案 – 去掉第一个字符后字符串的子序列 – 并用它来构造原始问题的解决方案,方法是取每个子序列并添加第一个字符或省略它。从某种意义上说,这是一种直接的递归实现,我们在其中使用现有的递归方法规范来解决子问题。

在某些情况下,要求递归步骤的规范更强(或不同),以使递归分解更简单或更优雅。在这种情况下,如果我们用单词的初始字母构建部分子序列,然后使用递归调用完成该部分子序列的剩余字母会怎么样?例如,假设原始单词是“orange”。我们将选择“o”作为部分子序列,并用“range”的所有子序列递归扩展它;我们将跳过“o”,将“”作为部分子序列,并再次用“range”的所有子序列递归扩展它。

使用这种方法,我们的代码现在看起来简单得多:

/**
 * Return all subsequences of word (as defined above) separated by commas,
 * with partialSubsequence prepended to each one.
 */
private static String subsequencesAfter(String partialSubsequence, String word) {
    if (word.isEmpty()) {
        // base case
        return partialSubsequence;
    } else {
        // recursive step
        return subsequencesAfter(partialSubsequence, word.substring(1))
             + ","
             + subsequencesAfter(partialSubsequence + word.charAt(0), word.substring(1));
    }
}

这个subsequencesAfter方法被称为辅助方法。它满足与原始的subsequences不同的规范,因为它有一个新的参数partialSubsequence。这个参数填充了在迭代实现中本地变量的类似角色。它在计算演变过程中保持临时状态。递归调用逐步扩展这个部分子序列,选择或忽略单词中的每个字母,直到最终达到单词的末尾(基本情况),此时部分子序列作为唯一的结果返回。然后递归回溯并填充其他可能的子序列。

要完成实现,我们需要实现原始的subsequences规范,通过用一个部分子序列参数的初始值调用辅助方法来启动该过程:

public static String subsequences(String word) {
    return subsequencesAfter("", word);
}

不要向客户暴露辅助方法。 你选择这种递归分解方式而不是其他方式完全是特定于实现的。特别是,如果你发现在递归中需要像partialSubsequence这样的临时变量,不要更改方法的原始规范,并且不要强迫客户正确初始化这些参数。这会向客户暴露你的实现,并降低你将来更改它的能力。对于递归,使用一个私有的辅助函数,并让你的公共方法以正确的初始化调用它,如上所示。

阅读练习

无用 1

路易斯·理性人不想使用辅助方法,因此他尝试通过将partialSubsequence存储为静态变量而不是参数来实现subsequences()。这是他的实现:

private static String partialSubsequence = "";
public static String subsequencesLouis(String word) {
    if (word.isEmpty()) {
        // base case
        return partialSubsequence;
    } else {
        // recursive step
        String withoutFirstLetter = subsequencesLouis(word.substring(1));
        partialSubsequence += word.charAt(0);
        String withFirstLetter = subsequencesLouis(word.substring(1));
        return withoutFirstLetter + "," + withFirstLetter;
    }
}

假设我们调用subsequencesLouis("c"),然后是subsequencesLouis("a")

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

无用 2

路易斯通过将 partialSubsequence 公开来解决了这个问题:

/**
 * Requires: caller must set partialSubsequence to "" before calling subsequencesLouis().
 */
public static String partialSubsequence;

当 Alyssa P. Hacker 看到路易斯做的事情时,她举起了手。关于他的代码,以下哪些陈述是正确的?

(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

无用 3

Louis 屈服于 Alyssa 的激烈争论,再次隐藏他的静态变量,并在开始递归之前正确初始化它:

public static String subsequences(String word) {
    partialSubsequence = "";
    return subsequencesLouis(word);
}

private static String partialSubsequence = "";

public static String subsequencesLouis(String word) {
    if (word.isEmpty()) {
        // base case
        return partialSubsequence;
    } else {
        // recursive step
        String withoutFirstLetter = subsequencesLouis(word.substring(1));
        partialSubsequence += word.charAt(0);
        String withFirstLetter = subsequencesLouis(word.substring(1));
        return withoutFirstLetter + "," + withFirstLetter;
    }
}

不幸的是,在递归中使用静态变量是一个坏主意。Louis 的解决方案仍然存在问题。为了说明问题,让我们跟踪调用subsequences("xy")。您可以通过此版本的交互式可视化来查看发生了什么。它将产生这些对subsequencesLouis()的递归调用:

1. subsequencesLouis("xy")
2.     subsequencesLouis("y")
3.         subsequencesLouis("")
4.         subsequencesLouis("")
5.     subsequencesLouis("y")
6.         subsequencesLouis("")
7.         subsequencesLouis("")

当这些调用开始时,静态变量 partialSubsequence 的值是多少?

  1. subsequencesLouis("xy")

    (缺失答案)

  2. subsequencesLouis("y")

    (缺失答案)

  3. subsequencesLouis("")

    (缺失答案)

  4. subsequencesLouis("")

    (缺失答案)

  5. subsequencesLouis("y")

    (缺失答案)

  6. subsequencesLouis("")

    (缺失答案)

  7. subsequencesLouis("")

    (缺少答案)

(缺少解释)

选择正确的递归子问题

让我们看另一个例子。假设我们想要将整数转换为给定基数的字符串表示,遵循这个规范:

/**
 * @param n integer to convert to string
 * @param base base for the representation. Requires 2<=base<=10.
 * @return n represented as a string of digits in the specified base, with 
 *           a minus sign if n<0.
 */
public static String stringValue(int n, int base)

例如,stringValue(16, 10) 应该返回 "16",而 stringValue(16, 2) 应该返回 "10000"

让我们开发这种方法的递归实现。这里一个递归步骤很简单:我们可以通过简单地递归调用相应正整数的表示来处理负整数:

if (n < 0) return "-" + stringValue(-n, base);

这表明递归子问题可以通过比数值参数的值或字符串或列表参数的大小更微妙的方式变得更小或更简单。我们仍然通过将问题简化为正整数有效地减小了问题。

下一个问题是,假设我们有一个正整数 n,比如 n=829,以十进制表示,我们应该如何将其分解为一个递归子问题?考虑到我们将数字写在纸上的方式,我们可以从 8 开始(最左边或最高位数),或者从 9 开始(最右边,较低位数)。从左边开始似乎很自然,因为这是我们写的方向,但在这种情况下会更困难,因为我们需要首先找出数字中的位数,以确定如何提取最左边的数字。相反,将 n 分解的更好方法是取余数模基数(给出最右边的数字)并且除以基数(给出子问题,剩余的更高位数):

return stringValue(n/base, base) + "0123456789".charAt(n%base);

考虑几种分解问题的方式,并尝试编写递归步骤。 你希望找到产生最简单、最自然的递归步骤。

还需要弄清楚基本情况是什么,并包含一个 if 语句,用于区分基本情况和这个递归步骤。

阅读练习

实现 stringValue

这是 stringValue() 的递归实现,递归步骤已汇集在一起,但基本情况仍然缺失:

/**
 * @param n integer to convert to string
 * @param base base for the representation. Requires 2<=base<=10.
 * @return n represented as a string of digits in the specified base, with 
 *           a minus sign if n<0\.  No unnecessary leading zeros are included. 
 */
public static String stringValue(int n, int base) {
    if (n < 0) {
        return "-" + stringValue(-n, base);
    } else if (BASE CONDITION) {
        BASE CASE
    } else {
        return stringValue(n/base, base) + "0123456789".charAt(n%base);
    }
}

哪个可以替换 BASE CONDITIONBASE CASE 以使代码正确?

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

调用 stringValue

假设代码已经完成,并且在前一个问题中已确定了一个基本情况,那么 stringValue(170, 16) 做什么?

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

递归问题 vs. 递归数据

到目前为止我们见过的例子都是问题结构自然地支持递归定义的情况。阶乘很容易根据更小的子问题来定义。像这样有一个递归问题是你应该从你的工具箱中拿出递归解决方案的一个提示。

另一个提示是当你操作的数据在结构上本质上是递归的时候。在几节课后我们会看到许多递归数据的例子,但现在让我们看一看每台笔记本电脑中都有的递归数据:它的文件系统。文件系统由命名的文件组成。一些文件是文件夹,它们可以包含其他文件。因此文件系统是递归的:文件夹包含其他文件夹,这些文件夹又包含其他文件夹,直到递归的最底层是普通(非文件夹)文件。

Java 库使用 java.io.File 表示文件系统。这是一个递归数据类型,因为 f.getParentFile() 返回文件 f 的父文件夹,它也是一个 File 对象,f.listFiles() 返回 f 包含的文件,这是其他 File 对象的数组。

对于递归数据,写递归实现是很自然的:

/**
 * @param f a file in the filesystem
 * @return the full pathname of f from the root of the filesystem
 */
public static String fullPathname(File f) {
    if (f.getParentFile() == null) {
        // base case: f is at the root of the filesystem
        return f.getName();  
    } else {
        // recursive step
        return fullPathname(f.getParentFile()) + "/" + f.getName();
    }
}

Java 的最新版本增加了一个新的 API,java.nio.Filesjava.nio.Path,提供了文件系统和用于命名其中文件的路径之间的更清晰的分离。但数据结构仍然基本上是递归的。

可重入代码

递归——一个方法调用自身——是编程中一个称为重入性的一般现象的特殊情况。可重入代码可以安全地重新进入,这意味着它可以在调用正在进行时再次被调用。可重入代码完全通过参数和局部变量保持其状态,并且不使用静态变量或全局变量,并且不与程序的其他部分或其他对自身的调用共享可变对象的别名。

直接递归是重入性发生的一种方式。在本文中我们已经看到了许多这样的例子。factorial() 方法被设计成 factorial(n-1) 可以被调用,即使 factorial(n) 还没有完成工作。

两个或多个函数之间的相互递归是另一种可能发生的情况——A 调用 B,B 再次调用 A。直接的相互递归几乎总是有意的,并由程序员设计。但意外的相互递归可能导致错误。

当我们在课程的后面讨论并发时,重入性会再次出现,因为在并发程序中,一个方法可能同时被程序的不同部分调用。

尽可能设计您的代码为可重入是很好的。可重入代码免于错误,并且可以在更多情况下使用,如并发、回调或相互递归。

何时使用递归而不是迭代。

我们已经看到使用递归的两个常见原因:

  • 问题自然是递归的(例如 Fibonacci)。

  • 数据自然是递归的(例如文件系统)。

使用递归的另一个原因是更多地利用不可变性。在理想的递归实现中,所有变量都是最终的,所有数据都是不可变的,并且递归方法都是纯函数,意味着它们不会改变任何东西。方法的行为可以简单地理解为其参数与其返回值之间的关系,不会对程序的任何其他部分产生副作用。这种范式称为函数式编程,比使用循环和变量的命令式编程要容易得多。

在迭代实现中,相反地,您不可避免地会有在迭代过程中修改的非最终变量或可变对象。因此,对程序的推理需要考虑在不同时间点的程序状态的快照,而不是考虑纯输入/输出行为。

递归的一个缺点是它可能比迭代解决方案占用更多空间。建立递归调用堆栈的堆栈会暂时消耗内存,并且堆栈大小是有限的,这可能成为您的递归实现能够解决的问题大小的限制。

递归实现中的常见错误。

以下是递归实现可能出错的两种常见方式:

  • 基本情况完全缺失,或者问题需要多个基本情况,但并非所有基本情况都被覆盖。

  • 递归步骤不会缩减为更小的子问题,因此递归不会收敛。

当您进行调试时,请查找这些问题。

从好的一面来看,迭代实现中会成为无限循环的东西通常会在递归实现中成为StackOverflowError。有错误的递归程序失败得更快。

阅读练习。

subsequences("123456")

回想一下从本阅读开始的subsequences()的实现:

public static String subsequences(String word) {
    if (word.isEmpty()) {
        return ""; // base case
    } else {
        char firstLetter = word.charAt(0);
        String restOfWord = word.substring(1);

        String subsequencesOfRest = subsequences(restOfWord);

        String result = "";
        for (String subsequence : subsequencesOfRest.split(",", -1)) {
            result += "," + subsequence;
            result += "," + firstLetter + subsequence;
        }
        if (result.startsWith(",")) result = result.substring(1);
        return result;
    }
}

对于subsequences("123456"),其递归调用堆栈有多深?同时可以有多少个递归调用subsequences()处于活动状态?

(缺失答案)

(缺失解释)

摘要

我们看到了这些概念:

  • 递归问题和递归数据。

  • 比较递归问题的替代分解。

  • 使用辅助方法来加强递归步骤。

  • 递归与迭代

今天阅读的主题与我们关于良好软件的三个关键属性连接如下:

  • 免于错误。递归代码更简单,通常使用不可变变量和不可变对象。

  • 易于理解。 对于自然递归问题和递归数据,递归实现通常比迭代解决方案更短、更易于理解。

  • 准备好变革。 递归代码也自然是可重入的,这使得它更安全免受错误影响,并且可以在更多情况下使用。

第 15 节:相等性阅读

6.005 中的软件

安全免于错误 易于理解 为变化做好准备
今天正确并且在未知的未来也正确。 与未来的程序员清晰沟通,包括未来的你。 设计用于适应变化而不需重写。

目标

  • 理解根据抽象函数、等价关系和观察定义的相等性。

  • 区分引用相等和对象相等。

  • 区分可变类型的严格观察相等和行为相等。

  • 理解对象契约并能够正确地为可变和不可变类型实现相等性。

介绍

▶︎ 播放 MITx 视频

在之前的阅读中,我们通过创建由操作而不是表示特征特征化的类型来发展了一个严格的数据抽象概念。对于抽象数据类型,抽象函数解释了如何将具体表示值解释为抽象类型的值,并且我们看到了抽象函数的选择如何确定如何编写实现每个 ADT 操作的代码。

在这篇阅读中,我们转向如何定义数据类型中值的相等性概念:抽象函数将为我们提供一种清晰地定义 ADT 上的相等性操作的方法。

在物理世界中,每个对象都是独一无二的 – 在某个层面上,即使是两片雪花也是不同的,即使区别只是它们在空间中所占的位置。(这并不严格适用于所有的亚原子粒子,但对于大型物体如雪花、棒球和人类来说足够真实。)因此,两个物理对象永远不会真正“相等”;它们只有相似程度。

然而,在人类语言世界和数学概念世界中,你可以对同一件事情有多个名称。因此,问两个表达式何时表示相同的事情是很自然的:1+2、√9 和 3 都是同一个理想数学值的替代表达式。

三种看待相等性的方式

从正式角度来看,我们可以以几种方式来看待相等性。

使用抽象函数。回想一下,一个抽象函数 f: R → A 将数据类型的具体实例映射到它们相应的抽象值。要使用 f 作为相等性的定义,我们会说 a 等于 b 当且仅当 f(a)=f(b)。

使用关系。一个等价关系是一个关系 E ⊆ T x T,具有以下性质:

  • 自反性:E(t,t) ∀ t ∈ T

  • 对称性:E(t,u) ⇒ E(u,t)

  • 传递性:E(t,u) ∧ E(u,v) ⇒ E(t,v)

要使用 E 作为相等性的定义,我们会说 a 等于 b 当且仅当 E(a,b)。

这两个概念是等价的。等价关系会引出一个抽象函数(该关系将 T 划分为若干部分,因此 f 将每个元素映射到其划分类别)。由抽象函数引出的关系是一个等价关系(自行验证这三个属性是否成立)。

我们谈论抽象值之间的相等的第三种方式是从外部人(客户端)可以观察到的角度来讨论:

使用观察。我们可以说两个对象相等,当它们无法通过观察来区分时 - 我们可以应用的每个操作对两个对象产生相同的结果。考虑集合表达式{1,2}和{2,1}。使用集合的观察操作,基数 |...| 和成员 ∈,这些表达式是无法区分的:

  • |{1,2}| = 2 and |{2,1}| = 2

  • 1 ∈ {1,2} 是真的,而且 1 ∈ {2,1} 是真的

  • 2 ∈ {1,2} 是真的,而且 2 ∈ {2,1} 是真的

  • 3 ∈ {1,2} 是假的,而且 3 ∈ {2,1} 是假的

  • … 等等

就抽象数据类型而言,“观察”意味着在对象上调用操作。因此,仅当两个对象不能通过调用抽象数据类型的任何操作来区分时,它们才相等。

例子:持续时间

这是一个不可变 ADT 的简单示例。

public class Duration {
    private final int mins;
    private final int secs;
    // rep invariant:
    //    mins >= 0, secs >= 0
    // abstraction function:
    //    represents a span of time of mins minutes and secs seconds

    /** Make a duration lasting for m minutes and s seconds. */
    public Duration(int m, int s) {
        mins = m; secs = s;
    }
    /** @return length of this duration in seconds */
    public long getLength() {
        return mins*60 + secs;
    }
}

现在,哪些值应该被视为相等?

Duration d1 = new Duration (1, 2);
Duration d2 = new Duration (1, 3);
Duration d3 = new Duration (0, 62);
Duration d4 = new Duration (1, 2);

从抽象函数定义相等和观察相等定义的角度来思考。

阅读练习

随时

考虑上面刚刚创建的Duration和对象d1d2d3d4的代码。

使用抽象函数的相等概念,以下哪个将被视为与d1相等?

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

留意时间

使用观察性的相等概念,以下哪个将被视为与d1相等?

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

== vs. equals()

▶︎ 播放 MITx 视频

像许多语言一样,Java 有两个不同的测试相等性的操作,具有不同的语义。

  • ==运算符比较引用。更确切地说,它测试引用相等性。如果两个引用指向内存中的相同存储,则两个引用是 == 的。就我们一直绘制的快照图中,如果它们的箭头指向同一个对象泡沫,则两个引用是 == 的。

  • equals()操作比较对象内容 - 换句话说,对象相等,在我们讨论中的意义上。equals 操作必须适当地为每种抽象数据类型定义。

作为比较,以下是几种语言中的相等运算符:

引用相等性 对象相等性
Java == equals()
Objective C == isEqual:
C# == Equals()
Python is ==
Javascript == n/a

请注意,==在 Java 和 Python 之间不幸地翻转了其含义。不要让这让你困惑:在 Java 中,==只是测试引用标识,它不比较对象内容。

作为任何这些语言的程序员,我们不能改变引用相等运算符的含义。在 Java 中,==始终表示引用相等。但是当我们定义一个新的数据类型时,我们有责任决定数据类型的值的对象相等性意味着什么,并适当地实现equals()操作。

不可变类型的相等性

equals()方法由Object定义,其默认实现如下所示:

public class Object {
    ...
    public boolean equals(Object that) {
        return this == that;
    }
}

换句话说,默认的equals()意味着引用相等。对于不可变数据类型,这几乎总是错误的。因此,您必须重写equals()方法,用自己的实现替换它。

这是我们为Duration的第一次尝试:

public class Duration {
    ...   
    // Problematic definition of equals()
    public boolean equals(Duration that) {
        return this.getLength() == that.getLength();        
    }
}

这里有一个微妙的问题。为什么这不起作用?让我们尝试这段代码:

Duration d1 = new Duration (1, 2);
Duration d2 = new Duration (1, 2);
Object o2 = d2;
d1.equals(d2) → true
d1.equals(o2) → false

您可以[在这里看到此代码的运行情况](http://www.pythontutor.com/java.html#code=public+class+Duration+{ ++++private+final+int+mins%3B ++++private+final+int+secs%3B ++++//+rep+invariant%3A ++++//++++mins+>%3D+0,+secs+>%3D+0 ++++//+abstraction+function%3A ++++//++++represents+a+span+of+time+of+mins+minutes+and+secs+seconds ++++/+Make+a+duration+lasting+for+m+minutes+and+s+seconds.+*/%0A++++public+Duration(int+m,+int+s%29+%7B%0A++++++++mins+%3D+m%3B+secs+%3D+s%3B%0A++++%7D%0A++++/+%40return+length+of+this+duration+in+seconds+/%0A++++public+long+getLength(%29+%7B%0A++++++++return+mins60+%2B+secs%3B%0A++++%7D%0A++++//+Problematic+definition+of+equals(%29%0A++++public+boolean+equals(Duration+that%29+%7B%0A++++++++return+this.getLength(%29+%3D%3D+that.getLength(%29%3B++++++++%0A++++%7D%0A++++public+static+void+main(String%5B%5D+args%29+%7B%0A++++++Duration+d1+%3D+new+Duration+(1,+2%29%3B%0A++++++Duration+d2+%3D+new+Duration+(1,+2%29%3B%0A++++++Object+o2+%3D+d2%3B%0A++++++System.out.println(%22d1.equals(d2%29%3D%22+%2B+d1.equals(d2%29%29%3B%0A++++++System.out.println(%22d1.equals(o2%29%3D%22+%2B+d1.equals(o2%29%29%3B%0A++++%7D%0A%7D&mode=display&origin=opt-frontend.js&cumulative=false&heapPrimitives=false&textReferences=false&py=java&rawInputLstJSON=%5B%5D&curInstr=33)。您会发现,即使d2o2最终引用了内存中的同一个对象,您仍然会从equals()得到不同的结果。

发生了什么?事实证明,Duration已经重载equals()方法,因为该方法签名与Object的不相同。实际上,我们在Duration中有两个equals()方法:从Object继承的隐式equals(Object),以及新的equals(Duration)

public class Duration extends Object {
    // explicit method that we declared:
    public boolean equals (Duration that) {
        return this.getLength() == that.getLength();
    }
    // implicit method inherited from Object:
    public boolean equals (Object that) {
        return this == that;
    }
}

从课程一开始我们就见过重载,静态检查中提到过。从Java 教程中可以回想起,编译器根据参数的编译时类型选择重载操作。例如,当使用/运算符时,编译器根据参数是 int 还是 float 选择整数除法或浮点除法。这里也发生了相同的编译时选择。如果我们传递一个Object引用,就像d1.equals(o2)一样,我们最终调用equals(Object)的实现。如果我们传递一个Duration引用,就像d1.equals(d2)一样,我们最终调用equals(Duration)版本。即使在运行时o2d2都指向同一个对象!相等性已经变得不一致。

在方法签名中很容易出错,并在打算重写时重载方法。这是一个常见错误,Java 有一个语言特性,注解@Override,当你打算在你的超类中重写方法时应该使用它。使用此注解,Java 编译器将检查超类中是否实际存在具有相同签名的方法,并在签名错误时给出编译器错误。

所以这是实现Durationequals()方法的正确方式:

@Override
public boolean equals (Object thatObject) {
    if (!(thatObject instanceof Duration)) return false;
    Duration thatDuration = (Duration) thatObject;
    return this.getLength() == thatDuration.getLength();
}

这样修复了问题:

Duration d1 = new Duration(1, 2);
Duration d2 = new Duration(1, 2);
Object o2 = d2;
d1.equals(d2) → true
d1.equals(o2) → true

你可以在[这个代码中查看它的运行情况](http://www.pythontutor.com/java.html#code=public+class+Duration+{ ++++private+final+int+mins%3B ++++private+final+int+secs%3B ++++//+rep+invariant%3A ++++//++++mins+>%3D+0,+secs+>%3D+0 ++++//+abstraction+function%3A ++++//++++represents+a+span+of+time+of+mins+minutes+and+secs+seconds ++++/+Make+a+duration+lasting+for+m+minutes+and+s+seconds.+*/%0A++++public+Duration(int+m,+int+s%29+%7B%0A++++++++mins+%3D+m%3B+secs+%3D+s%3B%0A++++%7D%0A++++/+%40return+length+of+this+duration+in+seconds+/%0A++++public+long+getLength(%29+%7B%0A++++++++return+mins60+%2B+secs%3B%0A++++%7D%0A++++%40Override%0A++++public+boolean+equals+(Object+thatObject%29+%7B%0A++++++++if+(!(thatObject+instanceof+Duration%29%29+return+false%3B%0A++++++++Duration+thatDuration+%3D+(Duration%29+thatObject%3B%0A++++++++return+this.getLength(%29+%3D%3D+thatDuration.getLength(%29%3B%0A++++%7D%0A++++public+static+void+main(String%5B%5D+args%29+%7B%0A++++++Duration+d1+%3D+new+Duration+(1,+2%29%3B%0A++++++Duration+d2+%3D+new+Duration+(1,+2%29%3B%0A++++++Object+o2+%3D+d2%3B%0A++++++System.out.println(%22d1.equals(d2%29%3D%22+%2B+d1.equals(d2%29%29%3B%0A++++++System.out.println(%22d1.equals(o2%29%3D%22+%2B+d1.equals(o2%29%29%3B%0A++++%7D%0A%7D&mode=display&origin=opt-frontend.js&cumulative=false&heapPrimitives=false&textReferences=false&py=java&rawInputLstJSON=%5B%5D&curInstr=49) 在在线 Python 教程中。

instanceof

instanceof 运算符 测试一个对象是否是特定类型的实例。使用 instanceof 是动态类型检查,而不是我们极力推荐的静态类型检查。一般来说,在面向对象编程中使用 instanceof 是一个坏习惯。在 6.005 中——这也是我们的另一个规则,适用于大多数良好的 Java 编程——instanceof 在除了实现 equals 之外的任何地方都被禁止。这个禁令也包括其他检查对象运行时类型的方式。例如,getClass 也被禁止。

我们将在未来的阅读中看到,何时可能会被诱惑使用 instanceof,以及如何编写更安全、更易于更改的替代方案的示例。

对象约定

Object 类的规范是如此重要,以至于经常被称为对象约定。该约定可以在 Object 类的方法规范中找到。在这里,我们将重点放在 equals 的约定上。当你重写 equals 方法时,你必须遵守它的一般约定。它规定:

  • equals 必须定义一个等价关系——即,一个反身性、对称性和传递性的关系;

  • equals 必须是一致的:对该方法的重复调用必须产生相同的结果,只要用于 equals 比较的对象上的信息没有被修改;

  • 对于非空引用 xx.equals(null) 应返回 false;

  • 对于被 equals 方法认定为相等的两个对象,hashCode 必须产生相同的结果。

破坏等价关系

让我们从等价关系开始。我们必须确保由 equals() 实现的等式定义实际上是一个等价关系,如前所定义:反身性、对称性和传递性。如果不是这样,那么依赖相等性的操作(如集合、搜索)将表现得不稳定和不可预测。你不想用一个数据类型编程,在其中有时候 a 等于 b,但 b 却不等于 a。将导致微妙而痛苦的错误。

这是一个使相等性更加灵活的天真尝试如何出错的示例。假设我们想要允许在比较 Duration 对象时容忍误差,因为不同的计算机可能具有略微不同步的时钟:

private static final int CLOCK_SKEW = 5; // seconds

@Override
public boolean equals (Object thatObject) {
    if (!(thatObject instanceof Duration)) return false;
    Duration thatDuration = (Duration) thatObject;
    return Math.abs(this.getLength() - thatDuration.getLength()) <= CLOCK_SKEW;
}

等价关系的哪个属性被违反了?

阅读练习

类似相等

考虑一下在阅读中最新的 Duration 实现,为方便起见在此重印:

public class Duration {
    private final int mins;
    private final int secs;
    // rep invariant:
    //    mins >= 0, secs >= 0
    // abstraction function:
    //    represents a span of time of mins minutes and secs seconds

    /** Make a duration lasting for m minutes and s seconds. */
    public Duration(int m, int s) {
        mins = m; secs = s;
    }
    /** @return length of this duration in seconds */
    public long getLength() {
        return mins*60 + secs;
    }

    private static final int CLOCK_SKEW = 5; // seconds

    @Override
    public boolean equals (Object thatObject) {
        if (!(thatObject instanceof Duration)) return false;
        Duration thatDuration = (Duration) thatObject;
        return Math.abs(this.getLength() - thatDuration.getLength()) <= CLOCK_SKEW;
    }
}

假设这些 Duration 对象被创建:

Duration d_0_60 = new Duration(0, 60);
Duration d_1_00 = new Duration(1, 0);
Duration d_0_57 = new Duration(0, 57);
Duration d_1_03 = new Duration(1, 3);

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

扭曲了(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

有问题的相等(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

破坏哈希表

▶︎ 播放 MITx 视频

要理解与hashCode方法相关的契约部分,您需要对哈希表的工作原理有一些了解。两种非常常见的集合实现,HashSetHashMap,使用哈希表数据结构,并依赖于正确实现hashCode方法的对象存储在集合中并用作地图中的键。

哈希表是映射的一种表示:一种将键映射到值的抽象数据类型。哈希表提供了常数时间的查找,因此它们通常比树或列表表现得更好。键不必被排序,也不必具有任何特定的属性,除了提供equalshashCode

哈希表的工作原理如下。它包含一个数组,该数组的大小初始化为我们预计要插入的元素数量。当提供键和值进行插入时,我们计算键的哈希码,并将其转换为数组范围内的索引(例如,通过取模运算)。然后在该索引处插入值。

哈希表的表示不变性包括一个基本约束,即键位于由它们的哈希码确定的槽中。

哈希码设计成使键均匀分布在索引上。但偶尔会发生冲突,两个键被放置在相同的索引上。因此,哈希表实际上不是在索引处保存单个值,而是保存一个称为哈希桶的键/值对列表,通常称为哈希桶。键/值对在 Java 中简单地被实现为具有两个字段的对象。在插入时,您将一对添加到由哈希码确定的数组槽中的列表中。对于查找,您对键进行哈希处理,找到正确的槽位,然后检查每个对,直到找到一个其键与查询键相等的对。

现在应该清楚为什么Object契约要求相等的对象具有相同的哈希码。如果两个相等的对象具有不同的哈希码,它们可能被放置在不同的槽中。所以如果你试图使用与插入它的键相等的键来查找值,查找可能会失败。

Object的默认hashCode()实现与其默认equals()一致:

public class Object {
  ...
  public boolean equals(Object that) { return this == that; }
  public int hashCode() { return /* the memory address of this */; }
}

对于引用ab,如果a == b,那么 a 的地址== b 的地址。因此,Object契约得到了满足。

但是不可变对象需要一个不同的hashCode()实现。对于Duration,由于我们尚未重写默认的hashCode(),因此我们目前正在违反Object契约:

Duration d1 = new Duration(1, 2);
Duration d2 = new Duration(1, 2);
d1.equals(d2) → true
d1.hashCode() → 2392
d2.hashCode() → 4823

d1d2equal()的,但它们有不同的哈希码。所以我们需要修复这个问题。

确保满足契约的一个简单而极端的方法是,hashCode 总是返回某个常量值,因此每个对象的哈希码都相同。这满足了 Object 的契约,但它会产生灾难性的性能影响,因为每个键都将存储在相同的槽中,每次查找都将退化为沿着长列表进行线性搜索。

构造更合理的哈希码的标准方法是计算用于确定相等性的对象的每个组件的哈希码(通常通过调用每个组件的 hashCode 方法),然后将这些组合起来,加入一些算术操作。对于 Duration 来说,这很容易,因为类的抽象值已经是一个整数值:

@Override
public int hashCode() {
    return (int) getLength();
}

乔希·布洛赫(Josh Bloch)的精彩著作,Effective Java,更详细地解释了这个问题,并提供了一些编写良好哈希码函数的策略。建议总结在 一个很好的 StackOverflow 帖子 中。最近的 Java 版本现在有一个实用方法 Objects.hash(),使得实现涉及多个字段的哈希码更容易。

请注意,只要满足相等的对象具有相同的哈希码值的要求,那么您使用的特定哈希技术对代码的正确性并不重要。它可能会影响其性能,因为在不同对象之间创建不必要的冲突,但是即使是性能不佳的哈希函数也比违反契约的好。

最关键的是,请注意,如果你根本不重写 hashCode,你将得到来自 Object 的一个,它基于对象的地址。如果你已经重写了 equals,这意味着你几乎肯定违反了契约。所以作为一个通用规则:

当你重写 equals 时,始终重写 hashCode

多年前,在(混乱编号的 6.005 的前身)6.170 中,一个学生花了几个小时追踪一个项目中的一个 bug,结果发现不过是将 hashCode 拼写成 hashcode。这创建了一个新方法,根本没有覆盖 ObjecthashCode 方法,发生了奇怪的事情。使用 @Override

阅读练习

给我代码

考虑以下 ADT 类:

class Person {
  private String firstName;
  private String lastName;
  ...

  public boolean equals(Object obj) {
      if (!(obj instanceof Person)) return false;
      Person that = (Person) obj;
      return this.lastName.toUpperCase().equals(that.lastName.toUpperCase());
  }

  public int hashCode() {
      // TODO
  }
}

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

可变类型的相等性

▶︎ 播放 MITx 视频

在本篇阅读中,我们一直专注于不可变对象的相等性。那么可变对象呢?

回想一下我们的定义:当两个对象无法通过观察加以区分时,它们相等。对于可变对象,有两种解释方法:

  • 当它们不能通过不改变对象状态的观察来区分,即仅调用观察者、生产者和创建者方法时。这通常被严格称为观察相等,因为它测试两个对象在程序当前状态下是否“看起来”相同。

  • 当它们不能通过任何观察来区分,甚至是状态更改时。这种解释允许在两个对象上调用任何方法,包括改变器。这通常被称为行为相等,因为它测试两个对象是否在当前状态和所有未来状态中都“行为”相同。

对于不可变对象,观察相等和行为相等是相同的,因为没有任何改变器方法。

对于可变对象,实现严格的观察相等是很诱人的。事实上,Java 对其大多数可变数据类型使用观察相等。如果两个不同的List对象包含相同的元素序列,则equals()报告它们相等。

但是使用观察相等会导致细微的错误,事实上,它还允许我们轻松地破坏其他集合数据结构的表征不变性。假设我们制作了一个List,然后将其放入Set中:

List<String> list = new ArrayList<>();
list.add("a");

Set<List<String>> set = new HashSet<List<String>>();
set.add(list);

我们可以检查集合是否包含我们放入其中的列表,确实包含:

set.contains(list) → true

但是现在我们改变了列表:

list.add("goodbye");

但它不再出现在集合中了!

set.contains(list) → false!

更糟糕的是,事实上是这样的:当我们遍历集合的成员时,我们仍然发现列表在其中,但contains()却说它不在那里!

for (List<String> l : set) { 
    set.contains(l) → false! 
}

如果集合自己的迭代器和自己的contains()方法在是否存在元素的问题上有分歧,那么显然集合是有问题的。你可以[在此代码中查看此代码的运行情况](http://www.pythontutor.com/java.html#code=import+java.util.*%3B public+class+WhyObservationalEqualityHurts+{ ++public+static+void+main(String[]+args)+{ ++++List<String>+list+%3D+new+ArrayList<>()%3B ++++list.add("a")%3B ++++Set<List<String>>+set+%3D+new+HashSet<List<String>>()%3B ++++set.add(list)%3B ++++System.out.println("set.contains(list)%3D"+%2B+set.contains(list))%3B ++++list.add("goodbye")%3B ++++System.out.println("set.contains(list)%3D"+%2B+set.contains(list))%3B ++++for+(List<String>+l+%3A+set)+{+ ++++++System.out.println("set.contains(l)%3D"+%2B+set.contains(l))%3B ++++} ++} }&mode=display&origin=opt-frontend.js&cumulative=false&heapPrimitives=false&textReferences=false&py=java&rawInputLstJSON=[]&curInstr=13)。

发生了什么?List<String>是一个可变对象。在标准 Java 集合类的实现中,如List,突变会影响equals()hashCode()的结果。当列表首次放入HashSet时,它存储在对应于其那时的hashCode()结果的哈希桶中。当列表随后发生突变时,其hashCode()会更改,但HashSet不会意识到它应该移动到另一个桶中。因此,它永远无法再次被找到。

equals()hashCode()可能受到变化的影响时,我们可能会破坏将该对象用作键的哈希表的表示不变式。

下面是java.util.Set规范的一句有意义的引用:

注意:如果将可变对象用作集合元素,则必须格外小心。如果在对象作为集合元素时以影响相等比较的方式更改对象的值,则集合的行为未指定。

Java 库对于其对可变类的equals()的解释不幸地不一致。集合使用观察性相等性,但其他可变类(如StringBuilder)使用行为上的相等性。

我们应该从这个例子中得出的教训是equals()应实现行为上的相等性。一般来说,这意味着两个引用只有在它们是同一对象的别名时才应该相等。因此,可变对象应该只是继承自Objectequals()hashCode()。对于需要观察性相等性概念的客户端(即两个可变对象在当前状态下是否“看起来”相同),最好定义一个新方法,例如similar()

equals()和 hashCode()的最终规则

对于不可变类型

  • equals()应该比较抽象值。这等同于说equals()应提供行为上的相等性。

  • hashCode()应将抽象值映射为整数。

因此,不可变类型必须同时覆盖equals()hashCode()

对于可变类型

  • equals()应该比较引用,就像==一样。同样,这等同于说equals()应提供行为上的相等性。

  • hashCode()应将引用映射到整数。

因此,可变类型根本不应该覆盖equals()hashCode(),而应该简单地使用Object提供的默认实现。不幸的是,Java 并没有遵循这个规则,而是在其集合中提供了默认实现,导致我们上面看到的陷阱。

阅读练习

Bag

假设Bag<E>是一个可变的 ADT,代表通常称为multiset的无序对象集合,其中一个对象可以出现多次。它有以下操作:

/** make an empty bag */
public Bag<E>()

/** modify this bag by adding an occurrence of e, and return this bag */
public Bag<E> add(E e)

/** modify this bag by removing an occurrence of e (if any), and return this bag */
public Bag<E> remove(E e)

/** return number of times e occurs in this bag */
public int count(E e)

假设我们运行以下代码:

Bag<String> b1 = new Bag<>().add("a").add("b");
Bag<String> b2 = new Bag<>().add("a").add("b");
Bag<String> b3 = b1.remove("b");
Bag<String> b4 = new Bag<>().add("b").add("a"); // swap!

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

Bag 的行为

如果使用行为上的相等性来实现Bag,那么以下哪些表达式是正确的?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

豆袋

如果Bag是 Java API 的一部分,它可能会实现观察性相等性,与阅读中的建议相悖。

如果Bag尽管存在危险,实现了观察性相等性,那么以下哪些表达式是正确的?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

自动装箱和相等性

▶︎ 播放 MITx 视频

Java 中的另一个有启发性的陷阱。我们已经讨论过原始类型及其对象类型等价物 - 例如,intInteger。对象类型以正确的方式实现了equals(),因此如果您创建两个具有相同值的Integer对象,它们将互相equals()

Integer x = new Integer(3);
Integer y = new Integer(3);
x.equals(y) → true

但这里有一个微妙的问题;==被重载了。对于像Integer这样的引用类型,它实现了引用相等性:

x == y // returns false

但对于像int这样的原始类型,==实现了行为相等性:

(int)x == (int)y // returns true

因此,您不能真正将Integerint互换使用。Java 自动在intInteger之间转换(这称为自动装箱自动拆箱)可能导致微妙的错误!您必须意识到您表达式的编译时类型是什么。考虑这个:

Map<String, Integer> a = new HashMap(), b = new HashMap();
a.put("c", 130); // put ints into the map
b.put("c", 130);
a.get("c") == b.get("c") → ?? // what do we get out of the map?

您可以在[这里查看代码运行情况](http://www.pythontutor.com/java.html#code=import+java.util.*%3B public+class+AutounboxingProblem+{ ++public+static+void+main(String[]+args)+{ ++++Map<String,+Integer>+a+%3D+new+HashMap<>(),+b+%3D+new+HashMap<>()%3B ++++a.put("c",+130)%3B+//+put+ints+into+the+map ++++b.put("c",+130)%3B ++++System.out.println("a.get(\"c\")+%3D%3D+b.get(\"c\")+returns+" ++++++++++++++++++++++++%2B+(a.get("c")+%3D%3D+b.get("c"))+)%3B ++} }&mode=display&origin=opt-frontend.js&cumulative=false&heapPrimitives=false&textReferences=false&py=java&rawInputLstJSON=[]&curInstr=6) 在 Online Python Tutor 上。

阅读练习

盒子

在上面的最后一个代码示例中...

表达式130的编译时类型是什么?

(缺失答案)

(缺失解释)

执行a.put("c", 130)后,在地图中表示值 130 所使用的运行时类型是什么?

(缺失答案)

(缺失解释)

a.get("c")的编译时类型是什么?

(缺失答案)

(缺失解释)

圆圈

Map<String, Integer> a = new HashMap<>(), b = new HashMap<>();
a.put("c", 130); // put ints into the map
b.put("c", 130);

在上面的代码执行后绘制一个快照图。你的快照图中有多少个HashMap对象?

(缺少答案)

(缺少解释)

你的快照图中有多少个Integer对象?

(缺少答案)

(缺少解释)

Equals

Map<String, Integer> a = new HashMap<>(), b = new HashMap<>();
a.put("c", 130); // put ints into the map
b.put("c", 130);

此代码执行后,a.get("c").equals(b.get("c"))会返回什么?

(缺少答案)

(缺少解释)

a.get("c") == b.get("c")会返回什么?

(缺少答案)

(缺少解释)

Unboxes

现在假设你将get()的结果赋给int变量:

int i = a.get("c");
int j = b.get("c");
boolean isEqual = (i == j);

执行完此代码后,isEqual的值是多少?

(缺少答案)

(缺少解释)

摘要

  • 相等性应该是一个等价关系(自反的,对称的,传递的)。

  • 相等性和哈希码必须彼此一致,以便使用哈希表的数据结构(如HashSetHashMap)正常工作。

  • 抽象函数是不可变数据类型中相等性的基础。

  • 引用相等性是可变数据类型中相等性的基础;这是确保随着时间的推移保持一致性并避免破坏哈希表表示不变式的唯一方法。

相等性是实现抽象数据类型的一部分,我们已经看到 ADT 对于实现我们的三个主要目标是多么重要。让我们特别看看相等性:

  • 免于错误。正确实现相等性和哈希码对于与集合数据类型(如集合和映射)一起使用是必要的。对于编写测试来说也是非常有益的。由于 Java 中的每个对象都继承了Object的实现,不可变类型必须覆盖它们。

  • 易于理解。阅读我们规范的客户和其他程序员会期望我们的类型实现适当的相等操作,如果我们没有这样做,他们会感到惊讶和困惑。

  • 为变更做好准备。对于不可变类型正确实现的相等性将引用的相等性与抽象值的相等性分开,从而隐藏了我们关于值是否共享的决策。对于可变类型选择行为而不是观察性的相等性有助于避免意外的别名错误。

阅读 16:递归数据类型

6.005 软件

免受错误侵扰 易于理解 为变化做好准备
今天正确,未来也正确。 与未来程序员清晰沟通,包括未来的自己。 设计以适应变化而无需重写。

目标

  • 理解递归数据类型

  • 阅读和编写数据类型定义

  • 理解并实现递归数据类型函数

  • 理解不可变列表,并了解不可变列表的标准操作

  • 了解并遵循使用 ADT 编写程序的步骤

介绍

在本阅读中,我们将看看递归定义的类型,如何指定这些类型的操作,以及如何实现它们。我们的主要例子将是不可变列表

然后我们将使用另一个递归数据类型的例子,矩阵乘法,来演示我们使用 ADT 进行编程的过程。

第一部分:递归数据类型

第二部分:使用 ADT 编写程序

摘要

让我们回顾一下递归数据类型如何符合本课程的主要目标:

  • 免受错误侵扰。递归数据类型使我们能够解决具有递归或无界结构的问题。实现适当的数据结构,封装重要操作并保持自身不变性对于正确性至关重要。

  • 易于理解。在抽象类型中指定的递归数据类型函数,并在每个具体变体中实现,组织了类型的不同行为。

  • 为变化做好准备。递归 ADT,像任何 ADT 一样,将抽象值与具体表示分离,使得可以在不改变客户端的情况下更改低级代码和实现的高级结构。

第 17 篇阅读:正则表达式和语法

6.005 中的软件

免受错误 易于理解 准备好变化
今天正确且未来未知也正确。 与未来的程序员清晰沟通,包括未来的你。 设计以适应变化而无需重写。

目标

今天的课后,你应该:

  • 理解语法产生和正则表达式运算符的思想

  • 能够阅读语法或正则表达式,并确定它是否匹配一个字符序列

  • 能够编写语法或正则表达式来匹配一组字符序列并将其解析成数据结构

介绍

今天的阅读介绍了几个思想:

  • 具有产生物、非终端、终端和操作符的语法

  • 正则表达式

  • 解析器生成器

一些程序模块以字节序列或字符序列的形式接收输入或产生输出,当它被简单地存储在内存中时,被称为字符串,当它流入或流出模块时,被称为。在今天的阅读中,我们讨论了如何为这样的序列编写规范。具体来说,一系列字节或字符可能是:

  • 磁盘上的文件,此时规范称为文件格式

  • 通过网络发送的消息,此时规范是线协议

  • 用户在控制台上键入的命令,此时规范是命令行界面

  • 存储在内存中的字符串

对于这些类型的序列,我们引入了语法的概念,它不仅允许我们区分合法和非法的序列,还允许我们将一个序列解析成程序可以处理的数据结构。从语法产生的数据结构通常是像我们在递归数据类型阅读中讨论过的那样的递归数据类型。

我们还讨论了一种称为正则表达式的语法的专门形式。除了用于规范和解析之外,正则表达式还是一种广泛使用的工具,用于许多需要拆解字符串、从中提取信息或转换它的字符串处理任务。

下面的阅读将讨论解析器生成器,一种工具,可以将语法自动转换为该语法的解析器。

语法

为了描述一系列符号,无论它们是字节、字符还是从固定集合中提取的其他类型的符号,我们使用了一个称为语法的紧凑表示。

语法定义了一组句子,其中每个句子都是符号序列。例如,我们的 URL 语法将指定 HTTP 协议中的合法 URL 集。

句子中的符号称为终端(或标记)。

它们被称为终端,因为它们是代表句子结构的树的叶子。它们没有任何子节点,也不能进一步扩展。我们通常用引号写终端,比如'http'':'

语法由一组产生式描述,其中每个产生式定义一个非终结符。你可以把一个非终结符看作是代表一组句子的变量,而产生式则是对这个变量的定义,以其他变量(非终结符)、运算符和常量(终结符)为基础。非终结符是表示句子的树的内部节点。

语法中的一个产生式的形式是

非终结符 ::= 终结符、非终结符和运算符的表达式

在 6.005 中,我们将使用小写标识符来命名非终结符,如xyurl

语法中的一个非终结符被指定为。语法识别的句子集合是与根非终结符匹配的句子。这个非终结符通常被称为rootstart,但在下面的语法中,我们通常会选择更容易记忆的名称,如urlhtmlmarkdown

语法运算符

产生式表达式中的三个最重要的运算符是:

  • 连接
x ::= y z     an x is a y followed by a z 
  • 重复
x ::= y*      an x is zero or more y 
  • 联合(也称为交替)
x ::= y | z     an x is a y or a z 

你还可以使用其他仅是语法糖的附加运算符(即,它们等价于大三运算符的组合):

  • 选项(0 或 1 次出现)
x ::=  y?      an x is a y or is the empty sentence
  • 1+ 重复(1 次或更多次)
x ::= y+       an x is one or more y
               (equivalent to  x ::= y y* )
  • 字符类
x ::= [abc]  is equivalent to  x ::= 'a' | 'b' | 'c' 

x ::= [^b]   is equivalent to  x ::= 'a' | 'c' | 'd' | 'e' | 'f' 
                                         | ... (all other characters)

按照惯例,运算符*?+具有最高的优先级,这意味着它们首先被应用。交替|具有最低的优先级,这意味着它在最后应用。括号可以用于覆盖这个优先级,使得一个序列或交替可以重复:

  • 使用括号进行分组
x ::=  (y z | a b)*   an x is zero or more y-z or a-b pairs

阅读练习

阅读语法 1

考虑这个语法:

S ::= (B C)* T
B ::= M+ | P B P
C ::= B | E+

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

阅读语法 2

root ::= 'a'+ 'b'* 'c'?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

阅读语法 3

root    ::= integer ('-' integer)+
integer ::= [0-9]+

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

阅读语法 4

root   ::= (A B)+
A      ::= [Aa]
B      ::= [Bb]

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

例如:网址

假设我们想要编写一个代表网址的语法。让我们通过从简单的示例开始并随着扩展语法而渐进地构建语法。

这是一个简单的网址:

http://mit.edu/

代表只有这个网址的句子集合的语法如下:

url ::= 'http://mit.edu/'

但让我们将其泛化以捕获其他领域:

http://stanford.edu/
http://google.com/

我们可以将其写成一行,如下所示:

url ::= 'http://' [a-z]+ '.' [a-z]+  '/'

此语法表示仅由两部分主机名组成的所有 URL,其中主机名的每个部分由 1 个或多个字母组成。因此,http://mit.edu/http://yahoo.com/将匹配,但http://ou812.com/不会。由于它只有一个非终结符,这个 URL 语法的解析树将看起来像右侧的图片。

在这种单行形式中,具有仅使用运算符和终结符的产生式的单一非终结符的语法称为正则表达式(稍后详细介绍)。但如果我们使用新的非终结符命名部分,将更容易理解:

使用具有 url、主机名和单词非终结符的语法解析'http://mit.edu'生成的解析树

url ::= 'http://' hostname '/'
hostname ::= word '.' word
word ::= [a-z]+

此语法的解析树现在显示在右侧。树现在具有更多结构。树的叶子是已解析的字符串部分。如果我们将叶子连接在一起,我们将恢复原始字符串。主机名单词非终结符标记了树的节点,其子树与语法中的规则匹配。请注意,像主机名这样的非终结符节点的直接子节点遵循主机名规则,单词'.'单词

我们还需要进行哪些泛化?主机名可以有多个组件,还可以有一个可选的端口号:

http://didit.csail.mit.edu:4949/

为了处理这种类型的字符串,现在的语法是:

使用具有递归主机名规则的语法解析'http://mit.edu'生成的解析树

url ::= 'http://' hostname (':' port)? '/' 
hostname ::= word '.' hostname | word '.' word
port ::= [0-9]+
word ::= [a-z]+

请注意,主机名现在是递归地定义的。 主机名定义的哪一部分是基本情况,哪一部分是递归步骤?允许哪些类型的主机名?

使用重复运算符,我们也可以这样写主机名:

hostname ::= (word '.')+ word

另一个需要观察的是,此语法允许端口号不符合技术上的规定,因为端口号只能范围从 0 到 65535。我们可以编写更复杂的端口定义,只允许这些整数,但在语法中通常不这样做。相反,约束 0 <= 端口 <= 65535 将与语法一起指定。

还有更多我们应该做的事情以走得更远:

  • http泛化以支持 URL 可能具有的其他协议

  • 将结尾的/泛化为斜杠分隔的路径

  • 允许使用完整合法字符集而不仅仅是 a-z 的主机名

阅读练习

编写语法

假设我们希望url语法也匹配以下形式的字符串:

https://websis.mit.edu/
ftp://ftp.athena.mit.edu/

但不是以下形式的字符串:

ptth://web.mit.edu/
mailto:bitdiddle@mit.edu

因此,我们将语法更改为:

url ::= protocol '://' hostname (':' port)? '/' 
protocol ::= TODO
hostname ::= word '.' hostname | word '.' word
port ::= [0-9]+
word ::= [a-z]+

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

示例:Markdown 和 HTML

现在让我们看看一些文件格式的语法。我们将使用两种不同的标记语言,它们表示文本中的排版样式。这里它们是:

Markdown

This is _italic_.

HTML

Here is an <i>italic</i> word.

为简单起见,我们的示例 HTML 和 Markdown 语法仅指定了斜体,但当然还有其他文本样式可能。

这是我们简化版 Markdown 的语法:

由 Markdown 语法产生的解析树

markdown ::=  ( normal | italic ) *
italic ::= '_' normal '_'
normal ::= text
text ::= [^_]*

这是我们简化版 HTML 的语法:

由 HTML 语法产生的解析树

html ::=  ( normal | italic ) *
italic ::= '<i>' html '</i>'
normal ::= text
text ::= [^<>]*

阅读练习

递归语法

查看上面的markdownhtml语法,并比较它们的italic产生式。注意,它们不仅在分隔符(在一个情况下为_,在另一个情况下为< >标记)上有所不同,而且在这些分隔符之间匹配的非终结符也不同。一个语法是递归的;另一个语法则不是。

对于下面的每个字符串,如果将指定的语法与之匹配,哪些字母在与italic非终结符匹配的内容内?您的答案应该是字母abcde的某个子集。

(缺少答案)

(缺少解释)

(缺少答案)

(缺少解释)

正则表达式

一个普通语法具有特殊属性:通过用其右侧替换每个非终结符(除了根终结符),您可以将其减少到根的单个产生式,右侧仅包含终结符和运算符。

我们的 URL 语法是正则的。通过用它们的产生式替换非终结符,它可以被减少为单个表达式:

url ::= 'http://' ([a-z]+ '.')+ [a-z]+ (':' [0-9]+)? '/' 

Markdown 语法也是正则的:

markdown ::= ([^_]* | '_' [^_]* '_' )*

但是我们的 HTML 语法无法完全简化。通过用非终结符的右侧替换,您最终可以将其减少到类似于这样的东西:

html ::=  ( [^<>]* | '<i>' html '</i>' )*

但是右侧的html的递归使用无法消除,并且也不能简单地用重复运算符替换。因此,HTML 语法不是正则的。

终端和运算符的简化表达式可以以更紧凑的形式编写,称为正则表达式。正则表达式摒弃了终端周围的引号和终端与运算符之间的空格,因此它只包含终端字符、用于分组的括号和运算符字符。例如,我们的markdown格式的正则表达式就是

([^_]*|_[^_]*_)*

正则表达式也被简称为regexes。正则表达式比原始语法不太可读,因为它缺少记录了每个子表达式含义的非终结符名称。但是正则表达式的实现速度很快,并且许多编程语言中都有支持正则表达式的库。

在编程语言库中常见的正则表达式语法还有一些特殊运算符,除了我们在语法中使用的运算符外。这里是一些常用的有用的运算符:

.       any single character

\d      any digit, same as [0-9]
\s      any whitespace character, including space, tab, newline
\w      any word character, including letters and digits

\., \(, \), \*, \+, ...
        escapes an operator or special character so that it matches literally

当存在会与特殊字符混淆的终端字符时,使用反斜杠是很重要的。因为我们的url正则表达式中包含.作为终端字符,所以我们需要使用反斜杠来转义它:

http://([a-z]+\.)+[a-z]+(:[0-9]+)/

阅读练习

正则表达式

考虑以下正则表达式:

[A-G]+(♭|♯)?

缺失答案 缺失答案 缺失答案 缺失答案 缺失答案

缺失解释

在 Java 中使用正则表达式

正则表达式("regexes")在编程中被广泛使用,你应该将它们放入你的工具箱中。

在 Java 中,你可以使用正则表达式来操作字符串(参见String.splitString.matchesjava.util.regex.Pattern)。它们作为现代脚本语言的一流特性内置在其中,如 Python、Ruby 和 Javascript,并且你可以在许多文本编辑器中使用它们进行查找和替换。正则表达式是你的朋友!大多数时候。以下是一些示例。

用单个空格替换所有连续的空格:

String singleSpacedString = string.replaceAll(" +", " ");

匹配一个 URL:

Pattern regex = Pattern.compile("http://([a-z]+\\.)+[a-z]+(:[0-9]+)?/");
Matcher m = regex.matcher(string);
if (m.matches()) {
    // then string is a url
}

提取 HTML 标签的一部分:

Pattern regex = Pattern.compile("<a href='\"['\"]>");
Matcher m = regex.matcher(string);
if (m.matches()) {
    String url = m.group(1); 
    // Matcher.group(n) returns the nth parenthesized part of the regex
}

注意 URL 和 HTML 标签示例中的反斜杠。在 URL 示例中,我们想匹配一个字面上的句号.,所以我们必须先将其转义为\.以防止它被解释为正则表达式的匹配任意字符操作符,然后我们必须进一步将其转义为\\.以防止反斜杠被解释为 Java 字符串转义字符。在 HTML 示例中,我们必须将引号"转义为\"以防止它结束字符串。反斜杠转义的频率使得正则表达式仍然不够可读。

阅读练习

在 Java 中使用正则表达式

写一个尽可能短的正则表达式来从字符串中移除只包含单词、小写字母的 HTML 标签:

String input = "The <b>Good</b>, the <i>Bad</i>, and the <strong>Ugly</strong>";
String regex = "TODO";
String output = input.replaceAll(regex, "");

如果期望的输出是"The Good, the Bad, and the Ugly",你可以用什么最短的正则表达式替换 TODO?你可能会发现在[在线 Python 教程中运行此示例](http://www.pythontutor.com/java.html#code=public+class+Regex+{ ++++public+static+void+main(String[]+args)+{ ++++++++String+input+%3D+"The+<b>Good</b>,+the+<i>Bad</i>,+and+the+<strong>Ugly</strong>"%3B+ ++++++++String+regex+%3D+"TODO"%3B ++++++++String+output+%3D+input.replaceAll(regex,+"")%3B ++++++++System.out.println(output)%3B ++++} }&mode=edit&origin=opt-frontend.js&cumulative=false&heapPrimitives=false&textReferences=false&py=java&rawInputLstJSON=[])中有所帮助。

缺失答案

缺失解释

上下文无关文法

一般来说,一个可以用我们的文法系统表达的语言被称为上下文无关。并不是所有的上下文无关语言都是正则的;也就是说,有些文法无法简化为单个的非递归产生式。我们的 HTML 文法是上下文无关的,但不是正则的。

大多数编程语言的语法也是上下文无关的。通常,任何具有嵌套结构的语言(如嵌套括号或大括号)都是上下文无关的,但不是正则的。该描述适用于 Java 语法,此处部分显示:

statement ::= 
  '{' statement* '}'
| 'if' '(' expression ')' statement ('else' statement)?
| 'for' '(' forinit? ';' expression? ';' forupdate? ')' statement
| 'while' '(' expression ')' statement
| 'do' statement 'while' '(' expression ')' ';'
| 'try' '{' statement* '}' ( catches | catches? 'finally' '{' statement* '}' )
| 'switch' '(' expression ')' '{' switchgroups '}'
| 'synchronized' '(' expression ')' '{' statement* '}'
| 'return' expression? ';'
| 'throw' expression ';' 
| 'break' identifier? ';'
| 'continue' identifier? ';'
| expression ';' 
| identifier ':' statement
| ';'

总结

在计算机科学中,机器处理的文本语言无处不在。语法是描述这种语言的最流行形式,而正则表达式是语法的一个重要子类,可以在没有递归的情况下表示。

今天阅读的主题与我们对好软件的三个特性有关,如下:

  • 免受错误的影响。语法和正则表达式是字符串和流的声明性规范,可以直接由库和工具使用。这些规范通常更简单、更直接,而且不太可能出错,与手工编写的解析代码相比。

  • 易于理解。语法捕捉了序列的形状,以一种比手写解析代码更易于理解的形式。不幸的是,正则表达式通常不容易理解,因为它们是可能更易于理解的正则语法的一行简化形式。

  • 易于更改。语法可以很容易地编辑,但是正则表达式,不幸的是,要改变就难得多,因为复杂的正则表达式晦涩难懂。

阅读 18:解析器生成器

6.005 中的软件

免受错误困扰 易于理解 为变化做好准备
今天正确,未来也正确。 与未来程序员清晰沟通,包括未来的你。 设计以适应变化而无需重写。

目标

今天的课后,你应该:

  • 能够使用语法与解析器生成器结合,将字符序列解析为解析树

  • 能够将解析树转换为有用的数据类型

解析器生成器

解析器生成器是一个很好的工具,你应该将其作为工具箱的一部分。解析器生成器接受语法作为输入,并自动生成可以使用语法解析字符流的源代码。

生成的代码是一个解析器,它接受一系列字符并尝试将该序列与语法匹配。解析器通常生成一个解析树,显示了语法产生如何扩展为与字符序列匹配的句子。解析树的根是语法的起始非终结符。解析树的每个节点扩展为语法的一个产生式。我们将在下一节看到解析树实际上是什么样子。

解析的最后一步是对这个解析树执行一些有用的操作。我们将把它转换为递归数据类型的值。递归抽象数据类型通常用于表示语言中的表达式,如 HTML、Markdown、Java 或代数表达式。表示语言表达式的递归抽象数据类型称为抽象语法树(AST)。

对于这门课程,我们将使用“ParserLib”,这是我们专门为 6.005 开发的 Java 解析器生成器。该解析器生成器在精神上类似于更广泛使用的解析器生成器,如Antlr,但它具有更简单的界面,通常更容易使用。

一个 ParserLib 语法

接下来的示例代码可以在 GitHub 上找到,名称为fa16-ex18-parser-generators

这是我们的 HTML 语法作为 ParserLib 源文件的样子:

root ::= html;
html ::= ( italic | normal ) *;
italic ::= '<i>' html '</i>';
normal ::= text; 
text ::= [^<>]+;  /* represents a string of one or more characters that are not < or > */

让我们来分解一下。

每个 ParserLib 规则由一个名称组成,后跟一个::=,然后是其定义,以分号结尾。ParserLib 语法还可以包括 Java 风格的注释,包括单行和多行。

按照惯例,我们使用小写字母表示非终结符:roothtmlnormalitalic。(ParserLib 库实际上对于非终结符名称是不区分大小写的;在内部,它将名称规范化为全小写,因此即使您没有将所有名称写成小写,当您打印您的语法时,您将看到它们为小写)。终结符可以是带引号的字符串,如'<i>',或者根据字符串的正则表达式定义的名称,如text

root ::= html;

root是语法的入口点。这是整个输入需要匹配的非终结符。我们不必称其为root。当将语法加载到我们的程序中时,我们将告诉库使用哪个非终结符作为入口点。

html ::= ( normal | italic ) *;

此规则显示 ParserLib 规则可以具有交替运算符|,重复运算符*+,以及用于分组的括号,方式与我们在语法阅读中使用的方式相同。可选部分可以用?标记,就像我们之前所做的那样,但是这个特定的语法不使用?

italic ::= '<i>' html '</i>';
normal ::= text; 
text ::= [^<>]+;

请注意,终端text使用之前的表示法[^<>]来表示除了<>之外的所有字符。

一般来说,终结符号不必是固定的字符串;它们可以是正则表达式,就像示例中的那样。例如,这里是我们在前文中使用的 URL 语法中使用的一些其他终结符模式,现在用 ParserLib 语法编写:

identifier ::= [a-z]+;
integer ::= [0-9]+;

空格

考虑下面显示的语法。

root ::= sum;
sum ::= primitive ('+' primitive)*;
primitive ::= number | '(' sum ')';
number ::= [0-9]+;

此语法将接受像42+2+5这样的表达式,但将拒绝类似的表达式,其中数字和+符号之间有任何空格。我们可以修改sum的产生规则以允许加号周围的空白,如下所示:

sum ::= primitive (whitespace* '+' whitespace* primitive)*;
whitespace ::= [ \t\r\n];

但是,一旦语法变得更复杂,这可能会变得很麻烦。ParserLib 允许使用一种简写来指示应跳过某些类型的字符。

//The IntegerExpression grammar
@skip whitespace{
    root ::= sum;
    sum ::= primitive ('+' primitive)*;
    primitive ::= number | '(' sum ')';
}
whitespace ::= [ \t\r\n];
number ::= [0-9]+;

@skip whitespace表示应该跳过与空格非终结符匹配的任何文本,这些文本位于构成sum rootprimitive定义的部分之间。有两件事值得注意。首先,whitespace没有任何特殊之处。@skip指令与语法中定义的任何非终结符或终结符一起工作。其次,需要注意number的定义被故意留在@skip块之外。这是因为我们想要接受像42 + 2 + 5这样的表达式,但我们想要拒绝像4 2 + 2 + 5这样的表达式。在接下来的文本中,我们将这个语法称为IntegerExpression语法。

生成解析器

本文的其余部分将以之前定义的IntegerExpression语法为运行示例,我们将其存储在名为IntegerExpression.g的文件中。

ParserLib 解析器生成工具将语法源文件(如IntegerExpression.g)转换为解析器。为了做到这一点,您需要遵循三个步骤。首先,您需要导入 ParserLib 库,该库位于一个包lib6005.parser中:

import lib6005.parser;

第二步是定义一个Enum类型,其中包含语法中使用的所有终结符和非终结符。这将告诉编译器在语法中期望哪些定义,并允许它检查任何缺失的定义。

enum IntegerGrammar {ROOT, SUM, PRIMITIVE, NUMBER, WHITESPACE};

注意,ParserLib 本身不区分大小写,但按照惯例,enum 值的名称全部大写。

在您的代码中,您可以通过调用 GrammarCompiler 中的 compile 静态方法来创建解析器。

...
Parser<IntegerGrammar> parser = GrammarCompiler.compile(new File("IntegerExpression.g"), IntegerGrammar.ROOT);

代码 打开文件 IntegerExpression.g 并使用 GrammarCompiler 将其编译为 Parser 对象。compile 方法的第二个参数是要用作语法入口点的非终结符的名称;在此示例中是 root

假设您的语法文件中没有语法错误,则结果将是一个可以用于解析字符串或文件中的文本的 Parser 对象。请注意,解析器是一个 泛型 类型,由您之前定义的 enum 进行参数化。

调用解析器

现在您已经生成了解析器对象,可以开始解析您自己的文本了。解析器有一个名为 parse 的方法,该方法接受要解析的文本(以 StringInputStreamFileReader 的形式)并返回一个 ParseTree。调用它会产生一个解析树:

ParseTree<IntegerGrammar> tree = parser.parse("5+2+3+21");

注意,ParseTree 也是一个泛型类型,由枚举类型 IntegerGrammar 进行参数化。

为了调试,我们可以打印出这棵树:

System.out.println(tree.toString());

您还可以尝试调用 display() 方法,它将尝试打开一个浏览器窗口,显示解析树的可视化效果。如果由于任何原因无法打开浏览器窗口,则该方法将在终端打印一个 URL,您可以将其复制并粘贴到浏览器中查看可视化效果。

在示例代码中:Main.java 第 34-35 行,使用了枚举中的 第 13-17 行

阅读练习

解析树(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

遍历解析树

因此,我们使用解析器将字符流转换为解析树,该树显示了语法与流的匹配情况。现在我们需要对这个解析树做一些事情。我们将把它翻译成递归抽象数据类型的值。

第一步是学习如何遍历解析树。ParseTree 对象有四个您需要熟悉的方法。

/**
 * Returns the substring of the original string that corresponds to this parse tree.
 * @return String containing the contents of this parse tree.
 */
public String getContents()

/**
 * Ordered list of all the children nodes of this ParseTree node.
 * @return a List of all children of this ParseTree node, ordered by position in input
 */
public List<ParseTree<Symbols>> children()

/**
 * Tells you whether a node corresponds to a terminal or a non-terminal. 
 * If it is terminal, it won't have any children.
 * @return true if it is a terminal value.
 */
public boolean isTerminal()

/**
 * Get the symbol for the terminal or non-terminal corresponding to this parse tree.
 * @return T will generally be an Enum representing the different symbols 
 *  in the grammar, so the return value will be one of those.
 */
public Symbols getName() 

另外,您可以查询 ParseTree,找出所有与特定产生式规则匹配的子节点:

/**
 * Get all the children of this PareseTree node corresponding to a particular production rule 
 * @param name 
 * Name of the non-terminal corresponding to the desired production rule.
 * @return 
 * List of children ParseTree objects that match that name.
 */
public List <ParseTree<Symbols>> childrenByName(Symbols name);

注意,像解析器本身一样,ParseTree 也是通过 Symbols 的类型进行参数化的,该类型预期是一个列出语法中所有符号的 enum 类型。

ParseTree实现了可迭代接口,因此您可以使用for循环遍历所有子节点。遍历解析树中所有节点的一种方法是编写一个递归函数。例如,下面的递归函数打印解析树中所有节点,并正确缩进。

/**
 * Traverse a parse tree, indenting to make it easier to read.
 * @param node
 * Parse tree to print.
 * @param indent
 * Indentation to use.
 */
void visitAll(ParseTree<IntegerGrammar> node, String indent){
    if(node.isTerminal()){
        System.out.println(indent + node.getName() + ":" + node.getContents());
    }else{
        System.out.println(indent + node.getName());
        for(ParseTree<IntegerGrammar> child: node){
            visitAll(child, indent + "   ");
        }
    }
}

构建抽象语法树

我们需要将解析树转换为递归数据类型。这里是我们将用来表示整数算术表达式的递归数据类型的定义:

IntegerExpression = Number(n:int)
                    + Plus(left:IntegerExpression, right:IntegerExpression)

如果这个语法看起来很神秘,请查看递归数据类型定义。

当递归数据类型以这种方式表示一种语言时,通常被称为抽象语法树。一个IntegerExpression值捕捉了表达式的重要特征 - 它的分组和其中的整数 - 同时省略了创建它的字符序列的不必要细节。

相比之下,我们刚刚用IntegerExpression解析器生成的解析树是一个具体语法树。它被称为具体,而不是抽象,是因为它包含了关于表达式如何在实际字符中表示的更多细节。例如,字符串2+2((2)+(2))0002+0002将分别产生不同的具体语法树,但这些树都对应于相同的抽象IntegerExpression值:Plus(Number(2), Number(2))

现在,我们可以创建一个简单的递归函数,遍历ParseTree以生成IntegerExpression如下。

这里是代码:

Main.java 第 41 行

/**
     * Function converts a ParseTree to an IntegerExpression. 
     * @param p
     *  ParseTree<IntegerGrammar> that is assumed to have been constructed by the grammar in IntegerExpression.g
     * @return
     */
    IntegerExpression buildAST(ParseTree<IntegerGrammar> p){

        switch(p.getName()){
        /*
         * Since p is a ParseTree parameterized by the type IntegerGrammar, p.getName() 
         * returns an instance of the IntegerGrammar enum. This allows the compiler to check
         * that we have covered all the cases.
         */
        case NUMBER:
            /*
             * A number will be a terminal containing a number.
             */
            return new Number(Integer.parseInt(p.getContents()));
        case PRIMITIVE:
            /*
             * A primitive will have either a number or a sum as child (in addition to some whitespace)
             * By checking which one, we can determine which case we are in.
             */             

            if(p.childrenByName(IntegerGrammar.number).isEmpty()){
                return buildAST(p.childrenByName(IntegerGrammar.sum).get(0));
            }else{
                return buildAST(p.childrenByName(IntegerGrammar.number).get(0));
            }

        case SUM:
            /*
             * A sum will have one or more children that need to be summed together.
             * Note that we only care about the children that are primitive. There may also be 
             * some whitespace children which we want to ignore.
             */
            boolean first = true;
            IntegerExpression result = null;
            for(ParseTree<IntegerGrammar> child : p.childrenByName(IntegerGrammar.PRIMITIVE)){                
                if(first){
                    result = buildAST(child);
                    first = false;
                }else{
                    result = new Plus(result, buildAST(child));
                }
            }
            if(first){ throw new RuntimeException("sum must have a non whitespace child:" + p); }
            return result;
        case ROOT:
            /*
             * The root has a single sum child, in addition to having potentially some whitespace.
             */
            return buildAST(p.childrenByName(IntegerGrammar.sum).get(0));
        case WHITESPACE:
            /*
             * Since we are always avoiding calling buildAST with whitespace, 
             * the code should never make it here. 
             */
            throw new RuntimeException("You should never reach here:" + p);
        }   
        /*
         * The compiler should be smart enough to tell that this code is unreachable, but it isn't.
         */
        throw new RuntimeException("You should never reach here:" + p);
    }

这个函数非常简单,并且非常遵循语法的结构。需要注意的一点是,代码非常强烈地假设将处理与IntegerExpression.g中的语法对应的ParseTree。如果您提供了不同类型的ParseTree,代码可能会因为RuntimeException而失败,但它总是会终止,永远不会返回空引用。

读取练习

字符串转换为 AST 1

如果输入字符串是"19+23+18",那么buildAST会产生哪个抽象语法树?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

字符串转换为 AST 2

以下哪个输入字符串会产生:

Plus(Plus(Number(1), Number(2)), 
     Plus(Number(3), Number(4)))

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

处理错误

解析文件时可能会出现几种问题。

  • 你的语法文件可能打不开。

  • 你的语法可能在语法上有错误。

  • 您尝试解析的字符串可能无法使用您提供的语法解析,要么是因为您的语法不正确,要么是因为您的字符串不正确。

在第一种情况下,compile方法将抛出IOException。在第二种情况下,它将抛出UnableToParseException。在第三种情况下,parse方法将抛出UnableToParseExceptionUnableToParseException异常将包含有关错误可能位置的一些信息,尽管解析错误有时本质上很难定位,因为解析器无法知道您打算写入什么字符串,因此您可能需要搜索一下才能找到错误的真实位置。

左递归和其他 ParserLib 的限制

ParserLib 通过生成自顶向下的递归下降解析器来工作。这种类型的解析器在解析语法方面有一些限制。有两个特别值得指出的限制。

左递归。 如果语法涉及左递归,递归下降解析器可能会陷入无限循环。这是一个情况,其中一个非终结符的定义包含该非终结符作为其最左边的符号。例如,下面的语法包括左递归,因为sum的可能定义之一是sum '+' number,其中sum是其最左边的符号。

//The IntegerExpression grammar
@skip whitespace{
    root ::= sum;
    sum ::=  number | sum '+' number;
}
whitespace ::= [ \t\r\n];
number ::= [0-9]+;

左递归也可能间接发生。例如,将上面的语法更改为下面的语法并不能解决问题,因为sum的定义仍然间接涉及一个具有sum作为其第一个符号的符号。

//The IntegerExpression grammar
@skip whitespace{
    root ::= sum;
    sum ::=  number | thing  number;
    thing ::= sum '+';
}
whitespace ::= [ \t\r\n];
number ::= [0-9]+;

如果你将任何这些语法提供给 ParserLib,然后尝试使用它们来解析一个符号,ParserLib 将以UnableToParse异常失败,并列出有问题的非终结符。

有一些通用技术来消除左递归;对于我们的目的,最简单的方法是用重复(*)替换左递归,因此上面的语法变为:

//The IntegerExpression grammar
@skip whitespace{
    root ::= sum;
    sum ::=  (number '+')* number;
}
whitespace ::= [ \t\r\n];
number ::= [0-9]+;

贪婪性。 这不是你在这门课上会遇到的问题,但这是 ParserLib 的一个限制,你应该注意到。ParserLib 解析器是贪婪的,因为在任何时候,它们都试图匹配当前考虑的任何规则的最大字符串。例如,考虑以下语法。

root ::= ab threeb;
ab ::= 'a'*'b'*
threeb ::= 'bbb';

字符串'aaaabbb'显然符合语法,但贪婪解析器无法解析它,因为它将尝试解析与ab符号匹配的最大子字符串,然后发现它无法解析threeb,因为它已经消耗了整个字符串。与左递归不同,这是 ParserLib 实现的解析器类型的更基本限制,但如前所述,在这门课程中不会遇到这种情况。

摘要

今天阅读的主题与我们对好软件的三个属性有关,如下所示:

  • 免受错误影响。 一个语法是对字符串和流的声明性规范,可以由解析器生成器自动实现。这些规范通常比手写的解析代码更简单、更直接,更不容易出错。

  • 易于理解。 一个语法以一种紧凑且比手写解析代码更容易理解的形式捕捉序列的形状。

  • 准备好改变。 一个语法可以很容易地被编辑,然后通过解析器生成器运行以重新生成解析代码。

阅读 19:并发性

6.005 中的软件

免受错误影响 易于理解 为变化做好准备
今天正确,未来也正确。 与未来的程序员清晰沟通,包括未来的自己。 设计以适应变化而无需重写。

目标

  • 消息传递和共享内存并发模型

  • 并发进程和线程,以及时间片

  • 竞争条件的危险

并发性

并发 意味着多个计算同时进行。并发在现代编程中随处可见,无论我们是否喜欢:

  • 网络中的多台计算机

  • 在一台计算机上运行的多个应用程序

  • 计算机中的多个处理器(今天,通常是单个芯片上的多个处理器核心)

实际上,并发在现代编程中是必不可少的:

  • 网站必须处理多个同时在线的用户。

  • 移动应用程序需要在服务器上进行一些处理(“在云端”)。

  • 图形用户界面几乎总是需要在不打扰用户的情况下进行后台工作。例如,Eclipse 在您编辑代码时编译您的 Java 代码。

未来仍然能够使用并发编程将是重要的。处理器时钟速度不再增加。相反,每一代新芯片都会获得更多的核心。因此,在未来,为了使计算运行更快,我们将不得不将计算分割成并发片段。

两种并发编程模型

并发编程有两种常见模型:共享内存消息传递

共享内存

共享内存。 在共享内存并发模型中,并发模块通过读取和写入内存中的共享对象进行交互。

共享内存模型的示例:

  • A 和 B 可能是同一台计算机中的两个处理器(或处理器核心),共享相同的物理内存。

  • A 和 B 可能是在同一台计算机上运行的两个程序,共享一个可以读写文件的公共文件系统。

  • A 和 B 可能是同一个 Java 程序中的两个线程(我们将在下面解释什么是线程),共享相同的 Java 对象。

消息传递

消息传递。 在消息传递模型中,并发模块通过通过通信通道向彼此发送消息来进行交互。模块发送消息,每个模块的传入消息都会排队等待处理。示例包括:

  • A 和 B 可能是通过网络连接进行通信的网络中的两台计算机。

  • A 和 B 可能是一个网络浏览器和一个网络服务器 - A 打开到 B 的连接并请求一个网页,B 将网页数据发送回 A。

  • A 和 B 可能是即时通讯客户端和服务器。

  • A 和 B 可能是在同一台计算机上运行的两个程序,它们的输入和输出已经通过管道连接起来,就像在命令提示符中键入 ls | grep 一样。

进程、线程、时间片分配

消息传递和共享内存模型涉及并发模块之间的通信方式。并发模块本身有两种不同的类型:进程和线程。

进程。进程是在同一台机器上与其他进程隔离的运行程序的实例。特别地,它拥有自己的机器内存的私有部分。

进程抽象是一个虚拟计算机。它使得程序感觉就像它拥有整个机器一样——就像一个全新的计算机被创建出来,拥有全新的内存,只是为了运行那个程序。

就像通过网络连接的计算机一样,进程通常之间不共享内存。一个进程无法访问另一个进程的内存或对象。在大多数操作系统上,进程之间共享内存是可能的,但需要特殊的努力。相比之下,新进程自动准备好进行消息传递,因为它是用标准输入和输出流创建的,这些流就是您在 Java 中使用的System.outSystem.in流。

线程。线程是程序内正在运行的控制流。可以将其视为程序中正在运行的位置,以及导致该位置的方法调用堆栈(因此当线程达到return语句时,它可以回溯到堆栈的上层)。

就像进程代表着一个虚拟计算机一样,线程抽象表示着一个虚拟处理器。创建一个新线程模拟了在进程所代表的虚拟计算机内创建一个全新的处理器。这个新的虚拟处理器运行着与进程中其他线程相同的程序,并共享着相同的内存。

线程自动准备好共享内存,因为线程共享进程中的所有内存。需要特殊的努力才能获得“线程本地”内存,即对单个线程私有的内存。还需要明确地设置消息传递,通过创建和使用队列数据结构来进行。我们将在以后的阅读中讨论如何做到这一点。

时间片分配

如何在我的计算机上只有一个或两个处理器的情况下拥有许多并发线程?当线程数量多于处理器时,通过时间片分配来模拟并发,这意味着处理器在线程之间切换。右图显示了在只有两个实际处理器的计算机上如何对三个线程 T1、T2 和 T3 进行时间片切片。在图中,时间向下进行,因此一开始一个处理器正在运行线程 T1,另一个处理器正在运行线程 T2,然后第二个处理器切换到运行线程 T3。线程 T2 只是暂停,直到它在同一处理器或另一个处理器上的下一个时间片。

在大多数系统上,时间片切换是不可预测和不确定的,这意味着线程可以随时暂停或恢复。

在 Java 教程中阅读:

第二个 Java 教程的阅读展示了创建线程的两种方式。

  • 永远不要使用他们的第二种方式(子类化 Thread)。

  • 总是实现 Runnable 接口,并使用 new Thread(..) 构造函数。

他们的例子声明了一个实现 Runnable 的命名类:

public class HelloRunnable implements Runnable {
    public void run() {
        System.out.println("Hello from a thread!");
    }
}
// ... in the main method:
new Thread(new HelloRunnable()).start();

一个非常常见的习惯是用一个匿名 Runnable 启动一个线程,这消除了命名类:

new Thread(new Runnable() {
    public void run() {
        System.out.println("Hello from a thread!");
    }
}).start();

阅读:使用匿名 Runnable 启动线程

阅读练习

进程和线程 1

当你运行一个 Java 程序(例如,在 Eclipse 中使用“运行”按钮)时,最初会创建多少个处理器、进程和线程?

处理器:

(缺少答案)

进程:

(缺少答案)

线程:

(缺少答案)

(缺少解释)

进程和线程 2

假设我们在这个包含错误的程序中运行 main

public class Moirai {
    public static void main(String[] args) {
        Thread clotho = new Thread(new Runnable() {
            public void run() { System.out.println("spinning"); };
        });
        clotho.start();
        new Thread(new Runnable() {
            public void run() { System.out.println("measuring"); };
        }).start();
        new Thread(new Runnable() {
            public void run() { System.out.println("cutting"); };
        });
    }
}

有多少个新的 Thread 对象被创建?

(缺少答案)

(缺少解释)

有多少个新线程被运行?

(缺少答案)

(缺少解释)

可能同时运行的最大线程数是多少?

(缺少答案)

(缺少解释)

进程和线程 3

假设我们在这个展示了两个常见错误的程序中运行 main

public class Parcae {
    public static void main(String[] args) {
        Thread nona = new Thread(new Runnable() {
            public void run() { System.out.println("spinning"); };
        });
        nona.run();
        Runnable decima = new Runnable() {
            public void run() { System.out.println("measuring"); };
        };
        decima.run();
        // ...
    }
}

有多少个新的 Thread 对象被创建?

(缺少答案)

(缺少解释)

有多少个新线程被运行?

(缺少答案)

(缺少解释)

共享内存示例

让我们看一个共享内存系统的例子。这个例子的重点是展示并发编程很难,因为它可能有微妙的错误。

银行账户的共享内存模型

假设一个银行有使用共享内存模型的取款机,因此所有取款机都可以读取和写入内存中相同的账户对象。

为了说明可能出现的问题,让我们简化银行为一个单一账户,其中存储在 balance 变量中的一美元余额,并且有两个操作 depositwithdraw,它们只是简单地添加或移除一美元:

// suppose all the cash machines share a single bank account
private static int balance = 0;

private static void deposit() {
    balance = balance + 1;
}
private static void withdraw() {
    balance = balance - 1;
}

客户使用取款机进行如下交易:

deposit(); // put a dollar in
withdraw(); // take it back out

在这个简单的例子中,每笔交易只是一个一美元存款,然后是一美元取款,所以它应该不会改变账户余额。在一天中,我们网络中的每台取款机都在处理一系列存款/取款交易。

// each ATM does a bunch of transactions that
// modify balance, but leave it unchanged afterward
private static void cashMachine() {
    for (int i = 0; i < TRANSACTIONS_PER_MACHINE; ++i) {
        deposit(); // put a dollar in
        withdraw(); // take it back out
    }
}

因此,无论一天结束时有多少台取款机在运行,或者我们处理了多少笔交易,我们应该期望账户余额仍然是 0。

但如果我们运行这段代码,我们会经常发现一天结束时余额不是0。如果多个 cashMachine() 调用同时运行 - 比如,在同一台计算机的不同处理器上 - 那么 balance 在一天结束时可能不为零。为什么呢?

交错

这是可能发生的一种情况。假设两台取款机 A 和 B 同时在进行存款操作。这是 deposit() 步骤通常分解为低级处理器指令的方式:

获取余额 (余额=0)
加 1
写回结果(余额=1)

当 A 和 B 同时运行时,这些低级指令会相互交错(在某种意义上甚至可能同时进行,但现在让我们只关注交错):

A B
A 获取余额 (余额=0)
A 加 1
A 写回结果(余额=1)
B 获取余额 (余额=1)
B 加 1
B 写回结果(余额=2)

这种交错是可以接受的 - 我们最终得到了余额 2,因此 A 和 B 都成功存入了一美元。但如果交错看起来像这样呢:

A B
A 获取余额 (余额=0)
B 获取余额 (余额=0)
A 加 1
B 加 1
A 写回结果(余额=1)
B 写回结果(余额=1)

现在余额是 1 - A 的一美元丢失了!A 和 B 同时读取余额,计算出各自的最终余额,然后争先存储新余额 - 但未考虑对方的存款。

竞争条件

这是一个竞争条件的例子。竞争条件意味着程序的正确性(后置条件和不变量的满足)取决于并发计算 A 和 B 中事件的相对时间。当这种情况发生时,我们说“A 与 B 在竞争中”。

一些事件的交错可能是可以接受的,因为它们与单个、非并发进程产生的结果一致,但其他交错会产生错误答案 - 违反后置条件或不变量。

调整代码不会有帮助

所有这些版本的银行账户代码都表现出相同的竞争条件:

// version 1
private static void deposit() { balance = balance + 1; }
private static void withdraw() { balance = balance - 1; }

// version 2
private static void deposit() { balance += 1; }
private static void withdraw() { balance -= 1; }

// version 3
private static void deposit() { ++balance; }
private static void withdraw() { --balance; }

仅仅通过查看 Java 代码,你无法确定处理器将如何执行它。你无法确定不可分割的操作 - 原子操作 - 将是什么。它不是原子的,仅仅因为它是一行 Java。它不仅仅因为余额标识符只在一行中出现一次,就仅仅触及 balance 一次。Java 编译器,事实上,处理器本身,对于它将从你的代码中生成的低级操作不作任何承诺。事实上,典型的现代 Java 编译器对这三个版本的代码产生的代码完全相同!

关键教训是,仅仅通过查看表达式,你无法确定它是否能够安全地免受竞态条件的影响。

阅读:线程干扰(仅 1 页)

重新排序

实际上,情况甚至比那更糟。银行账户余额上的竞态条件可以解释为在不同处理器上对不同顺序的顺序操作进行了交错。但实际上,当您使用多个变量和多个处理器时,您甚至不能指望对这些变量的更改以相同的顺序出现。

这是一个例子。请注意,它使用一个循环不断检查并发条件;这被称为忙等待,这不是一个好的模式。在这种情况下,代码也是错误的:

private boolean ready = false;
private int answer = 0;

// computeAnswer runs in one thread
private void computeAnswer() {
    answer = 42;
    ready = true;
}

// useAnswer runs in a different thread
private void useAnswer() {
    while (!ready) {
        Thread.yield();
    }
    if (answer == 0) throw new RuntimeException("answer wasn't ready!");
}

我们有两种在不同线程中运行的方法。computeAnswer进行长时间计算,最终得出答案为 42,并将其放入答案变量中。然后,它将ready变量设置为 true,以向在另一个线程中运行的方法useAnswer发出信号,表明答案已准备好供其使用。查看代码,answerready设置之前设置,因此一旦useAnswer看到ready为 true,那么假设answer将为 42 似乎是合理的,对吗?不是这样的。

问题在于现代编译器和处理器会对代码进行很多优化以提高速度。其中一项操作是在更快的存储器(处理器上的寄存器或高速缓存)中制作 answer 和 ready 等变量的临时副本,并在最终将它们存储回它们在内存中的官方位置之前暂时使用它们。存储可能以与在代码中操作变量的顺序不同的顺序发生。下面是可能在幕后进行的操作(但以 Java 语法表达,以便清晰表达)。处理器实际上正在创建两个临时变量,tmprtmpa,来操作字段readyanswer

private void computeAnswer() {
    boolean tmpr = ready;
    int tmpa = answer;

    tmpa = 42;
    tmpr = true;

    ready = tmpr;
                   // <-- what happens if useAnswer() interleaves here?
                   // ready is set, but answer isn't.
    answer = tmpa;
}

阅读练习

交错 1

这是我们之前练习中的有错误的代码,其中启动了两个新线程:

public class Moirai {
    public static void main(String[] args) {
        Thread clotho = new Thread(new Runnable() {
            public void run() { System.out.println("spinning"); };
        });
        clotho.start();
        new Thread(new Runnable() {
            public void run() { System.out.println("measuring"); };
        }).start();
        new Thread(new Runnable() {
            public void run() { System.out.println("cutting"); };
        });
        // bug! never started
    }
}

以下哪些是此程序可能的输出:

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

交错 2

这是我们之前练习中的有错误的代码,其中没有启动新线程:

public class Parcae {
    public static void main(String[] args) {
        Thread nona = new Thread(new Runnable() {
            public void run() { System.out.println("spinning"); };
        });
        nona.run(); // bug! called run instead of start
        Runnable decima = new Runnable() {
            public void run() { System.out.println("measuring"); };
        };
        decima.run(); // bug? maybe meant to create a Thread?
        // ...
    }
}

此程序的可能输出是以下哪些:

(答案丢失)(答案丢失)(答案丢失)(答案丢失)

(解释丢失)

竞争条件 1

考虑以下代码:

private static int x = 1;

public static void methodA() {
  x *= 2;
  x *= 3;
}

public static void methodB() {
  x *= 5;
}

假设methodAmethodB顺序运行,即先一个再另一个。 x的最终值是多少?

(答案丢失)

(解释丢失)

竞争条件 2

现在假设methodAmethodB并发运行,因此它们的指令可能会任意交错。 下面哪些是x的可能最终值?

(答案丢失)(答案丢失)(答案丢失)(答案丢失)(答案丢失)(答案丢失)(答案丢失)

(解释丢失)

消息传递示例

消息传递银行账户示例

现在让我们看看我们的银行帐户示例的消息传递方法。

现在不仅现金机模块,而且帐户也是模块。 模块通过向彼此发送消息进行交互。 输入的请求被放置在队列中以便逐个处理。 发送方在等待答案时不会停止工作。 它处理来自自己队列的更多请求。 对其请求的回复最终会作为另一条消息返回。

不幸的是,消息传递并不能消除竞争条件的可能性。 假设每个帐户都支持get-balancewithdraw操作,具有相应的消息。 两个用户,在 A 和 B 的现金机上,都试图从同一个帐户中取出一美元。 他们首先检查余额,以确保他们从未取出超过帐户持有的金额,因为透支会触发大额银行罚款:

get-balance
if balance >= 1 then withdraw 1

问题再次是交错,但这次是银行账户发送的消息的交错,而不是 A 和 B 执行的指令。 如果账户一开始有一美元,那么什么消息的交错会愚弄 A 和 B 以为他们都可以取出一美元,从而透支账户?

这里的一个教训是你需要仔细选择消息传递模型的操作。 withdraw-if-sufficient-funds比仅withdraw更好。

并发测试和调试很困难

如果我们还没有说服您并发是棘手的,那么这是最糟糕的情况。 使用测试很难发现竞争条件。 即使一次测试找到了一个错误,要将其定位到导致错误的程序部分也可能非常困难。

并发错误表现出非常差的可再现性。 很难使它们以相同的方式发生两次。 指令或消息的交错取决于事件的相对时间,这些事件受环境的强烈影响。 延迟可能是由其他运行中的程序,其他网络流量,操作系统调度决策,处理器时钟速度的变化等引起的。 每次运行包含竞争条件的程序时,您可能会获得不同的行为。

这些类型的错误是海森堡错误,它们是不确定性的,难以复现,与玻尔错误相对,后者每次查看时都会出现。几乎所有的顺序编程中的错误都是玻尔错误。

甚至当你尝试用printlndebugger查看时,海森堡错误可能会消失!原因是打印和调试比其他操作慢得多,通常慢 100-1000 倍,它们会显著改变操作的时间和交错。因此,在 cashMachine()中插入一个简单的打印语句:

private static void cashMachine() {
    for (int i = 0; i < TRANSACTIONS_PER_MACHINE; ++i) {
        deposit(); // put a dollar in
        withdraw(); // take it back out
        System.out.println(balance); // makes the bug disappear!
    }
}

…突然之间,余额总是 0,正如所期望的那样,错误似乎消失了。但这只是掩盖了,而不是真正修复了。程序中其他地方的时间变化可能会突然使错误再次出现。

并发很难做到正确。阅读的一部分目的是让你有点害怕。在接下来的几篇阅读中,我们将看到设计并发程序的原则性方法,使其更安全,避免这些类型的错误。

阅读练习

测试并发性

你正在运行一个 Junit 测试套件(由其他人编写的代码),一些测试失败了。你在所有失败测试用例中调用的一个方法中添加了System.out.println语句,以显示一些局部变量,结果测试用例突然通过了。以下哪些是可能的原因?

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

总结

  • 并发:多个计算同时运行

  • 共享内存和消息传递范式

  • 进程和线程

    • 进程就像一个虚拟计算机;线程就像一个虚拟处理器
  • 竞争条件

    • 当结果的正确性(后置条件和不变性)取决于事件的相对时间

这些想法与我们的好软件三个关键属性大多以不好的方式连接。并发是必要的,但它会给正确性带来严重问题。我们将在接下来的几篇阅读中努力解决这些问题。

  • 免受错误困扰。 并发错误是最难找到和修复的错误之一,需要仔细设计以避免。

  • 易于理解。 预测并发代码如何与其他并发代码交错对程序员来说非常困难。最好设计你的代码,使程序员根本不必考虑交错。

  • 为变化做好准备。 在这里并不特别相关。

阅读 20:线程安全

6.005 中的软件

免受错误困扰 易于理解 为变化做好准备
今天正确且未来未知时也正确。 与未来的程序员清晰沟通,包括未来的自己。 设计以适应变化而无需重写。

目标

回想一下竞争条件:多个线程共享相同的可变变量而不协调它们的操作。这是不安全的,因为程序的正确性可能取决于它们低级操作的时间巧合。

在共享内存并发中使变量访问安全的基本上有四种方法:

  • 封闭。 不要在线程之间共享变量。这个概念被称为封闭,我们今天将探讨它。

  • 不可变性。 使共享数据不可变。我们已经讨论了很多关于不可变性的内容,但在本篇阅读中,我们将讨论一些与并发编程相关的额外约束。

  • 线程安全数据类型。 将共享数据封装在现有的线程安全数据类型中,该类型会为你进行协调。我们今天将讨论这个。

  • 同步。 使用同步来防止线程同时访问变量。同步是构建自己的线程安全数据类型所需的内容。

我们将在本篇阅读中讨论前三种方法,以及如何通过这三种方法来证明你的代码是线程安全的。我们将在后续的阅读中讨论第四种方法,即同步。

本篇阅读的内容受到一本优秀书籍的启发:Brian Goetz 等人的《Java 并发实践》,Addison-Wesley,2006 年。

什么是线程安全

如果一个数据类型或静态方法在多个线程中使用时行为正确,无论这些线程如何执行,并且不需要调用代码进行额外的协调,那么它就是线程安全的。

  • “正确运行”意味着满足其规范并保持其表示不变性;

  • “无论线程如何执行”意味着线程可能在多个处理器上执行,或在同一处理器上进行时间切片;

  • “不需要额外的协调”意味着数据类型不能对其调用者的时间相关性提出前提条件,比如“在set()正在进行时不能调用get()”。

还记得Iterator吗?它不是线程安全的。Iterator的规范指出,你不能在迭代集合的同时修改它。这是对调用者的时间相关前提条件,如果违反该条件,Iterator不保证能正确运行。

策略 1:封闭

我们实现线程安全的第一种方式是封闭。线程封闭是一个简单的概念:通过将数据限制在单个线程中,避免对可变数据的竞争。不要让其他线程直接读取或写入数据。

由于共享的可变数据是竞争条件的根本原因,限定通过不共享可变数据来解决它。

本地变量始终是线程限定的。本地变量存储在栈中,每个线程都有自己的栈。可能会有多个方法调用同时运行(在不同的线程或甚至在单个线程的堆栈的不同级别,如果方法是递归的),但每个调用都有自己的变量的私有副本,因此变量本身是限定的。

但要小心 - 变量是线程限定的,但如果它是一个对象引用,则还需要检查它指向的对象。如果对象是可变的,那么我们希望检查对象也是限定的 - 不能有其他线程可以从中访问的引用。

限定是使得像这样的代码中对 niresult 的访问是安全的:

public class Factorial {

    /**
     * Computes n! and prints it on standard output.
     * @param n must be >= 0
     */
    private static void computeFact(final int n) {
        BigInteger result = new BigInteger("1");
        for (int i = 1; i <= n; ++i) {
            System.out.println("working on fact " + n);
            result = result.multiply(new BigInteger(String.valueOf(i)));
        }
        System.out.println("fact(" + n + ") = " + result);
    }

    public static void main(String[] args) {
        new Thread(new Runnable() { // create a thread using an
            public void run() {     // anonymous Runnable
                computeFact(99);
            }
        }).start();
        computeFact(100);
    }
}

此代码使用 匿名 RunnablecomputeFact(99) 启动线程,这是之前阅读中讨论的一种常见习语。

让我们看看此代码的快照图。悬停或点击每个步骤以更新图表:

|

  1. 当我们启动程序时,我们从一个运行 main 的线程开始。

  2. main 使用匿名 Runnable 习语创建第二个线程,并启动该线程。

  3. 此时,我们有两个并发的执行线程。它们的交错是未知的!但是下一步可能发生的一种可能性是线程 1 进入 computeFact

  4. 然后,可能会发生的下一件事是线程 2 也进入 computeFact

    此时,我们看到限定如何帮助确保线程安全:每次执行 computeFact 都有自己的 niresult 变量。它们指向的对象都不可变;如果它们是可变的,我们需要检查对象不是从其他线程别名引用的。

  5. computeFact 计算独立进行,更新它们各自的变量。

避免全局变量。

与局部变量不同,静态变量不会自动线程限定。

如果程序中有静态变量,则必须说明只有一个线程会使用它们,并且必须清楚地记录这一事实。更好的做法是完全消除静态变量。

这是一个例子:

// This class has a race condition in it.
public class PinballSimulator {

    private static PinballSimulator simulator = null;
    // invariant: there should never be more than one PinballSimulator
    //            object created

    private PinballSimulator() {
        System.out.println("created a PinballSimulator object");
    }

    // factory method that returns the sole PinballSimulator object,
    // creating it if it doesn't exist
    public static PinballSimulator getInstance() {
        if (simulator == null) {
            simulator = new PinballSimulator();
        }
        return simulator;
    }
}

此类在 getInstance() 方法中存在竞争 - 两个线程可能同时调用它并最终创建两个 PinballSimulator 对象的副本,这是我们不想要的。

使用线程限定方法修复此竞争,您将指定只允许某个线程(也许是“弹球模拟线程”)调用 PinballSimulator.getInstance()。风险在于 Java 不会帮助您保证这一点。

一般来说,静态变量对并发性非常危险。它们可能隐藏在一个看似没有副作用或突变的无害函数后面。考虑以下示例:

// is this method threadsafe?
/**
 * @param x integer to test for primeness; requires x > 1
 * @return true if x is prime with high probability
 */
public static boolean isPrime(int x) {
    if (cache.containsKey(x)) return cache.get(x);
    boolean answer = BigInteger.valueOf(x).isProbablePrime(100);
    cache.put(x, answer);
    return answer;
}

private static Map<Integer,Boolean> cache = new HashMap<>();

此函数将先前调用的答案存储起来,以防再次请求。这种技术称为记忆化,对于像精确素性测试这样的慢函数来说,这是一个明智的优化。但现在isPrime方法不适合从多个线程调用,而且其客户端可能甚至没有意识到。原因是静态变量cache引用的HashMap被所有对isPrime()的调用共享,而HashMap不是线程安全的。如果多个线程同时对映射进行突变,通过调用cache.put(),那么映射可能会变得损坏,就像上次阅读中的银行账户变得损坏一样。如果幸运的话,损坏可能会导致哈希映射中的异常,比如Null­Pointer­ExceptionIndex­OutOfBounds­Exception。但它也可能悄悄地给出错误的答案,就像我们在银行账户示例中看到的那样。

阅读练习

阶乘

在上述限制策略 1 中,main看起来像:

public static void main(String[] args) {
    new Thread(new Runnable() { // create a thread using an
        public void run() {     // anonymous Runnable
            computeFact(99);
        }
    }).start();
    computeFact(100);
}

以下哪些是可能的交错执行?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

弹球模拟器

这是上面避免全局变量的弹球模拟器示例的一部分:

public class PinballSimulator {

    private static PinballSimulator simulator = null;

    // ...

    public static PinballSimulator getInstance() {
1)      if (simulator == null) {
2)          simulator = new PinballSimulator();
        }
3)      return simulator;
    }
}

该代码存在竞争条件,违反了只创建一个模拟器对象的不变性。

假设两个线程正在运行getInstance()。一个线程即将执行上述编号的其中一行;另一个线程即将执行另一行。对于每对可能的行号,是否可能违反不变性?

(缺失答案)(缺失答案)

(缺失解释)

(缺失答案)(缺失答案)

(缺失解释)

(缺失答案)(缺失答案)

(缺失解释)

限制

在以下代码中,哪些变量限制在单个线程中?

public class C {
    public static void main(String[] args) {
        new Thread(new Runnable() {
            public void run() {
                threadA();
            }
        }).start();

        new Thread(new Runnable() {
            public void run() {
                threadB();
            }
        }).start();
    }

    private static String name = "Napoleon Dynamite";
    private static int cashLeft = 150;

    private static void threadA() {
        int amountA = 20;
        cashLeft = spend(amountA);
    }

    private static void threadB() {
        int amountB = 30;
        cashLeft = spend(amountB);
    }

    private static int spend(int amountToSpend) {
        return cashLeft - amountToSpend;
    }
}

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

策略 2: 不可变性

我们实现线程安全的第二种方法是使用不可变引用和数据类型。不可变性解决了竞争条件的共享可变数据原因,并通过使共享数据不可变来简单解决它。

最终变量是不可变引用,因此声明为最终的变量可以安全地从多个线程访问。您只能读取变量,而不能写入。请注意,这种安全性仅适用于变量本身,我们仍然必须证明变量指向的对象是不可变的。

不可变对象通常也是线程安全的。我们在这里说“通常”,是因为我们目前对不可变性的定义对于并发编程来说太宽松了。我们说一个类型是不可变的,如果该类型的对象在其整个生命周期中始终表示相同的抽象值。但实际上,这允许类型对其 rep 进行突变,只要这些突变对客户端不可见。当我们查看在第一次客户端请求长度时将长度缓存在可变字段中的不可变列表时,我们就看到了这个概念的一个示例,称为善意或有益的突变。缓存是一种典型的善意突变。

但是,对于并发来说,这种隐藏的突变是不安全的。使用有益突变的不可变数据类型将不得不使用锁来使自己线程安全(与需要对可变数据类型进行的相同技术),我们将在以后的阅读中讨论这一点。

更严格的不可变性定义

因此,为了确保不使用锁就可以信任不可变数据类型的线程安全性,我们需要更严格的不可变性定义:

  • 没有改变器方法

  • 所有字段都是私有和最终的

  • 没有表示暴露

  • 在 rep 中对可变对象不进行任何变异 – 甚至不进行有益的变异

如果您遵循这些规则,那么您可以确信您的不可变类型也将是线程安全的。

在 Java 教程中阅读:

阅读练习

不可变性

假设您正在审查一个指定为不可变的抽象数据类型,以确定其实现是否真的是不可变且线程安全的。

您需要查看以下哪些元素?

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

策略 3:使用线程安全数据类型

我们实现线程安全的第三个主要策略是将共享的可变数据存储在现有的线程安全数据类型中。

当 Java 库中的数据类型是线程安全时,其文档将明确声明这一事实。例如,这是StringBuffer的说明:

[StringBuffer]是一个线程安全的、可变的字符序列。字符串缓冲区类似于字符串,但可以被修改。在任何时刻,它都包含某个特定的字符序列,但是通过某些方法调用,可以改变序列的长度和内容。

字符串缓冲区可以安全地供多个线程使用。这些方法在必要时是同步的,以使任何特定实例上的所有操作的行为都像发生在一些序列顺序中,这些序列顺序与每个涉及的单个线程所做的方法调用的顺序一致。

这与StringBuilder形成对比:

[StringBuilder 是]一个可变的字符序列。该类提供了与 StringBuffer 兼容的 API,但不保证同步。该类被设计为在字符串缓冲区被单个线程使用时(通常情况下)作为字符串缓冲区的替代品。如果可能的话,建议在大多数实现下使用这个类来代替 StringBuffer,因为它会更快。

在 Java API 中,找到两种执行相同操作的可变数据类型,一种是线程安全的,另一种不是,已经变得很常见。这句话表明的原因是:与不安全类型相比,线程安全的数据类型通常会带来性能损失。

非常不幸的是,StringBufferStringBuilder的命名如此相似,没有任何指示名称中线程安全性是它们之间关键差异的。同样不幸的是,它们没有共享一个公共接口,所以当你需要线程安全性的时候,不能简单地将一个实现替换为另一个实现。在这方面,Java 集合接口做得更好,我们将在接下来看到的。

线程安全的集合

Java 中的集合接口——ListSetMap——具有基本的非线程安全实现。你之前习惯使用的ArrayListHashMapHashSet的实现不能安全地在多个线程中使用。

幸运的是,就像集合 API 提供了使集合不可变的包装方法一样,它还提供了另一组包装方法,使集合具有线程安全性,同时仍然可变。

这些包装器有效地使集合的每个方法在其他方法方面都是原子的。原子操作实际上是一次性完成的——它不会将其内部操作与其他操作的操作交错,并且在整个操作完成之前,其他线程不会看到操作的任何效果,因此它永远不会看起来是部分完成的。

现在我们看到了修复我们之前在阅读中遇到的isPrime()方法的方法:

private static Map<Integer,Boolean> cache =
                Collections.synchronizedMap(new HashMap<>());

这里有几个要点。

不要绕过包装器。 确保丢弃对底层非线程安全集合的引用,并且只通过同步包装器访问它。在上面的代码行中,这是自动发生的,因为新的HashMap只传递给synchronizedMap(),并且没有存储在其他地方。(我们在不可修改的包装器中看到了相同的警告:底层集合仍然是可变的,具有对其的引用的代码可能绕过不可变性。)

迭代器仍然不是线程安全的。 即使集合本身的方法调用(get()put()add()等)现在是线程安全的,但从集合创建的迭代器仍然不是线程安全的。因此,你不能使用iterator()或者 for 循环语法:

for (String s: lst) { ... } // not threadsafe, even if lst is a synchronized list wrapper

解决这个迭代问题的方法是在需要迭代时获取集合的锁,这将在未来的阅读中讨论。

最后,原子操作并不足以防止竞争: 你使用同步集合的方式仍然可能存在竞争条件。考虑这段代码,它检查列表是否至少有一个元素,然后获取该元素:

if ( ! lst.isEmpty()) { String s = lst.get(0); ... }

即使你将lst变成同步列表,这段代码仍然可能存在竞争条件,因为另一个线程可能在isEmpty()调用和get()调用之间删除元素。

即使isPrime()方法仍然存在潜在的竞争:

if (cache.containsKey(x)) return cache.get(x);
boolean answer = BigInteger.valueOf(x).isProbablePrime(100);
cache.put(x, answer);

同步映射确保containsKey()get()put()现在是原子的,因此从多个线程使用它们不会破坏映射的不变量。但是这三个操作现在可以以任意方式相互交错,这可能会破坏isPrime从缓存中需要的不变量:如果缓存将整数x映射到值f,那么x是素数当且仅当f为真。如果缓存违反了这个不变量,那么我们可能会返回错误的结果。

因此,我们必须论证containsKey()get()put()之间的竞争不会威胁这个不变量。

  1. containsKey()get()之间的竞争并不会有害,因为我们从不从缓存中删除项目 - 一旦它包含 x 的结果,它将继续保留。

  2. containsKey()put()之间存在竞争。因此,可能会出现两个线程同时测试相同 x 的素数性质,并且都竞相用答案调用put()。但是它们两个应该用相同的答案调用put(),所以无论哪个赢得比赛,结果都是一样的。

即使你使用线程安全的数据类型,也需要对安全性做出这种谨慎的论证,这是并发变得困难的主要原因。

在 Java 教程中阅读:

阅读练习

线程安全的数据类型

考虑这个类的表示:

public class Building {
    private final String buildingName;
    private int numberOfFloors;
    private final int[] occupancyPerFloor;
    private final List<String> companyNames = Collections.synchronizedList(new ArrayList<>());
    ...
}

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

如何进行安全性论证

我们已经看到并发很难测试和调试。因此,如果你想说服自己和他人你的并发程序是正确的,最好的方法是明确地论证它没有竞争,并把它写下来。

安全性论证需要记录模块或程序中存在的所有线程以及它们使用的数据,并论证你正在使用哪种技术来保护每个数据对象或变量免受竞争的影响:封闭、不可变性、线程安全数据类型或同步。当你使用最后两种技术时,你还需要论证所有对数据的访问都是适当原子的 - 也就是说,你所依赖的不变量不会受到交错的威胁。我们在上面为isPrime给出了一个这样的论证。

数据类型的线程安全性论证

让我们看一些如何为数据类型进行线程安全性论证的示例。记住我们的四种线程安全性方法:封闭、不可变性、线程安全数据类型和同步。由于在本文中我们还没有讨论同步,所以我们将专注于前三种方法。

当我们只是针对一个数据类型进行论证时,封闭通常不是一个选项,因为你必须知道系统中存在哪些线程以及它们已经访问了哪些对象。如果数据类型创建了自己的一组线程,那么你可以谈论关于这些线程的封闭性。否则,线程是从外部进入的,携带客户端调用,数据类型可能无法保证哪些线程引用了什么。因此,在这种情况下,封闭性不是一个有用的论证。通常我们在更高的层次上使用封闭性,讨论整个系统,并论证为什么我们不需要一些模块或数据类型的线程安全性,因为它们不会被设计为跨线程共享。

不可变性通常是一个有用的论证:

/** MyString is an immutable data type representing a string of characters. */
public class MyString {
    private final char[] a;
    // Thread safety argument:
    //    This class is threadsafe because it's immutable:
    //    - a is final
    //    - a points to a mutable char array, but that array is encapsulated
    //      in this object, not shared with any other object or exposed to a
    //      client

这里是另一个需要更加小心论证的 MyString rep:

/** MyString is an immutable data type representing a string of characters. */
public class MyString {
    private final char[] a;
    private final int start;
    private final int len;
    // Rep invariant:
    //    0 <= start <= a.length
    //    0 <= len <= a.length-start
    // Abstraction function:
    //    represents the string of characters a[start],...,a[start+length-1]
    // Thread safety argument:
    //    This class is threadsafe because it's immutable:
    //    - a, start, and len are final
    //    - a points to a mutable char array, which may be shared with other
    //      MyString objects, but they never mutate it
    //    - the array is never exposed to a client

请注意,由于这个MyString rep 是为了在多个MyString对象之间共享数组而设计的,我们必须确保共享不会威胁其线程安全性。然而,只要不威胁MyString的不可变性,我们可以确信它不会威胁线程安全性。

我们还必须避免 rep 暴露。对于任何数据类型来说,rep 暴露都是不好的,因为它威胁到数据类型的 rep 不变性。对于线程安全性来说,这也是致命的。

不良的安全性论证

这里有一些不正确的线程安全性论证:

/** MyStringBuffer is a threadsafe mutable string of characters. */
public class MyStringBuffer {
    private String text;
    // Rep invariant:
    //   none
    // Abstraction function:
    //   represents the sequence text[0],...,text[text.length()-1]
    // Thread safety argument:
    //   text is an immutable (and hence threadsafe) String,
    //   so this object is also threadsafe

为什么这个论点不起作用?字符串确实是不可变的且线程安全的;但是指向该字符串的表示,特别是text变量,不是不可变的。text不是一个 final 变量,实际上在这种数据类型中它不能是 final 的,因为我们需要数据类型支持插入和删除操作。因此,对text变量本身的读取和写入不是线程安全的。这个论点是错误的。

这是另一个有问题的论点:

public class Graph {
    private final Set<Node> nodes =
                   Collections.synchronizedSet(new HashSet<>());
    private final Map<Node,Set<Node>> edges =
                   Collections.synchronizedMap(new HashMap<>());
    // Rep invariant:
    //    for all x, y such that y is a member of edges.get(x),
    //        x, y are both members of nodes
    // Abstraction function:
    //    represents a directed graph whose nodes are the set of nodes
    //        and whose edges are the set (x,y) such that
    //                         y is a member of edges.get(x)
    // Thread safety argument:
    //    - nodes and edges are final, so those variables are immutable
    //      and threadsafe
    //    - nodes and edges point to threadsafe set and map data types

这是一个图数据类型,它将其节点存储在集合中,将其边存储在映射中。(快速测验:Graph是可变还是不可变数据类型?final 关键字与其可变性有什么关系?)图依赖于其他线程安全的数据类型来帮助它实现其表示——特别是我们上面提到的线程安全的集合和映射包装器。这可以防止一些竞争条件,但不是全部,因为图的表示不变量包括节点集合和边映射之间的关系。因此,可能会有这样的代码:

public void addEdge(Node from, Node to) {
    if ( ! edges.containsKey(from)) {
        edges.put(from, Collections.synchronizedSet(new HashSet<>()));
    }
    edges.get(from).add(to);
    nodes.add(from);
    nodes.add(to);
}

这段代码存在竞争条件。在edges映射被改变之后但nodes集合被改变之前的关键时刻,表示不变量被违反了。在那一刻,图上的另一个操作可能交错进行,发现表示不变量被破坏,并返回错误的结果。尽管线程安全的集合和映射数据类型保证它们自己的add()put()方法是原子的且不干扰的,但它们不能将这种保证扩展到两个数据结构之间的交互。因此,Graph的表示不变量不安全免受竞争条件的影响。当表示不变量依赖于对象之间的关系时,仅仅使用不可变和线程安全的可变数据类型是不够的。

我们将不得不通过同步来解决这个问题,我们将在以后的阅读中看到如何解决。

阅读练习

安全论证

考虑以下具有上面出现的错误安全论证的 ADT:

/** MyStringBuffer is a threadsafe mutable string of characters. */
public class MyStringBuffer {
    private String text;
    // Rep invariant:
    //   none
    // Abstraction function:
    //   represents the sequence text[0],...,text[text.length()-1]
    // Thread safety argument:
    //   text is an immutable (and hence threadsafe) String,
    //   so this object is also threadsafe

    /** @return the string represented by this buffer,
     *          with all letters converted to uppercase */
    public String toUpperCase() { return text.toUpperCase(); }

    /** @param pos position to insert text into the buffer,
     *             requires 0 <= pos <= length of the current string
     *  @param s text to insert
     *  Mutates this buffer to insert s as a substring at position pos. */
    public void insert(int pos, String s) {
        text = text.substring(0, pos) + s + text.substring(pos);
    }

    /** @return the string represented by this buffer */
    public void toString() { return text; }

    /** Resets this buffer to the empty string. */
    public void clear() { text = ""; }

    /** @return the first character of this buffer, or "" if this buffer is empty */
    public String first() {
        if (text.length() > 0) {
            return String.valueOf(text.charAt(0));
        } else {
            return "";
        }
    }
}

这些方法中哪些是错误安全论证的反例,因为它们存在竞争条件?

特别是,如果方法A在一个线程正在运行方法A的同时,另一个线程正在运行其他方法,某些交错可能会违反A的后置条件,那么您应该将方法A标记为反例:

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

可序列化性

再次查看上面练习的代码。我们也可能担心clearinsert可能会交错,以至于客户端看到clear违反了其后置条件。

A B
调用sb.clear()
调用sb.insert(0, "a")
— 在clear中:text = ""
— 在insert中:text = "" + "a" + "z"
clear返回
insert返回
assert sb.toString()             .equals("")

假设两个线程共享表示"z"MyStringBuffer sb。它们同时运行clearinsert,如右侧所示。

线程 A 的断言将失败,但并非因为clear违反了其后置条件。事实上,当clear中的所有代码都运行完毕时,后置条件得到满足。

真正的问题在于线程 A 没有预料到clear()assert之间可能的交错。在任何线程安全的可变类型中,当原子变异器同时被调用时,某些变异必须通过成为最后一个应用的方式“获胜”。线程 A 观察到的结果与下面的执行相同,其中变异器根本不交错:

A B
调用sb.clear()
— 在clear中:text = ""
clear返回
调用sb.insert(0, "a")
— 在insert中:text = "" + "a" + ""
insert返回
assert sb.toString()             .equals("")

我们从线程安全数据类型中要求的是,当客户端同时调用其原子操作时,结果与这些调用的某些顺序一致。在这种情况下,清除和插入,这意味着要么是clear-后-insert,要么是insert-后-clear。这个属性被称为可串行化:对于任何同时执行的操作集,结果(客户端可观察到的值和状态)必须是由这些操作的某些顺序给出的结果。

阅读练习

可串行化

假设两个线程共享一个表示"z"MyStringBuffer

对于每一对并发调用及其结果,该结果是否违反了可串行化(因此表明MyStringBuffer不是线程安全的)?

clear()insert(0, "a")insert抛出IndexOutOfBoundsException

(缺少答案)(缺少答案)

(缺少解释)

clear()insert(1, "a")insert抛出IndexOutOfBoundsException

(缺少答案)(缺少答案)

(缺少解释)

first()insert(0, "a")first返回"a"

(缺少答案)(缺少答案)

(缺少解释)

first()clear()first返回"z"

(缺少答案)(缺少答案)

(缺少解释)

first()clear()first抛出IndexOutOfBoundsException

(缺少答案)(缺少答案)

(缺少解释)

摘要

本文讨论了三种主要方法来避免共享可变数据的竞争条件:

  • 限制:不共享数据。

  • 不可变性:共享,但保持数据不可变。

  • 线程安全数据类型:将共享的可变数据存储在单个线程安全数据类型中。

这些想法与我们关于良好软件的三个关键属性的联系如下:

  • 免于错误。 我们试图通过设计而不仅仅是通过时间的偶然性来消除一类主要的并发错误,竞争条件。

  • 易于理解。 应用这些通用、简单的设计模式比复杂讨论哪些线程交错可能,哪些不可能更容易理解。

  • 为变更做好准备。 我们将这些理由明确地写在线程安全性论证中,以便维护程序员知道代码在线程安全性方面依赖于什么。

读书 21:套接字与网络

软件在 6.005 中

安全免于错误 易于理解 准备好变化
今天正确,未来也正确。 与未来的程序员(包括未来的自己)清晰沟通。 设计以容纳变化而不必重写。

目标

在本文中,我们使用套接字抽象来检查网络上的客户端/服务器通信

网络通信本质上是并发的,因此构建客户端和服务器将要求我们思考它们的并发行为,并以线程安全的方式实现它们。我们还必须设计客户端和服务器用于通信的通信协议,就像我们设计 ADT 的客户端使用的操作一样。

一些套接字操作是阻塞的:它们会阻塞线程的进度,直到能够返回结果。阻塞使得编写某些代码更容易,但它也预示着一个新的并发错误类别,我们将很快深入讨论:死锁。

客户端/服务器设计模式

在本文(以及问题集)中,我们探讨了用于消息传递的客户端/服务器设计模式以进行通信。

在这种模式中,有两种类型的进程:客户端和服务器。客户端通过连接到服务器来启动通信。客户端向服务器发送请求,服务器发送回复。最后,客户端断开连接。服务器可能会同时处理来自许多客户端的连接,客户端也可能连接到多个服务器。

许多互联网应用程序都是这样工作的:网页浏览器是 web 服务器的客户端,像 Outlook 这样的邮件程序是邮件服务器的客户端,等等。

在互联网上,客户端和服务器进程通常在不同的机器上运行,只通过网络连接,但不一定非要这样 — 服务器可以是在与客户端相同的机器上运行的进程。

网络套接字

IP 地址

网络接口由一个IP 地址标识。IPv4 地址是 32 位数,写成四个 8 位部分。例如(截至本文撰写时):

  • 18.9.22.69是 MIT 网页服务器的 IP 地址。每个第一个八位组是18的地址都在 MIT 网络上。

  • 18.9.25.15是 MIT 的入站邮件处理程序的地址。

  • 173.194.123.40是一个谷歌网页服务器的地址。

  • 127.0.0.1环回本地主机地址:它始终指向本地机器。从技术上讲,任何第一个八位组是127的地址都是环回地址,但127.0.0.1是标准的。

你可以向谷歌查询你的当前 IP 地址。一般来说,当你携带着你的笔记本电脑四处移动时,每次将你的机器连接到网络时,它都可以被分配一个新的 IP 地址。

主机名

主机名是可以转换为 IP 地址的名称。单个主机名可以在不同时间映射到不同的 IP 地址;多个主机名可以映射到同一个 IP 地址。例如:

  • web.mit.edu 是 MIT 网页服务器的名称。你可以使用命令行上的 dighostnslookup 自己将此名称翻译为 IP 地址,例如:

    $ dig +short web.mit.edu
    18.9.22.69
    
    
  • dmz-mailsec-scanner-4.mit.edu 是 MIT 的垃圾邮件过滤器机器之一,负责处理传入的电子邮件。

  • google.com 就是你想象的那样。尝试使用上面的其中一条命令找到 google.com 的 IP 地址。你看到了什么?

  • localhost127.0.0.1 的名称。当你想要与自己机器上运行的服务器通信时,请与 localhost 通信。

主机名到 IP 地址的转换是 域名系统(DNS)的工作。它非常酷,但不是我们今天讨论的内容的一部分。

端口号

单台机器可能有多个客户端希望连接到的服务器应用程序,因此我们需要一种将同一网络接口上的流量定向到不同进程的方法。

网络接口有多个由 0(保留,因此我们实际上从 1 开始)到 65535 的 16 位数字标识的端口

服务器进程绑定到特定的端口 —— 现在它在该端口上监听。客户端必须知道服务器正在监听的端口号。有一些众所周知的端口保留用于系统级进程,并为某些服务提供标准端口。例如:

  • 端口 22 是标准的 SSH 端口。当你使用 SSH 连接到 athena.dialup.mit.edu 时,软件会自动使用端口 22。

  • 端口 25 是标准的电子邮件服务器端口。

  • 端口 80 是标准的网络服务器端口。当你在网络浏览器中连接到 URL http://web.mit.edu 时,它连接到端口 80 上的 18.9.22.69

当端口不是标准端口时,它被指定为地址的一部分。例如,URL http://128.2.39.10:9000 指的是位于 128.2.39.10 的机器上的端口 9000。

当客户端连接到服务器时,出站连接也使用客户端网络接口上的端口号,通常从可用的-众所周知的端口中随机选择。

网络套接字

套接字表示客户端和服务器之间连接的一端。

  • 监听套接字由服务器进程用于等待来自远程客户端的连接。

    在 Java 中,使用 ServerSocket 创建一个监听套接字,并使用它的 accept 方法监听它。

  • 一个连接的套接字可以与连接另一端的进程发送和接收消息。它由本地 IP 地址和端口号以及远程地址和端口号标识,这使得服务器可以区分来自不同 IP 的并发连接,或者来自同一 IP 的不同远程端口。

    在 Java 中,客户端使用Socket构造函数来建立与服务器的套接字连接。服务器从ServerSocket.accept返回的Socket对象中获取一个连接的套接字。

I/O

缓冲区

客户端和服务器在网络上交换的数据是以块的形式发送的。这些块很少只是字节大小的块,尽管可能是。发送方(发送请求的客户端或发送响应的服务器)通常会写入一个大块(也许是一个整个字符串,比如“HELLO, WORLD!”,或者是 20 兆字节的视频数据)。网络将该块切成数据包,每个数据包单独在网络上路由。在另一端,接收方将数据包重新组装成字节流。

结果是一种突发的数据传输方式——当您想要读取数据时,数据可能已经存在,或者您可能需要等待它们到达并重新组装。

当数据到达时,它们进入一个缓冲区,这是一个在内存中保存数据直到您读取它的数组。

读取练习

客户端服务器套接字缓冲区*

您正在自己的笔记本电脑上开发一个新的 Web 服务器程序。您在端口 8080 上启动服务器运行。

填写应在 Web 浏览器中访问的 URL 以与服务器通信:

__A__://__B__:__C__

(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

地址主机名网络填充物*(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

进入或离开套接字的数据是字节的

在 Java 中,InputStream对象代表流入程序的数据源。例如:

OutputStream 对象代表数据的汇集地,我们可以将数据写入其中。例如:

在 Java 教程中,阅读:

  • I/O Streams 包括 从命令行进行 I/O(8 页)。

对于套接字,请记住一个进程的 输出 是另一个进程的 输入。如果 Alice 和 Bob 有一个套接字连接,那么 Alice 有一个输出流流向 Bob 的输入流,反之亦然

阻塞。

阻塞 意味着线程等待(而不是继续工作),直到事件发生。我们可以用这个术语来描述方法和方法调用:如果一个方法是 阻塞方法,那么对该方法的调用可能会 阻塞,等待某些事件发生后才返回给调用者。

套接字的输入/输出流表现出阻塞行为:

  • 当传入套接字的缓冲区为空时,调用 read 将阻塞,直到数据可用。

  • 当目标套接字的缓冲区已满时,调用 write 将阻塞,直到有空间可用。

从程序员的角度来看,阻塞非常方便,因为程序员可以编写代码,仿佛 read(或 write)调用总是有效的,不管数据到达的时间如何。如果缓冲区中已经有数据(或者对于 write,有空间),那么调用可能会非常快速地返回。但是如果读取或写入不能成功,调用将阻塞。操作系统会处理延迟线程直到 readwrite 可以成功的细节。

阻塞发生在整个并发编程过程中,不仅仅是在I/O(进程之间的通信,可能通过网络,或者到/从文件,或者与用户在命令行或 GUI 上的交互等)中。并发模块不像顺序程序那样一步一步地工作,因此当需要协调行动时,它们通常必须等待对方赶上。

我们将在下一篇阅读中看到,这种等待会引发并发编程中的第二种主要错误(第一种是竞争条件):死锁,其中模块等待对方执行某些操作,因此它们都无法取得任何进展。但这是下次的话题。

使用网络套接字。

确保你已经阅读了上面 Java 教程链接中关于流的内容,然后再阅读关于网络套接字的内容:

在 Java 教程中,阅读:

这篇阅读介绍了关于创建服务器端和客户端套接字以及写入和读取它们的 I/O 流的一切知识。

在第二页

该示例使用了我们没有见过的语法:try-with-resources语句。该语句的形式为:

try (
    // create new objects here that require cleanup after being used,
    // and assign them to variables
) {
    // code here runs with those variables
    // cleanup happens automatically after the code completes
} catch(...) {
    // you can include catch clauses if the code might throw exceptions
}

在最后一页

注意ServerSocket.accept()in.readLine()都是阻塞的。这意味着服务器将需要一个新线程来处理每个新客户端的 I/O。当客户端特定的线程正在与该客户端交互(可能在读取或写入时被阻塞),另一个线程(可能是主线程)正在等待accept一个新连接。

不幸的是,他们的多线程 Knock Knock 服务器实现通过子类化Thread来创建新线程。这不是推荐的策略。相反,创建一个实现Runnable的新类,或者使用匿名Runnable调用一个方法,在那里处理客户端连接直到关闭。不要使用extends Thread。虽然在 Java API 设计时子类化很流行,但我们不讨论或推荐它,因为它有很多缺点。

阅读练习

网络套接字 1

爱丽丝与鲍勃有一个连接的套接字。她如何向鲍勃发送消息?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

网络套接字 2

客户端为了连接和与服务器通信,有必要了解这些哪些内容?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

回声回声回声回声

EchoClient示例中,哪些可能阻塞

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

EchoServer中,哪些可能阻塞

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

阻塞 阻塞 阻塞 阻塞

由于BufferedReader.readLine()是一个阻塞方法,以下哪个是正确的:

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

线路协议

现在我们已经通过套接字连接了客户端和服务器,它们之间通过这些套接字传递什么?

协议是两个通信方可以交换的消息集合。特别是线路协议是一组以字节序列表示的消息,比如hello worldbye(假设我们已经同意了一种将这些字符编码成字节的方式)。

许多互联网应用程序使用简单的基于 ASCII 的线路协议。你可以使用一个名为 Telnet 的程序来查看它们。

Telnet 客户端

telnet是一个实用程序,允许你直接连接到一个监听服务器并通过终端界面与其通信。Linux 和 Mac OS X 应该默认安装了telnet

Windows 用户应该首先通过在命令行上运行telnet命令来检查 telnet 是否已安装。

  • 如果你没有安装telnet,可以通过控制面板 → 程序和功能 → 打开或关闭 Windows 功能 → Telnet 客户端进行安装。然而,这个版本的telnet可能会非常难用。如果它不显示你正在输入的内容,你需要打开localecho选项

  • 一个更好的选择是 PuTTY:下载putty.exe。使用 PuTTY 进行连接时,输入主机名和端口,选择连接类型:原始,以及在退出时关闭窗口:从不。最后一个选项将防止窗口在服务器关闭连接端时立即消失。

让我们看一些线路协议的示例:

HTTP

超文本传输协议(HTTP)是万维网的语言。我们已经知道端口 80 是向 Web 服务器发出 HTTP 请求的常用端口,所以让我们在命令行上与一个通信。

你将在问题集中使用 Telnet,所以现在试试这些吧。用户输入显示为绿色,Telnet 连接的输入使用表示新行(按回车键):

$ telnet www.eecs.mit.edu 80
Trying 18.62.0.96...
Connected to eecsweb.mit.edu.
Escape character is '^]'.
GET /↵
<!DOCTYPE html>
*... lots of output ...*
<title>Homepage | MIT EECS</title>
*... lots more output ...*

GET命令获取一个网页。/是网站上你想要的页面的路径。所以这个命令获取了http://www.eecs.mit.edu:80/页面。由于 80 是 HTTP 的默认端口,这相当于在你的 Web 浏览器中访问www.eecs.mit.edu/。结果是 HTML 代码,你的浏览器渲染以显示 EECS 主页。

互联网协议由RFC 规范定义(RFC 代表“请求评论”,一些 RFC 最终被采纳为标准)。RFC 1945定义了 HTTP 1.0 版,并被RFC 2616中的 HTTP 1.1 所取代。所以对于许多网站,如果你想与它们交流,你可能需要使用 HTTP 1.1。例如:

$ telnet web.mit.edu 80
Trying 18.9.22.69...
Connected to web.mit.edu.
Escape character is '^]'.
GET /aboutmit/ HTTP/1.1↵
Host: web.mit.edu↵
↵
HTTP/1.1 200 OK
Date: Tue, 31 Mar 2015 15:14:22 GMT
*... more headers ...*

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
*... more HTML ...*
<title>MIT — About</title>
*... lots more HTML ...*

这次,你的请求必须以一个空行结束。HTTP 1.1 要求客户端在请求中指定一些额外信息(称为头部),而空行表示头部的结束。

你很可能会发现,在发出请求后,telnet 并没有退出 — 这次,服务器保持连接打开,这样你可以立即发出另一个请求。要手动退出 Telnet,请键入转义字符(可能是Ctrl-])以显示telnet>提示符,然后键入quit

*... lots more HTML ...*
</html>
*Ctrl-]*↵
telnet> quit↵
Connection closed.

SMTP

简单邮件传输协议(SMTP)是用于发送电子邮件的协议(用于从收件箱检索电子邮件的客户端程序使用不同的协议)。因为电子邮件系统是在垃圾邮件出现之前设计的,现代电子邮件通信充满了旨在防止滥用的陷阱和启发式方法。但我们仍然可以尝试使用 SMTP。请记住,众所周知的 SMTP 端口是 25,dmz-mailsec-scanner-4.mit.edu是 MIT 的一个电子邮件处理程序的名称。

你需要填写your-IP-address-hereyour-username-here,↵表示换行以便清晰显示。只有当你在 MITnet 上时才能正常工作,即使如此,你的邮件可能因看起来可疑而被拒绝:

$ telnet dmz-mailsec-scanner-4.mit.edu 25
Trying 18.9.25.15...
Connected to dmz-mailsec-scanner-4.mit.edu.
Escape character is '^]'.
220 dmz-mailsec-scanner-4.mit.edu ESMTP Symantec Messaging Gateway
HELO *your-IP-address-here*↵
250 2.0.0 dmz-mailsec-scanner-4.mit.edu says HELO to *your-ip-address*:*port*
MAIL FROM: <*your-username-here*@mit.edu>↵
250 2.0.0 MAIL FROM accepted
RCPT TO: <*your-username-here*@mit.edu>↵
250 2.0.0 RCPT TO accepted
DATA↵
354 3.0.0 continue.  finished with "\r\n.\r\n"
From: <*your-username-here*@mit.edu>↵
To: <*your-username-here*@mit.edu>↵
Subject: testing↵
This is a hand-crafted artisanal email.↵
.↵
250 2.0.0 OK 99/00-11111-22222222
QUIT↵
221 2.3.0 dmz-mailsec-scanner-4.mit.edu closing connection
Connection closed by foreign host.

与 HTTP 相比,SMTP 相当啰嗦,提供一些人类可读的指令,比如continue. finished with "\r\n.\r\n"告诉我们如何终止消息内容。

设计线路协议

在设计线路协议时,应用设计抽象数据类型操作时使用的相同经验法则:

  • 保持不同消息的数量。最好有一些可以组合的简单命令和响应,而不是许多复杂的消息。

  • 每条消息应该有明确定义的目的和连贯行为。

  • 消息集必须足够充分,以便客户端发出需要的请求,服务器交付结果。

正如我们要求类型具有表示独立性一样,我们应该在协议中追求平台独立性。HTTP 可以被任何操作系统上的任何 Web 服务器和任何 Web 浏览器使用。协议不涉及 Web 页面如何存储在磁盘上,它们如何由服务器准备或生成,客户端将使用什么算法来呈现它们���等。

我们也可以在这节课中应用三个重要的想法:

  • 免受错误的影响

    • 协议应该易于客户端和服务器生成和解析。使用解析器生成器(如 ANTLR)、正则表达式等编写的读取和写入协议的简单代码将减少错误的机会。

    • 考虑破坏或恶意客户端或服务器可能将垃圾数据插入协议以破坏另一端过程的方式。

      电子邮件垃圾邮件就是一个例子:当我们上面使用 SMTP 时,邮件服务器要求我们说出谁发送了电子邮件,而 SMTP 中没有任何内容可以阻止我们彻底撒谎。我们不得不在 SMTP 之上构建系统,以尝试阻止谎称From:地址的垃圾邮件发送者。

      安全漏洞是一个更严重的例子。例如,允许客户端发送具有任意数量数据的请求的协议需要服务器小心处理,以避免耗尽缓冲区空间,或更糟

  • 易于理解:例如,选择文本协议意味着我们可以通过阅读客户端/服务器交换的文本来调试通信错误。甚至允许我们像上面看到的那样手动“说”协议。

  • 准备好改变:例如,HTTP 包括指定版本号的能力,因此客户端和服务器可以互相协商他们将使用的协议版本。如果我们需要在将来对协议进行更改,较旧的客户端或服务器可以继续通过宣布他们将使用的版本来工作。

序列化是将内存中的数据结构转换为可以轻松存储或传输的格式的过程(与线程安全中的可序列化性不同)。与发明一个新的格式来在客户端和服务器之间序列化您的数据不同,使用现有的格式。例如,JSON(JavaScript 对象表示法)是一种简单、广泛使用的格式,用于序列化基本值、数组和具有字符串键的映射。

指定一个线路协议

为了准确地为客户端和服务器定义协议允许的消息,使用一个语法。

例如,这是来自RFC 2616 第 5 节的 HTTP 1.1 请求语法的一个非常小的部分:

request ::= request-line
            ((general-header | request-header | entity-header) CRLF)*
            CRLF
            message-body?
request-line ::= method SPACE request-uri SPACE http-version CRLF
method ::= "OPTIONS" | "GET" | "HEAD" | "POST" | ...
...

使用语法,我们可以看到在这个早期的请求示例中:

GET /aboutmit/ HTTP/1.1
Host: web.mit.edu
  • GETmethod:我们要求服务器为我们获取一个页面。

  • /aboutmit/request-uri:描述我们想要获取的内容。

  • HTTP/1.1http-version

  • Host: web.mit.edu 是某种标题 — 我们需要检查每个 ...-header 选项的规则,以确定哪一个是。

  • 我们可以看到为什么我们必须以一个空行结束请求:因为一个request可以有多个以 CRLF(换行符)结尾的头部,我们在末尾有另一个 CRLF 来结束request

  • 我们没有任何message-body — 而且由于服务器没有等待看我们是否会发送一个,可以推断这只适用于其他类型的请求。

语法是不够的:它填补了在定义 ADT 时的方法签名的类似角色。我们仍然需要规范:

  • 消息的前提条件是什么? 例如,如果消息中的特定字段是一串数字,任何数字都有效吗?还是必须是服务器已知的记录的 ID 号?

    在什么情况下可以发送消息?某些消息只有在特定顺序发送时才有效吗?

  • 后置条件是什么?服务器将根据消息采取什么行动?服务器端数据将发生什么变化?服务器将向客户端发送什么回复?

阅读练习

传输协议 1

以下哪些工具可以用来与 Web 服务器通信?

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

传输协议 2

考虑这个示例的传输协议,使用两个语法规则来指定……

客户端发送到服务器的消息

客户端可以打开和关闭由数字 ID 标识的灯。客户端还可以请求帮助。

MESSAGE ::= ( ON | OFF | HELP_REQ ) NEWLINE
ON ::= "on " ID
OFF ::= "off " ID
HELP_REQ ::= "help"
NEWLINE ::= "\r"? "\n"
ID ::= [1-9][0-9]*

服务器发送到客户端的消息

服务器可以报告灯的状态并提供任意的帮助消息。

MESSAGE ::= ( STATUS | HELP ) NEWLINE
STATUS ::= ONE_STATUS ( NEWLINE "and " ONE_STATUS )*
ONE_STATUS ::= ID " is " ( "on" | "off" )
HELP ::= [^\r\n]+
NEWLINE ::= "\r"? "\n"
ID ::= [1-9][0-9]*

我们将使用 ↵ 来表示换行符。

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

测试客户端/服务器代码

记住,并发测试和调试很困难。我们无法可靠地重现竞争条件,而且网络增加了一个完全超出我们控制范围的延迟源。你需要设计并仔细论证代码的正确性。

将网络代码与数据结构和算法分开

在您的客户端/服务器程序中,大多数 ADT 不需要依赖网络。确保您将它们指定、测试和实现为独立的组件,这些组件不容易出错,易于理解,并且准备好进行更改——部分原因是因为它们不涉及任何网络代码。

如果那些 ADT 需要从多个线程并发使用(例如,处理不同客户端连接的线程),我们下一步的阅读将讨论您的选择。否则,请使用封装、不可变性和现有线程安全数据类型的线程安全策略。

将套接字代码与流代码分开

需要从套接字读取和写入的函数或模块可能只需要访问输入/输出流,而不需要访问套接字本身。这种设计允许您通过连接到不来自套接字的流来测试该模块。

用于此目的的两个有用的 Java 类是ByteArray­InputStreamByteArray­OutputStream。假设我们要测试这个方法:

void upperCaseLine(BufferedReader input, PrintWriter output) throws IOException
  *requires*: input and output are open
  *effects*: attempts to read a line from input
           and attempts to write that line, in upper case, to output

该方法通常与套接字一起使用:

Socket sock = ...

// read a stream of characters from the socket input stream
BufferedReader in = new BufferedReader(new InputStreamReader(sock.getInputStream()));
// write characters to the socket output stream
PrintWriter out = new PrintWriter(sock.getOutputStream(), true);

upperCaseLine(in, out);

如果大小写转换是我们实现的一个函数,那么它应该已经被指定、测试和单独实现了。但现在我们还可以测试upperCaseLine的读/写行为:

// fixed input stream of "dog" (line 1) and "cat" (line 2)
String inString = "dog\ncat\n";
ByteArrayInputStream inBytes = new ByteArrayInputStream(inString.getBytes());
ByteArrayOutputStream outBytes = new ByteArrayOutputStream();

// read a stream of characters from the fixed input string
BufferedReader in = new BufferedReader(new InputStreamReader(inBytes));
// write characters to temporary storage
PrintWriter out = new PrintWriter(outBytes, true);

upperCaseLine(in, out);

// check that it read the expected amount of input
assertEquals("expected input line 2 remaining", "cat", in.readLine());
// check that it wrote the expected output
assertEquals("expected upper case of input line 1", "DOG\n", outBytes.toString());

在这个测试中,inBytesoutBytes测试桩。为了隔离并测试 upperCaseLine,我们用满足相同规范但具有固定行为的组件(一个具有固定输入的输入流,以及将输出存储在内存中的输出流)替换它通常依赖的组件(来自套接字的输入/输出流)。

对于更复杂模块的测试策略可能会使用模拟对象来模拟真实客户端或服务器的行为,通过生成整个预定义的交互序列并断言从另一个组件接收到的每条消息的正确性。

摘要

客户端/服务器设计模式中,并发是不可避免的:多个客户端和多个服务器连接在网络上,同时发送和接收消息,并期望及时回复。一个服务器如果因为等待一个慢速客户端而阻塞,而其他客户端正在等待连接或接收回复,那么这个服务器将无法让这些客户端满意。同时,一个服务器如果因为不同客户端对共享可变数据的并发修改而执行不正确的计算或返回虚假结果,也不会让任何人满意。

在设计网络客户端和服务器时,使我们的多线程代码免受错误易于理解为变更做好准备的所有挑战都适用。这些进程同时运行(通常在不同的机器上),任何想要同时与多个客户端通信的服务器(或想要同时与多个服务器通信的客户端)都必须管理这种多线程通信。

第 22 篇阅读:队列和消息传递

6.005 的软件

免受错误的影响 易于理解 随时准备更改
今天正确,未来也正确。 与未来的程序员清晰地沟通,包括未来的您。 设计以适应未来的变化而无需重写。

目标

阅读完这节课的笔记并查看代码后,您应该能够使用消息传递(带同步队列)而不是共享内存来进行线程间通信。

并发的两种模型

在我们的并发简介中,我们看到并发编程的两种模型:共享内存消息传递

  • 多处理器共享内存

    共享内存模型中,并发模块通过在内存中读取和写入共享的可变对象来交互。在单个 Java 进程中创建多个线程是我们共享内存并发的主要示例。

  • 消息传递模型中,并发模块通过在通信通道上相互发送不可变消息来交互。到目前为止,我们只有一个消息传递的例子:客户端/服务器模式,其中客户端和服务器是并发进程,通常在不同的计算机上,通信通道是一个网络套接字。

    网络消息传递

消息传递模型相对于共享内存模型有几个优点,这些优点归结为对错误的更大安全性。在消息传递中,通过通过通信通道传递消息,而不是通过共享数据的突变来进行显式交互。共享内存的隐式交互太容易导致无意的交互,在程序的某些部分共享和操作数据,而这些部分并不知道它们是并发的,并且在线程安全策略中没有适当地合作。消息传递还仅在模块之间共享不可变对象(消息),而共享内存需要共享可变对象,我们已经看到这可能是错误的来源。

在这篇阅读中,我们将讨论如何在单个进程内实现消息传递,而不是在网络上的进程之间。我们将使用阻塞队列(一种现有的线程安全类型)来在进程内的线程之间实现消息传递。

带线程的消息传递

我们之前已经讨论过进程之间的消息传递:客户端和服务器通过网络套接字进行通信。我们也可以在同一个进程内的线程之间使用消息传递,这种设计通常比使用锁的共享内存设计更可取,关于锁我们将在下一篇阅读中讨论。

使用同步队列进行线程之间的消息传递。队列的功能与客户端/服务器消息传递中的缓冲网络通信通道相同。Java 为具有阻塞操作的队列提供了BlockingQueue接口:

在普通的Queue中:

  • add(e)将元素e添加到队列的末尾。

  • remove()移除并返回队列头部的元素,如果队列为空则抛出异常。

BlockingQueue扩展了此接口:

此外,它支持在检索元素时等待队列变为非空,并在存储元素时等待队列中有可用空间。

  • put(e) 阻塞,直到可以将元素e添加到队列的末尾(如果队列没有大小限制,put不会阻塞)。

  • take() 阻塞,直到可以移除并返回队列头部的元素,等待直到队列非空。

当您在线程之间进行消息传递时使用BlockingQueue,请确保使用put()take()操作,而不是 add()remove()

生产者-消费者消息传递

与网络消息传递中的客户端/服务器模式类似的是,线程之间的消息传递的生产者-消费者设计模式。生产者线程和消费者线程共享一个同步队列。生产者将数据或请求放入队列,而消费者则移除并处理它们。一个或多个生产者和一个或多个消费者可能都在向同一个队列添加和移除项目。这个队列必须对并发操作是安全的。

Java 提供了BlockingQueue的两种实现:

  • ArrayBlockingQueue是使用数组表示的固定大小队列。如果队列已满,则put新项目到队列将会阻塞。

  • LinkedBlockingQueue是使用链表表示的可增长队列。如果没有指定最大容量,则队列永远不会填满,因此put永远不会阻塞。

与通过套接字发送和接收的字节流不同,这些同步队列(与 Java 中的普通集合类一样)可以保存任意类型的对象。我们必须选择或设计队列中的消息类型,而不是设计一个线路协议。它必须是不可变类型。就像我们在线程安全的 ADT 或线路协议中的消息上所做的操作一样,我们必须在这里设计我们的消息,以防止竞态条件并使客户能够执行它们需要的原子操作。

银行账户示例

银行账户消息传递模型

我们的第一个消息传递示例是银行账户示例。

每个取款机和每个账户都是独立的模块,模块之间通过发送消息来进行交互。传入的消息会到达队列。

我们为get-balancewithdraw设计了消息,并说每个取款机在取款之前都会检查账户余额,以防止透支:

get-balance
if balance >= 1 then withdraw 1

但是仍然有可能交错两个取款机的消息,使它们都被欺骗以为可以安全地从只有 1 美元的账户中提取最后 1 美元。

我们需要选择一个更好的原子操作:withdraw-if-sufficient-funds比仅仅withdraw更好。

使用队列实现消息传递

你可以在 GitHub 上查看此示例的所有代码:平方器示例。以下是所有相关部分的摘录。

这是一个用于平方整数的消息传递模块:

SquareQueue.java 第 6 行

/** Squares integers. */
public class Squarer {

    private final BlockingQueue<Integer> in;
    private final BlockingQueue<SquareResult> out;
    // Rep invariant: in, out != null

    /** Make a new squarer.
     *  @param requests queue to receive requests from
     *  @param replies queue to send replies to */
    public Squarer(BlockingQueue<Integer> requests,
                   BlockingQueue<SquareResult> replies) {
        this.in = requests;
        this.out = replies;
    }

    /** Start handling squaring requests. */
    public void start() {
        new Thread(new Runnable() {
            public void run() {
                while (true) {
                    // TODO: we may want a way to stop the thread
                    try {
                        // block until a request arrives
                        int x = in.take();
                        // compute the answer and send it back
                        int y = x * x;
                        out.put(new SquareResult(x, y));
                    } catch (InterruptedException ie) {
                        ie.printStackTrace();
                    }
                }
            }
        }).start();
    }
}

传入Squarer的消息是整数;平方器知道其任务是对这些数字进行平方,因此不需要进一步的细节。

传出的消息是SquareResult的实例:

SquareQueue.java 第 48 行

/** An immutable squaring result message. */
public class SquareResult {
    private final int input;
    private final int output;

    /** Make a new result message.
     *  @param input input number
     *  @param output square of input */
    public SquareResult(int input, int output) {
        this.input = input;
        this.output = output;
    }

    @Override public String toString() {
        return input + "² = " + output;
    }
}

我们可能会为SquareResult添加额外的观察者,以便客户端可以检索输入数字和输出结果。

最后,这是一个使用平方器的主方法:

SquareQueue.java 第 77 行

public static void main(String[] args) {

    BlockingQueue<Integer> requests = new LinkedBlockingQueue<>();
    BlockingQueue<SquareResult> replies = new LinkedBlockingQueue<>();

    Squarer squarer = new Squarer(requests, replies);
    squarer.start();

    try {
        // make a request
        requests.put(42);
        // ... maybe do something concurrently ...
        // read the reply
        System.out.println(replies.take());
    } catch (InterruptedException ie) {
        ie.printStackTrace();
    }
}

这段代码和使用套接字实现消息传递的代码非常相似,这并不奇怪。

阅读练习

表示不变式

写出SquareResult的表示不变式,作为可以在下面的checkRep()中使用的表达式。在你的答案中使用最少数量的字符,不包含任何方法调用。

private void checkRep() {
  assert REP_INVARIANT;
}

(缺失答案)代码审查

上述代码经过代码审查并产生以下评论。评估评论。

(缺失答案)(缺失答案)

(缺失解释)

(缺失答案)(缺失答案)

(缺失解释)

(缺失答案)(缺失答案)

(缺失解释)

停止

如果我们想要关闭Squarer,使其不再等待新的输入,我们该怎么办?在客户端/服务器模型中,如果我们想要客户端或服务器停止监听我们的消息,我们关闭套接字。如果我们想要客户端或服务器停止运行,我们可以退出该进程。但在这里,Squarer只是同一进程中的另一个线程,我们无法“关闭”一个队列。

一种策略是毒丸:队列中的特殊消息,表示该消息的消费者应该结束其工作。要关闭方阵器,由于其输入消息仅是整数,我们必须选择一个魔法毒丸整数(每个人都知道 0 的平方是 0 对吗?没有人会需要问 0 的平方…)或使用 null(不要使用 null)。相反,我们可能会将请求队列上的元素类型更改为 ADT:

SquareRequest = IntegerRequest + StopRequest 

带有操作:

input : SquareRequest → int
shouldStop : SquareRequest → boolean

当我们想要停止方阵器时,我们在队列中排队一个StopRequest,其中shouldStop返回true

例如,在Squarer.start()中:

public void run() {
    while (true) {
        try {
            // block until a request arrives
            SquareRequest req = in.take();
            // see if we should stop
            if (req.shouldStop()) { break; }
            // compute the answer and send it back
            int x = req.input();
            int y = x * x;
            out.put(new SquareResult(x, y));
        } catch (InterruptedException ie) {
            ie.printStackTrace();
        }
    }
}

也可以通过调用其interrupt()方法中断线程。如果线程被阻塞等待,那么它被阻塞的方法将抛出InterruptedException(这就是为什么我们几乎在调用阻塞方法时都必须尝试捕获该异常)。如果线程未被阻塞,则将设置一个中断标志。线程必须检查此标志以确定是否应停止工作。例如:

public void run() {
    // handle requests until we are interrupted
    while ( ! Thread.interrupted()) {
        try {
            // block until a request arrives
            int x = in.take();
            // compute the answer and send it back
            int y = x * x;
            out.put(new SquareResult(x, y));
        } catch (InterruptedException ie) {
            // stop
            break;
        }
    }
}

阅读练习

实现毒丸

使用上述数据类型定义:

SquareRequest = IntegerRequest + StopRequest

对于下面的每个选项:代码片段是否是以最大程度利用静态检查的正确概要来实现这个 Java 的?

interface SquareRequest { ... }
class IntegerRequest implements SquareRequest { ... }
class StopRequest implements SquareRequest { ... }

(缺失答案)(缺失答案)

(缺失解释)

class SquareRequest { ... }
class IntegerRequest { ... }
class StopRequest { ... }

(缺失答案)(缺失答案)

(缺失解释)

class SquareRequest {
  private final String requestType;
  public static final String INTEGER_REQUEST = "integer";
  public static final String STOP_REQUEST    = "stop";
  ...
}

(缺失答案)(缺失答案)

(缺失解释)

使用消息传递的线程安全性论证

带有消息传递的线程安全性论证可能依赖于:

  • 现有的线程安全数据类型用于同步队列。该队列肯定是共享的,肯定是可变的,所以我们必须确保它对并发是安全的。

  • 可能同时由多个线程访问的消息或数据的不可变性

  • 数据的限制到各个生产者/消费者线程。由一个生产者或消费者使用的局部变量对其他线程不可见,这些线程只能使用队列中的消息进行通信。

  • 可变消息或数据的限制,这些消息或数据通过队列发送,但一次只能由一个线程访问。这个论点必须仔细阐明和实现。但如果一个模块一旦将可变数据放入队列以便传递给另一个线程,就像热土豆一样立即放弃所有对这些数据的引用,那么一次只有一个线程会访问这些数据,从而排除了并发访问。

与同步相比,消息传递可以使并发系统中的每个模块更容易地维护其自己的线程安全性不变式。如果数据而不是通过线程安全的通信通道在模块之间传输,我们不必考虑多个线程访问共享数据。

阅读练习

消息传递

Leif Noad 刚刚开始在一家股票交易公司工作:

public interface Trade {
    public int numShares();
    public String stockName();
}

public class TradeWorker implements Runnable {
    private final Queue<Trade> tradesQueue;

    public TradeWorker(Queue<Trade> tradesQueue) {
        this.tradesQueue = tradesQueue;
    }

    public void run() {
        while (true) {
            Trade trade = tradesQueue.poll();
            TradeProcessor.handleTrade(trade.numShares(), trade.stockName());
        }
    }
}

public class TradeProcessor {
    public static void handleTrade(int numShares, String stockName) {
        /* ... process the trade ... takes a while ... */
    }
}

什么是TradeWorker

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

错误发生了

数据:text/html,

假设我们有几个TradeWorker处理来自同一个共享队列的交易。

请注意,我们没有使用BlockingQueue工作线程调用poll来从队列中检索项目。

Queue.poll()的 Javadoc

以下哪种情况可能发生?

(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

摘要

  • 而不是使用锁进行同步,消息传递系统在共享通信通道上进行同步,例如流或队列。

  • 在单个进程内,线程之间使用阻塞队列进行通信是一种有用的模式。

阅读 23:锁和同步

在 6.005 软件中

免受错误影响 易于理解 可随时更改
今天正确,未来也正确。 与未来程序员清晰沟通,包括未来的你。 设计以适应更改而无需重写。

目标

  • 了解锁如何用于保护共享可变数据

  • 能够识别死锁并知道预防死锁的策略。

  • 了解监视器模式并能够将其应用于数据类型

介绍

早些时候,我们定义了数据类型或函数的线程安全性,即在多个线程使用时表现正确,无论这些线程如何执行,都不需要额外的协调

这是一般原则:并发程序的正确性不应取决于时间的偶然性

为了实现这种正确性,我们列举了使代码安全并发的四种策略:

  1. 隔离:不要在线程之间共享数据。

  2. 不可变性:使共享数据不可变。

  3. 使用现有线程安全的数据类型:使用一个为您进行协调的数据类型。

  4. 同步:防止线程同时访问共享数据。这是我们用来实现线程安全类型的方法,但当时我们没有讨论它。

我们之前讨论过策略 1-3。在本文中,我们将结束对策略 4 的讨论,使用同步来实现您自己的数据类型,使其适用于共享内存并发

同步

并发程序的正确性不应取决于时间的偶然性。

由于并发操作共享可变数据导致的竞态条件是灾难性的错误——难以发现、难以重现、难以调试——我们需要一种让共享内存的并发模块之间同步的方法。

是一种同步技术。锁是一种允许最多一个线程拥有它的抽象。持有锁是一个线程告诉其他线程:“我正在处理这个东西,请现在不要碰它。”

锁有两个操作:

  • acquire 允许线程获取锁的所有权。如果一个线程试图获取当前由另一个线程拥有的锁,它会阻塞,直到另一个线程释放锁。此时,它将与试图获取锁的任何其他线程竞争。最多只能有一个线程拥有该锁。

  • release 释放锁的所有权,允许另一个线程获取它的所有权。

使用锁还告诉编译器和处理器,你正在同时使用共享内存,因此寄存器和缓存将被刷新到共享存储中。这避免了重新排序的问题,确保锁的所有者始终查看最新的数据。

银行账户示例

银行账户的共享内存模型

我们第一个共享内存并发的示例是一个带有取款机的银行。该示例的图表在右侧。

银行有几台取款机,它们都可以读取和写入内存中相同的账户对象。

当然,如果并发读写账户余额之间没有任何协调,事情会变得非常糟糕。

要解决这个锁的问题,我们可以为每个银行账户添加一个保护锁。现在,在取款机可以访问或更新账户余额之前,必须首先获取该账户的锁。

使用锁同步银行账户

在右侧的图表中,A 和 B 都试图访问账户 1。假设 B 先获取锁。然后 A 必须等待读取和写入余额,直到 B 完成并释放锁。这确保了 A 和 B 的同步,但另一个取款机 C 可以独立运行在另一个账户上(因为该账户受到不同锁的保护)。

死锁

当锁被正确和谨慎地使用时,可以防止竞争条件。但另一个问题随之而来。因为使用锁需要线程等待(当另一个线程持有锁时,acquire会阻塞),可能会出现两个线程互相等待对方的情况,因此都无法继续执行。

银行账户死锁

在右侧的图中,假设 A 和 B 在我们的银行账户之间进行同时转账。

在账户之间进行转账需要锁定两个账户,以防资金从系统中消失。A 和 B 分别获取其各自“从”账户的锁:A 获取账户 1 的锁,B 获取账户 2 的锁。现在,每个人都必须获取其“到”账户的锁:因此 A 正在等待 B 释放账户 2 的锁,而 B 正在等待 A 释放账户 1 的锁。僵局!A 和 B 被困在“致命的拥抱”中,账户被锁定。

死锁发生在并发模块相互等待对方执行某些操作时。死锁可能涉及两个以上的模块:死锁的特征是依赖循环,例如 A 等待 B,B 等待 C,C 等待 A。它们都无法继续执行。

你也可以在不使用任何锁的情况下发生死锁。例如,当消息缓冲区填满时,消息传递系统可能会发生死锁。如果客户端用请求填满了服务器的缓冲区,然后阻塞等待添加另一个请求,那么服务器可能会用结果填满客户端的缓冲区,然后自己阻塞。所以客户端正在等待服务器,服务器正在等待客户端,两者都无法取得进展,直到另一个取得进展。同样,死锁随之而来。

在 Java 教程中,阅读:

开发线程安全的抽象数据类型

让我们看看如何使用同步来实现一个线程安全的 ADT。

您可以在 GitHub 上查看此示例的所有代码:编辑缓冲区示例。您需要阅读和理解所有代码。以下是所有相关部分的摘录。

假设我们正在构建一个多用户编辑器,类似于 Google Docs,允许多人同时连接并编辑。我们需要一个可变的数据类型来表示文档中的文本。这是接口;基本上它表示一个带有插入和删除操作的字符串:

EditBuffer.java

/** An EditBuffer represents a threadsafe mutable
 *  string of characters in a text editor. */
public interface EditBuffer {
    /**
     * Modifies this by inserting a string.
     * @param pos position to insert at
                      (requires 0 <= pos <= current buffer length)
     * @param ins string to insert
     */
    public void insert(int pos, String ins);

    /**
     * Modifies this by deleting a substring
     * @param pos starting position of substring to delete 
     *                (requires 0 <= pos <= current buffer length)
     * @param len length of substring to delete 
     *                (requires 0 <= len <= current buffer length - pos)
     */
    public void delete(int pos, int len);

    /**
     * @return length of text sequence in this edit buffer
     */
    public int length();

    /**
     * @return content of this edit buffer
     */
    public String toString();
}

这种数据类型的一个非常简单的表示只是一个字符串:

SimpleBuffer.java

public class SimpleBuffer implements EditBuffer {
    private String text;
    // Rep invariant: 
    //   text != null
    // Abstraction function: 
    //   represents the sequence text[0],...,text[text.length()-1]

这种表示的缺点是,每次进行插入或删除操作时,都必须将整个字符串复制到一个新字符串中。这很昂贵。我们可以使用的另一种表示方法是字符数组,末尾带有空间。如果用户只是在文档末尾输入新文本(我们不必复制任何内容),那么这是很好的,但如果用户在文档开头输入文本,那么我们就必须在每次按键时复制整个文档。

更有趣的表示方法,在实践中许多文本编辑器都使用,称为间隙缓冲区。它基本上是一个带有额外空间的字符数组,但是额外空间不是全部在末尾,而是一个间隙,可以出现在缓冲区的任何位置。每当需要进行插入或删除操作时,数据类型首先将间隙移动到操作的位置,然后执行插入或删除。如果间隙已经存在,那么就不需要复制任何内容——插入只消耗部分间隙,删除只扩大间隙!间隙缓冲区特别适合表示由具有光标的用户编辑的字符串,因为插入和删除倾向于围绕光标进行,所以间隙很少移动。

GapBuffer.java

/** GapBuffer is a non-threadsafe EditBuffer that is optimized
 *  for editing with a cursor, which tends to make a sequence of
 *  inserts and deletes at the same place in the buffer. */
public class GapBuffer implements EditBuffer {
    private char[] a;
    private int gapStart;
    private int gapLength;
    // Rep invariant: 
    //   a != null
    //   0 <= gapStart <= a.length
    //   0 <= gapLength <= a.length - gapStart
    // Abstraction function: 
    //   represents the sequence a[0],...,a[gapStart-1],
    //                           a[gapStart+gapLength],...,a[length-1]

在多用户场景中,我们希望有多个间隙,每个用户的光标都有一个,但现在我们暂时只使用一个间隙。

开发数据类型的步骤

回想一下我们设计和实现 ADT 的方法:

  1. 指定。 定义操作(方法签名和规范)。我们在EditBuffer接口中完成了这项工作。

  2. 测试。 为操作开发测试用例。请参阅所提供代码中的EditBufferTest。测试套件包括基于对操作的参数空间进行分区的测试策略。

  3. Rep。 选择一个 rep。我们为EditBuffer选择了两个 rep,这通常是个好主意:

    1. 首先实现一个简单的、蛮力的版本。这样做更容易,您更有可能做对,并且它将验证您的测试用例和规范,以便您在继续进行更难的实现之前可以解决其中的问题。这就是为什么我们在转向GapBuffer之前先实现了SimpleBuffer。不要丢弃您的简单版本,保留它,以便您有一些东西可以测试和与之比较,以防更复杂的版本出现问题。

    2. 记录 rep 不变式和抽象函数,并实现checkRep() checkRep()在每个构造函数、生产者和修改器方法的末尾断言 rep 不变式。(通常不需要在观察者的末尾调用它,因为 rep 没有改变。)实际上,断言对于测试复杂实现非常有用,因此在复杂方法的末尾也断言后条件并不是一个坏主意。您将在本文中的代码中GapBuffer.moveGap()中看到这样的示例。

在所有这些步骤中,我们首先完全单线程工作。在编写规范和选择代表时,多线程客户端应始终在我们的头脑中,因为(我们将在稍后看到,小心选择操作可能是必要的,以避免数据类型客户端中的竞争条件)。但首先使其在顺序的、单线程的环境中工作,并进行彻底测试。

现在我们准备进行下一步:

  1. 同步。 提出您的 rep 是线程安全的论点。将其明确写成注释添加到您的类中,就在 rep 不变式旁边,以便维护人员知道您如何将线程安全性设计到类中。

本阅读的这部分是关于如何执行第 4 步的。我们已经看到如何提出线程安全性论点,但这次,我们将依赖于该论点中的同步。

然后我们上面提到的额外步骤:

  1. 迭代。您可能会发现,您选择的操作方式使得编写符合客户要求的线程安全类型变得困难。您可能会在第 1 步中发现这一点,或者在编写测试时发现,在步骤 3 或 4 中实现时发现。如果是这种情况,请返回并完善您的 ADT 提供的操作集。

锁定

锁是如此常用,以至于 Java 将它们作为内置语言特性提供。

在 Java 中,每个对象都隐含与之关联的锁 —— 一个String、一个数组、一个ArrayList,以及您创建的每个类,它们的所有对象实例都有一个锁。甚至一个简单的Object也有一个锁,因此裸Object经常用于显式锁定。

Object lock = new Object();

但是在 Java 的内置锁上不能调用acquirerelease。相反,您可以使用synchronized语句在语句块的持续时间内获取锁:

synchronized (lock) { // thread blocks here until lock is free
    // now this thread has the lock
    balance = balance + 1;
    // exiting the block releases the lock
}

像这样的同步区域提供了互斥:同一时间只能有一个线程在由给定对象的锁保护的同步区域中。换句话说,您回到了顺序编程的世界中,一次只有一个线程在运行,至少是对于引用相同对象的其他同步区域而言。

锁保护数据的访问

锁用于保护共享数据变量,如此处显示的账户余额。如果对数据变量的所有访问都由相同的锁对象保护(包围在同步块中),那么这些访问将被保证是原子的——不会被其他线程打断。

因为 Java 中的每个对象都隐含与之关联的锁,所以您可能会认为拥有对象的锁就会阻止其他线程访问该对象。事实并非如此。使用下面的代码获取与对象obj关联的锁时,

synchronized (obj) { ... }

线程t只做一件事:防止其他线程进入synchronized(obj)块,直到线程t完成其同步块。就是这样。

锁只与获取相同锁的其他线程提供互斥。对数据变量的所有访问都必须由相同的锁保护。您可能会将整个变量集合放在单个锁后面,但所有模块必须同意它们将获取和释放哪个锁。

监视器模式

当你编写类的方法时,最方便的锁是对象实例本身,即this。作为一种简单的方法,我们可以通过在所有对 rep 的访问都包装在synchronized (this)内部来保护整个类的 rep。

/** SimpleBuffer is a threadsafe EditBuffer with a simple rep. */
public class SimpleBuffer implements EditBuffer {
    private String text;
    ...
    public SimpleBuffer() {
        **synchronized (this) {**
            text = "";
            checkRep();
        **}**
    }
    public void insert(int pos, String ins) {
        **synchronized (this) {**
            text = text.substring(0, pos) + ins + text.substring(pos);
            checkRep();
        **}**
    }
    public void delete(int pos, int len) {
        **synchronized (this) {**
            text = text.substring(0, pos) + text.substring(pos+len);
            checkRep();
        **}**
    }
    public int length() {
        **synchronized (this) {**
            return text.length();
        **}**
    }
    public String toString() {
        **synchronized (this) {**
            return text;
        **}**
    }
} 

请注意这里非常谨慎的规范。每个触及 rep 的方法都必须使用锁进行保护——甚至看似小而琐碎的方法,如length()toString()。这是因为读取必须像写入一样受到保护——如果读取未经保护,则它们可能能够看到 rep 处于部分修改状态。

这种方法称为监视器模式。监视器是一个类,其方法是互斥的,因此一次只能有一个线程在类的实例内部。

Java 为监视器模式提供了一些语法糖。如果你在方法签名中添加关键字synchronized,那么 Java 会像你在方法体周围写了synchronized (this)一样运行。因此,下面的代码是实现同步的SimpleBuffer的等效方式:

/** SimpleBuffer is a threadsafe EditBuffer with a simple rep. */
public class SimpleBuffer implements EditBuffer {
    private String text;
    ...
    public SimpleBuffer() {
        text = "";
        checkRep();
    }
    public **synchronized** void insert(int pos, String ins) {
        text = text.substring(0, pos) + ins + text.substring(pos);
        checkRep();
    }
    public **synchronized** void delete(int pos, int len) {
        text = text.substring(0, pos) + text.substring(pos+len);
        checkRep();
    }
    public **synchronized** int length() {
        return text.length();
    }
    public **synchronized** String toString() {
        return text;
    }
} 

请注意,SimpleBuffer构造函数没有synchronized关键字。实际上,Java 在语法上禁止这样做,因为一个正在构造的对象预期应该在返回构造函数之前被限制在一个线程中。因此,同步构造函数应该是不必要的。

在 Java 教程中,阅读:

阅读练习

使用锁进行同步

如果线程 B 尝试获取线程 A 当前持有的锁:

线程 A 会发生什么?

(缺少答案)(缺少答案)(缺少答案)

线程 B 会发生什么?

(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

这个列表是我的,完全属于我。

假设list是一个ArrayList<String>的实例。

当 A 在synchronized (list) { ... }块中时,哪些是真的?

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

好吧,但是这个同步的 List 完全属于我。

假设sharedList是由Collections.synchronizedList返回的List

现在可以在多个线程中安全地使用sharedList而不需要获取任何锁……除了!以下哪种情况需要一个synchronized(sharedList) { ... }块?

(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

我听说你喜欢锁,所以我获得了你的锁,这样你就可以在获得的同时锁定。

假设我们运行这段代码:

synchronized (obj) {
    // ...
    synchronized (obj) { // <-- uh oh, deadlock?
        // ...
    }
    // <-- do we own the lock on obj?
}

在“噢噢,死锁了?”那一行,我们会经历死锁吗?

(缺少答案)(缺少答案)

如果我们没有死锁,在“我们是否拥有 obj 上的锁”的那一行,线程是否拥有 obj 上的锁?

(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

使用同步进行线程安全论证

现在我们正在使用锁保护SimpleBuffer的 rep,我们可以写一个更好的线程安全论证:

/** SimpleBuffer is a threadsafe EditBuffer with a simple rep. */
public class SimpleBuffer implements EditBuffer {
    private String text;
    // Rep invariant: 
    //   text != null
    // Abstraction function: 
    //   represents the sequence text[0],...,text[text.length()-1]
    // Thread safety argument:
    //   all accesses to text happen within SimpleBuffer methods,
    //   which are all guarded by SimpleBuffer's lock

如果我们使用监视器模式来同步GapBuffer的所有方法,那么相同的论点适用于GapBuffer

请注意,类的封装,即没有 rep 曝光,对于提出这个论点非常重要。如果 text 是 public 的:

 public String text;

那么SimpleBuffer之外的客户端将能够读取和写入它,而不知道他们应该首先获取锁,而SimpleBuffer将不再是线程安全的。

锁定纪律

锁定纪律是确保同步代码是线程安全的策略。我们必须满足两个条件:

  1. 每个共享的可变变量都必须由某个锁保护。除了在获得该锁的同步块内部读取或写入数据外,不得读取或写入数据。

  2. 如果不变量涉及多个共享可变变量(甚至可能在不同的对象中),则所涉及的所有变量必须由相同的锁保护。一旦线程获取锁,则必须在释放锁之前重新建立不变量。

此处使用的监视器模式满足了两条规则。所有与 rep 不变量相关的共享可变数据都由相同的锁保护。

原子操作

考虑对EditBuffer数据类型进行查找和替换操作:

/** Modifies buf by replacing the first occurrence of s with t.
 *  If s not found in buf, then has no effect.
 *  @returns true if and only if a replacement was made
 */
public static boolean findReplace(EditBuffer buf, String s, String t) {
    int i = buf.toString().indexOf(s);
    if (i == -1) {
        return false;
    }
    buf.delete(i, s.length());
    buf.insert(i, t);
    return true;
}

此方法对buf进行了三个不同的调用 —— 将其转换为字符串以便搜索s,删除旧文本,然后在其位置插入t。即使这些调用中的每个调用都是原子的,findReplace方法作为一个整体却不是线程安全的,因为其他线程可能会在findReplace工作时对缓冲区进行突变,导致删除错误的区域或将替换放回到错误的位置。

为了防止这种情况发生,findReplace需要与buf的所有其他客户端同步。

给客户端访问锁

有时,将您数据类型的锁提供给客户可能是有用的,这样他们就可以使用它来实现使用您数据类型的更高级原子操作。

因此,解决findReplace问题的一种方法是记录客户端可以使用EditBuffer的锁来彼此同步:

/** An EditBuffer represents a threadsafe mutable string of characters
 *  in a text editor. **Clients may synchronize with each other using the
 *  EditBuffer object itself.** */
public interface EditBuffer {
   ...
} 

然后findReplace可以对buf进行同步:

public static boolean findReplace(EditBuffer buf, String s, String t) {
    **synchronized (buf) {**
        int i = buf.toString().indexOf(s);
        if (i == -1) {
            return false;
        }
        buf.delete(i, s.length());
        buf.insert(i, t);
        return true;
    **}**
} 

这样做的效果是将监视器模式已经放置在个别toStringdeleteinsert方法周围的同步区域扩大到一个单一的原子区域,确保所有三个方法在没有其他线程干扰的情况下执行。

到处都加上synchronized吗?

所以线程安全仅仅是在程序的每个方法上都加上synchronized关键字吗?不幸的是,不是。

首先,您实际上不希望随意同步方法。同步对您的程序施加了很大的成本。由于需要获取锁(并刷新缓存并与其他处理器通信),进行同步方法调用可能需要更长的时间。Java 默认情况下将其许多可变数据类型保持不同步正是出于这些性能原因。当您不需要同步时,请不要使用它。

更谨慎地使用synchronized的另一个论点是,它将锁的访问范围最小化。将synchronized添加到每个方法中意味着您的锁是对象本身,并且每个具有对您对象引用的客户端自动具有对您锁的引用,可以随意获取和释放。因此,您的线程安全机制是公开的,并且可以被客户端干扰。相比之下,使用作为对象内部的锁,使用Synchronized()块适当且节制地获取。

最后,仅仅在任何地方添加synchronized是不够的。毫无思考地在方法上添加synchronized意味着你在获取一个锁而没有考虑是哪个锁,或者是否是用于保护即将进行的共享数据访问的正确锁。假设我们尝试通过简单地在findReplace的声明上添加synchronized来解决同步问题:

public static synchronized boolean findReplace(EditBuffer buf, ...) {

这不会达到我们的目的。它确实会获取一个锁——因为findReplace是一个静态方法,它会获取整个类的静态锁,而不是实例对象锁。因此,只有一个线程可以一次调用findReplace——即使其他线程想要操作不同的缓冲区,这应该是安全的,它们仍然会被阻塞,直到单个锁被释放。因此,我们会遭受显著的性能损失,因为我们庞大的多用户编辑器只允许一个用户一次执行查找和替换,即使他们都在编辑不同的文档。

然而更糟糕的是,它不会提供有用的保护,因为触及文档的其他代码可能不会获取相同的锁。它实际上不会消除我们的竞态条件。

synchronized关键字并非万能药。线程安全需要纪律——使用封装、不可变性或锁来保护共享数据。而且这种纪律需要被记录下来,否则维护者就不会知道是什么。

为并发设计数据类型

findReplace的问题可以另一种方式解释:EditBuffer接口对于多个同时客户端并不友好。它依赖于整数索引来指定插入和删除位置,这对其他变化非常脆弱。如果其他人在索引位置之前插入或删除,那么索引就会变得无效。

因此,如果我们专门为并发系统设计数据类型,我们需要考虑提供在交错时具有更明确定义语义的操作。例如,将EditBuffer与表示缓冲区中光标位置的Position数据类型配对,甚至是表示选定范围的Selection数据类型。一旦获得,Position可以在文本中的插入和删除操作中保持其位置,直到客户端准备使用该Position。如果其他线程删除了Position周围的所有文本,那么Position将能够通知后续客户端发生了什么(可能会有异常),并允许客户端决定如何处理。在设计用于并发的数据类型时,这些考虑因素会起作用。

另一个例子是考虑 Java 中的ConcurrentMap接口。该接口扩展了现有的Map接口,添加了一些常用的在共享可变映射上作为原子操作的关键方法,例如:

  • map.putIfAbsent(key,value)

    如果(map.containsKey(key))则map.put(key, value);

  • map.replace(key, value)

    如果(map.containsKey(key))则map.put(key, value);

死锁出现了

线程安全的加锁方法非常强大,但是(与封装和不可变性不同)它引入了程序中的阻塞。有时线程必须等待其他线程退出同步区域才能继续。而且阻塞会增加死锁的可能性 —— 这是一个非常真实的风险,并且坦率地说在这种设置中比在具有阻塞 I/O 的消息传递中要常见得多

通过加锁,死锁发生在线程同时获取多个锁并且两个线程最终被阻塞,同时持有它们各自等待另一个释放的锁。监视器模式不幸地使这相当容易实现。下面是一个例子。

假设我们正在对一系列书的社交网络进行建模:

public class Wizard {
    private final String name;
    private final Set<Wizard> friends;
    // Rep invariant:
    //    name, friends != null
    //    friend links are bidirectional: 
    //        for all f in friends, f.friends contains this
    // Concurrency argument:
    //    threadsafe by monitor pattern: all accesses to rep 
    //    are guarded by this object's lock

    public Wizard(String name) {
        this.name = name;
        this.friends = new HashSet<Wizard>();
    }

    public synchronized boolean isFriendsWith(Wizard that) {
        return this.friends.contains(that);
    }

    public synchronized void friend(Wizard that) {
        if (friends.add(that)) {
            that.friend(this);
        } 
    }

    public synchronized void defriend(Wizard that) {
        if (friends.remove(that)) {
            that.defriend(this);
        } 
    }
}

像 Facebook 一样,这个社交网络是双向的:如果xy是朋友,那么y也是x的朋友。friend()defriend()方法通过修改两个对象的代表来执行不变性,这意味着它们使用了监视器模式,因此需要获取两个对象的锁。

让我们创建一对巫师:

 Wizard harry = new Wizard("Harry Potter");
    Wizard snape = new Wizard("Severus Snape");

然后想想当两个独立的线程反复运行时会发生什么:

 // thread A                   // thread B
    harry.friend(snape);          snape.friend(harry);
    harry.defriend(snape);        snape.defriend(harry);

我们将非常迅速地发生死锁。原因如下。假设线程 A 即将执行harry.friend(snape),而线程 B 即将执行snape.friend(harry)

  • 线程 A 获取了harry上的锁(因为friend方法是同步的)。

  • 然后线程 B 以相同的原因获取了snape上的锁。

  • 它们都独立地更新各自的代表,并尝试在另一个对象上调用friend() —— 这要求它们获取另一个对象上的锁。

因此 A 持有 Harry 并等待 Snape,而 B 持有 Snape 并等待 Harry。两个线程都被卡在friend()中,因此它们中的任何一个都不会成功退出同步区域并释放锁给另一个。这是一个经典的致命拥抱。程序停止运行。

问题的本质是获取多个锁,并在等待另一个锁变得可用时保持某些锁的状态。

请注意,线程 A 和线程 B 可能交错执行,以致死锁不会发生:也许线程 A 在线程 B 有足够时间获取第一个锁之前获取并释放了两个锁。如果死锁涉及的锁也涉及竞争条件 —— 而很多时候确实如此 —— 那么死锁将同样难以重现或调试。

死锁解决方案 1:锁的排序

防止死锁的一种方法是对需要同时获取的锁进行排序,并确保所有代码按照该顺序获取锁。

在我们的社交网络示例中,我们可能总是按照巫师的名字的字母顺序获取Wizard对象的锁。由于线程 A 和线程 B 都需要 Harry 和 Snape 的锁,它们都会按照这个顺序获取:首先是 Harry 的锁,然后是 Snape 的锁。如果线程 A 在 B 之前获取了 Harry 的锁,那么它也会在 B 之前获取 Snape 的锁,因为 B 在 A 释放 Harry 的锁之前无法继续。锁的顺序强制了获取它们的线程的顺序,因此无法在等待图中产生循环。

代码可能如下所示:

 public void friend(Wizard that) {
        Wizard first, second;
        if (this.name.compareTo(that.name) < 0) {
            first = this; second = that;
        } else {
            first = that; second = this;
        }
        synchronized (first) {
            synchronized (second) {
                if (friends.add(that)) {
                    that.friend(this);
                } 
            }
        }
    }

(请注意,按照人名的字母顺序对锁进行排序的决定对于本书来说是可以的,但在现实生活中的社交网络中不适用。为什么?在锁的排序中,比名字更好的选择是什么?)

尽管锁的排序在实践中很有用(特别是在像操作系统内核这样的代码中),但实际上有许多缺点。

  • 首先,这不是模块化的 —— 代码必须了解系统中的所有锁,或者至少了解其子系统中的所有锁。

  • 其次,代码可能很难或不可能知道在获取第一个锁之前将需要哪些锁。它可能需要进行一些计算来弄清楚。例如,考虑在社交网络图上进行深度优先搜索 —— 在开始查找之前,你如何知道哪些节点需要被锁定?

死锁解决方案 2:粗粒度锁

比锁的排序更常见的方法,特别是对于应用程序编程(而不是操作系统或设备驱动程序编程),是使用更粗的锁 —— 使用单个锁来保护许多对象实例,甚至是程序的整个子系统。

例如,我们可能为整个社交网络设置一个单独的锁,并使其所有组成部分的操作在该锁上同步。在下面的代码中,所有Wizard都属于一个Castle,我们只需使用该Castle对象的锁来同步:

public class Wizard {
    private final Castle castle;
    private final String name;
    private final Set<Wizard> friends;
    ...
    public void friend(Wizard that) {
        synchronized (castle) {
            if (this.friends.add(that)) {
                that.friend(this);
            }
        }
    }
}

粗粒度锁可能会带来显著的性能损失。如果用单个锁保护一大堆可变数据,那么你就放弃了同时访问任何数据的能力。在最坏的情况下,如果一个锁保护所有内容,你的程序可能基本上是顺序执行的 —— 一次只允许一个线程取得进展。

阅读练习

死锁

在下面的代码中,三个线程 1、2 和 3 正在尝试获取对象alphabetagamma上的锁。

线程 1 线程 2 线程 3

|

synchronized (alpha) {
    // using alpha
    // ...
}

synchronized (gamma) {
    synchronized (beta) {
        // using beta & gamma
        // ...
    }
}
// finished

|

synchronized (gamma) {
    synchronized (alpha) {
        synchronized (beta) {
            // using alpha, beta, & gamma
            // ...
        }
    }
}
// finished

|

synchronized (gamma) {
    synchronized (alpha) {
        // using alpha & gamma
        // ...
    }
}

synchronized (beta) {
    synchronized (gamma) {
        // using beta & gamma
        // ...
    }
}
// finished

|

这个系统容易发生死锁。

对于下面的每个情景,请确定如果线程当前位于指定的代码行,则系统是否处于死锁状态。

**情景 A

线程 1 在using alpha内部

线程 2 被阻塞在synchronized (alpha)

线程 3 完成

(缺失答案)(缺失答案)

(缺失解释)

**情景 B

线程 1 完成

线程 2 被阻塞在synchronized (beta)

线程 3 被阻塞在第 2 个synchronized (gamma)

(缺失答案)(缺失答案)

(缺失解释)

**情景 C

线程 1 在运行synchronized (beta)

线程 2 被阻塞在synchronized (gamma)

线程 3 被阻塞在第 1 个synchronized (gamma)

(缺失答案)(缺失答案)

(缺失解释)

**情景 D

线程 1 被阻塞在synchronized (beta)

线程 2 完成

线程 3 被阻塞在第 2 个synchronized (gamma)

(缺失答案)(缺失答案)

(缺失解释)******** ****被锁定

再次检查代码。

在前一个问题中,我们看到了涉及betagamma的死锁。

什么情况下会出现alpha

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)**** ****## 并发程序设计目标

现在是升级并查看我们正在做的内容的好时机。回想一下我们的主要目标是创建免受错误易于理解能够应对变化的软件。

构建并发软件显然是所有这三个目标的挑战。我们可以将问题分为两类。当我们询问并发程序是否免受错误时,我们关心两个属性:

  • 安全性。并发程序是否满足其不变量和规范?在访问可变数据时的竞争威胁到安全性。安全性问的问题是:你能证明一些坏事永远不会发生吗?

  • 活跃性。程序是否持续运行并最终按照您的意愿执行,或者它是否在某个地方永远等待永远不会发生的事件?你能证明一些好事最终会发生吗?

死锁威胁到活跃性。活跃性可能还需要公平性,这意味着并发模块被给予处理能力以在其计算上取得进展。公平性在很大程度上是操作系统的线程调度器的问题,但你可以通过设置线程优先级来影响它(好的或坏的)。

实践中的并发

在实际程序中通常采用哪些策略?

  • 库数据结构要么不使用同步(为单线程客户端提供高性能,同时让多线程客户端在顶部添加锁定),要么使用监视器模式。

  • 具有许多部分的可变数据结构通常使用粗粒度锁定或线程封闭。大多数图形用户界面工具包遵循这些方法之一,因为图形用户界面基本上是一个由可变对象组成的大型可变树。Java Swing,图形用户界面工具包,使用线程封闭。只允许单个专用线程访问 Swing 的树。其他线程必须向该专用线程发送消息以访问树。

  • 搜索通常使用不可变数据类型。我们的布尔公式可满足性搜索很容易实现多线程,因为涉及的所有数据类型都是不可变的。既不会出现竞争,也不会出现死锁的风险。

  • 操作系统通常使用细粒度锁以获得高性能,并使用锁排序来处理死锁问题。

我们省略了一种重要的可变共享数据的方法,因为它超出了本课程的范围,但值得一提:数据库。数据库系统广泛用于分布式客户端/服务器系统,如 Web 应用程序。数据库使用事务来避免竞争条件,类似于同步区域,其效果是原子的,但不必获取锁,尽管如果发生竞争,则事务可能失败并回滚。数据库还可以管理锁,并自动处理锁定顺序。关于如何在系统设计中使用数据库,强烈推荐 6.170 软件工作室;关于数据库内部工作原理的更多信息,请参加 6.814 数据库系统课程。

如果您对并发程序的性能感兴趣 - 因为性能通常是我们首次向系统添加并发性的原因之一 - 那么 6.172 性能工程课程就是适合您的课程。

总结

制作一个安全免于错误、易于理解且准备好进行更改的并发程序需要仔细思考。当您试图确定 Heisenbugs 时,它们会立即逃跑,因此调试根本不是实现正确线程安全代码的有效方法。线程可以以许多不同的方式交错执行其操作,以至于您永远无法测试所有可能的执行的一小部分。

  • 对于您的数据类型进行线程安全性论证,并在代码中进行文档记录。

  • 获取锁允许线程独占访问由该锁保护的数据,迫使其他线程阻塞 - 只要这些线程也试图获取相同的锁。

  • 监视器模式使用单个锁来保护数据类型的表示,该锁由每个方法获取。

  • 由于获取多个锁导致的阻塞可能会导致死锁的可能性。

阅读 24:Map、Filter、Reduce

6.005 中的软件

安全免于错误 易于理解 可轻松更改
今天正确且未来未知时也正确。 与未来程序员清晰沟通,包括未来的你。 设计以适应更改而无需重写。

目标

在本篇阅读中,您将学习一种用于实现对元素序列操作的函数的设计模式,并且您将看到将函数本身视为第一类值,我们可以在程序中传递和操作它们,这是一个特别强大的想法。

  • Map/filter/reduce

  • Lambda 表达式

  • 功能对象

  • 高阶函数

简介:一个示例

假设我们面临以下问题:编写一个方法,找出项目中 Java 文件中的单词。

遵循良好的实践,我们将其拆分为几个较简单的步骤,并为每个步骤编写一个方法:

  • 查找项目中的所有文件,通过从项目的根文件夹递归扫描

  • 将它们限制在具有特定后缀的文件中,本例中为.java

  • 打开每个文件并逐行读取

  • 将每行拆分成单词

编写这些子步骤的各个方法时,我们会发现自己写了很多低级别的迭代代码。例如,这是项目文件夹的递归遍历可能看起来像什么:

/**
 * Find all the files in the filesystem subtree rooted at folder.
 * @param folder root of subtree, requires folder.isDirectory() == true
 * @return list of all ordinary files (not folders) that have folder as
 *         their ancestor
 */
public static List<File> allFilesIn(File folder) {
    List<File> files = new ArrayList<>();
    for (File f : folder.listFiles()) {
        if (f.isDirectory()) {
            files.addAll(allFilesIn(f));
        } else if (f.isFile()) {
            files.add(f);
        }
    }
    return files;
}

这是过滤方法可能的样子,它将文件列表限制为只有 Java 文件(想象一下像这样调用它:onlyFilesWithSuffix(files, ".java")):

/**
 * Filter a list of files to those that end with suffix.
 * @param files list of files (all non-null)
 * @param suffix string to test
 * @return a new list consisting of only those files whose names end with
 *         suffix
 */
public static List<File> onlyFilesWithSuffix(List<File> files, String suffix) {
    List<File> result = new ArrayList<>();
    for (File f : files) {
        if (f.getName().endsWith(suffix)) {
            result.add(f);
        }
    }
    return result;
}

→ 示例的完整 Java 代码

在本篇阅读中,我们讨论map/filter/reduce,这是一种设计模式,可以大大简化对元素序列操作的函数的实现。在这个例子中,我们会有很多序列——文件列表;作为行序列的输入流;作为单词序列的行;作为(单词,计数)对序列的频率表。Map/filter/reduce 将使我们能够在这些序列上操作,而无需显式的控制流程——没有一个for循环或if语句。

在这个过程中,我们还将看到一个重要的大思想:函数作为“第一类”数据值,意味着它们可以存储在变量中,作为参数传递给函数,并像其他值一样动态创建。

在 Java 中使用第一类函数更加冗长,使用了一些不熟悉的语法,并且与静态类型的交互增加了一些复杂性。因此,为了开始使用 map/filter/reduce,我们将切换回 Python。

抽象出控制流

我们已经看到了一个设计模式,它抽象出了对数据结构进行迭代的细节:迭代器。

迭代器抽象

迭代器为您提供了来自数据结构的元素序列,而无需担心数据结构是集合、令牌流、列表还是数组 — 无论数据结构是什么,Iterator看起来都是一样的。

例如,给定一个List<File> files,我们可以使用索引进行迭代:

for (int ii = 0; ii < files.size(); ii++) {
    File f = files.get(ii);
    // ...

但这段代码依赖于Listsizeget方法,这在另一个数据结构中可能会有所不同。使用迭代器可以抽象出这些细节:

Iterator<File> iter = files.iterator();
while (iter.hasNext()) {
    File f = iter.next();
    // ...

现在循环对于任何提供Iterator的类型都是相同的。实际上,有一个适用于这些类型的接口:Iterable。任何Iterable都可以与 Java 的增强 for 语句一起使用 — for (File f : files) — 在幕后,它使用一个迭代器。

映射/过滤/归约抽象

本文中的映射/过滤/归约模式与迭代器做的事情类似,但在更高的层面上:它们将整个元素序列视为一个单元,使程序员无需单独命名和处理元素。在这种范式中,控制语句消失了:具体来说,我们介绍示例代码中的for语句、if语句和return语句将不复存在。我们还将能够摆脱大部分临时名称(即,局部变量filesfresult)。

序列

让我们想象一个抽象数据类型Seq<E>,表示类型为E的元素序列

例如,[1, 2, 3, 4]Seq<Integer>

任何具有迭代器的数据类型都可以作为序列:数组、列表、集合等。字符串也是一个序列(字符的序列),尽管 Java 的字符串不提供迭代器。在这方面,Python 更加一致:不仅列表可迭代,字符串、元组(不可变列表)甚至输入流(产生一系列行)也是可迭代的。我们将首先在 Python 中看到这些示例,因为语法非常易读且对您来说很熟悉,然后我们将看看它在 Java 中是如何工作的。

我们将有三个序列操作:映射、过滤和归约。让我们依次看看每个操作,然后看看它们如何一起工作。

映射

映射将一个一元函数应用于序列中的每个元素,并返回一个包含结果的新序列,顺序相同:

map:(E → F) × Seq<‍E> → Seq<‍F>

例如,在 Python 中:

>>> from math import sqrt
>>> map(sqrt, [1, 4, 9, 16])
[1.0, 2.0, 3.0, 4.0]
>>> map(str.lower, ['A', 'b', 'C'])
['a', 'b', 'c']

map是内置的,但在 Python 中实现也很简单:

def map(f, seq):
    result = []
    for elt in seq:
        result.append(f(elt))
    return result

这个操作捕捉了对序列进行操作的常见模式:对序列的每个元素执行相同的操作。

函数作为值

让我们在这里停顿一下,因为我们正在对函数做一些不寻常的事情。map函数将一个函数的引用作为其第一个参数 — 而不是该函数的结果。当我们写下

map(sqrt, [1, 4, 9, 16])

我们没有调用sqrt(像sqrt(25)是一次调用一样),而是只使用了它的名称。在 Python 中,函数的名称是对表示该函数的对象的引用。如果您愿意,您可以将该对象分配给另一个变量,并且它仍然像sqrt一样行为:

>>> mySquareRoot = sqrt
>>> mySquareRoot(25)
5.0

您还可以将函数对象的引用作为参数传递给另一个函数,并且这就是我们在这里使用map所做的。您可以像在 Python 中使用任何其他数据值(如数字、字符串或对象)一样使用函数对象。

在 Python 中,函数是一等公民,这意味着它们可以赋值给变量、作为参数传递、作为返回值使用,并且存储在数据结构中。一等函数是一个非常强大的编程思想。第一个使用它们的实用编程语言是由 MIT 的 John McCarthy 发明的 Lisp。但是使用函数作为一等值进行编程的想法实际上早于计算机,可以追溯到 Alonzo Church 的 lambda 演算。lambda 演算使用希腊字母λ来定义新函数;这个术语流行开来,您将在 Lisp 及其后代中看到它作为关键字,还会在 Python 中看到它。

我们已经看到如何将内置库函数用作一等值;我们如何创建我们自己的函数呢?一种方法是使用熟悉的函数定义,为函数赋予一个名称:

>>> def powerOfTwo(k):
...     return 2**k
... 
>>> powerOfTwo(5)                 
32
>>> map(powerOfTwo, [1, 2, 3, 4])
[2, 4, 8, 16]

但是,当您只需要函数在一个地方时 —— 这在函数编程中经常出现 —— 使用lambda 表达式更方便:

lambda k: 2**k

这个表达式表示一个参数(称为k)的函数,它返回值为 2^k。您可以在任何您将使用powerOfTwo的地方使用它:

>>> (lambda k: 2**k)(5)
32
>>> map(lambda k: 2**k, [1, 2, 3, 4])
[2, 4, 8, 16]

Python 的 lambda 表达式可惜地在语法上受到限制,只能编写具有return语句而没有其他内容(没有if语句、没有for循环、没有局部变量)的函数。但请记住,这正是我们使用map/filter/reduce的目标,因此这不会是一个严重的障碍。

Python 的创始人 Guido Von Rossum 在一篇博文中讨论了这种设计原则,这种原则不仅导致了 Python 中的一等函数,还导致了一等方法:一切皆一等

更多使用map的方法

即使您不关心函数的返回值,map也是有用的。例如,当您有一系列可变对象时,您可以在它们上面映射一个改变操作:

map(IOBase.close, streams) # closes each stream on the list
map(Thread.join, threads)  # waits for each thread to finish

一些版本的map(包括 Python 内置的map)还支持将具有多个参数的函数映射。例如,您可以对元素逐个添加两个数字列表:

>>> import operator
>>> map(operator.add, [1, 2, 3], [4, 5, 6])
[5, 7, 9]

阅读练习

map 1

如果您不确定,请在 Python 解释器中尝试!

map(len, [ [1], [2], [3] ])的结果是什么?

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

map 2

map(len, [1, 2, 3])的结果是什么?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

映射 3

map(len, ['1', '2', '3']) 的结果是什么?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

映射 4

map(lambda x: x.split(' '), 'a b c') 的结果是什么?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

映射 5

map(lambda x: x.split(' '), ['a b c']) 的结果是什么?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

过滤器

我们接下来重要的序列操作是过滤器,它使用一元谓词测试每个元素。满足谓词的元素被保留;不满足的被移除。返回一个新列表;过滤器不会修改其输入列表。

过滤器:(E → boolean) × Seq<‍E> → Seq<‍E>

Python 示例:

>>> filter(str.isalpha, ['x', 'y', '2', '3', 'a']) 
['x', 'y', 'a']
>>> def isOdd(x): return x % 2 == 1
... 
>>> filter(isOdd, [1, 2, 3, 4])
[1, 3]
>>> filter(lambda s: len(s)>0, ['abc', '', 'd'])
['abc', 'd']

我们可以直接定义过滤器:

def filter(f, seq):
    result = []
    for elt in seq:
        if f(elt):
            result.append(elt)
    return result

阅读练习

过滤器 1

如果不确定,可以在 Python 解释器中尝试这些!

给定:

x1 = {'x': 1}
y2 = {'y': 2}
x3_y4 = {'x': 3, 'y': 4}

filter(lambda d: 'x' in d.keys(), [ x1, y2, x3_y4 ]) 的结果是什么?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

过滤器 2

再次给定:

x1 = {'x': 1}
y2 = {'y': 2}
x3_y4 = {'x': 3, 'y': 4}

filter(lambda d: 0 in d.values(), [ x1, y2, x3_y4 ]) 的结果是什么?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

过滤器 3

filter(str.isalpha, [ 'a', '1', 'b', '2' ]) 的结果是什么?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

过滤器 4

filter(str.swapcase, [ 'a', '1', 'b', '2' ]) 的结果是什么?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

缩减

我们的最后一个操作符,reduce,使用二元函数将序列的元素组合在一起。除了函数和列表,它还接受一个初始值来初始化缩减,并且如果列表为空,它最终成为返回值。

缩减:(F × E → F) × Seq<‍E> × F → F

reduce(f, list, init) 将列表的元素从左到右组合在一起,如下所示:

result[0] = init

result[1] = f(result[0], list[0])

result[2] = f(result[1], list[1])

...

result[n] = f(result[n-1], list[n-1])

result[n] 是一个 n 元素列表的最终结果。

加法运算可能是最直接的例子:

>>> reduce(lambda x,y: x+y, [1, 2, 3], 0)
6
# --or--
>>> import operator
>>> reduce(operator.add, [1, 2, 3], 0)
6

在 reduce 操作中有两个设计选择。首先是是否需要一个初始值。在 Python 的 reduce 函数中,初始值是可选的,如果省略它,reduce 将使用列表的第一个元素作为其初始值。所以你会得到如下的行为:

result[0] = undefined(如果列表为空,reduce 会抛出异常)

result[1] = list[0]

result[2] = f(result[1], list[1])

...

result[n] = f(result[n-1], list[n-1])

这使得使用像max这样没有明确定义初始值的减少器更容易:

>>> reduce(max, [5, 8, 3, 1])
8

第二个设计选择是元素累积的顺序。对于像addmax这样的关联运算符,顺序没有影响,但对于其他运算符可能会有影响。Python 的 reduce 在其他编程语言中也被称为fold-left,因为它从左边(第一个元素)开始组合序列。Fold-right则相反:

fold-right : (E × F → F) × Seq<‍E> × F → F

对于 n 个元素列表的fold-right(f, list, init)遵循以下模式:

result[0] = init

result[1] = f(list[n-1], result[0])

result[2] = f(list[n-2], result[1])

...

result[n] = f(list[0], result[n-1])

产生 result[n]作为最终结果。

这是从左边或从右边减少的两种方式的图示:

fold-left : (F × E → F) × Seq<‍E> × F → F fold-left(-, [1, 2, 3], 0) = -6
fold-right : (E × F → F) × Seq<‍E> × F → F fold-right(-, [1, 2, 3], 0) = 2

减少操作的返回类型不必与列表元素的类型匹配。例如,我们可以使用 reduce 将序列粘合成一个字符串:

>>> reduce(lambda s,x: s+str(x), [1, 2, 3, 4], '') 
'1234'

或者将嵌套子列表展平为单个列表:

>>> reduce(operator.concat, [[1, 2], [3, 4], [], [5]], [])
[1, 2, 3, 4, 5]

这是一个足够有用的序列操作,我们将其定义为flatten,尽管它只是一个减少步骤内部:

def flatten(list):
    return reduce(operator.concat, list, [])

更多例子

假设我们有一个表示为系数列表的多项式,a[0],a[1],...,a[n-1],其中 a[i]是 x^i 的系数。然后我们可以使用 map 和 reduce 来评估它:

def evaluate(a, x):
    xi = map(lambda i: x**i, range(0, len(a))) # [x⁰, x¹, x², ..., x^(n-1)]
    axi = map(operator.mul, a, xi)             # [a[0]*x⁰, a[1]*x¹, ..., a[n-1]*x^(n-1)]
    return reduce(operator.add, axi, 0)        # sum of axi

这段代码使用了方便的 Python 生成器方法range(a,b),它生成从 a 到 b-1 的整数列表。在 map/filter/reduce 编程中,这种方法取代了从 a 到 b 索引的for循环。

现在让我们看一个典型的数据库查询示例。假设我们有一个关于数码相机的数据库,其中每个对象都是Camera类型,具有其属性的观察方法(brand()pixels()cost()等)。整个数据库在名为cameras的列表中。然后我们可以使用 map/filter/reduce 描述对该数据库的查询:

# What's the highest resolution Nikon sells? 
reduce(max, map(Camera.pixels, filter(lambda c: c.brand() == "Nikon", cameras)))

关系数据库使用 map/filter/reduce 范式(在其中称为 project/select/aggregate��。SQL(结构化查询语言)是查询关系数据库的事实上标准语言。典型的 SQL 查询如下:

select max(pixels) from cameras where brand = "Nikon" 

cameras是一个sequence(一系列行,每行包含一个相机的数据)

where brand = "Nikon"是一个filter

pixels是一个map(仅提取行中的像素字段)

max是一个reduce

阅读练习

reduce 1

哪个是对这种减少的最佳描述?

reduce(lambda x, y: x and (y == 'True'), [ ... ], True)

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

减少 2

如果您不确定,请在 Python 解释器中尝试这些!

的结果是什么:

reduce(lambda a,b: a * b, [1, 2, 3], 0)

(缺少答案)

(缺少解释)

的结果是什么:

reduce(lambda a,b: a if len(a) > len(b) else b, ["oscar", "papa", "tango"])

(缺少答案)

(缺少解释)

回到介绍示例

回到我们开始的例子,我们想要找到项目中 Java 文件中的所有单词,让我们尝试创建一个有用的抽象,以按后缀过滤文件:

def fileEndsWith(suffix):
    return lambda file: file.getName().endsWith(suffix)

fileEndsWith返回作为过滤器有用的函数:它接受像.java这样的文件名后缀,并动态生成一个我们可以与 filter 一起使用来测试该后缀的函数:

filter(fileEndsWith(".java"), files)

fileEndsWith与我们通常的函数不同。它是一种高阶函数,意味着它是一个接受另一个函数作为参数的函数,或者将另一个函数作为其结果返回的函数。高阶函数是对函数数据类型的操作;在这种情况下,fileEndsWith是一个函数的创建者

现在让我们使用 map、filter 和 flatten(我们以上使用 reduce 定义的)来递归地遍历文件夹树:

def allFilesIn(folder):
    children = folder.listFiles()
    subfolders = filter(File.isDirectory, children)
    descendants = flatten(map(allFilesIn, subfolders))
    return descendants + filter(File.isFile, children)

第一行获取文件夹的所有子级,可能看起来像这样:

["src/client", "src/server", "src/Main.java", ...] 

第二行是关键部分:它过滤子级,只保留子文件夹,然后对这个子文件夹列表递归地映射allFilesIn!结果可能会像这样:

[["src/client/MyClient.java", ...], ["src/server/MyServer.java", ...], ...] 

所以我们必须将其展平以消除嵌套结构。然后我们添加那些不是文件夹而是普通文件的直接子级,这就是我们的结果。

我们还可以使用 map/filter/reduce 来解决问题的其他部分。一旦我们有了要从中提取单词的文件列表,我们就可以加载它们的内容。我们可以使用 map 获取它们的路径名作为字符串,打开它们,然后将每个文件读入为一个文件列表:

pathnames = map(File.getPath, files)
streams = map(open, pathnames)
lines = map(list, streams)

实际上这看起来像是一个单一的 map 操作,我们想要将三个函数应用于元素,所以让我们暂停一下,创建另一个有用的高阶函数:将函数组合在一起。

def compose(f, g):
    """Requires that f and g are functions, f:A->B and g:B->C.
    Returns a function A->C by composing f with g.""" 
    return lambda x: g(f(x))

现在我们可以使用一个单一的映射:

lines = map(compose(compose(File.getPath, open), list), files)

更好的是,既然我们已经有了三个要应用的函数,让我们设计一种方法来组合任意链式函数:

def chain(funcs):
    """Requires funcs is a list of functions [A->B, B->C, ..., Y->Z]. 
    Returns a fn A->Z that is the left-to-right composition of funcs."""
    return reduce(compose, funcs)

以至于 map 操作变成了:

lines = map(chain([File.getPath, open, list]), files)

现在我们看到更多一等函数的威力。我们可以将函数放入数据结构中,并在这些数据结构上使用像 map、reduce 和 filter 这样的操作,对函数本身进行操作!

由于此映射将生成一个行列表的列表(每个文件的一行列表),让我们将其展平以获得一个单行列表,忽略文件边界:

allLines = flatten(map(chain([File.getPath, open, list]), files))

然后我们类似地将每一行分割成单词:

words = flatten(map(str.split, lines))

好了,我们完成了,我们得到了项目的 Java 文件中所有单词的列表!正如承诺的那样,控制语句已经消失了。

→ 示例的完整 Python 代码

抽象控制的好处

Map/filter/reduce 通常可以使代码更简短、更简单,并允许程序员专注于计算的核心,而不是循环、分支和控制流的细节。

通过使用 map、filter 和 reduce 组织我们的程序,特别是尽可能使用不可变数据类型和纯函数(不会改变数据的函数),我们为安全并发创造了更多机会。使用纯函数在不可变数据类型上的映射和过滤可以立即并行化——对序列的不同元素的函数调用可以在不同的线程、不同的处理器甚至不同的机器上运行,结果仍然相同。MapReduce是一种以这种方式并行化大型计算的模式。

阅读练习

map/filter/reduce

这个 Python 函数接受一个数字列表,并计算所有奇数的乘积:

def productOfOdds(list):
    result = 1
    for x in list:
        if x % 2 == 1:
            result *= x
    return result

使用 map、filter 和 reduce 重写 Python 代码:

def productOfOdds(list):
    return reduce(r_func, filter(f_func, map(m_func, list)))

其中m_funcf_funcr_func分别是以下之一:

A.
list
H.
def is_odd(x):
  return x % 2 == 1

|

B.
x
I.
x_is_odd = x % 2 == 1

|

C.
y
J.
def odd_or_identity(x):
  return x if is_odd(x) else 1

|

D.
def identity_function(x):
  return x
K.
def sum(x, y):
  return x + y

|

E.
identity = lambda x: x
L.
def product(x, y):
  return x * y

|

F.
def always_true(x):
  return True
M.
operator.mul

|

G.
def modulus_tester(i):
  return lambda x: x % 2 == i
N.
x * y

|

对于下面的每个选项,是否是正确的实现?

def productOfOdds(list):
    return reduce(r_func, filter(f_func, map(m_func, list)))

D + H + L: reduce(product, filter(is_odd, map(identity_function, list)))

(缺失答案)(缺失答案)

(缺失解释)

E + F + L: reduce(product, filter(always_true, map(identity, list)))

(缺失答案)(缺失答案)

(缺失解释)

E + G + L: reduce(product, filter(modulus_tester, map(identity, list)))

(缺失答案)(缺失答案)

(缺失解释)

B + J + L: reduce(product, filter(odd_or_identity, map(x, list)))

(缺失答案)(缺失答案)

(缺失解释)

J + F + M: reduce(operator.mul, filter(always_true, map(odd_or_identity, list)))

(缺失答案)(缺失答案)

(缺失解释)

D + I + N: reduce(x * y, filter(x_is_odd, map(identity_function, list)))

(缺失答案)(缺失答案)

(缺失解释)

Java 中的一等函数

我们已经在 Python 中看到了一等函数是什么样的;那么在 Java 中这一切是如何工作的?

在 Java 中,唯一的一等值是原始值(int、布尔值、字符等)和对象引用。但是对象可以携带函数,以方法的形式。所以事实证明,在像 Java 这样不直接支持一等函数的面向对象编程语言中实现一等函数的方式是使用一个代表函数的方法的对象。

实际上,我们已经多次看到这个:

  • 传递给Thread构造函数的Runnable对象是一个一等函数,void run()

  • 传递给排序集合(例如SortedSet)的Comparator<T>对象是一个一等函数,int compare(T o1, T o2)

  • 在以后的课程中,我们将会看到你可以注册到图形用户界面工具包的 KeyListener 对象来获取键盘事件。它们作为多个函数的集合,例如 keyPressed(KeyEvent)keyReleased(KeyEvent) 等等。

这种设计模式称为函数对象函数子,一个代表函数目的的对象。

Java 中的 Lambda 表达式

Java 的 lambda 表达式语法提供了一种简洁的方式来创建函数对象的实例。例如,不是写成:

new Thread(new Runnable() {
    public void run() {
        System.out.println("Hello!");
    }
}).start();

我们可以使用 lambda 表达式:

new Thread(() -> {
    System.out.println("Hello");
}).start();

在 Lambda 表达式的 Java 教程页面中,阅读 Lambda 表达式的语法

这里没有魔法:Java 仍然没有一级函数。所以只有当 Java 编译器能够验证两件事时,你才能使用 lambda:

  1. 它必须能够确定 lambda 将创建的函数对象的类型。在这个例子中,编译器看到 Thread 构造函数接受一个 Runnable,所以它将推断类型必须是 Runnable

  2. 推断出的类型必须是 函数式接口:一个只有一个(抽象)方法的接口。在这个例子中,Runnable 确实只有一个方法 — void run() — 所以编译器知道 lambda 表达式中的代码属于新的 Runnable 对象的 run 方法的主体。

Java 提供了一些标准函数接口,我们可以使用它们来按照 map/filter/reduce 模式编写代码,例如:

所以我们可以像这样在 Java 中实现 map:

/**
 * Apply a function to every element of a list.
 * @param f function to apply
 * @param list list to iterate over
 * @return [f(list[0]), f(list[1]), ..., f(list[n-1])]
 */
public static <T,R> List<R> map(Function<T,R> f, List<T> list) {
    List<R> result = new ArrayList<>();
    for (T t : list) {
        result.add(f.apply(t));
    }
    return result;
}

这里是一个使用 map 的示例;首先我们将使用熟悉的语法来写:

// anonymous classes like this one are effectively lambda expressions
Function<String,String> toLowerCase = new Function<>() {
    public String apply(String s) { return s.toLowerCase(); }
};
map(toLowerCase, Arrays.asList(new String[] {"A", "b", "C"}));

并且使用 lambda 表达式:

map(s -> s.toLowerCase(), Arrays.asList(new String[] {"A", "b", "C"}));
// --or--
map((s) -> s.toLowerCase(), Arrays.asList(new String[] {"A", "b", "C"}));
// --or--
map((s) -> { return s.toLowerCase(); }, Arrays.asList(new String[] {"A", "b", "C"}));

在这个例子中,lambda 表达式只是包装了对 StringtoLowerCase 的调用。我们可以使用 方法引用 来避免编写 lambda,语法是 ::。我们引用的方法的签名必须与静态类型所需的函数接口的签名匹配才能满足类型检查:

map(String::toLowerCase, Arrays.asList(new String[] {"A", "b", "C"}));

在 Java 教程中,如果你想了解更多关于方法引用的细节,可以阅读更多内容。

在 Java 中使用方法引用(与调用它不同)的目的与在 Python 中通过名称引用函数(与调用它不同)的目的相同。

Java 中的 Map/filter/reduce

我们在上面定义的抽象序列类型在 Java 中存在为Stream,它定义了mapfilterreduce和许多其他操作。

类似ListSet的集合类型提供了一个stream()操作,返回集合的一个Stream,而Arrays.stream函数可以从数组创建一个Stream

这是一个在 Java 中使用映射和过滤实现的allFilesIn的一个例子:

public class Words {
    static Stream<File> allFilesIn(File folder) {
        File[] children = folder.listFiles();
        Stream<File> descendants = Arrays.stream(children)
                                         .filter(File::isDirectory)
                                         .flatMap(Words::allFilesIn);
        return Stream.concat(descendants,
                             Arrays.stream(children).filter(File::isFile));
    }

映射和扁平化模式非常常见,Java 提供了flatMap操作来执行此操作,我们已经使用它代替了定义flatten

这是endsWith

 static Predicate<File> endsWith(String suffix) {
        return f -> f.getPath().endsWith(suffix);
    }

给定一个Stream<File> files,我们现在可以编写例如files.filter(endsWith(".java"))来获取一个新的过滤流。

查看此示例的修订 Java 代码

您可以比较所有三个版本:熟悉的 Java 实现、具有 map/filter/reduce 的 Python 和具有 map/filter/reduce 的 Java。

Java 中的高阶函数

Map/filter/reduce 当然是高阶函数;endsWith也是。让我们再看两个我们之前看过的:composechain

Function接口提供了compose——但实现非常简单。特别是,一旦您正确获取了参数和返回值的类型,Java 的静态类型使得几乎不可能错误地编写方法体:

/**
 * Compose two functions.
 * @param f function A->B
 * @param g function B->C
 * @return new function A->C formed by composing f with g
 */
public static <A,B,C> Function<A,C> compose(Function<A,B> f,
                                            Function<B,C> g) {
    return t -> g.apply(f.apply(t));
    // --or--
    // return new Function<A,C>() {
    //     public C apply(A t) { return g.apply(f.apply(t)); }
    // };
}

结果证明,我们无法在强类型的 Java 中编写chain,因为List(和其他集合)必须是同构的——我们可以指定一个元素类型都为Function<A,B>的列表,但不能指定一个第一个元素为Function<A,B>、第二个元素为Function<B,C>,依此类推的列表。

但是这里是用于相同输入/输出类型的chain

/**
 * Compose a chain of functions.
 * @param funcs list of functions A->A to compose
 * @return function A->A made by composing list[0] ... list[n-1]
 */
public static <A> Function<A,A> chain(List<Function<A,A>> funcs) {
    return funcs.stream().reduce(Function.identity(), Function::compose);
}

我们的 Python 版本在reduce中没有使用初始值,它需要一个非空函数列表。在 Java 中,我们已经提供了身份函数(即,f(t) = t)作为归约的身份值。

读取练习

Comparator<‌Dog>

在 Java 中,假设我们有:

public interface Dog {
    public String name();
    public Breed breed();
    public int loudnessOfBark();
}

我们有几个Dog对象,我们想保留它们的一个按照它们叫声响亮程度排序的集合。

Java 为对象的排序集提供了一个接口SortedSetTreeSet实现了SortedSet,所以我们将使用它。

TreeSet构造函数接受一个Comparator作为参数,告诉它如何比较集合中的两个对象;在这种情况下,是两个Dog

Comparator是一个函数接口:它有一个未实现的方法:int compare(...)

因此我们的代码看起来像这样:

SortedSet<Dog> dogsQuietToLoud = new TreeSet<>(COMPARATOR);
dogsQuietToLoud.add(...);
dogsQuietToLoud.add(...);
dogsQuietToLoud.add(...);
// ...

Comparator的一个实例是一个示例:

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

错误的 TreeSet

这些中哪一个会创建一个 TreeSet 来将我们的狗按照吠声从最安静到最响进行排序?

阅读Comparator.compare(...)的文档,了解它需要做什么。

new TreeSet<>(new Comparator<Dog>() {
  public int compare(Dog dog1, Dog dog2) {
    return dog2.loudnessOfBark() - dog1.loudnessOfBark();
  }
});

(缺少答案)(缺少答案)

(缺少解释)

new TreeSet<>(new Comparator<Dog>() {
  public Dog compare(Dog dog1, Dog dog2) {
    return dog1.loudnessOfBark() > dog2.loudnessOfBark() ? dog1 : dog2;
  }
});

(缺少答案)(缺少答案)

(缺少解释)

new TreeSet<>((dog1, dog2) -> {
  return dog1.loudnessOfBark() - dog2.loudnessOfBark();
});

(缺少答案)(缺少答案)

(缺少解释)

public class DogBarkComparator implements Comparator<Dog> {
  public int compare(Dog dog1, Dog dog2) {
    return dog1.loudnessOfBark() - dog2.loudnessOfBark();
  }
}
// ...
new TreeSet<>(new DogBarkComparator());

(缺少答案)(缺少答案)

(缺少解释)

总结

本文介绍的是用 不可变数据 和实现 纯函数 的操作来对问题建模和实现系统,而不是使用 可变数据 和具有 副作用 的操作。函数式编程 就是这种编程风格的名称。

当你的语言中有 一等函数 并且你可以构建抽象控制流代码的 高阶函数 时,函数式编程就变得更容易了。

一些语言 — HaskellScalaOCaml — 与函数式编程紧密相关。许多其他语言 — JavaScriptSwift几种 .NET 语言Ruby 等 — 在不同程度上使用函数式编程。随着 Java 最近增加的函数式语言特性,如果你继续在 Java 中编程,你也应该预期在那里看到更多的函数式编程。

阅读 25:图形用户界面

6.005 中的软件

免于错误 易于理解 准备好改变
今天正确,未来未知时也正确。 与未来的程序员清晰沟通,包括未来的自己。 设计以适应变化,无需重写。

目标

今天我们将高层次地审视 GUI 软件的软件架构,重点关注已被证明最有用的设计模式。其中三个最重要的模式是:

  • 视图树,是每个重要 GUI 工具包架构中的核心特性之一;

  • 模型-视图-控制器模式,将输入、输出和数据分离;

  • 监听器模式,对于解耦模型与视图和控制器至关重要。

视图树

一个带有标签的图形用户界面 视图树的快照图

图形用户界面由视图对象组成,每个对象占据屏幕的一部分,通常是一个被称为其边界框的矩形区域。在各种 UI 工具包中,视图概念以各种名称命名。在Java Swing中,它们是JComponent对象;在 HTML 中,它们是元素或节点;在其他工具包中,它们可能被称为小部件、控件或交互器。

这导致了我们今天将要讨论的第一个重要模式:视图树。视图被排列成包含的层次结构,其中一些视图包含其他视图。典型的容器有窗口、面板和工具栏。视图树不仅仅是一个任意的层次结构,实际上是一个空间层次结构:子视图嵌套在其父视图的边界框内。

如何使用视图树

几乎每个 GUI 系统都有一种视图树。视图树是一个强大的结构化思想,在典型的 GUI 中承担着很多责任:

输出。视图负责显示自己,并且视图树指导显示过程。GUI 通过改变视图树来改变输出。例如,在照片相册 GUI 中显示一组新照片,当前的缩略图将从视图树中移除,然后新的缩略图将添加到相同位置。GUI 工具包中内置的重绘算法会自动重绘受影响的子树部分。在 Java Swing 中,树中的每个视图都有一个paint()方法,知道如何在屏幕上绘制自己。重新绘制过程通过在树的根上调用paint()来驱动,该方法递归调用所有视图树的后代节点的paint()方法。

输入。视图可以有输入处理程序,而视图树控制鼠标和键盘输入的处理方式。稍后会详细介绍。

布局。视图树控制视图在屏幕上的布局,即分配它们的边界框的方式。自动布局算法会自动计算视图的位置和大小。专用容器(如JSplitPaneJScrollPane)自行布局。更通用的容器(JPanelJFrame)将布局决策委托给布局管理器(例如GroupLayoutBorderLayoutBoxLayout,……)。

阅读练习

视图树

Swing 的视图树类型,JComponent,是一个递归数据类型。这里是它的部分数据类型定义:

JComponent = JLabel(label:String)
             + JPanel(children:JComponent[])
             + ...

这个定义是部分的,因为它省略了一些 reps 的细节(例如,JLabel 不仅有一个字符串标签,还有许多字段),并且因为它省略了许多实现 JComponent 的变体类。

让我们填写这个定义右侧的“…”。为了回答下面的问题,您可能需要查看特定类的文档。

(答案缺失)(答案缺失)(答案缺失)

(解释缺失)

(答案缺失)(答案缺失)(答案缺失)

(解释缺失)

(答案缺失)(答案缺失)(答案缺失)

(解释缺失)

输入处理

在 GUI 中,输入的处理方式与我们在解析器和服务器中处理的方式略有不同。在那些系统中,我们看到了一个单独的解析器,它会解析输入并决定如何将其定向到程序的不同模块。如果以这种方式编写 GUI,它可能会像这样(伪代码中):

while (true) {
    read mouse click
    if (clicked on Thrash button) doThrash();
    else if (clicked on textbox) doPlaceCursor();
    else if (clicked on a name in the listbox) doSelectItem();
    ...
}

在 GUI 中,我们不直接编写这种方法,因为它不是模块化的 - 它混合了按钮、列表框和文本框的责任。相反,GUI 利用视图树提供的空间分隔来提供功能分离。鼠标点击和键盘事件根据它们发生的位置分布在视图树周围。

GUI 输入事件处理是监听器模式(也称为发布-订阅)的一个实例。在监听器模式中:

  • 事件源生成一系列离散事件,这些事件对应于源中的状态转换。

  • 一个或多个监听器注册对事件流的兴趣(订阅),提供在发生新事件时调用的函数。

在这种情况下,鼠标是事件源,事件是鼠标状态的变化:它的 x、y 位置或其按钮的状态(它们是否被按下或释放)。事件通常包括有关过渡的其他信息(例如鼠标的 x、y 位置),这些信息可能被捆绑到事件对象中或作为参数传递。

当事件发生时,事件源通过调用它们的回调方法将其分发给所有订阅的监听器。

图形用户界面的控制流程如下:

  • 顶层事件循环从鼠标和键盘读取输入。在 Java Swing 和大多数图形用户界面工具包中,这个循环实际上对你是隐藏的。它被隐藏在工具包内部,监听器似乎被神奇地调用。

  • 对于每个输入事件,它通过查看鼠标的 x、y 位置在树中找到正确的视图,并将事件发送给该视图的监听器。

  • 每个监听器都会执行其操作(可能涉及修改视图树中的对象),然后立即返回到事件循环

最后一部分 - 监听器尽快返回到事件循环中 - 非常重要,因为它保持了用户界面的响应性。我们稍后会在阅读中回到这个问题。

监听器模式不仅用于低级输入事件(如鼠标点击和键盘按键)。许多 GUI 对象生成它们自己的更高级事件,通常是作为一些低级输入事件的组合的结果。例如:

  • 当按下 JButton 时,它发送一个动作事件(无论是通过鼠标还是键盘)

  • 当选定元素更改时,JList 发送一个选择事件(无论是通过鼠标还是键盘)

  • 当文本框内的文本因任何原因而更改时,JTextField 发送更改事件

按钮可以通过鼠标(通过鼠标按下和鼠标释放事件)或键盘(对于无法使用鼠标的人,如盲人用户)来按下。因此,您应该始终监听这些高级事件,而不是低级输入事件。使用 ActionListener 来响应 JButton 的按下,而不是鼠标监听器。

阅读练习

监听器

根据 Swing 图形用户界面执行时发生的顺序,将以下项目按顺序排列。

launchButton = new JButton("发射导弹");

(缺失答案)

launchButton.addActionListener(launchMissiles);

(缺失答案)

调用 launchMissilesactionPerformed() 方法

(缺失答案)

鼠标点击启动按钮的事件由 Swing 事件循环处理。

(缺失答案)

(缺失解释)

将前端与后端分离开来

我们已经看到 GUI 程序是围绕视图树结构组织的,以及如何通过将监听器附加到视图来处理输入事件。这是关注点分离的开始 - 输出由视图处理,输入由监听器处理。

但我们仍然缺少应用程序本身 - 表示用户界面显示和编辑的数据和逻辑的后端。 (为什么我们要将其与用户界面分离?)

Model-View-Controller 模式的主要目标是将关注点分离。它通过将后端代码放入模型中并将前端代码放入视图和控制器中,将用户界面前端与应用程序后端分离开来。MVC 还将输入与输出分离; 控制器应该处理输入,而视图应该处理输出。

Model-View-Controller 模式

模型(Model)负责维护特定于应用程序的数据并提供对该数据的访问。模型通常是可变的,并且它们提供了用于安全更改状态的方法,保留其表示不变式。好吧,所有可变对象都是这样做的。但是模型还必须在其数据发生变化时通知其客户端,以便依赖视图可以更新其显示,依赖控制器可以做出适当的响应。模型使用监听器模式进行此通知,其中感兴趣的视图和控制器注册自己作为模型生成的更改事件的监听器。

View(视图)对象负责输出。视图通常占据屏幕的某个区域,通常是一个矩形区域。基本上,视图会向模型查询数据,并将数据绘制在屏幕上。它会监听来自模型的变化,以便能够更新屏幕以反映这些变化。

最后,控制器(controller)处理输入。它接收键盘和鼠标事件,并指示模型相应地进行更改。

Model-View-Controller 模式在 JTextField 中的显示

MVC 模式的一个简单示例是文本字段。右侧的图显示了 Java Swing 的文本字段,称为 JTextField。其模型是一个可变的字符字符串。视图是一个对象,用于在屏幕上绘制文本(通常用矩形框围绕它以表示它是一个可编辑的文本字段)。控制器是一个接收用户键入的按键并将其插入可变字符串的对象。

MVC 模式的实例在 GUI 软件中出现在许多规模上。在更高的层面上,这个文本字段可能是视图的一部分(如地址簿编辑器),有一个不同的控制器监听它(用于文本更改事件),有一个不同的模型(如地址簿)。但是当您深入到更低的层次时,文本字段本身就是 MVC 的一个实例。

模型-视图-控制器模式在文件系统浏览器中的显示

这里有一个更大的例子,其中视图是一个文件系统浏览器(类似于 Mac Finder 或 Windows 资源管理器),模型是磁盘文件系统,控制器是一个输入处理程序,将用户的按键和鼠标点击转换为对模型和视图的操作。

模型和视图的分离有几个好处。首先,它允许界面具有显示相同应用程序数据的多个视图。例如,数据库字段可以同时显示在表格和可编辑表单中。其次,它允许视图和模型在其他应用程序中被重用。MVC 模式实现了用户界面工具包的创建,这些工具包是可重用视图的库。Java Swing 就是这样一个工具包。您可以轻松地从这个库中重用视图类(如JButtonJTree),同时将自己的模型插入其中。

阅读练习

模型-视图-控制器

考虑模型-视图-控制器模式所暗示的关注点分离,不知道有关问题程序的其他信息,哪些设计决策是有意义的?

“所有数据都保存在窗口中的JTextField对象中,其他类可以通过获取对JTextField的引用并调用getText()来查找它。”

(缺失答案)(缺失答案)

(缺失解释)

“如果视图监听来自弹球板的球移动事件,那么我们可以有多个视图显示相同的板。”

(缺失答案)(缺失答案)

(缺失解释)

“让我们把双击监听器放在模型类中。”

(缺失答案)(缺失答案)

(缺失解释)

“看起来模型是存储弹球板名称的最佳位置。”

(缺失答案)(缺失答案)

(缺失解释)

图形用户界面中的后台处理

今天的最后一个主要话题与并发有关。

首先,一些动机。为什么我们需要在图形用户界面中进行后台处理?尽管计算机系统正在变得越来越快,但我们也要求它们做更多的事情。许多程序需要执行可能需要一些时间的操作:从网络检索 URL,运行数据库查询,扫描文件系统,进行复杂计算等。

但是图形用户界面是事件驱动程序,这意味着(一般来说)一切都是由输入事件处理程序触发的。例如,在 Web 浏览器中,点击超链接会开始加载一个新的网页。但是,如果点击处理程序被编写成实际上是检索网页本身,那么 Web 浏览器将非常难以使用。为什么?因为其界面将会在点击处理程序完成检索网页并返回到事件循环之前冻结。原因如下。

这是因为输入处理和屏幕重绘都是由单个线程处理的。该线程(称为事件分发线程)有一个循环,从队列中读取输入事件并将其分派给视图树上的监听器。当没有输入事件需要处理时,它会重新绘制屏幕。但是,如果您编写的输入处理程序延迟返回到此循环 - 因为它在网络读取上阻塞,或者因为它正在寻找一个大数独难题的解决方案 - 那么输入事件将停止处理,屏幕将停止更新。因此,长时间运行的任务需要在后台运行:在不同的线程上,而不是事件分发线程上。

在 Java 中,事件分发线程与程序的主线程是不同的(见下文)。当创建用户界面对象时,它会自动启动。因此,每个 Java GUI 程序都自动是多线程的。许多程序员没有注意到,因为主线程在 GUI 程序中通常不做太多事情 - 它开始创建视图,然后主线程只是退出,只留下事件分发线程来完成程序的主要工作。

Swing 程序默认是多线程的,这会带来风险。在您的 GUI 中很常见的一个共享可变数据类型是模型。如果您使用后台线程修改模型而不阻塞事件分发线程,那么您必须确保您的数据结构是线程安全的。

但是,在您的 GUI 中,另一个重要的共享可变数据类型是视图树。Java Swing 的视图树不是线程安全的。一般来说,您不能安全地从任何地方调用 Swing 对象的方法,除非是在事件分发线程中。

视图树是一个大的共享状态的复杂结构,并且 Swing 规范并不保证有任何锁来保护它。相反,根据规范,视图树限定在事件分发线程中。因此,从事件分发线程(即响应输入事件)访问视图对象是可以的,但 Swing 规范禁止从不同线程触及 - 读取或写入 - 任何 JComponent 对象。请参阅Swing threading and the event-dispatch thread

在实际的 Swing 实现中,有一个大锁(Component.getTreeLock()),但只有一些 Swing 方法使用它,所以它不是一个有效的同步机制。

安全地访问视图树的方法是从事件分派线程中进行。 因此,Swing 采用了一种巧妙的方法:它将事件队列本身用作消息传递队列。 换句话说,你可以将自己的定制消息放在事件队列上,与鼠标点击、按键、按钮动作事件等相同的队列。 你的自定义消息实际上是一段可执行代码,一个实现了 Runnable 接口的对象,你可以使用 SwingUtilities.invokeLater 将它放在队列上。 例如:

SwingUtilities.invokeLater(new Runnable() { 
    public void run() { 
        content.add(thumbnail); 
        ...
    } 
});

invokeLater() 在队列末尾放置了这个 Runnable 对象,当 Swing 的事件循环到达它时,它简单地调用 run()。 因此,run() 的主体最终由事件分派线程运行,它可以安全地调用视图树上的观察者和变化器。

在 Java 教程中阅读:

阅读练习

后台处理

假设你正在使用用 Java Swing 编写的图形用户界面。 你按下一个按钮,UI 就死锁了 - 你不能滚动,按其他按钮,甚至输入任何内容到文本框。 以下哪些是可能的解释?

死锁 - UI 的两个不同部分试图在视图树上获取锁,并且彼此死锁。

(缺失答案)(缺失答案)

(缺失解释)

事件队列阻塞 - 事件循环正在等待事件队列上的输入事件,但队列为空,因此程序中没有任何操作。

(缺失答案)(缺失答案)

(缺失解释)

事件分派线程中的工作过多 - UI 在响应您的按钮按下时正在进行大量计算,并且它还没有返回到事件循环以处理更多的输入事件。

(缺失答案)(缺失答案)

(缺失解释)

事件分派线程上的网络延迟 - UI 正试图在响应您的按钮按下时从网络获取数据,并且它还没有返回到事件循环以处理更多的输入事件。

(缺失答案)(缺失答案)

(缺失解释)

摘要

  • 视图树将屏幕组织成嵌套矩形的树,它用于分派输入事件以及显示输出。

  • 监听器模式将事件流(如鼠标或键盘事件,或按钮动作事件)发送到注册的监听器。

  • 模型-视图-控制器模式分离了责任:模型 = 数据,视图 = 输出,控制器 = 输入。

  • 长时间运行的处理应该移到后台线程,但 Swing 视图树被限制在事件调度线程中。因此,从另一个线程访问 Swing 对象需要使用事件循环作为消息传递队列,以返回到事件调度线程。

阅读 26:小语言

6.005 中的软件

免于错误 易于理解 为变化做好准备
今天正确且未来未知时也正确。 与未来程序员清晰沟通,包括未来的自己。 设计以适应变化而无需重写。

目标

在本文中,我们将开始探讨用于构建和操作音乐的小语言的设计。关键是:当你需要解决一个问题时,不要编写一个仅解决这一个问题的程序,而是构建一个可以解决一系列相关问题的语言

本文的目标是介绍将代码表示为数据的概念,并让您熟悉音乐语言的初始版本。

将代码表示为数据

回顾递归数据类型中的Formula数据类型:

Formula = Variable(name:String)
          + Not(formula:Formula)
          + And(left:Formula, right:Formula)
          + Or(left:Formula, right:Formula)

我们使用Formula的实例来接受命题逻辑公式,例如(p ∨ q) ∧ (¬p ∨ r),并将其表示为数据结构,例如:

And(Or(Variable("p"), Variable("q")),
    Or(Not(Variable("p")), Variable("r")))

在语法和解析器的术语中,公式是一种语言,而Formula是一个抽象语法树

但是为什么我们要定义一个Formula类型呢?Java 已经有一种表示布尔变量表达式的方式,包括逻辑与。例如,给定boolean变量pqr

(p || q) && ((!p) || r) 

完成!

当我们在运行程序中遇到 Java 代码表达式(p || q) && ((!p) || r)时,该表达式会立即被求值。FormulaAnd(Or(...), Or(...))是一个一等值,可以被存储、传递和从一个方法返回到另一个方法,可以根据需要进行操作和立即或以后(或多次)进行评估。

Formula类型是将代码表示为数据的一个示例,我们已经看到了许多其他示例。

考虑这个函数对象:

class VariableNameComparator implements Comparator<Variable> {
    public int compare(Variable v1, Variable v2) {
        return v1.name().compareTo(v2.name());
    }
}

VariableNameComparator的一个实例是一个可以传递、返回和存储的值。但是随时可以通过调用其compare方法并提供一对Variable参数来调用它所代表的函数:

Variable v1, v2;
Comparator<Variable> c = new VariableNameComparator();
...
int a = c.compare(v1, v2);
int b = c.compare(v2, v1);
SortedSet<Variable> vars = new TreeSet<>(c); // vars is sorted by name

Lambda 表达式允许我们使用简洁的语法创建函数对象:

Comparator<Variable> c = (v1, v2) -> v1.name().compareTo(v2.name());

构建用于解决问题的语言

当我们定义一个抽象数据类型时,我们正在扩展 Java 提供的内置类型的范围,包括一个新类型,具有适用于我们问题域的新操作。这种新类型就像是一种新语言:一组新的名词(值)和动词(操作)可以操作。当然,这些名词和动词是建立在现有名词和动词之上的抽象。

语言比单纯的程序具有更大的灵活性,因为我们可以使用语言来解决一大类相关问题,而不仅仅是一个单一问题。

  • 这就是写 (p || q) && ((!p) || r) 与设计 Formula 类型来表示语义上等价的布尔表达式之间的区别。

  • 这就是编写矩阵乘法函数与设计 MatrixExpression 类型 来表示矩阵乘法之间的区别—以及存储它们、操作它们、优化它们、评估它们等等。

一级函数和函数对象使我们能够创建特别强大的语言,因为我们可以将计算模式捕获为可重用的抽象。

音乐语言

在课堂上,我们将设计和实现一个生成和播放音乐的语言。为了准备,让我们首先了解使用 MIDI 合成器播放音乐的 Java API。我们将看到如何编写一个程序来播放 MIDI 音乐。然后,我们将开始开发我们的音乐语言,编写一个递归的简单音乐旋律的抽象数据类型。我们将选择一种字符串写音乐的符号,并实现解析器来创建我们的 Music 类的实例。

基本音乐语言的完整源代码在 GitHub 上。

克隆fa16-ex26-music-starting 存储库,以便您可以运行代码并跟踪下面的讨论。

播放 MIDI 音乐

music.midi.MidiSequencePlayer 使用 Java MIDI API 来播放音符序列。这是相当多的代码,你不需要理解它是如何工作的。

MidiSequencePlayer 实现了music.SequencePlayer接口,使得客户端可以在不依赖特定 MIDI 实现的情况下使用它。我们确实需要理解这个接口及其所依赖的类型:

addNote : SequencePlayer × Instrument × Pitch × double × double → void (SequencePlayer.java:15) 是我们音乐播放器的主力军。调用此方法会在音乐片段中的某个时间安排播放一个音高。

play : SequencePlayer → void (SequencePlayer.java:20) 实际上播放音乐。在调用此方法之前,我们只是调度将来会播放的音乐。

addNote 操作还依赖于两个更多的类型:

Instrument 是所有可用 MIDI 乐器的枚举。

Pitch是用于音高的抽象数据类型(类似钢琴键盘上的键)。

阅读并理解Pitch文档以及其公共构造函数和所有公共方法的规范。

我们的音乐数据类型将依赖于Pitch,因此请确保理解Pitch的规范以及其表示和抽象函数。

使用 MIDI 序列播放器和Pitch,我们已经准备好为我们的第一段音乐编写代码了!

阅读并理解music.examples.ScaleSequence的代码。

运行ScaleSequence中的主方法。 您应该听到一个一个八度音阶!

阅读练习

音高

MidiSequencePlayer可以使用哪些观察者来确定任意Pitch代表的频率?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

转位

Pitch.transpose(int) 是一个:

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

添加音符

SequencePlayer.addNote(..) 是一个:

(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

音乐数据类型

Pitch数据类型很有用,但如果我们想使用Pitch对象来表示整个音乐作品,我们应该创建一个抽象数据类型来封装该表示。

首先,我们将使用一些操作定义Music类型:

notes : String × Instrument → Music (MusicLanguage.java:51) 从简化的 abc 记谱字符串中创建新的音乐,下面进行描述。

duration : Music → double (Music.java:11) 返回音乐片段的节拍时长。

play : Music × SequencePlayer × double → void (Music.java:18) 使用给定的序列播放器播放音乐片段。

我们将在Music接口中将durationplay实现为实例方法,因此我们在Music接口中声明它们。

notes将是一个静态工厂方法;我们不会将其放在Music中(虽然我们可以这样做),而是将其放在一个单独的类中:MusicLanguage将是我们编写的所有静态方法操作Music的地方。

现在我们已经在Music的规范中选择了一些操作,让我们选择一种表示方式。

  • 查看ScaleSequence,我们可能会注意到的第一个具体变体是捕获每次调用addNote中的信息:在特定乐器上演奏一定时间的特定音高。我们将其称为Note

  • 音乐的另一个基本元素是音符之间的沉黙:Rest

  • 最后,我们需要一种方法将这些基本元素粘合在一起形成更大的音乐片段。我们将选择一种类似树状结构的方式:Concat(m1,m2:Music)代表m1后跟m2,其中m1m2可以是任何音乐。

    这种树结构事实证明是一个优雅的决定,当我们后来进一步开发我们的Music类型时。在真正的设计过程中,我们可能会在找到最佳实现之前对Music的递归结构进行迭代。

这是数据类型定义:

Music = Note(duration:double, pitch:Pitch, instrument:Instrument)
        + Rest(duration:double)
        + Concat(m1:Music, m2:Music)

组合

Music组合模式的一个例子,在其中我们以相同的方式处理单个对象(原始元素,例如NoteRest)和对象组(组合元素,例如Concat)。

  • Formula也是组合模式的一个例子。

  • GUI 视图树在很大程度上依赖于组合模式:有一些原始视图,如JLabelJTextField,它们没有子元素,还有一些组合视图,如JPanelJScollPage,它们包含其他视图作为子元素。两者都实现了共同的JComponent接口。

组合模式产生了一种树状数据结构,叶子节点是原始元素,内部节点是组合元素。

空白

最后一个设计考虑:我们如何表示空音乐?拥有表示空白的表示总是很好的,我们肯定不会使用null

我们可以引入一个Empty变体,但我们将使用持续时间为0Rest来表示空白。

实现基本操作

首先,我们需要创建 NoteRestConcat 变体。所有三者都很容易实现,从构造函数、checkRep、一些观察者、toString 和相等方法开始。

  • 由于 duration 操作是一个实例方法,因此每个变体都适当地实现了 duration

  • play 操作也是一个实例方法;我们将在下面的 实现播放器 中讨论它。

我们将在 实现解析器 中讨论 notes 操作。

阅读并理解 NoteRestConcat 类。

为了避免暴露表示,让我们向 Music 接口添加一些额外的静态工厂方法:

note : double × Pitch × Instrument → Music (MusicLanguage.java:92)

rest : double → Music (MusicLanguage.java:100)

concat : Music × Music → Music (MusicLanguage.java:113) 是我们的第一个生产者操作。

所有三者都易于通过构造适当的变体来实现。

阅读练习

音乐表示

假设我们有

import music.*;
import static music.Instrument.*;
import static music.MusicLanguage.*;

以下哪个代表中音 C 后面的 C 上面的 A?

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

音乐符号

我们将使用简化版本的 abc 符号,一种基于文本的音乐格式,来编写音乐片段。

我们已经使用熟悉的字母表示音高。我们简化的 abc 符号表示具有指示它们的 持续时间意外记号(升音或降音)和八度音符休止符序列的语法。

例如:

C D E F G A B C' B A G F E D C 代表我们在 ScaleSequence 中演奏的一个八度升降 C 大调音阶。C 是中音 C,C' 是中音 C 上面的一个八度。每个音符都是四分音符。

C/2 D/2 _E/2 F/2 G/2 _A/2 _B/2 C' 是以 C 小调演奏的升音音阶,速度加倍。E、A 和 B 是降调。每个音符都是八分音符。

阅读 并理解 MusicLanguage 中的 notes 的规范

你现在不需要理解解析器的实现,但你应该足够理解简化的 abc 符号来理解示例。

如果你对音乐理论不熟悉 — 为什么八度有 8 个音符,但只有 12 个半音? — 不要担心。你可能无法看着 abc 字符串猜出它们的声音是什么样的,但你可以理解选择方便的文本语法的意义。

阅读练习

简化的 abc 语法

这些音符中哪些是 E/4 的两倍长?

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

实现解析器

notes 方法将简化的 abc 符号字符串解析为 Music

notes : String × Instrument → Music (MusicLanguage.java:51) 将输入拆分为单个符号(例如 A,,/2.1/2)。我们从空的 Musicrest(0) 开始,符号被逐个解析,然后我们使用 concat 构建 Music

parseSymbol : String × Instrument → Music (MusicLanguage.java:62) 返回一个 Rest 或一个 Note,对于一个单个的 abc 符号(语法中的 symbol)。它仅解析类型(休止符或音符)和持续时间;它依赖于 parsePitch 来处理音高、升降号和八度。

parsePitch : String → Pitch (MusicLanguage.java:77) 通过解析 pitch 语法产生来返回一个 Pitch。你应该能够理解递归 — 基本情况是什么?递归情况是什么?

阅读练习

parsePitch

parsePitch 的基本情况处理这些输入中的哪些?

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

实现播放器

回想一下我们播放音乐的操作:

play : Music × SequencePlayer × double → void (Music.java:18) 在给定节拍延迟之后,使用给定的序列播放器播放音乐片段。

为什么这个操作要花费atBeat?为什么不直接现在播放音乐呢?

如果我们以这种方式定义play,那么除非我们实际上在play操作期间暂停,例如使用Thread.sleep,否则我们将无法随时间播放音符序列。我们的序列播放器的addNote操作已经设计好了以安排未来的音符 - 它处理延迟。

有了这个设计决定,就可以在每个Music变体中轻松实现play

阅读并理解Note.playRest.play,以及Concat.play方法。

你应该能够理解它们的递归实现。

在我们准备好进行即兴演奏之前,再加一点实用代码:music.midi.MusicPlayer使用MidiSequencePlayer播放MusicMusic不知道序列播放器的具体类型,因此我们需要一些代码将它们联系起来。

将所有内容整合在一起,让我们使用Music ADT:

阅读并理解music.examples.ScaleMusic代码。

运行ScaleMusic中的主方法。 你应该再次听到同一个一个八度音阶。

那并不是很令人兴奋,所以阅读music.examples.RowYourBoatInitial运行主方法。你应该听到Row, row, row your boat

你能够从调用notes(..)到拥有Music实例再到递归play(..)调用再到单独的addNote(..)调用的代码流程吗?

阅读练习

音符

Row, row, row your boat中有 27 个音符。

根据实际实现,RowYourBoatInitial中的notes调用将创建多少个Music对象?

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

持续时间

rowYourBoat.duration()的结果应该是什么?

(缺少答案)

(缺少解释)

音乐

假设我们有

import music.*;
import static music.Instrument.*;
import static music.MusicLanguage.*;

并且

Music r = rest(1);
Pitch p = new Pitch('A').transpose(6);
Music n = note(1, p, GLOCKENSPIEL);
List<Music> s = Arrays.asList(r, n);

以下哪个是有效的Music

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

未完待续

弹奏Row, row, row your boat是非常令人兴奋的,但到目前为止,我们所做的最强大的事情并不是音乐语言,而是非常基础的音乐解析器。使用简化的 abc 记谱写音乐显然比一页页写addNote addNote addNote易于理解免受错误的影响,并且随时可以更改

在课堂上,我们将扩展我们的音乐语言,并将其转化为一个构建和操纵复杂音乐结构的强大工具。

阅读 27:团队版本控制

6.005 中的软件

免受错误影响 易于理解 准备好变化
今天正确,未来也正确。 与未来的程序员清晰沟通,包括未来的自己。 设计以适应变化而无需重写。

目标

  • 复习 Git 基础知识和提交图

  • 练习多用户 Git 场景

Git 工作流程

您已经使用 Git 进行问题集和课堂练习一段时间了。大多数情况下,您不必与其他人协调推送和拉取到同一存储库,并且与您同时进行。对于团队项目,情况将发生变化。

在本阅读中,通过回顾您所了解的内容并熟悉一些命令,为课堂 Git 练习做好准备。现在您对 Git 基础知识更加熟悉,是时候回顾本学期初的一些资源了。

查看发明版本控制:一个开发人员,多个开发人员和分支。

如果需要,可以查看学习 Git 工作流程来自入门页面。

查看提交历史

Pro Git中查看2.3 查看提交历史

您不需要记住书中介绍的所有不同命令行选项!相反,了解可能的操作,这样当您需要时就知道要搜索什么。

版本控制克隆示例存储库:

https://github.com/mit6005/fa16-ex05-hello-git.git

使用log命令确保您了解存储库的历史。

提交的图表

请记住,Git 存储库中记录的历史是一个有向无环图。存储库中任何特定分支(如默认的master分支)的历史始于某个初始提交,然后如果多个开发人员并行进行更改(或者单个开发人员在切换之前在两台不同的机器上工作而没有提交-推送-拉取),则其历史可能会分裂并重新汇合。

这是示例存储库的git lol输出,显示了一个 ASCII 艺术图:

* b0b54b3 (HEAD, origin/master, origin/HEAD, master) Greeting in Java
*   3e62e60 Merge
|\  
| * 6400936 Greeting in Scheme
* | 82e049e Greeting in Ruby
|/  
* 1255f4e Change the greeting
* 41c4b8f Initial commit

这里是 DAG 的图表:

ex05-hello-git示例存储库中,确保您能解释master历史何时分裂,何时重新汇合。

版本控制阅读中查看合并。

您应该理解整个过程的每一步,以及它与示例存储库中的结果之间的关系。

查看合并部分,包括合并和合并冲突。

阅读练习

合并

Alice 和 Bob 都从相同的 Java 文件开始:

public class Hello {
    public static void greet(String name) {
        System.out.println(greeting() + ", " + name);
    }
    public static String greeting() {
        return "Hello";
    }
}

| Alice 更改greet(..)

public static void greet(String name) {
    System.out.println(greeting() +
                       ", " + name + "!");
}

| Bob 改变 greeting()

public static String greeting() {
    return "Ciao";
}

|

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

危险的合并在前方

相同的起始程序:

public class Hello {
    public static void greet(String name) {
        System.out.println(greeting() + ", " + name);
    }
    public static String greeting() {
        return "Hello";
    }
}

| Alice 改变 greeting()

public static String greeting() {
    return "Ciao";
}

| Bob 改变了函数如何一起工作:

public static void greet(String name) {
    greeting();
    System.out.println(", " + name);
}
public static void greeting() {
    System.out.println("Hello");
}

|

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

继续合并

相同的起始程序:

public class Hello {
    public static void greet(String name) {
        System.out.println(greeting() + ", " + name);
    }
    public static String greeting() {
        return "Hello";
    }
}

Alice 改变 greet(..) 以返回而不是打印:

 public static String greet(String name) {
        return greeting() + ", " + name;
    }

Bob 创建了一个新文件,Main.java

public class Main {
    public static void main(String[] args) {
        // print a greeting to Eve
        Hello.greet("Eve");
    }
}

(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)(缺失答案)

(缺失解释)

在团队中使用版本控制

每个团队都为版本控制制定了自己的标准,团队的规模和他们正在处理的项目是一个主要因素。以下是适用于你将在 6.005 中进行的小范围团队项目的一些指导方针:

  • 沟通。 告诉您的队友您将要做什么。告诉他们您正在做这件事。告诉他们您已经做过了。沟通是避免浪费时间和精力清理破损代码的最佳方式。

  • 撰写规范。 对于我们在 6.005 中关心的事项是必要的,也是良好沟通的一部分。

  • 编写测试。 不要等待大量代码堆积起来才尝试进行测试。避免一个人编写测试,另一个人编写实现(除非实现是你计划放弃的原型)。首先编写测试以确保您对规范的理解一致。每个人都应对其代码的正确性负责。

  • 运行测试。 如果不运行测试,测试是无法帮助您的。在开始工作之前运行它们,再次在提交之前运行它们。

  • 自动化。 您已经使用像 JUnit 这样的工具自动化了您的测试,但现在您希望在项目更改时自动运行这些测试。对于 6.005 组项目,我们提供 Didit 作为一种在团队成员向 Athena 推送时自动运行测试的方式。这也消除了“它在我的机器上运行正常”的问题:它要么在自动构建中正常工作,要么需要修复。

  • 检查您提交的内容。 使用 git diff --staged 或 GUI 程序查看您即将提交的内容。运行测试。不要使用 commit -a,这是填充您的仓库与 println 和其他您不打算提交的内容的绝佳方式。不要通过提交无法编译、产生调试输出、实际上未使用等代码来惹恼您的队友。

  • 开始工作前先拉取最新版本。 否则,您可能没有最新版本作为起点 —— 您正在编辑旧版本的代码!您肯定要稍后合并您的更改,并且有可能浪费时间解决合并冲突。

  • 同步。 在一天结束或工作会话结束时,确保每个人都已推送和拉取所有更改,大家都处于相同的提交状态,并且每个人对项目状态满意。

我们不建议在 6.005 大型项目中使用分支或变基等功能。

我们强烈建议在同一时间、同一地点一起工作,特别是如果这是你第一次进行团队软件工程体验。

阅读练习

团队版本控制

这些哪些展示了良好的团队软件开发实践?

(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)(缺少答案)

(缺少解释)

团队项目

项目阶段的大部分课时将用于团队合作。

这些课程是必修课,就像普通课程一样,你必须在课堂上与你的项目导师 TA 进行沟通。

posted @ 2026-02-20 16:42  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报