# INFO1113 — Java 面向对象编程

> Leon | Original study notes

[English](INFO1113.md) · [中文](INFO1113.zh.md)

![Leon — 学习路线图](../assets/maps/info1113.zh.svg)

<a id="extensions"></a>

## 继续深入：完整案例

先读对应章节，再跟着故事预测输出、运行完整程序、核对解释。每个案例都在原有概念上增加一个实际问题。

1. [保护成绩快照，隔离外部修改](#lab-classes-invariants-defensive-snapshot) — 沿构造器和 getter 跟踪数据所有权。

2. [用值对象作为 Map 的键](#lab-equality-hashing-immutable-grid-key) — 区分引用身份、值相等与哈希冲突。

3. [做一个可以撤销的小编辑器](#lab-application-design-undoable-notebook) — 把对象模型变成可测试的小功能。

通过原创情景、逐步跟踪和设计判断，建立精确的 Java 心智模型。每章把语法与运行行为联系起来，并用迁移问题检查理解。

完整的 Java 8 学习路径涵盖执行、对象契约、输入输出、集合、泛型、异常、测试、递归、回调与应用设计。23 章各含两个独立可运行程序、行为跟踪、引导问答与工程分析。

## 把知识连接起来

### 追踪程序执行
- [从源代码到有类型的值](#java-execution-types)
- [条件、循环与不变式](#control-flow)
- [方法、调用记录与复制的引用](#methods-references)
- [数组、不规则网格与浅复制](#arrays)
- [文本值、相等性与构建器](#strings)

### 建立有契约的对象
- [类、构造器与受保护状态](#classes-invariants)
- [输入、文件与资源生命周期](#text-io)
- [二进制流与匹配的数据布局](#binary-io)
- [继承、组合与可替换性](#inheritance-composition)
- [重载选择签名，重写选择行为](#overload-override)
- [抽象类、接口与默认行为](#abstractions)

### 安全组织数据
- [选择列表、集合、映射与链式结构](#collections)
- [身份、值相等与哈希契约](#equality-hashing)
- [泛型类、类型上界与类型安全](#generics)
- [通配符与数据流方向](#wildcards)
- [迭代器、别名与可达性](#iteration-memory)

### 处理变化与失败
- [把异常作为明确的失败路径](#exceptions)
- [枚举与有限状态模型](#enums-state)
- [断言、测试与有用边界](#testing-contracts)
- [递归、调用树与结果复用](#recursion-memoization)

### 连接与检查应用
- [匿名类、lambda 与捕获上下文](#lambdas)
- [依据证据调试，记录明确承诺](#debugging-documentation)
- [从对象模型到小型事件驱动应用](#application-design)

<a id="java-execution-types"></a>

## 从源代码到有类型的值

### 01 / 从一个故事开始

想象工坊收到一套模型铁路图纸。检查员先拒绝不可能的连接，再制作不同工坊都能理解的标准装配说明。每个工坊仍需要自己的工具来执行说明。Java 编译器像检查员，字节码像标准说明，兼容的虚拟机提供本地执行设备。这个比喻有边界。通过检查并不能证明铁路正常运行。列车仍可能开错目的地；类型正确的程序也可能算错答案。

### 02 / 理解原理

常规 Java 程序写在 .java 源文件中。javac 把它编译成 .class 字节码，再由兼容 JVM 执行。编译检查可发现语法及许多类型错误。运行时检查和测试负责其他失败。变量声明的类型限制可存储的值与可执行操作。int、double、boolean 是基本类型。引用值指向对象，或为 null。int 是 32 位有符号整数；double 表示二进制浮点近似值。整数算术可能溢出。整数除法向零截断。扩大类型的转换往往自动进行；缩小类型的强制转换可能丢失信息。局部变量使用前必须确定已赋值。作用域决定名字在哪里可访问，不等于每个值在内存中的物理位置。常规 main(String[] args) 以 String 接收命令行参数。先检查数组长度，再访问下标；数字文本需要明确解析。

### 03 / 逐项读懂公式

```text
源代码 → javac → 字节码 → JVM 执行
int 范围：−2³¹ ≤ x ≤ 2³¹ − 1
7 / 2 = 3；7 / 2.0 = 3.5
```

箭头表示常规编译运行流程，不要求每次 Java 启动都必须分成独立命令。x 是 int 中存储的值。两个除法例子区分了全整数操作数与包含 double 操作数的表达式。整数除法结束后再转换，无法恢复丢弃的小数。这里描述 Java 算术规则；具体处理器指令与对象布局属于实现细节，本模型不作假设。

### 04 / 先看一个完整例子

骑行记录程序保存 7 次行程与 2 天活动。double average = 7 / 2 得到 3.0，因为整数除法在转换前完成。写成 double average = 7 / 2.0 得到 3.5。int whole = (int) 3.9 得到 3，这是截断，不是四舍五入。把 public 类 TripStats 保存在 TripStats.java，编译后启动 TripStats。若编译器拒绝未初始化局部变量，就补齐赋值。若程序能运行却报告 3.0，说明编译成功，而数值表达式需要修改。

### 先认识这些词

- **基本类型值:** 直接表示数值或布尔值，而非对象引用。

- **强制类型转换:** 显式请求转换数值，可能丢失信息。

- **溢出:** 数学结果超出类型可表示的范围。

### 一步步想清楚

**为什么把整数除法结果赋给 double 不能恢复小数？**

除法已经完成，Java 之后才转换截断后的整数结果。应在相除前转换一个操作数。

**为什么 50000 乘 50000 要使用 long？**

结果 2500000000 超过 Integer.MAX_VALUE。乘法前先让至少一个操作数为 long，运算才使用更宽类型。

**能解析成功是否代表所有整数都可接受？**

不代表。解析检查表示方式；计数仍可能要求非负或不超过上限。

<a id="lab-java-execution-types-java-numeric-conversions"></a>

### 观察转换发生的时点

比较整数除法、浮点除法与乘法前扩宽。

```java
public class Main {

    public static void main(String[] args) {
        int trips = 7, days = 2;
        double late = trips / days;
        double early = (double)trips / days;
        int width = 50000;
        long area = (long)width * width;
        System.out.println("late=" + late);
        System.out.println("early=" + early);
        System.out.println("area=" + area);
        System.out.println("truncate=" + (int)-3.9);
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
late=3.0
early=3.5
area=2500000000
truncate=-3
```

**读懂这里的语法**

- `public class Main`: 声明名为 Main 的公开类。完整程序保存为 Main.java。花括号包围类主体。

- `public static void main(String[] args)`: 这是 JVM 入口方法。public 允许公开调用；static 不需 Main 实例；void 不返回值；args 是命令行字符串数组。

- `System.out.println(value)`: 把值写到标准输出流并换行。点号选择成员，圆括号传入实参，分号结束语句。

- `(double) trips / days`: 强制转换先把 trips 转为 double，再做除法。类型周围括号表示转换，不是方法调用。

**按执行顺序理解**

1. trips 和 days 都声明为 int，因此第一次除法采用整数运算。

2. 在除法前转换 trips，在乘法前转换 width，以改变运算类型。

3. 打印带名称的结果，显示信息在哪里丢失。

**跟踪状态的变化**

| 表达式 | 结果 |
| --- | --- |
| 7 / 2 | 3 |
| (double) 7 / 2 | 3.5 |
| (long) 50000 * 50000 | 2500000000 |

**逐步读懂代码**

操作数在赋值之前决定除法类型。7/2 已变为 3，所以 late 接收 3.0。early 先转换 trips，因此保留小数。

乘法前的转换让结果使用 long。最后的转换向零截断，得到 −3，而非 −4。这些是语言规则，不是对处理器存储的猜测。

<a id="lab-java-execution-types-java-parse-and-range"></a>

### 分开解析与有效范围

处理固定文本，不需要交互输入。

```java
public class Main {

    public static void main(String[] args) {
        for (String text : new String[] {"12", "-2", "twelve", "2147483648"}) {
            try {
                int value = Integer.parseInt(text);
                if (value < 0)
                    System.out.println(text + ": negative count");
                else
                    System.out.println(text + ": accepted " + value);
            } catch (NumberFormatException ex) {
                System.out.println(text + ": invalid int text");
            }
        }
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
12: accepted 12
-2: negative count
twelve: invalid int text
2147483648: invalid int text
```

**按执行顺序理解**

1. 数组按固定顺序提供四种不同输入。

2. parseInt 先检查十进制 int 表示，再执行符号规则。

3. catch 报告转换失败，并让下一次迭代继续。

**逐步读懂代码**

每段文本在自己的 try 块内解析。格式合法的负整数进入领域检查。非数值文本与超过 int 范围的整数都会解析失败。

处理器位于循环内部，所以单项失败后继续处理。没有把无效值悄悄改为零。

### Leon 的进一步思考

**移动转换位置，而不是只换接收类型**

long result = a*b 可能先在 int 运算中溢出，再赋值。写成 (long)a*b 才会扩宽运算本身。这也解释整数除法示例。

**表示检查与领域检查**

Integer.parseInt 能拒绝格式错误或超范围文本。另一个范围检查落实任务含义。区分两类失败有助于提供有效错误信息。

**注意这个边界:** 不要声称所有基本类型值都在栈上，也不要把编译成功当作正确性证明。存储取决于上下文与实现；类型正确只是正确性的一层。

<details>
<summary>检查自己的理解</summary>

(double) (9 / 4) 得到什么？怎样得到 2.25？

得到 2.0。整数除法先发生。使用 9 / 4.0 或 ((double) 9) / 4，在除法前引入浮点运算。

</details>

<a id="control-flow"></a>

## 条件、循环与不变式

### 01 / 从一个故事开始

图书管理员检查推车的一层书架，再前往下一层。规则不只是不断走动。每检查完一层，所有更早的层都必须已经检查。这个始终记住的事实让进展有意义。while 像开始前先确认是否还有书架。do-while 像先检查一层再决定是否继续。增强 for 把移动路线交给遍历机制。这个比喻提醒我们：循环既需要停止规则，也需要始终成立的事实，而不只是不断变化的计数器。

### 02 / 理解原理

if 根据 boolean 条件选择分支。&& 与 || 采用短路求值，只有必要时才计算右侧。因此 x != null && x.isReady() 可以先保护后调用。while 在循环体前检查，do-while 在循环体后检查。for 把初始化、条件和更新写在一起。增强 for 访问元素，但不会自动提供下标。分析循环时，先写出不变式，再证明初始化建立它、循环体保持它，最后结合退出条件得到结果。还要指出一个朝终止方向变化的量。break 退出循环；continue 跳过本轮剩余内容，进入下一轮步骤。

### 03 / 逐项读懂公式

```text
第 i 轮开始前：total = a[0] + … + a[i−1]
0 ≤ i ≤ n；remaining = n − i
退出时 i = n：total 等于全部 n 个元素之和
```

a 是含 n 个数的数组。i 是下一个待处理下标。total 是已累计的和。i = 0 时空和为零。remaining 是非负进度量，每轮减一。推理假设每轮推进 i、数组长度固定且算术没有溢出。这种证明结构也适用于搜索、计数和验证循环。

### 04 / 先看一个完整例子

累加每日记录 [4, 0, 7]。初始化 total = 0、i = 0。不变式表示尚未处理的元素还没有被计入。第一轮后 total = 4、i = 1。第二轮后 total 仍为 4、i = 2。第三轮后 total = 11、i = 3。此时 i < a.length 为假，所以答案是 11。若改成 i <= a.length，就会访问下标 3 并失败。若数组为空，原循环不执行任何一轮，正确返回零。

### 先认识这些词

- **循环不变式:** 每次完整迭代前后都成立的事实。

- **短路求值:** 第一个布尔操作数已决定结果时，不求第二个。

- **进展度量:** 朝终止方向变化、且有界的量。

### 一步步想清楚

**为什么总和从零开始？**

尚未处理任何值，空前缀之和为零，这建立了不变式。

**什么保证循环结束？**

i 每次增加一，不可能永远小于固定长度。剩余数量 length−i 递减到零。

**为什么 text != null 要放在 text.length() 前？**

text 为 null 时，&& 在调用 length 前停止。倒过来会先执行不安全调用。

<a id="lab-control-flow-java-prefix-sum-trace"></a>

### 追踪已处理前缀

每次更新后打印不变式状态。

```java
public class Main {

    public static void main(String[] args) {
        int[] values = {4, 0, 7};
        int total = 0;
        for (int i = 0; i < values.length; i++) {
            total += values[i];
            System.out.println("processed=" + (i + 1) + ", total=" + total);
        }
        int emptyTotal = 0;
        for (int value : new int[0])
            emptyTotal += value;
        System.out.println("empty total=" + emptyTotal);
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
processed=1, total=4
processed=2, total=4
processed=3, total=11
empty total=0
```

**读懂这里的语法**

- `for (int i = 0; i < values.length; i++)`: 初始化一次，每轮主体前检查，完成后更新。i++ 将 i 加一。

- `values[i]`: 方括号选择零起始下标 i 的元素。length 字段给出数组元素数。

- `total += values[i]`: 把当前元素加到 total 并保存新值。这里的小输入不会超过数值类型范围。

**按执行顺序理解**

1. 初始化 total，建立空前缀不变式。

2. 使用 i<length，让每次索引读取都在边界内。

3. 相加后打印，再在空输入上运行同样的累加思路。

**跟踪状态的变化**

| 已处理项 | 总和 |
| --- | --- |
| 0 | 0 |
| 1 | 4 |
| 2 | 4 |
| 3 | 11 |

**逐步读懂代码**

第 i 次迭代前，total 恰含前 i 项。加 values[i] 后，前缀扩展一项。打印发生在加法后，所以已处理数量为 i+1。

空数组的增强 for 不执行循环体，总和保持零。这个对照检查边界，而无需特判返回。

<a id="lab-control-flow-java-guard-and-search"></a>

### 保护空引用并在首个匹配处停止

组合短路保护与有界搜索。

```java
public class Main {

    public static void main(String[] args) {
        String[] names = {null, "", "oak", "pine", "elm"};
        int found = -1;
        for (int i = 0; i < names.length; i++) {
            String name = names[i];
            if (name == null || name.isEmpty())
                continue;
            if (name.length() == 4) {
                found = i;
                break;
            }
        }
        System.out.println("first length-four index=" + found);
        System.out.println("matched=" + names[found]);
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
first length-four index=3
matched=pine
```

**按执行顺序理解**

1. 使用 −1 作为明确的未找到标记。

2. 先用 continue 跳过无效项，再调用其他 String 方法。

3. 保存首个匹配索引，并用 break 退出循环。

**逐步读懂代码**

保护条件跳过缺失或空名称。name 为 null 时，|| 不调用 isEmpty。留下的名称可安全检查。

首个四字母名称 pine 在索引三。break 保持“首个匹配”的含义。本例已知存在匹配，一般调用者必须先检查 found 再索引。

### Leon 的进一步思考

**区分安全与进展**

即使循环永不结束，不变式也可能成立。例如循环什么也不做，前缀和仍正确。必须同时说明保持的事实与递减的剩余工作量。

**空情况属于设计的一部分**

i<length 的求和循环对空数组自然返回零。do-while 至少进入一次，因此需要额外保护。按是否允许零次迭代选择控制结构。

**注意这个边界:** 对零起始数组使用 <= length 会多访问一格。把 null 检查写在方法调用之后，也无法发挥短路保护作用。

<details>
<summary>检查自己的理解</summary>

为什么循环能保持不变式，却仍不结束？

不变式描述安全性，不描述进展。若 i 从不增加，不变式可能永远成立。证明终止还需要一个不断减小且有下界的进度量。

</details>

<a id="methods-references"></a>

## 方法、调用记录与复制的引用

### 01 / 从一个故事开始

两名协调员各有一张写着同一社区公告板地址的卡片。任何一人都可修改公告板，两人都能看到变化。若其中一人把自己的卡片换成另一块公告板的地址，另一人的卡片不会改变。这可以解释把引用传入 Java 方法：复制的是引用值，对象可以共享。每次调用还得到独立工作桌，用来放局部变量。返回时可以概念上清空工作桌，但其他地方仍可访问的对象不会因此自动销毁。

### 02 / 理解原理

调用方法会建立新的调用记录，并用实参值初始化形参变量。Java 始终按值传递。基本类型实参复制基本值；对象实参复制引用值。重新给形参赋值，绝不会重新给调用者变量赋值。修改共享对象的字段或数组元素，仍可能被调用者观察到。每次调用都有自己的局部变量，递归调用也一样。static 方法属于类上下文，没有隐含 this。实例方法接收特定接收者对象。return 结束调用；若返回类型不是 void，还会提供一个值。明确输入、输出和副作用，可以让方法边界更易理解。

### 03 / 逐项读懂公式

```text
形参值 ← 实参值的副本
调用者 r ─┐
          ├→ 共享对象 O
被调用者 p ─┘
p = anotherObject 改变 p，不改变 r
```

r 是调用者的引用变量，p 是方法形参，O 是它们最初共同指向的对象。箭头表示引用，不是程序直接暴露的数字内存地址。赋值箭头表示把值复制进另一变量。这个模型同时适用于数组和普通对象。方法可以刻意返回新引用，但调用者必须把返回值赋给自己的变量，才能改变该变量。

### 04 / 先看一个完整例子

设 int[] scores = {6, 8}。方法以 x 接收 scores，先执行 x[0] = 10，再执行 x = new int[]{1, 2}。第一句把共享原数组改成 [10, 8]。第二句只让局部形参 x 指向新数组。返回后，scores 仍指向 [10, 8]。若方法返回 x，而调用者写 scores = replace(scores)，调用者就明确采用返回的 [1, 2]。区分对象修改与引用重新赋值，就能解释两种结果，无需错误地称 Java 为按引用传递。

```java
import java.util.Arrays;

public class ReferenceDemo {
    static void change(int[] x) {
        x[0] = 10;
        x = new int[]{1, 2};
    }
    public static void main(String[] args) {
        int[] scores = {6, 8};
        change(scores);
        System.out.println(Arrays.toString(scores)); // [10, 8]
    }
}
```

将这个短例子另存为 ReferenceDemo.java，放在单独文件夹中。运行：

```sh
javac -encoding UTF-8 ReferenceDemo.java
java -ea ReferenceDemo
```

### 先认识这些词

- **实参:** 调用方法时提供的值。

- **形参:** 用实参值的副本初始化的新变量。

- **别名:** 指向同一对象的另一个引用。

### 一步步想清楚

**哪种赋值效果可跨越方法边界？**

写 x[0] 改变共享数组；给 x 赋新数组只改变形参变量。

**调用者怎样采用新数组？**

返回新引用，再由调用者赋给自己的变量。这样调用者明确改变自己的引用。

**为什么递增 Integer 不改变调用者变量？**

Integer 不可变。拆箱、相加与装箱只是为局部形参产生替代值。

<a id="lab-methods-references-java-mutation-reassignment"></a>

### 比较修改与重新赋值

调用后观察调用者原数组。

```java
import java.util.Arrays;

public class Main {
    static void change(int[] x) {
        x[0] = 10;
        System.out.println("after mutation=" + Arrays.toString(x));
        x = new int[] {1, 2};
        System.out.println("local replacement=" + Arrays.toString(x));
    }
    public static void main(String[] args) {
        int[] scores = {6, 8};
        change(scores);
        System.out.println("caller=" + Arrays.toString(scores));
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
after mutation=[10, 8]
local replacement=[1, 2]
caller=[10, 8]
```

**读懂这里的语法**

- `static void change(int[] x)`: 声明类方法，接收数组引用的副本。void 表示不返回值。

- `x[0] = 10`: 修改 x 所指对象的第零个元素，与给 x 本身赋值不同。

- `x = new int[] {1, 2}`: 创建数组并只替换局部形参值。new 创建新对象。

**按执行顺序理解**

1. 把 scores 的引用值复制到新形参 x。

2. 先修改共享元素，再重新绑定形参。

3. 返回后检查 scores，判断哪种改动对调用者可见。

**跟踪状态的变化**

| 时点 | 调用者数组 | 局部数组 |
| --- | --- | --- |
| entry | [6,8] | [6,8] |
| element write | [10,8] | [10,8] |
| parameter reassignment | [10,8] | [1,2] |

**逐步读懂代码**

两个引用起初到达同一数组。元素赋值改变该数组，重设 x 只改变被调用方的路径。

调用者仍看到 [10,8]。方法内与返回后分别打印，能展示两种状态，而不把引用误当成可见数值地址。

<a id="lab-methods-references-java-return-new-reference"></a>

### 明确返回替代引用

比较复制包装值与接收返回数组。

```java
import java.util.Arrays;

public class Main {
    static void increment(Integer x) {
        x++;
        System.out.println("local count=" + x);
    }
    static int[] doubled(int[] input) {
        int[] result = input.clone();
        for (int i = 0; i < result.length; i++)
            result[i] *= 2;
        return result;
    }
    public static void main(String[] args) {
        Integer count = 4;
        increment(count);
        System.out.println("caller count=" + count);
        int[] values = {2, 5};
        int[] replacement = doubled(values);
        System.out.println("original=" + Arrays.toString(values));
        values = replacement;
        System.out.println("adopted=" + Arrays.toString(values));
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
local count=5
caller count=4
original=[2, 5]
adopted=[4, 10]
```

**按执行顺序理解**

1. 分别观察局部包装值替换与调用者 count。

2. 先克隆再加倍，使输入不被修改。

3. 由调用者明确接收返回引用。

**逐步读懂代码**

递增包装对象只重新赋值局部形参。doubled 创建独立基本类型数组，并修改副本。

原数组保持 [2,5]，直到调用者选择用返回引用替换自己的变量。其他指向旧数组的别名仍会到达旧数组。

### Leon 的进一步思考

**修改共享状态属于接口决策**

编辑实参的方法应说明副作用。返回复制结果可避免影响其他别名，但需要分配与复制。应明确选择契约并测试。

**final 形参仍允许对象修改**

final int[] x 不允许让 x 指向其他数组，却不阻止 x[0]=10。不可变性针对可达对象，不只是局部引用能否改变。

**注意这个边界:** “按引用传递”会掩盖关键区别。Java 按值传递引用值。对象修改可以跨越方法边界；形参重新赋值不能。

<details>
<summary>检查自己的理解</summary>

方法对 Integer 形参执行 x++，调用者的 Integer 变量会改变吗？

不会。Integer 不可变。拆箱、加一、装箱会让局部形参重新赋值，调用者仍保留原引用。

</details>

<a id="arrays"></a>

## 数组、不规则网格与浅复制

### 01 / 从一个故事开始

储物柜抽屉从零编号。订购五个抽屉的柜子会固定大小，却不会在里面自动创建五件工具。用于存放工具的抽屉可以为空。柜中柜可以包含不同大小的内柜，也可以有内柜尚未到货的空位。复制外柜标签，不等于复制内柜全部物品。这个比喻解释了数组长度固定、引用元素默认为 null、多维数组允许不规则形状，以及复制时必须明确深度。

### 02 / 理解原理

数组是对象，长度固定，元素具有同一种组件类型。有效下标从零到长度减一。基本类型元素获得对应默认值；引用元素初始为 null。Java 二维数组是数组引用的数组，因此各行可以长度不同，也可以为 null。遍历每个已分配的行时，要使用该行自己的长度。把一个数组变量赋给另一个会产生别名，二者指向同一数组。clone 创建新的外层数组，但只是复制元素。元素若为引用，其对象仍共享。引用类型数组具有协变性，因此某些赋值可以通过编译，却需要运行时存储检查。泛型刻意采用不同规则，以避免这种特定风险。

### 03 / 逐项读懂公式

```text
有效下标：0 ≤ i < a.length
不规则网格格数 = 对所有非 null 行求 Σ row.length
outerCopy = original.clone()：outerCopy ≠ original；行仍可能共享
```

i 是整数下标，a 是非 null 数组。Σ 表示把每个已分配行的长度加起来。所有行等长的矩形只是特殊情况。外层数组不相等表示身份不同，不表示内容不同。外层 clone 不会递归复制嵌套数组。复制深度取决于后续代码是否需要独立修改原来共享的元素。

### 04 / 先看一个完整例子

创建 int[][] rack = {{2, 5}, {9}}。共存三个数字，不是四个，因为第二行长度为一。把外层数组 clone 给 copy。执行 copy[0][1] = 7 也改变 rack[0][1]，因为两个外层数组仍指向同一第一行。再执行 copy[1] = new int[]{4, 6, 8}，只替换 copy 的第二行引用；rack 第二行仍为 [9]。要隔离全部整数格，还需复制每个非 null 行。若行内存放可变对象，还要进一步决定是否复制这些对象。

### 先认识这些词

- **不规则数组:** 各行长度不必相同的数组。

- **浅复制:** 新外层容器仍共享其引用的内部对象。

- **元素类型:** 数组允许存入的元素类型。

### 一步步想清楚

**{{2,5},{9}} 有几个单元？**

三个：第一行两个，第二行一个。只有已确定为矩形时才能行数乘列数。

**outer.clone 会隔离行内元素吗？**

不会。它复制的是行引用。需要独立修改单元时，应逐行克隆基本类型数组。

**为什么 Object[] 别名不能向 String[] 写任意值？**

实际数组仍保留 String 元素类型，运行时存储检查会拒绝不兼容对象。

<a id="lab-arrays-java-ragged-copy"></a>

### 追踪浅复制与逐行复制

观察哪些修改越过各复制边界。

```java
import java.util.Arrays;

public class Main {

    public static void main(String[] args) {
        int[][] original = {{2, 5}, {9}};
        int[][] shallow = original.clone();
        shallow[0][1] = 7;
        shallow[1] = new int[] {4, 6, 8};
        System.out.println("original=" + Arrays.deepToString(original));
        System.out.println("shallow=" + Arrays.deepToString(shallow));
        int[][] isolated = new int[original.length][];
        for (int i = 0; i < original.length; i++)
            isolated[i] = original[i].clone();
        isolated[0][0] = 99;
        System.out.println("original after isolated edit=" +
                           Arrays.deepToString(original));
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
original=[[2, 7], [9]]
shallow=[[2, 7], [4, 6, 8]]
original after isolated edit=[[2, 7], [9]]
```

**读懂这里的语法**

- `int[][] rack`: 声明元素为 int 数组引用的数组。两对方括号不要求矩形。

- `rack.clone()`: 调用 clone 创建新外层数组。各元素值会复制，包括每个行引用。

- `copy[0][1]`: 先选第零行，再选该行第一个下标位置。引用与边界都必须有效。

**按执行顺序理解**

1. 克隆外层数组并修改共享行。

2. 替换一个行引用，区分槽位修改与单元修改。

3. 独立修改之前先逐行克隆。

**跟踪状态的变化**

| 操作 | 原第一行 |
| --- | --- |
| start | [2,5] |
| shallow[0][1]=7 | [2,7] |
| isolated[0][0]=99 | [2,7] |

**逐步读懂代码**

外层克隆共享两个原始行。修改单元会通过 original 可见。替换 shallow[1] 则只改变该外层槽位。

第二种复制克隆每个基本类型行，因此之后修改其单元不会改变 original。本例没有 null 行，一般复制器需要检查。

<a id="lab-arrays-java-array-store-and-null-rows"></a>

### 处理 null 行与受检查的存储

展示两种数组边界，并明确处理失败。

```java
public class Main {

    public static void main(String[] args) {
        int[][] rows = {{1, 2}, null, {}, {3}};
        int cells = 0;
        for (int[] row : rows)
            if (row != null)
                cells += row.length;
        System.out.println("cells=" + cells);
        Object[] values = new String[2];
        values[0] = "safe";
        try {
            values[1] = Integer.valueOf(5);
        } catch (ArrayStoreException ex) {
            System.out.println("incompatible store rejected");
        }
        System.out.println("second slot=" + values[1]);
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
cells=3
incompatible store rejected
second slot=null
```

**按执行顺序理解**

1. 读取 length 前先检查 row!=null。

2. 将 String 数组赋给较宽引用类型，但实际元素类型保留。

3. 捕获刻意触发的存储失败，并检查状态未变。

**逐步读懂代码**

计数使用各已存在行自己的长度。null 行没有数组，所以不贡献单元数。

Object[] 变量引用实际 String 数组。写入 Integer 在改变槽位前抛异常，因此该槽位仍为 null。这也说明泛型集合为何选择不变的类型规则。

### Leon 的进一步思考

**复制深度由所有权决定**

克隆行可隔离基本类型单元。若单元是可变对象，逐行克隆仍共享这些对象。应说明调用者能改哪些部分，不能一概称为深复制。

**null 行不同于空行**

空行是已分配、长度为零的数组。null 行没有可查询数组。不规则遍历可以都跳过，但其含义与合法操作不同。

**注意这个边界:** 除非已经保证矩形形状，不要用 a[0].length 遍历每一行。不要假设 clone 会递归复制整个对象图。

<details>
<summary>检查自己的理解</summary>

为什么 Object[] values = new String[2] 能编译，而 values[0] = 5 在运行时失败？

数组协变允许前一种赋值。实际数组仍只接受 String 引用，存入装箱后的 Integer 会触发 ArrayStoreException。

</details>

<a id="strings"></a>

## 文本值、相等性与构建器

### 01 / 从一个故事开始

在另一张说明纸上写“改成大写”，不会改动已经打印的票。请求大写票会产生另一张票。除非替换自己保存的引用，原票保持原样。两张独立打印的票可以文字完全相同，却不是同一张实体票。StringBuilder 更像白板上的草稿：不断添加会修改同一工作对象，最后再输出文本。这个比喻把内容与身份分开，也解释了文本转换为什么必须返回新值，或使用可变编辑对象。

### 02 / 理解原理

String 是不可变引用类型。substring、replace、toUpperCase 等操作提供结果，却不改变原 String 的值。== 比较引用身份，equals 比较 String 内容。驻留机制可能让某些相等字面量共享身份，但不能据此设计一般文本比较。StringBuilder 支持可变追加，适合在循环中拼接许多文本片段。toString 产生 String 值。Java 字符串使用 UTF-16 代码单元。length 和 charAt 按代码单元工作，因此一个显示符号可能占多个 char。特别在处理补充字符时，要根据应用对“字符”的定义选择操作。

### 03 / 逐项读懂公式

```text
a == b：是否为同一引用？
a.equals(b)：String 内容是否相同？
builder.append(piece) 修改构建器
s.length() 统计 UTF-16 代码单元
```

a、b 是 String 引用；调用 a.equals 要求 a 非 null。已知字面量可以安全作接收者，如 "ready".equals(status)。也可用 Objects.equals 比较可能为 null 的引用。builder 是 StringBuilder，piece 是追加的文本。这四行代表不同操作，不是可互换的比较或编辑方式。代码单元长度适合 Java 下标，但可能不同于码点数或用户感知字符数。

### 04 / 先看一个完整例子

程序接收 new String("harbor")，并与字面量 "harbor" 比较。内容相同，因此 equals 为 true。== 不保证为 true；此处对象不同，所以为 false。调用 input.toUpperCase() 却忽略结果，会让 input 继续保存 "harbor"。赋值 input = input.toUpperCase() 才保留大写结果。构建 "Day 1, Day 2, Day 3" 时，从空构建器开始。仅当编号大于一时追加分隔符，再追加标签。最后转换一次，得到没有开头逗号的完整文本。

### 先认识这些词

- **不可变:** 对象构造后，其逻辑值不能改变。

- **UTF-16 编码单元:** Java 字符串索引使用的一个 16 位单元。

- **可变字符串构建器:** 创建 String 前用于组合文本的可变缓冲区。

### 一步步想清楚

**两个新字符串为何 equals 真而 == 假？**

字符内容相同，但对象不同。值相等与引用身份回答不同问题。

**忽略 toUpperCase 的返回值会怎样？**

算出的新值未被使用，原 String 和变量引用都保持不变。

**一个可见符号能占两个 char 吗？**

可以。补充平面 Unicode 码点使用 UTF-16 代理对。用户感知字符还可能更复杂。

<a id="lab-strings-java-string-value-identity"></a>

### 区分文本值与对象身份

使用不同对象，避免字符串常量驻留掩盖错误比较。

```java
public class Main {

    public static void main(String[] args) {
        String a = new String("harbor");
        String b = new String("harbor");
        System.out.println("identity=" + (a == b));
        System.out.println("content=" + a.equals(b));
        a.toUpperCase(java.util.Locale.ROOT);
        System.out.println("ignored result=" + a);
        a = a.toUpperCase(java.util.Locale.ROOT);
        System.out.println("assigned result=" + a);
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
identity=false
content=true
ignored result=harbor
assigned result=HARBOR
```

**读懂这里的语法**

- `new String("harbor")`: 构造包含字面文本的独立 String 对象。这里用于显露身份区别。

- `left.equals(right)`: 在非 null 接收者上调用内容比较，区别于 == 检查引用身份。

- `toUpperCase(Locale.ROOT)`: 使用语言中立区域规则返回大写结果，原不可变字符串不会改变。

**按执行顺序理解**

1. 在不同对象中创建相同文本，打印两种比较结果。

2. 调用变换但不接收结果。

3. 接收返回的 String，再观察变量改变。

**跟踪状态的变化**

| 时点 | a 的值 |
| --- | --- |
| initial | harbor |
| ignored conversion | harbor |
| assigned conversion | HARBOR |

**逐步读懂代码**

new 保证本例使用不同 String 对象，equals 检查内容。Locale.ROOT 使大小写转换不依赖机器默认地区。

第一次转换结果被丢弃。第二次赋给 a，改变的是变量中的引用，而不是修改旧 String。

<a id="lab-strings-java-builder-and-unicode"></a>

### 构建标签并统计 Unicode 单位

对照文本组合与索引使用的单位。

```java
public class Main {

    public static void main(String[] args) {
        StringBuilder builder = new StringBuilder();
        for (int day = 1; day <= 3; day++) {
            if (day > 1)
                builder.append(", ");
            builder.append("Day ").append(day);
        }
        System.out.println(builder.toString());
        String symbol = new String(Character.toChars(0x1F680));
        System.out.println("code units=" + symbol.length());
        System.out.println("code points=" + symbol.codePointCount(0, symbol.length()));
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
Day 1, Day 2, Day 3
code units=2
code points=1
```

**按执行顺序理解**

1. 循环生成标签时有条件地追加分隔符。

2. 用 Character.toChars 创建补充码点。

3. 在相同完整字符串范围内比较 length 与 codePointCount。

**逐步读懂代码**

只有首个标签之后才加入分隔符，因此没有开头逗号。链式 append 作用于同一个构建器。

火箭码点在基本多文种平面之外，Java 用两个编码单元表示，而 codePointCount 返回一个码点。这并不意味着码点始终等于用户感知字符。

### Leon 的进一步思考

**先选择要统计的单位**

String.length 统计编码单元，不是屏幕宽度。码点计数能处理补充码点，却仍不等于所有字素簇。选择接口前先明确应用所需单位。

**构建器改变了共享问题**

局部 StringBuilder 便于反复追加。把这个可变构建器交给其他代码，就允许编辑同一草稿。发布稳定结果时转换为 String。

**注意这个边界:** 字面量驻留可能掩盖错误的 == 比较。要用运行时创建的文本测试。StringBuilder 本身可变，不应随意共享。

<details>
<summary>检查自己的理解</summary>

为什么 s.replace("a", "b") 看起来没有效果？

它返回新的 String 结果。忽略结果时，s 仍引用旧值。应赋值保存结果，或直接使用返回值。

</details>

<a id="classes-invariants"></a>

## 类、构造器与受保护状态

### 01 / 从一个故事开始

饮水器有容量和当前水位。使用者按加水或出水按钮，不会拆开面板重写刻度。构造器像安装检查，先建立有效初态。封装有价值，是因为以后每次操作都可依赖同一规则。若公开按钮仍接受不可能的数值，单纯把刻度藏在 private 盖板下没有用。this 也可这样理解：按钮操作的是当前这台饮水器，不是同一种设计制造的所有饮水器。

### 02 / 理解原理

类定义字段与行为，每个实例拥有自己的实例字段。构造器初始化新实例，并可在无效对象暴露前拒绝错误参数。this 表示当前接收者，也能区分字段与同名形参。private 字段让修改必须经过受检查操作，从而帮助维持不变式。getter 和 setter 不会自动带来良好封装。允许不可能状态的 setter 会破坏设计。只有完全没有声明构造器的类，才获得编译器提供的无参数构造器。static 字段属于类并被共享，应表示真正共享的事实，而不是意外的全局状态。final 引用不能重新赋值，但仍可能指向可变对象。

### 03 / 逐项读懂公式

```text
不变式：0 ≤ level ≤ capacity
fill(q)：要求 q ≥ 0 且 q ≤ capacity − level
加水后：level′ = level + q
```

level 与 capacity 用相同单位描述一台饮水器。q 是请求添加量，level′ 是操作后的状态。不变式在构造完成后及每次公开操作完成后都必须成立。在不变式原本成立时，用 capacity − level 检查可避免有界整数加法溢出。这些条件属于对象契约，不应只放在调用者可能绕过的图形界面中。

### 04 / 先看一个完整例子

构造容量 12、水位 4 的饮水器。加 5 合法，因为 5 ≤ 12 − 4；新水位为 9。随后加 4 会被拒绝，因为只剩 3 单位空间，水位保持 9。另一台容量 8、水位 0 的饮水器始终为空。若把 level 声明为 static，两台设备就会错误耦合。构造器中 this.capacity = capacity 把形参存入实例字段。capacity = capacity 只会把形参赋给自身，字段仍停留在默认值。

### 先认识这些词

- **对象不变式:** 每个可用对象状态都必须满足的规则。

- **构造器:** 建立新实例初始状态的代码。

- **防御性复制:** 为防止外部别名改变受保护状态而创建的副本。

### 一步步想清楚

**为什么改变 level 前先检查？**

验证失败时旧有效状态保持完整。先改变再检查可能暴露无效状态，或需要回滚。

**为什么比较 q 与 capacity−level？**

在 0≤level≤capacity 下，剩余容量可安全计算。先算 level+q 可能在大整数时溢出。

**final int[] 代表元素不可变吗？**

不代表。它固定存储的引用；除非限制访问，数组元素仍可写。

**为什么 private final 数组仍然不够？**

private 隐藏字段名，final 禁止重新赋值。getter 返回的引用仍能修改数组。应返回副本，或设计只读操作。

<a id="lab-classes-invariants-java-dispenser-contract"></a>

### 保护容量不变式

测试成功、拒绝与独立实例状态。

```java
public class Main {
    static final class Dispenser {
        private final int capacity;
        private int level;
        Dispenser(int capacity, int level) {
            if (capacity < 0 || level < 0 || level > capacity)
                throw new IllegalArgumentException("invalid state");
            this.capacity = capacity;
            this.level = level;
        }
        void fill(int amount) {
            if (amount < 0 || amount > capacity - level)
                throw new IllegalArgumentException("does not fit");
            level += amount;
        }
        int level() {
            return level;
        }
    }
    public static void main(String[] args) {
        Dispenser a = new Dispenser(12, 4);
        Dispenser b = new Dispenser(8, 0);
        a.fill(5);
        System.out.println("after fill=" + a.level());
        try {
            a.fill(4);
        } catch (IllegalArgumentException ex) {
            System.out.println("rejected");
        }
        System.out.println("after rejection=" + a.level());
        System.out.println("other instance=" + b.level());
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
after fill=9
rejected
after rejection=9
other instance=0
```

**读懂这里的语法**

- `private int level`: 在类访问边界内保存每对象可变状态。private 阻止外部直接访问。

- `this.capacity = capacity`: 左侧是当前接收者字段，右侧是构造器参数。

- `throw new IllegalArgumentException(...)`: 输入违反方法契约时，创建并抛出异常，跳过后续正常语句。

**按执行顺序理解**

1. 构建字段彼此独立的两个有效实例。

2. 接受五单位，再拒绝四单位且不修改状态。

3. 读取两个实例，检查拒绝与隔离。

**跟踪状态的变化**

| 请求 | a.level | b.level |
| --- | --- | --- |
| construct | 4 | 0 |
| fill(5) | 9 | 0 |
| fill(4) rejected | 9 | 0 |

**逐步读懂代码**

构造器先建立不变式。每次 fill 在加法前检查剩余容量，从而保持规则。私有字段阻止调用者绕过操作直接写入。

被拒请求后 level 仍为九。第二实例仍为空，因为 level 是实例字段，而非共享静态状态。

<a id="lab-classes-invariants-java-defensive-array-copies"></a>

### 保护内部数组

通过刻意修改检查输入与输出别名。

```java
import java.util.Arrays;

public class Main {
    static final class Snapshot {
        private final int[] values;
        Snapshot(int[] values) {
            this.values = values.clone();
        }
        int[] values() {
            return values.clone();
        }
    }
    public static void main(String[] args) {
        int[] supplied = {2, 5};
        Snapshot snapshot = new Snapshot(supplied);
        supplied[0] = 99;
        int[] exported = snapshot.values();
        exported[1] = 77;
        System.out.println("supplied=" + Arrays.toString(supplied));
        System.out.println("exported=" + Arrays.toString(exported));
        System.out.println("protected=" + Arrays.toString(snapshot.values()));
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
supplied=[99, 5]
exported=[2, 77]
protected=[2, 5]
```

**按执行顺序理解**

1. 构造时复制数组，不保留调用者别名。

2. 暴露值时返回副本。

3. 两次外部编辑后，重新获取并打印内部值。

**逐步读懂代码**

构造器保存克隆，因此改 supplied 不影响快照。getter 返回另一个克隆，因此改 exported 也不影响快照。

对于基本类型整数单元，这已经足够。可变对象数组还需要更深的所有权规则。仅有 final 字段不能提供任一保护。

<a id="lab-classes-invariants-defensive-snapshot"></a>

### 保护成绩快照，隔离外部修改

米娜保存了三次测验成绩。她把数组传入报告对象，随后修改自己的工作数组，却发现报告也变了。private 没能阻止这个问题：两处引用指向同一个数组。于是她让构造器复制输入。接着她又发现，getter 也可能把内部数组交出去。在输出边界再复制一次，报告才成为稳定的快照。

```java
import java.util.Arrays;
public class Main {
    static final class ScoreSnapshot {
        private final int[] scores;
        ScoreSnapshot(int[] source) {
            if (source == null) throw new IllegalArgumentException("missing scores");
            int[] copy = source.clone();
            for (int score : copy) {
                if (score < 0 || score > 100)
                    throw new IllegalArgumentException("score out of range");
            }
            scores = copy;
        }
        int[] values() { return scores.clone(); }
        int total() {
            int sum = 0;
            for (int score : scores) sum = Math.addExact(sum, score);
            return sum;
        }
    }
    public static void main(String[] args) {
        int[] input = {70, 80, 90};
        ScoreSnapshot report = new ScoreSnapshot(input);
        input[0] = 0;
        int[] exported = report.values();
        exported[1] = 0;
        assert report.total() == 240;
        System.out.println("snapshot: " + Arrays.toString(report.values()));
        System.out.println("total: " + report.total());
        try { new ScoreSnapshot(new int[]{101}); }
        catch (IllegalArgumentException error) { System.out.println("invalid score rejected"); }
        assert new ScoreSnapshot(new int[0]).total() == 0;
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
snapshot: [70, 80, 90]
total: 240
invalid score rejected
```

**读懂这里的语法**

- `source.clone()`: 创建数值相同、存储独立的基本类型数组。

- `private final int[]`: 隐藏字段，并禁止把字段改为另一个引用。

- `Math.addExact`: 执行整数加法，溢出时抛出异常。

**按执行顺序理解**

1. 先复制，再保存。校验针对对象实际拥有的那份副本。

2. getter 再返回一份数组，修改返回值不会影响内部存储。

3. 分别修改两份外部数组，再核对报告，测试的是边界保护而非初始求和。

4. 拒绝 101；空数组的总分为零。addExact 会把整数溢出变成明确异常。

**逐步读懂代码**

不变量是：每个内部元素都在 0 到 100 之间。调用者接触不到内部数组，普通外部写入就无法破坏它。final 只禁止字段重新指向别的数组，不会冻结数组内容；真正提供保护的是两次复制。

n 个成绩的构造和每次 values() 都花费 O(n) 时间与 O(n) 新空间。total() 花费 O(n) 时间和 O(1) 辅助空间。这是为了稳定接口而付出的成本。int[] 的元素是基本类型，复制一层足够；若元素是可变对象，还要另行决定是否复制每个对象。

工程顺序：先写不变量，再确定谁拥有数据，最后测试所有接收或返回可变数据的边界。本例是单线程程序；如果源数组正被其他线程修改，复制不能代替同步。

### Leon 的进一步思考

**拒绝时不留下部分修改**

先验证后修改能给出简单异常状态保证：拒绝后对象不变。测试应检查捕获异常后的状态，而不只是异常类型。

**在两个所有权边界复制**

复制构造输入能防止调用者通过旧数组编辑内部数据。复制 getter 输出能避免新别名逃逸。只做一边，另一条修改路径仍开放。

**注意这个边界:** 若不允许调用者修改内部列表，就不要通过 getter 直接暴露可变列表。返回防御性副本，或提供合适的不可修改视图，并明确说明共享关系。

<details>
<summary>检查自己的理解</summary>

final List 字段还能添加元素吗？

能。final 阻止替换字段引用，不会让所指 List 不可变，也不会禁用修改方法。

</details>

<a id="text-io"></a>

## 输入、文件与资源生命周期

### 01 / 从一个故事开始

文员通过服务窗口读取一叠物品表单。按词元读取时，每次取一项；按行读取时，取到下一行边界为止。切换两种习惯，可能留下意外的空余部分。工作结束要关闭窗口，即使损坏表单中断处理也一样。Java 输入有同样两项责任：一致地解释数据流，并释放提供数据的资源。垃圾回收不是负责准时关窗的文员。

### 02 / 理解原理

Scanner 可从标准输入或文件读取词元，并转为基本类型。nextInt 这样的词元调用不会消费随后整行，因此后面的 nextLine 可能只读到剩余的行结束边界。要明确选择解析策略，并在适当时机先验证再转换。路径表示位置，但文件可能不存在、无法读取或格式错误。读写因而同时涉及数据逻辑与失败处理。try-with-resources 在代码块退出时关闭 AutoCloseable 资源，异常退出也如此。可移植文本文件应明确字符编码。只关闭自己拥有的资源。关闭包装 System.in 的 Scanner，也会关闭共享底层输入流。

### 03 / 逐项读懂公式

```text
打开 → 读取或写入 → 关闭
try (资源声明) { 使用资源; }
行文本 → 验证结构 → 解析字段 → 领域对象
```

第一行描述资源生命周期。try-with-resources 让关闭步骤成为语言管理的控制流，不必要求每条返回路径都记得关闭。行是原始文本单位。解析把字段转成有类型值，领域验证再判断值是否合理。这些检查彼此不同。语法有效的整数 −8，仍不能表示收到的箱子数量。

### 04 / 先看一个完整例子

一个小型清单文件逐行写着 "lamp,3"、"chair,5"。读取完整一行，拆成恰好两个字段，去掉数量周围空白，解析为整数，再拒绝负数，最后创建条目。重复后得到两项，总数量 8。第三行 "desk,many" 无法解析为数字，应报告行号，不能默默记为零。"shelf,-2" 可以解析，却违反领域规则。把 reader 放在 try-with-resources 中，保证两种失败都仍关闭文件句柄。

### 先认识这些词

- **词法项:** 按读取器分隔规则划分的一个输入单位。

- **行边界:** 逻辑输入行的结尾，其处理方式与数字词法项不同。

- **资源所有权:** 使用后负责关闭资源的责任。

### 一步步想清楚

**nextLine 为什么返回空的剩余内容？**

nextInt 消耗数字，却未消耗行终止。下一次行读取取得终止符前空的剩余内容。

**为什么区分解析错误与负计数？**

前者表示文本不是合法整数；后者是合法整数，却违反数据契约。两种情况需要不同解释。

**这里为什么读取字符串而非外部文件？**

这样把解析与文件系统可用性分开。同一行解析器以后可接收具有明确所有权的文件读取器。

<a id="lab-text-io-java-scanner-line-boundary"></a>

### 观察数字项之后的行

全部输入来自内存。

```java
import java.util.Scanner;

public class Main {

    public static void main(String[] args) {
        try (Scanner scanner = new Scanner("12\nlamp\n")) {
            int count = scanner.nextInt();
            String remainder = scanner.nextLine();
            String name = scanner.nextLine();
            System.out.println("count=" + count);
            System.out.println("remainder length=" + remainder.length());
            System.out.println("name=" + name);
        }
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
count=12
remainder length=0
name=lamp
```

**读懂这里的语法**

- `try (Scanner scanner = new Scanner(text))`: 为代码块创建所拥有资源，退出时自动关闭。

- `scanner.nextInt()`: 读取并解析下一个整数词元，不会消费整行剩余内容。

- `scanner.nextLine()`: 读取到下一行边界之前的剩余文本，不含行结束符。

**按执行顺序理解**

1. 提供一行数字与下一行名称。

2. 读取数字项，并明确检查行的剩余部分。

3. 读取下一整行，再关闭自己的 scanner。

**跟踪状态的变化**

| 调用 | 返回值 |
| --- | --- |
| nextInt() | 12 |
| nextLine() | empty string |
| nextLine() | lamp |

**逐步读懂代码**

nextInt 在数字后停止。第一次 nextLine 读取同一行剩下的空部分，下一次才读取 lamp。

资源型 try 关闭本地拥有的 scanner。这个程序刻意展示数字项与行混读；一致的逐行格式通常能避免该边界问题。

<a id="lab-text-io-java-validated-line-records"></a>

### 验证完整设备记录

拒绝格式错误与负数量，同时保留有效记录。

```java
import java.io.BufferedReader;
import java.io.StringReader;

public class Main {

    public static void main(String[] args) throws Exception {
        String data = "lamp,3\nchair,5\ndesk,many\nshelf,-2\n";
        int total = 0, lineNumber = 0;
        try (BufferedReader reader = new BufferedReader(new StringReader(data))) {
            String line;
            while ((line = reader.readLine()) != null) {
                lineNumber++;
                String[] fields = line.split(",", -1);
                if (fields.length != 2 || fields[0].trim().isEmpty()) {
                    System.out.println("line " + lineNumber + ": bad structure");
                    continue;
                }
                try {
                    int count = Integer.parseInt(fields[1].trim());
                    if (count < 0) {
                        System.out.println("line " + lineNumber + ": negative count");
                        continue;
                    }
                    total += count;
                } catch (NumberFormatException ex) {
                    System.out.println("line " + lineNumber + ": bad integer");
                }
            }
        }
        System.out.println("accepted total=" + total);
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
line 3: bad integer
line 4: negative count
accepted total=8
```

**读懂这里的语法**

- `throws Exception`: 此演示让受检查资源失败从 main 向上传播。较大应用应在能够明确报告或恢复的边界捕获。

**按执行顺序理解**

1. 读取整行，并维护从一开始的行号。

2. 先检查结构，再做数值转换与领域验证。

3. 只累加被接受记录，报告确定的最终总量。

**逐步读懂代码**

readLine 仅在输入结束时返回 null。split 的 −1 保留末尾空字段，便于检查结构，避免掩盖缺失数量。

只有结构合法、解析成功且非负的记录才计入 total。本例数量很小，不会溢出；一般大型数据需要更宽累加器或受检查加法。

### Leon 的进一步思考

**完整解析记录后再接受**

在累加前检查字段数、数值语法与领域范围，可避免部分接受记录。行号让拒绝记录可定位，不必猜哪个词法项失败。

**关闭包装器可能关闭共享流**

Scanner 通常会关闭底层来源。本例创建的 StringReader 归本例所有。System.in 包装器则可能使用进程共享输入，其关闭责任需要由调用设计决定。

**注意这个边界:** 不要把所有输入错误当作文件结束，也不要默默代入默认值。要区分缺失、格式损坏，以及格式正确但违反领域规则的数据。

<details>
<summary>检查自己的理解</summary>

nextInt 读到 12 后，为什么 nextLine 可能返回空字符串？

数字词元已被消费，但行结束符仍在。nextLine 读取该行剩余部分。可以统一按行解析，或有意消费剩余部分。

</details>

<a id="binary-io"></a>

## 二进制流与匹配的数据布局

### 01 / 从一个故事开始

仓库可以在箱子上印字符 "258"，也可以用固定数量的电子开关存储该数。二者编码同一数字，但读取者必须知道收到哪种表示。把开关模式当文字读取，不会恢复原标签。二进制文件同样需要约定布局。若第一字段为数量，第二字段为重量，读写双方都必须遵循该顺序。扩展名不会提供这个约定，程序的格式契约才会。

### 02 / 理解原理

文本输入输出利用编码在字符与字节之间转换。二进制输入输出则按明确布局处理字节序列。DataOutputStream 可以按规定表示写入 Java 基本类型；DataInputStream 读取匹配表示。例如 writeInt 按最高有效字节在前的顺序写四字节，readInt 期待相同四字节。双方必须同意字段顺序、宽度和表示。字段截断应当视为格式失败，不能默默替换成零。若格式需要演进，应定义版本与验证规则，不能假设全部文件都由当前程序生成。二进制流也需要资源管理与输入输出失败处理。

### 03 / 逐项读懂公式

```text
writeInt(258) → 00 00 01 02（十六进制字节）
258 = 1 × 256 + 2
全部字段定宽时：记录字节数 = 各字段宽度之和
```

01 这样的每一对数字表示一个十六进制字节。数字 0 到 F 表示零到十五。四对字节表示 DataOutput 格式中的一个 32 位 int。最高有效字节在前，表示高位字节先出现。记录大小规则仅适用于已说明的定宽布局。字符串等变长字段还需长度或分隔符约定。该字节序是 API 保证，与机器本地整数布局无关。

### 04 / 先看一个完整例子

传感器记录先放 int 类型 stationId，再放 double 类型 temperature。写 stationId = 258、temperature = 18.5，共用 4 + 8 = 12 字节。匹配的读取者先调用 readInt 得到 258，再调用 readDouble 得到 18.5。若先调用 readDouble，就会错误解释前八字节并破坏字段边界。若文件只有十字节，末尾 double 不完整，必须触发读取失败。先在内存中测试往返，再引入文件系统行为，并独立测试缺失和截断文件。

```java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;

public class BinaryDemo {
    public static void main(String[] args) throws IOException {
        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        try (DataOutputStream out = new DataOutputStream(bytes)) {
            out.writeInt(258);
            out.writeDouble(18.5);
        }
        byte[] record = bytes.toByteArray();
        System.out.println(record.length); // 12
        try (DataInputStream in = new DataInputStream(
                new ByteArrayInputStream(record))) {
            System.out.println(in.readInt());    // 258
            System.out.println(in.readDouble()); // 18.5
        }
    }
}
```

将这个短例子另存为 BinaryDemo.java，放在单独文件夹中。运行：

```sh
javac -encoding UTF-8 BinaryDemo.java
java -ea BinaryDemo
```

### 先认识这些词

- **二进制布局:** 存储字段约定的顺序、宽度与表示方式。

- **字节序:** 高位与低位字节写出的顺序。

- **截断记录:** 所需字段字节尚未完整到达就结束的记录。

### 一步步想清楚

**传感器记录为何是十二字节？**

DataOutput 用四字节写 int、八字节写 double。所述记录没有额外头部或字符串。

**机器本地字节序会改变 writeInt 吗？**

不会。DataOutput 契约规定高位字节在前，由接口定义文件表示。

**缺失字节能当作零吗？**

不能。那会编造另一条记录。readInt 或 readDouble 字节不足时抛 EOFException。

<a id="lab-binary-io-java-binary-round-trip"></a>

### 写入再读取传感器记录

使用字节数组流，不需要文件。

```java
import java.io.*;

public class Main {

    public static void main(String[] args) throws Exception {
        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        try (DataOutputStream out = new DataOutputStream(bytes)) {
            out.writeInt(258);
            out.writeDouble(18.5);
        }
        byte[] record = bytes.toByteArray();
        System.out.println("bytes=" + record.length);
        try (DataInputStream in =
                 new DataInputStream(new ByteArrayInputStream(record))) {
            System.out.println("station=" + in.readInt());
            System.out.println("temperature=" + in.readDouble());
            System.out.println("at end=" + (in.read() == -1));
        }
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
bytes=12
station=258
temperature=18.5
at end=true
```

**读懂这里的语法**

- `new DataOutputStream(bytes)`: 包装字节目的地，提供基本类型编码方法。

- `out.writeInt(258)`: 按 API 规定的高位字节在前表示，写入恰好四字节。

- `in.readDouble()`: 按匹配 double 表示读取下八字节。读取顺序必须匹配写入顺序。

- `throws Exception`: 此演示让受检查资源失败从 main 向上传播。较大应用应在能够明确报告或恢复的边界捕获。

**按执行顺序理解**

1. 按有名称、固定的顺序写字段。

2. 取出字节数组并检查完整大小。

3. 读取匹配类型并确认无剩余字节。

**跟踪状态的变化**

| 字段 | 起始偏移 | 字节数 |
| --- | --- | --- |
| stationId | 0 | 4 |
| temperature | 4 | 8 |
| end | 12 | 0 |

**逐步读懂代码**

写入顺序固定为 int、double。读取按相同顺序消耗四字节和八字节，最后 read 在内存流结束处返回 −1。

关闭 DataOutputStream 会刷新包装层，ByteArrayOutputStream 之后仍可取出字节。这样独立于文件系统验证基本类型布局。

<a id="lab-binary-io-java-binary-truncation"></a>

### 检查精确字节并拒绝截断

故意缩短副本，使 int 缺少一个必要字节。

```java
import java.io.*;

public class Main {

    public static void main(String[] args) throws Exception {
        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        try (DataOutputStream out = new DataOutputStream(bytes)) {
            out.writeInt(258);
        }
        byte[] full = bytes.toByteArray();
        StringBuilder hex = new StringBuilder();
        for (byte value : full) {
            if (hex.length() > 0)
                hex.append(' ');
            hex.append(String.format(java.util.Locale.ROOT, "%02X", value & 0xff));
        }
        System.out.println("hex=" + hex);
        byte[] shortRecord = java.util.Arrays.copyOf(full, 3);
        try (DataInputStream in =
                 new DataInputStream(new ByteArrayInputStream(shortRecord))) {
            in.readInt();
            System.out.println("unexpected complete record");
        } catch (EOFException ex) {
            System.out.println("truncated int rejected");
        }
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
hex=00 00 01 02
truncated int rejected
```

**读懂这里的语法**

- `throws Exception`: 此演示让受检查资源失败从 main 向上传播。较大应用应在能够明确报告或恢复的边界捕获。

**按执行顺序理解**

1. 编码已知整数，并格式化各无符号字节。

2. 创建确定的三字节截断记录。

3. 仅捕获不完整基本类型读取产生的 EOFException。

**逐步读懂代码**

有符号 byte 与 0xff 按位与后得到无符号显示值。每个字节显示两位十六进制，顺序为 00 00 01 02。

三字节副本不足以表示完整 DataOutput int。处理器识别截断，不编造第四字节，也不吞掉其他 I/O 异常。

### Leon 的进一步思考

**往返与无效输入检查不同问题**

读写双方可能共享同一种错误字段顺序，往返仍能成功。还应检查已知字节并测试截断，独立验证格式。

**不要序列化偶然对象布局**

明确的基本类型格式说明字节数和解码方法。类的私有字段布局不是文件格式。需要演进时，应加入版本与边界规则。

**注意这个边界:** .bin 扩展名没有定义格式。不要把 Scanner 文本解析与二进制基本类型布局混用，也不要假设所有写入方法采用一种通用字符编码。

<details>
<summary>检查自己的理解</summary>

一个记录包含两个 int 和一个 double，在文件头之前共需多少字节？

16 字节。DataOutput 的每个 int 占 4 字节，double 占 8，所以 4 + 4 + 8 = 16。版本字段或字符串还会增加自身表示所需空间。

</details>

<a id="collections"></a>

## 选择列表、集合、映射与链式结构

### 01 / 从一个故事开始

活动组织者需要三类记录：有顺序的流程表、不重复的胸牌编号，以及从编号查找参加者的索引。List、Set、Map 分别回答这些问题。把熟悉的容器用于全部任务，会让后续操作变麻烦。列表内部可以是按编号排列的盒子，也可以是一串相连卡片。盒子便于按位置直接访问，卡片便于沿链接行进。没有一种表示适合所有任务。找到卡片的代价，可能比找到以后改动链接的代价更大。

### 02 / 理解原理

List 保存有序序列并允许重复。Set 表示唯一性。Map 将键关联到值，本身不是 Collection 的子类型。ArrayList 使用可扩容底层数组。按下标访问为常数时间，尾部追加为均摊常数时间，中间插入或删除要移动元素。LinkedList 使用链式节点。按下标访问需遍历；在已知端点或迭代器位置操作，可避免移动其他元素。HashMap 和 HashSet 使用哈希及相等性。在合适条件下，常见操作期望为常数时间，但不承诺遍历顺序。若调用者只需接口契约，变量应优先声明为接口类型。泛型集合用 Integer 等包装类型；还要区分列表实际大小与底层容量。

### 03 / 逐项读懂公式

```text
ArrayList：get(i) O(1)；尾部追加均摊 O(1)；insert(i) O(n)
LinkedList：按下标查找 O(n)；端点更新 O(1)
Map：每个相等的键对应一个当前值；Set：每个相等类保留一个成员
```

n 是元素数量，i 是有效下标。大 O 描述输入增大时的增长趋势，不是实际毫秒数。均摊表示一串操作的平均成本，某次扩容仍可能复制很多元素。端点更新假设已经拿到对应端点；中间操作可能包含搜索。相等类表示按集合规则被视为相等的元素，不一定是同一对象。

### 04 / 先看一个完整例子

组织者将到达记录 ["Mia", "Leo", "Mia"] 放入 List，保留三次事件及顺序。用这些记录创建 HashSet，大小为 2，因为重复名字等于已有成员。Map 中 "Mia" → 2、"Leo" → 1 表示计数。把 Mia 的值改为 3，不会增加第三个键。若主要任务是反复显示第 500 个到达者，ArrayList 合适。若主要任务是反复从队列两端添加和删除，应考虑队列抽象及适合该访问方式的实现。

### 先认识这些词

- **列表:** 可重复包含相等元素的有序序列。

- **集合:** 每种相等值最多保留一个成员的容器。

- **映射:** 每个键对应一个当前关联值的结构。

### 一步步想清楚

**三次到达为何只有两个集合成员？**

重复名称在列表中仍是独立事件，在集合中却等于已有值。容器回答不同问题。

**替换映射值会增加键吗？**

相等键已存在时不会。它只更新关联，键数不变。

**队列为何适合使用 Deque？**

操作直接表达端点，避免把反复从头删除当作一般索引列表操作。

<a id="lab-collections-java-list-set-map"></a>

### 表示事件、唯一值与计数

用同一输入序列展示三种不同契约。

```java
import java.util.*;

public class Main {

    public static void main(String[] args) {
        List<String> arrivals = Arrays.asList("Mia", "Leo", "Mia");
        Set<String> unique = new HashSet<>(arrivals);
        Map<String, Integer> counts = new LinkedHashMap<>();
        for (String name : arrivals)
            counts.put(name, counts.getOrDefault(name, 0) + 1);
        System.out.println("events=" + arrivals);
        System.out.println("unique count=" + unique.size());
        System.out.println("counts=" + counts);
        counts.put("Mia", 3);
        System.out.println("after replacement=" + counts + ", keys=" + counts.size());
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
events=[Mia, Leo, Mia]
unique count=2
counts={Mia=2, Leo=1}
after replacement={Mia=3, Leo=1}, keys=2
```

**读懂这里的语法**

- `List<String> arrivals`: 用 List 接口声明变量，以 String 为元素类型。

- `new LinkedHashMap<>()`: 创建保留插入遍历顺序的映射。菱形语法让编译器推断类型实参。

- `for (String name : arrivals)`: 依次访问元素。冒号分隔循环变量与可迭代来源。

**按执行顺序理解**

1. 用 List 保留事件顺序，用 Set 得到唯一成员。

2. 为每个相等名称累计一个映射计数。

3. 替换一个关联，再检查键数不变。

**跟踪状态的变化**

| 到达 | 计数 |
| --- | --- |
| Mia | {Mia=1} |
| Leo | {Mia=1, Leo=1} |
| Mia | {Mia=2, Leo=1} |

**逐步读懂代码**

列表保留全部到达事件。这里只打印 HashSet 大小，未规定的集合顺序不会影响输出。LinkedHashMap 为显示的计数保留插入顺序。

getOrDefault 对新名称给零，put 保存增加后的计数。以后写入相等键只是替换值，不产生新逻辑键。

<a id="lab-collections-java-deque-order"></a>

### 用双端队列比较先入先出与后入先出

两个例子都使用端点操作，而非索引。

```java
import java.util.*;

public class Main {

    public static void main(String[] args) {
        Deque<String> queue = new ArrayDeque<>();
        queue.addLast("oak");
        queue.addLast("pine");
        queue.addLast("elm");
        StringJoiner fifo = new StringJoiner(", ");
        while (!queue.isEmpty())
            fifo.add(queue.removeFirst());
        Deque<String> stack = new ArrayDeque<>();
        stack.push("oak");
        stack.push("pine");
        stack.push("elm");
        StringJoiner lifo = new StringJoiner(", ");
        while (!stack.isEmpty())
            lifo.add(stack.pop());
        System.out.println("FIFO=" + fifo);
        System.out.println("LIFO=" + lifo);
        System.out.println("empty poll=" + queue.pollFirst());
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
FIFO=oak, pine, elm
LIFO=elm, pine, oak
empty poll=null
```

**按执行顺序理解**

1. 从一端入队、另一端出队。

2. 在同一端压入与弹出，形成栈。

3. 比较要求非空的删除与空时返回标记的轮询。

**逐步读懂代码**

尾部加入、头部移除会保持到达顺序。push 与 pop 使用同一端，因此顺序反转。

removeFirst 要求非空，循环先检查。pollFirst 在空队列返回 null。ArrayDeque 拒绝 null 元素，因此这个标记没有歧义。

### Leon 的进一步思考

**确定输出需要顺序契约**

HashMap 不承诺遍历顺序。结果需要插入顺序时用 LinkedHashMap，或显示前排序键。不要从一次运行推断保证。

**分别计算查找与修改成本**

知道位置后，链式节点可以低成本重新连接，但查找任意索引仍可能线性耗时。应按完整访问模式选择容器。

**注意这个边界:** LinkedList 不会让每次插入都便宜：定位任意下标仍可能花线性时间。不要把 HashMap 的遍历顺序当作有序流程表。

<details>
<summary>检查自己的理解</summary>

哪种集合适合保存用户名及最近登录时间？同名插入两次会怎样？

使用从用户名映射到时间戳的 Map。put 遇到相等的已有键会替换对应值，键数量不会增加。

</details>

<a id="equality-hashing"></a>

## 身份、值相等与哈希契约

### 01 / 从一个故事开始

图书馆为同一账号打印两张补办卡。它们是不同塑料片，但借阅系统应识别相同账号。查找柜先按短码分组，再在组内比较完整账号身份。若等价卡被送往不同柜子，比较就没有公平机会。这解释了 equals 与 hashCode 的联系。哈希只是引导查找的提示，不是相等证明。不同账号仍可能进入同一个柜子。

### 02 / 理解原理

引用相等询问两个引用是否指向同一对象。类可重写 equals(Object) 定义值相等。在相关状态不变时，该关系应满足自反、对称、传递与一致性。非 null 对象与 null 应不相等。相等对象必须有相等哈希值，不相等对象允许碰撞。因此重写 equals 通常也要用同一组逻辑字段重写 hashCode。equals(Account) 这样的重载不会替代集合使用的 equals(Object)。键存入哈希集合期间，参与相等判断的字段应保持稳定。否则查找可能进入不同于插入时的桶。不可变值对象更容易维持契约。

### 03 / 逐项读懂公式

```text
a.equals(b) ⇒ a.hashCode() = b.hashCode()
哈希值相等 ⇏ 对象相等
a == b 检查身份；equals(Object) 定义值关系
```

a、b 是正确实现相等性的类中的非 null 对象。蕴含只有一个方向：相等约束哈希，哈希本身不能证明相等。哈希值是 int，若可能值足够多，碰撞不可避免。契约要求同一次执行中的相等实例遵循此规则，并在相关字段不变时保持一致。它不要求未来每次程序执行都产生相同哈希。

### 04 / 先看一个完整例子

定义 final Seat 类，以不可变的 (row, number) 作为逻辑身份。独立构造两个 B 排 7 号座位。引用不同，但正确 equals(Object) 比较两字段后返回 true。hashCode 用相同字段计算，因此把二者放进 HashSet 只保留一个逻辑座位。第三个座位 B8 仍不同，即使哈希碰撞也一样。若 equals 参数只写 Seat，通过 Object 调用可能退回 Object 的身份行为。加 @Override 可在编译时暴露错误签名。

```java
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

public class EqualityDemo {
    static final class Seat {
        private final String row;
        private final int number;
        Seat(String row, int number) {
            this.row = Objects.requireNonNull(row);
            this.number = number;
        }
        @Override
        public boolean equals(Object other) {
            if (this == other) return true;
            if (!(other instanceof Seat)) return false;
            Seat seat = (Seat) other;
            return number == seat.number && row.equals(seat.row);
        }
        @Override
        public int hashCode() { return Objects.hash(row, number); }
    }
    public static void main(String[] args) {
        Set<Seat> seats = new HashSet<>();
        seats.add(new Seat("B", 7));
        seats.add(new Seat("B", 7));
        seats.add(new Seat("B", 8));
        System.out.println(seats.size()); // 2
    }
}
```

将这个短例子另存为 EqualityDemo.java，放在单独文件夹中。运行：

```sh
javac -encoding UTF-8 EqualityDemo.java
java -ea EqualityDemo
```

### 先认识这些词

- **对象身份:** 两个引用是否表示完全同一个对象。

- **值相等:** 类定义的规则，判断两个对象是否表示相同逻辑值。

- **哈希冲突:** 不相等值拥有相同哈希码，这是合法情况。

### 一步步想清楚

**为何同时重写 equals 与 hashCode？**

哈希容器先用哈希寻找候选，再用相等关系区分。相等对象必须得到一致路由。

**哈希相同就表示值相等吗？**

不表示。只有相等推出同哈希，反向不成立。第二示例保留两个冲突键。

**为什么把键字段设为 final？**

稳定的相等字段有助于在键存储期间保持查找契约。final 不会递归冻结引用的可变对象。

**发生哈希冲突，就一定会丢失一条说明吗？**

不会。不同键可以共享哈希；HashMap 还会检查相等关系，所以本例保留两条记录。

<a id="lab-equality-hashing-java-immutable-value-key"></a>

### 用不可变座位作为值键

不同但相等的对象成为同一个逻辑集合成员。

```java
import java.util.*;

public class Main {
    static final class Seat {
        private final String row;
        private final int number;
        Seat(String row, int number) {
            this.row = Objects.requireNonNull(row);
            this.number = number;
        }
        @Override
        public boolean equals(Object other) {
            if (this == other)
                return true;
            if (!(other instanceof Seat))
                return false;
            Seat seat = (Seat)other;
            return number == seat.number && row.equals(seat.row);
        }
        @Override
        public int hashCode() {
            return Objects.hash(row, number);
        }
    }
    public static void main(String[] args) {
        Seat a = new Seat("B", 7), b = new Seat("B", 7);
        Set<Seat> seats = new HashSet<>();
        seats.add(a);
        seats.add(b);
        seats.add(new Seat("B", 8));
        System.out.println("identity=" + (a == b));
        System.out.println("equal=" + a.equals(b));
        System.out.println("equal hashes=" + (a.hashCode() == b.hashCode()));
        System.out.println("logical seats=" + seats.size());
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
identity=false
equal=true
equal hashes=true
logical seats=2
```

**读懂这里的语法**

- `@Override`: 要求编译器验证方法以正确签名重写继承方法。

- `public boolean equals(Object other)`: 重写 Object 的相等入口。参数必须是 Object，不能只写具体类。

- `Objects.hash(row, number)`: 从 equals 使用的同一组逻辑字段计算哈希。相等字段值因此产生相等哈希。

**按执行顺序理解**

1. 以 Object 参数定义相等，使容器调用正确重写。

2. 用恰好相等关系涉及的字段计算哈希。

3. 插入不同但相等的实例，检查逻辑成员数量。

**跟踪状态的变化**

| 插入 | 集合大小 |
| --- | --- |
| B7 instance one | 1 |
| B7 instance two | 1 |
| B8 | 2 |

**逐步读懂代码**

Seat 是 final，其相等字段稳定。equals 先处理同一对象、不相容类型，再比较两个逻辑字段。hashCode 使用相同字段。

虽然创建三个对象，集合大小为二。程序不打印集合遍历顺序，把相等行为与排序分开。

<a id="lab-equality-hashing-java-legal-hash-collisions"></a>

### 区分哈希冲突的不等键

刻意使用差的哈希，仍保持正确相等关系。

```java
import java.util.*;

public class Main {
    static final class Key {
        private final int id;
        Key(int id) {
            this.id = id;
        }
        @Override
        public boolean equals(Object other) {
            return other instanceof Key && id == ((Key)other).id;
        }
        @Override
        public int hashCode() {
            return 42;
        }
    }
    public static void main(String[] args) {
        Map<Key, String> values = new HashMap<>();
        values.put(new Key(1), "north");
        values.put(new Key(2), "south");
        values.put(new Key(1), "updated");
        System.out.println("keys=" + values.size());
        System.out.println("key 1=" + values.get(new Key(1)));
        System.out.println("key 2=" + values.get(new Key(2)));
        System.out.println("same hash=" +
                           (new Key(1).hashCode() == new Key(2).hashCode()));
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
keys=2
key 1=updated
key 2=south
same hash=true
```

**按执行顺序理解**

1. 以不可变整数编号定义相等。

2. 用常数哈希强制冲突。

3. 通过新建相等键查找各编号。

**逐步读懂代码**

两个编号都得到哈希 42，但 equals 仍区分一和二，所以两个映射都保留。再插入编号一，只替换与它相等的映射。

示例刻意不测耗时。合法但差的哈希只展示冲突下的正确性，不是推荐哈希策略。

<a id="lab-equality-hashing-immutable-grid-key"></a>

### 用值对象作为 Map 的键

机器人社用行号和列号标记地砖。米娜以一个地砖对象为键保存说明，之后创建了相同坐标的新对象，希望仍能找到说明。引用身份无法回答这个问题。她定义坐标相等，配套实现哈希函数，并保证键的坐标不会改变。

```java
import java.util.HashMap;
import java.util.Map;
public class Main {
    static final class Cell {
        private final int row;
        private final int column;
        Cell(int row, int column) { this.row = row; this.column = column; }
        @Override public boolean equals(Object other) {
            if (this == other) return true;
            if (!(other instanceof Cell)) return false;
            Cell cell = (Cell) other;
            return row == cell.row && column == cell.column;
        }
        @Override public int hashCode() { return 31 * row + column; }
    }
    public static void main(String[] args) {
        Cell a = new Cell(2, 3);
        Cell b = new Cell(2, 3);
        Cell collision = new Cell(1, 34);
        Map<Cell, String> notes = new HashMap<>();
        notes.put(a, "charging");
        notes.put(collision, "door");
        assert a != b && a.equals(b) && b.equals(a);
        assert a.hashCode() == collision.hashCode();
        assert !a.equals(collision) && !a.equals(null);
        assert "charging".equals(notes.get(b));
        System.out.println("same object: " + (a == b));
        System.out.println("same value: " + a.equals(b));
        System.out.println("lookup: " + notes.get(b));
        System.out.println("collision keeps both: " + notes.size());
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
same object: false
same value: true
lookup: charging
collision keeps both: 2
```

**读懂这里的语法**

- `instanceof Cell`: 在类型转换之前检查类型。

- `@Override`: 让编译器检查该方法确实重写父类方法。

- `Map<Cell, String>`: 保存 Cell 键与 String 值，并获得编译期类型检查。

**按执行顺序理解**

1. 比较两个坐标，而不是内存身份。final 类避免子类引入不兼容的相等规则。

2. 哈希由 equals 使用的相同字段计算，因此相同坐标的哈希一致。

3. 故意让 (1,34) 与 (2,3) 冲突：哈希都是 65，但它们仍是不同的键。

4. 用从未插入的 b 查询。查询成功说明是按值查找。

**逐步读懂代码**

哈希先缩小查找范围，equals 再判断候选对象是否就是要找的键。正确方向是 a.equals(b) 成立时，二者 hashCode 必须相同。反过来不成立，示例中的冲突已经说明这一点。

公式 h = 31r + c 中，r 是行号，c 是列号。这只是教学用哈希，不是密码学哈希。Java int 运算可能回绕，但仍应保证相等值得到相同哈希。更好的分布能减少冲突，却无法让无限多种键在有限整数范围内完全无冲突。

键存入 Map 后，不要修改参与相等比较的字段。否则查询可能按新哈希查找，而条目仍留在原来的位置。不可变键可以避免这种失败；但不可变键不会让普通 HashMap 自动变成线程安全的容器。

### Leon 的进一步思考

**重载不等于相等方法重写**

equals(Seat) 只是增加另一方法，容器调用 equals(Object)。在目标方法上加 @Override，让编译器检查这个准确要求。

**合法冲突仍可能增加耗时**

只要相等实现正确，返回常数哈希合法，但许多键会成为同一区域内候选。契约正确与预期性能是两件事。

**注意这个边界:** 不要单凭 hashCode 推断相等。键仍在哈希集合中时，不要修改参与相等判断的字段。先移除、修改、重新插入，或直接设计不可变键。

<details>
<summary>检查自己的理解</summary>

两个不相等对象合法地返回哈希值 42，可以吗？

可以。碰撞合法，集合必须继续用相等性区分。全部对象返回 42 也能满足契约，但性能可能很差。

</details>

<a id="inheritance-composition"></a>

## 继承、组合与可替换性

### 01 / 从一个故事开始

面包房拥有烤箱，但面包房不是一种烤箱。热风烤箱若遵守相同基本承诺，则可以是一种烤箱。混淆这些关系会导致奇怪设计：仅为复用代码使用继承，顾客就可能要求面包房“预热自己”。组合让面包房把加热委托给拥有的烤箱。继承对实例的使用方式作出更强承诺。因此应先判断行为含义，再计算能省几行代码。

### 02 / 理解原理

子类 extends 父类，可在期待父类的地方使用，但必须遵守父类契约。Java 类只能有一个直接父类，却可实现多个接口。继承带来可访问成员与行为；构造器不被继承。子类构造器必须安排有效的父类初始化，常用 super 传入适当参数。父类私有状态应通过其行为管理，不能绕过。组合保存协作对象引用，并委托部分工作，通常更易替换和测试。public、private、包访问与 protected 决定可见性。protected 包含包内访问，跨包子类访问还有额外限制。可替换性关乎保留承诺，不只是拥有同名字段。

### 03 / 逐项读懂公式

```text
继承：SpecializedService 是一种 Service
组合：Coordinator 拥有一个 Service
子类型责任：保留使用父类型契约的调用者预期
```

Service 是抽象，其公开操作具有调用者依赖的含义。SpecializedService 在保留含义时增加或更换实现。Coordinator 是独立对象，引用 Service 并可委托工作。这是概念关系，不是算术。“是一种”只能作为初步检查，还需确认父类有效操作仍有效，子类结果仍符合原承诺。

### 04 / 先看一个完整例子

ReportPrinter 承诺对全部非 null 文本执行 print(text)。FilePrinter 可以通过写文件实现，同时保留契约。BatchReporter 包含 ReportPrinter，对每份报告调用一次。让 BatchReporter 继承 FilePrinter 会暴露无关的打印内部实现。测试中换成把字符串存起来的 RecordingPrinter。若批次含三份报告，就检查三个有序输出。所谓子类若拒绝全部超过十字符的文本，就违反了原本不限制长度的非 null 输入承诺，除非此限制原来就在共同契约中。

### 先认识这些词

- **子类型:** 保持父类型行为承诺、可通过父类型使用的类型。

- **组合:** 对象持有协作者，并把工作委托给它。

- **可替换性:** 用子类型替换实现后，调用者的有效预期仍成立。

### 一步步想清楚

**为什么批量报告器持有 Sink，而不是本身作为 Sink？**

它负责安排报告顺序，单条发送交给协作者。包含关系避免暴露无关继承操作。

**子类构造器为什么调用 super？**

子类可用前，父类部分必须建立有效状态。构造器不像普通方法那样被继承。

**子类型能拒绝父类型承诺接受的输入吗？**

那会加强前置条件，破坏可替换性，除非共同契约本来就允许拒绝。

<a id="lab-inheritance-composition-java-composed-reporter"></a>

### 注入记录型协作者

不依赖外部输出服务，测试消息顺序。

```java
import java.util.*;

public class Main {
    interface Sink {
        void send(String text);
    }
    static final class RecordingSink implements Sink {
        final List<String> messages = new ArrayList<>();
        public void send(String text) {
            messages.add(Objects.requireNonNull(text));
        }
    }
    static final class Reporter {
        private final Sink sink;
        Reporter(Sink sink) {
            this.sink = Objects.requireNonNull(sink);
        }
        void sendAll(List<String> messages) {
            for (String message : messages)
                sink.send(message);
        }
    }
    public static void main(String[] args) {
        RecordingSink sink = new RecordingSink();
        Reporter reporter = new Reporter(sink);
        reporter.sendAll(Arrays.asList("ready", "running", "done"));
        System.out.println(sink.messages);
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
[ready, running, done]
```

**读懂这里的语法**

- `interface Sink`: 为协作对象声明命名能力契约。

- `implements Sink`: 承诺提供 Sink 要求的抽象操作。

- `private final Sink sink`: 保存构造后不能替换的协作对象引用，其具体实现可以变化。

**按执行顺序理解**

1. 声明报告器需要的能力。

2. 通过接口传入具体记录实现。

3. 报告器委托完消息后，检查有序效果。

**跟踪状态的变化**

| 发送 | 已记录消息 |
| --- | --- |
| ready | [ready] |
| running | [ready, running] |
| done | [ready, running, done] |

**逐步读懂代码**

Reporter 保存 Sink 接口而非具体输出设备。循环为每条消息委托一次 send。记录型实现让本地测试看到顺序。

测试替身拥有自己的列表。公开生产接口通常应返回受保护快照，而非暴露可变状态。本例重点是职责与替换。

<a id="lab-inheritance-composition-java-subclass-initialization"></a>

### 保持父类契约

命名计数器增加描述，同时保留有效计数操作。

```java
public class Main {
    static class Counter {
        private int value;
        Counter(int start) {
            if (start < 0)
                throw new IllegalArgumentException();
            value = start;
        }
        void add(int amount) {
            if (amount < 0 || amount > Integer.MAX_VALUE - value)
                throw new IllegalArgumentException();
            value += amount;
        }
        int value() {
            return value;
        }
        String describe() {
            return "count=" + value;
        }
    }
    static final class NamedCounter extends Counter {
        private final String name;
        NamedCounter(String name, int start) {
            super(start);
            this.name = name;
        }
        @Override
        String describe() {
            return name + "=" + value();
        }
    }
    public static void main(String[] args) {
        Counter counter = new NamedCounter("samples", 2);
        counter.add(3);
        System.out.println(counter.describe());
        try {
            counter.add(-1);
        } catch (IllegalArgumentException ex) {
            System.out.println("negative rejected");
        }
        System.out.println("count=" + counter.value());
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
samples=5
negative rejected
count=5
```

**按执行顺序理解**

1. 先初始化父类，再保存子类特有状态。

2. 通过父类型引用调用继承操作。

3. 重写展示行为，但不削弱状态不变式。

**逐步读懂代码**

super(start) 建立继承部分的有效计数。子类通过 value 读取，而非直接改变私有字段。重写只改变描述。

变量类型是 Counter，但实际接收者提供命名描述。add 保留父类检查，包括防溢出检查。

### Leon 的进一步思考

**替换协作者以测试边界**

记录型实现让测试无需真实输出设备即可检查消息。报告器仍使用相同接口，因此测试直接检查其排序责任。

**代码复用不能证明所有继承都合理**

只有所有合法计数操作仍有意义时，继承计数器才合理。包装器需要不同契约时，应持有计数器字段，只暴露目标操作。

**注意这个边界:** 不要只为方便访问代码使用继承。拥有关系、不稳定实现依赖或更严格输入限制，通常需要组合或更合适的抽象。

<details>
<summary>检查自己的理解</summary>

只读集合为什么很难作为“保证 add 成功”的契约的子类型？

它无法保留承诺操作。抽象应区分读取能力与修改能力，或明确约定修改可能不受支持。

</details>

<a id="overload-override"></a>

## 重载选择签名，重写选择行为

### 01 / 从一个故事开始

服务中心先根据表单上的请求类型选择窗口，再由实际值班人员回应。重载像根据调用运行前可见的信息选窗口。重写像由实际接收者提供回应。以后改变请求内容，不会重新进行早先的窗口选择。把两步分开，就能解释常见困惑：变量里放着特殊对象，仍可能选中参数更一般的重载。

### 02 / 理解原理

重载方法名称相同，参数列表不同。选择依据编译时类型与适用性规则。只有返回类型不同，不能形成有效重载。构造器也可重载，并通过 this 委托另一构造器。重写为继承的实例方法提供子类型实现，签名与返回类型必须兼容。选定实例方法签名后，动态分派根据接收者运行时类选择实现。重写不能降低访问权限，也不能扩大受检查异常范围。用 @Override 捕捉错误。static 方法被隐藏，不是动态重写；字段也不动态分派。实参运行时类不会独立触发一次新的重载搜索。

### 03 / 逐项读懂公式

```text
1. 编译时：选择适用的方法签名
2. 运行时：对被重写的实例方法，选择接收者实现
同名 + 不同参数 → 重载
兼容的继承实例签名 → 重写
```

在入门例子中，签名由方法名和参数类型确定。仅返回类型不足以区分重载。接收者是实例调用中点号前的对象。编译时类型是编译器已知的变量或表达式类型；运行时类属于实际对象。这两阶段适用于普通虚实例方法。构造器、静态方法、私有方法与显式 super 调用有各自规则，不能随意说成同一种分派。

### 04 / 先看一个完整例子

Printer 定义 show(Object) 返回 "general"，show(String) 返回 "text"。FancyPrinter 将 show(Object) 重写为返回 "fancy general"，但继承 show(String)。令 Printer p = new FancyPrinter()，Object item = "hello"。p.show(item) 在编译时选择 show(Object)，随后执行 FancyPrinter 重写，返回 "fancy general"。p.show("hello") 则选择 show(String)，返回 "text"。两次实际值都是 String，但 item 的声明类型 Object 控制重载选择。这样可把签名选择与运行时接收者分派分别观察。

```java
public class DispatchDemo {
    static class Printer {
        String show(Object value) { return "general"; }
        String show(String value) { return "text"; }
    }
    static class FancyPrinter extends Printer {
        @Override
        String show(Object value) { return "fancy general"; }
    }
    public static void main(String[] args) {
        Printer p = new FancyPrinter();
        Object item = "hello";
        System.out.println(p.show(item));    // fancy general
        System.out.println(p.show("hello")); // text
    }
}
```

将这个短例子另存为 DispatchDemo.java，放在单独文件夹中。运行：

```sh
javac -encoding UTF-8 DispatchDemo.java
java -ea DispatchDemo
```

### 先认识这些词

- **重载:** 方法同名，但参数列表不同。

- **重写:** 子类型为继承的实例方法提供实现。

- **接收者:** 实例方法被调用时作用的对象。

### 一步步想清楚

**为什么 Object 变量选择 show(Object)？**

重载解析使用表达式编译期类型。内部装的是 String，也不会在运行时重新搜索重载。

**之后什么选择 FancyPrinter 的实现？**

运行时接收者类，为已经选定的 show(Object) 签名提供重写实现。

**只改返回类型能建立重载吗？**

不能。普通重载必须由参数列表区分。子类型重写则可采用兼容的协变返回类型。

<a id="lab-overload-override-java-two-stage-dispatch"></a>

### 追踪先重载、后重写

实参值始终为 String，但声明类型不同。

```java
public class Main {
    static class Printer {
        String show(Object value) {
            return "general";
        }
        String show(String value) {
            return "text";
        }
    }
    static final class FancyPrinter extends Printer {
        @Override
        String show(Object value) {
            return "fancy general";
        }
    }
    public static void main(String[] args) {
        Printer p = new FancyPrinter();
        Object item = "hello";
        System.out.println(p.show(item));
        System.out.println(p.show("hello"));
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
fancy general
text
```

**读懂这里的语法**

- `extends Printer`: 声明 Printer 子类，继承可继承成员及其契约。

- `show(Object value) / show(String value)`: 不同参数类型形成重载，签名在编译时选择。

- `Printer printer = new FancyPrinter()`: 变量声明类型是 Printer，实际接收者类是 FancyPrinter。要同时记住这两个事实。

**按执行顺序理解**

1. 在基类声明两个重载。

2. 子类仅重写 Object 签名。

3. 比较 Object 类型与 String 类型的实参表达式。

**跟踪状态的变化**

| 调用 | 选定签名 | 实现 |
| --- | --- | --- |
| p.show(item) | show(Object) | FancyPrinter |
| p.show("hello") | show(String) | Printer |

**逐步读懂代码**

第一调用先选择 show(Object)，再派发到重写实现。第二调用选择 show(String)，子类原样继承该方法。

重载选择没有对 item 做运行时检查或转换。两个输出区分编译器选择与运行时接收者行为。

<a id="lab-overload-override-java-static-and-virtual"></a>

### 区分类方法与实例方法

对隐藏的静态方法明确使用类名。

```java
public class Main {
    static class Base {
        static String kind() {
            return "base kind";
        }
        String describe() {
            return "base instance";
        }
        Object label() {
            return "base label";
        }
    }
    static final class Derived extends Base {
        static String kind() {
            return "derived kind";
        }
        @Override
        String describe() {
            return "derived instance";
        }
        @Override
        String label() {
            return "derived label";
        }
    }
    public static void main(String[] args) {
        Base value = new Derived();
        System.out.println("base static=" + Base.kind());
        System.out.println("derived static=" + Derived.kind());
        System.out.println("instance=" + value.describe());
        System.out.println("covariant value=" + value.label());
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
base static=base kind
derived static=derived kind
instance=derived instance
covariant value=derived label
```

**按执行顺序理解**

1. 声明持有子类实例的基类变量。

2. 静态调用都明确指定类名。

3. 观察虚方法行为与协变返回重写。

**逐步读懂代码**

两个静态调用明确使用不同声明类名，不依赖 value 的接收者。实例方法则使用实际 Derived 对象。

Base.label 承诺 Object，Derived.label 返回 String。所有 String 都是 Object，因此更窄结果保持调用者承诺。这不是在同一类中仅按返回类型重载。

### Leon 的进一步思考

**分别追踪两次选择**

先写选定签名，再写接收者实现。这样避免用“运行时实参类型选择重载”的错误规则解释正确结果。

**静态方法选择遵循另一规则**

静态方法属于类，只会隐藏，不会重写。使用类名调用能明确这一点。不要用看似实例的语法暗示并不存在的多态。

**注意这个边界:** 不要说 Java 总选实参运行时最特殊的类型，这混淆了重载与重写。还要区分 @Override 注解与语言关键字。

<details>
<summary>检查自己的理解</summary>

子类的 String describe() 可以重写 Object describe() 吗？

可以，前提是其他条件兼容且都是实例方法。String 属于协变返回类型。但在同一类中只增加这两个方法，不构成合法重载。

</details>

<a id="abstractions"></a>

## 抽象类、接口与默认行为

### 01 / 从一个故事开始

城市可以公布自行车租借契约，却不拥有特定自行车。彼此无关的组织都能承诺相同预约与归还操作。另一边，同一制造者可以让多个车型共用车架设计，复用部分结构并留下未完成细节。公共契约像接口，部分完成的系列设计像抽象类。两者都让调用者面向一般概念，却解决不同问题。共享契约说明可依赖什么；共享实现还可管理履行承诺所需状态。

### 02 / 理解原理

抽象类不能直接实例化，但可声明字段、构造器、具体方法和抽象方法。具体子类必须实现所有剩余抽象方法。接口描述一种能力，可由无关类实现。在 Java 8 中，接口可含抽象方法、default 实例方法、static 方法与常量。私有接口辅助方法从 Java 9 才加入，不属于本课程 Java 8 示例。接口字段隐含为 public static final，不提供每实例可变状态。类可实现多个接口，同时只继承一个类。default 方法提供继承行为，但无关接口的默认方法冲突必须明确解决。调用者只需某项能力时，优先小接口；真正的类族需要共享实现或受控构造时，可用抽象类。

### 03 / 逐项读懂公式

```text
抽象类：共享状态 + 部分实现 + 必须补齐的操作
接口：共同能力契约 + 可选默认行为
具体实现必须满足全部继承的抽象义务
```

加号连接设计职责，不表示数字相加。共享状态是抽象父类管理的实例字段。能力是调用者无需知道具体实现就能请求的操作。必须补齐的操作是尚无主体的抽象方法。default 方法是接口中的实例实现，区别于有时也被口头称作“默认访问”的包访问。分别命名可以避免同词不同义造成混淆。

### 04 / 先看一个完整例子

定义 AlertSink 接口，含 void send(String text)。ConsoleSink 打印消息，RecordingSink 保存消息供测试。提醒服务接收 AlertSink，无需了解两种实现。若多个网络接收器共用连接配置和重连逻辑，抽象 NetworkSink 可管理状态，并让 send 保持抽象。具体 EmailSink 补齐行为。若两个接口提供相同签名但互不相关的 default 描述，兼实现它们的类必须明确选择或替换。不能依靠接口书写顺序决定胜者。

### 先认识这些词

- **抽象方法:** 声明了操作，但实现需由具体子类型提供。

- **接口:** 不同类都可实现的命名能力契约。

- **默认方法:** 由接口提供、可继承的实例方法实现。

### 一步步想清楚

**抽象类构造器能运行吗？**

可以。它初始化具体子类对象的父类部分。禁止的是直接构造抽象类。

**为什么必须解决冲突默认方法？**

两个无关接口实现没有谁更具体。类必须明确选择或定义组合行为。

**接口字段是每个实例的可变数据吗？**

不是。它们隐含为 public static final。实例状态应放在实现类或抽象父类中。

<a id="lab-abstractions-java-abstract-state"></a>

### 共享构造，同时要求具体行为

矩形补全抽象图形的面积操作。

```java
public class Main {
    static abstract class Shape {
        private final String name;
        Shape(String name) {
            this.name = java.util.Objects.requireNonNull(name);
        }
        abstract int area();
        String describe() {
            return name + ": area=" + area();
        }
    }
    static final class Rectangle extends Shape {
        private final int width, height;
        Rectangle(String name, int width, int height) {
            super(name);
            if (width <= 0 || height <= 0)
                throw new IllegalArgumentException();
            this.width = width;
            this.height = height;
        }
        @Override
        int area() {
            return Math.multiplyExact(width, height);
        }
    }
    public static void main(String[] args) {
        Shape shape = new Rectangle("panel", 3, 4);
        System.out.println(shape.describe());
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
panel: area=12
```

**读懂这里的语法**

- `abstract class Shape`: 定义可共享状态与代码、但不能直接实例化的类。

- `abstract int area();`: 声明没有主体的操作，具体子类必须提供实现。

- `super(name)`: 调用父类构造器，先初始化继承部分，再继续子类主体。

**按执行顺序理解**

1. 通过抽象父类构造器初始化共同状态。

2. 保存具体状态之前验证尺寸。

3. 调用共享方法，由它委托给子类必须提供的操作。

**跟踪状态的变化**

| 步骤 | 状态或结果 |
| --- | --- |
| Shape constructor | name=panel |
| Rectangle constructor | width=3, height=4 |
| area() | 12 |

**逐步读懂代码**

Shape 拥有共同名称并实现描述，但不知道具体图形，故 area 保持抽象。Rectangle 提供尺寸与缺失操作。

继承的 describe 在同一对象上调用具体 area。multiplyExact 让溢出成为明确失败，而非悄悄改变面积。

<a id="lab-abstractions-java-interface-default-conflict"></a>

### 解决两个接口默认实现的冲突

明确组合行为，不依赖声明顺序。

```java
public class Main {
    interface Description {
        String describe();
    }
    interface Left extends Description {
        default String describe() {
            return "left";
        }
        static String category() {
            return "description provider";
        }
    }
    interface Right extends Description {
        default String describe() {
            return "right";
        }
    }
    static final class Both implements Left, Right {
        @Override
        public String describe() {
            return Left.super.describe() + "+" + Right.super.describe();
        }
    }
    public static void main(String[] args) {
        Description item = new Both();
        System.out.println(item.describe());
        System.out.println(Left.category());
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
left+right
description provider
```

**按执行顺序理解**

1. 声明一个共同能力与两个默认实现。

2. 重写冲突方法，用接口名限定两个 super 调用。

3. 通过共同接口使用对象，同时明确静态行为的归属。

**逐步读懂代码**

Both 继承同签名的两个无关默认实现，必须解决冲突。它的重写明确调用各接口默认实现。

变量只需要 Description，不需要具体类。静态辅助方法属于 Left，通过接口名调用。全部语法受 Java 8 支持。

### Leon 的进一步思考

**能力与共享状态**

小接口让无关实现服务同一调用者。抽象类能保护共同字段与构造检查。应按真实共享责任选择工具。

**本版程序保持 Java 8 兼容**

Java 8 接口支持默认与静态方法。私有接口辅助方法在后续版本才加入。本版不使用它们，即使现代语言介绍可能提及。

**注意这个边界:** 接口不只包含没有主体的方法；接口常量也不是普通实例字段。调用者只需要一种能力时，不要制造庞大抽象父类。

<details>
<summary>检查自己的理解</summary>

抽象类不能直接实例化，为什么还能有构造器？

构造器用于初始化具体子类实例中的父类部分。abstract 阻止直接构造，不阻止子类初始化时执行父类构造器。

</details>

<a id="generics"></a>

## 泛型类、类型上界与类型安全

### 01 / 从一个故事开始

可复用运输箱的标签注明接受哪类物品。同一种设计可以装书或乐器，却阻止书箱意外收到自行车。去掉标签写“任何东西”看似灵活，收货人却必须猜取出的类型。泛型类保留放入与取出之间的关系。上界还能增加有用承诺：例如每件物品都必须可称重，让箱子程序不猜具体种类也能调用称重操作。

### 02 / 理解原理

泛型用类型参数化类、接口和方法，让编译器检查输入输出关系。在 Box<T> 中，T 字段与返回 T 的 get 保留选定类型。类型实参必须是引用类型，所以使用 Integer，不是 int。T extends Number 这样的上界让泛型代码可使用 Number 操作。上界为接口时也写 extends。静态泛型方法在返回类型前声明自己的类型参数，因为它不能依赖某个实例的 T。大多数泛型信息在运行时被擦除。原始类型与未经检查的强制转换会削弱保证。new T[n] 不是通用合法的泛型数组创建方式。

### 03 / 逐项读懂公式

```text
Box<T>：put(T value) → get(): T
<T extends Number> double read(T value)
static <T> T first(List<T> values)
```

T 是类或方法某次使用时选定的引用类型占位符。第一行表示容器保留类型关系，不表示 put 立即调用 get。上界限制可选 T，也告诉编译器有哪些操作可用。静态方法声明独立 T，作用域在该方法内。first 还需要非空列表前置条件；类型安全签名不能保证元素一定存在。

### 04 / 先看一个完整例子

Box<String> 存入 "north"，无需强制转换便返回 String。尝试存入整数 4 是编译错误。Box<Integer> 复用相同实现，但返回 Integer。再写 static <T> T first(List<T> values)，拒绝空列表后返回 values.get(0)。输入 ["red", "blue"] 时，类型推断得到值为 "red" 的 String。输入 [8, 13] 时，得到值为 8 的 Integer。算法不变，因为它依赖位置与类型保留，不依赖 String 或 Integer 特有行为。

### 先认识这些词

- **类型参数:** 由泛型类或方法声明的类型占位符。

- **类型界限:** 限制哪些类型能替换类型参数的要求。

- **类型擦除:** 编译移除许多泛型类型信息，同时保留所需检查与转换。

### 一步步想清楚

**Box<String>.get 为什么不需调用者强转就返回 String？**

类型参数在编译期连接存储与返回类型，编译器为每次使用检查该关系。

**静态方法返回类型前为何声明类型参数？**

方法要声明自己的类型参数，不能把某实例特有的 T 当作共享静态方法的类型。

**类型安全保证列表有首项吗？**

不保证。元素类型与集合大小是不同条件，方法仍需检查空列表。

<a id="lab-generics-java-generic-box-first"></a>

### 通过容器与方法保留类型

同一代码安全处理无关引用类型。

```java
public class Main {
    static final class Box<T> {
        private final T value;
        Box(T value) {
            this.value = value;
        }
        T get() {
            return value;
        }
    }
    static <T> T first(java.util.List<T> values) {
        if (values.isEmpty())
            throw new IllegalArgumentException("empty list");
        return values.get(0);
    }
    public static void main(String[] args) {
        Box<String> direction = new Box<>("north");
        Box<Integer> count = new Box<>(8);
        String firstColor = first(java.util.Arrays.asList("red", "blue"));
        Integer firstNumber = first(java.util.Arrays.asList(8, 13));
        System.out.println(direction.get() + ", " + count.get());
        System.out.println(firstColor + ", " + firstNumber);
        try {
            first(java.util.Collections.<String>emptyList());
        } catch (IllegalArgumentException ex) {
            System.out.println("empty rejected");
        }
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
north, 8
red, 8
empty rejected
```

**读懂这里的语法**

- `class Box<T>`: T 命名类型参数，在类定义内一致使用。

- `static <T> T first(List<T> values)`: 方法在返回类型之前声明自己的 T，并返回相同类型的元素。

- `Box<String>`: 选择 String 作为类类型实参。类型实参使用引用类型，包括 Integer 等包装类型。

**按执行顺序理解**

1. 存储与访问使用相同 T。

2. 让各泛型方法调用推断自己的结果类型。

3. 独立于类型检查验证非空条件。

**跟踪状态的变化**

| 调用 | 所选 T | 结果 |
| --- | --- | --- |
| first([red,blue]) | String | red |
| first([8,13]) | Integer | 8 |
| first([]) | String | rejection |

**逐步读懂代码**

每次使用 Box 都选择一个 T，并通过 get 保持关系。静态 first 声明独立 T，由各输入列表推断。

空列表的元素类型有效，但没有元素。明确拒绝展示了泛型无法证明的值层面前置条件。

<a id="lab-generics-java-number-bound"></a>

### 用 Number 界限进行明确转换

从不同数值包装类型的列表计算 double 总和。

```java
public class Main {
    static <T extends Number> double sum(java.util.List<T> values) {
        double total = 0.0;
        for (T value : values)
            total += value.doubleValue();
        return total;
    }
    public static void main(String[] args) {
        System.out.println("integers=" + sum(java.util.Arrays.asList(3, 7)));
        System.out.println("decimals=" + sum(java.util.Arrays.asList(2.5, 1.25)));
        System.out.println("empty=" + sum(java.util.Collections.<Integer>emptyList()));
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
integers=10.0
decimals=3.75
empty=0.0
```

**按执行顺序理解**

1. 用类型界限声明 Number 能力。

2. 算术前明确转换每个元素。

3. 返回明确的 double 结果，不假称仍为 T。

**逐步读懂代码**

界限保证每个 T 都有 doubleValue，但不表示可直接对任意 T 使用 +。方法明确选择 double 作为结果表示。

空和保持零。当前小数值能得到所显示结果，但不是所有大整数都可被 double 精确表示。null 元素违反本方法的输入假设。

### Leon 的进一步思考

**返回类型关系比 Object 更明确**

返回 Object 会丢失本可保留的编译期信息。方法级 T 无需知道 String 或 Integer，也能连接输入与输出类型。

**界限只授予明确操作**

T extends Number 授予 doubleValue 等方法，不授予任意 T 算术。计算时应选定明确结果类型，并说明宽整数转 double 时可能丢精度。

**注意这个边界:** 不要把原始 List 当成 List<Object> 的简写。原始类型会关闭一部分泛型检查。优先用 List<T> 作为存储，不要暴露未经检查的 Object[] 到 T[] 转换。

<details>
<summary>检查自己的理解</summary>

T extends Number 允许用 + 相加两个 T 吗？

不允许。上界允许 doubleValue 等 Number 方法，却没有为任意 T 定义数值运算符。应明确选择结果表示，或传入运算行为。

</details>

<a id="wildcards"></a>

## 通配符与数据流方向

### 01 / 从一个故事开始

仓库可以把苹果箱看成某种水果箱并检查内容，却不能安全加入梨，因为实际箱子可能只允许苹果。反过来，保证能接受苹果的箱子可以再放苹果，但读取时不能保证每项都是苹果，因为它可能是一般水果箱。通配符表达这两类不完整知识。它不是装饰性的“不确定”，而是说明：当前方法不知道完整元素类型时，哪些操作仍安全。

### 02 / 理解原理

Java 泛型是不变的：List<Integer> 不是 List<Number> 子类型。否则接受 List<Number> 的代码可把 Double 插进承诺只放 Integer 的列表。List<? extends Number> 接受某个未知 Number 子类型的列表，因此能安全读为 Number，却不能安全加入指定的非 null Number。List<? super Integer> 接受未知元素类型为 Integer 或其父类型的列表，因此可安全加入 Integer；没有进一步证据时，读取只能得到 Object。PECS 表示生产者用 extends、消费者用 super。这是相对于方法的数据流。通配符视图不一定不可变，底层列表仍可能支持删除和清空。

### 03 / 逐项读懂公式

```text
生产者：List<? extends T> → 安全读取 T
消费者：List<? super T> ← 安全写入 T
复制：来源 extends T；目的地 super T
```

T 是被传递值的类型。生产者向方法提供值，消费者接收方法传出的值。? 代表某一个确定但未知的类型，不是每次操作都能重新任选类型。箭头描述允许的数据流。读写安全指静态类型安全；具体集合仍可能用 UnsupportedOperationException 拒绝修改。null 是特殊写入情况，但不是忽略一般规则的理由。

### 04 / 先看一个完整例子

source 是含 [3, 7] 的 List<Integer>；destination 是初始含 [2.5] 的 List<Number>。复制方法可声明 <T> void copy(List<? extends T> src, List<? super T> dst)。把来源元素读为 T，再加入目的地，得到 [2.5, 3, 7]。source 的 List<? extends Number> 视图不能加入 2.5，因为隐藏类型可能是 Integer。destination 的 List<? super Integer> 视图可以加入 9，但首项只能读为 Object，因为它可能是原有 Double 2.5。

```java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class WildcardDemo {
    static <T> void copy(List<? extends T> source,
                         List<? super T> destination) {
        for (T item : source) destination.add(item);
    }
    public static void main(String[] args) {
        List<Integer> source = Arrays.asList(3, 7);
        List<Number> destination = new ArrayList<>();
        destination.add(2.5);
        copy(source, destination);
        System.out.println(destination); // [2.5, 3, 7]
    }
}
```

将这个短例子另存为 WildcardDemo.java，放在单独文件夹中。运行：

```sh
javac -encoding UTF-8 WildcardDemo.java
java -ea WildcardDemo
```

### 先认识这些词

- **不变性:** 虽然元素类型有继承关系，List<Integer> 仍不是 List<Number> 子类型。

- **生产者:** 当前方法从中读取值的参数。

- **消费者:** 当前方法向其中写入值的参数。

### 一步步想清楚

**为什么 extends 视图不能接收 Double？**

隐藏元素类型可能为 Integer，加入 Double 会破坏原列表承诺。

**从 ? super Integer 能安全读成什么类型？**

Object。列表可能含先前加入的更宽类型值。写 Integer 安全，但并非所有存储值都是 Integer。

**? extends Number 禁止 clear 或 remove 吗？**

不禁止。通配符限制元素类型操作，不限制所有修改。具体列表决定支持哪些修改方法。

<a id="lab-wildcards-java-wildcard-copy"></a>

### 从生产者复制到消费者

把 Integer 值转移到也能接受 Double 的列表。

```java
import java.util.*;

public class Main {
    static <T> void copy(List<? extends T> source, List<? super T> destination) {
        for (T value : source)
            destination.add(value);
    }
    public static void main(String[] args) {
        List<Integer> source = Arrays.asList(3, 7);
        List<Number> destination = new ArrayList<>();
        destination.add(2.5);
        copy(source, destination);
        System.out.println("source=" + source);
        System.out.println("destination=" + destination);
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
source=[3, 7]
destination=[2.5, 3, 7]
```

**读懂这里的语法**

- `List<? extends T> source`: 隐藏元素类型是 T 或子类型，读为 T 安全。

- `List<? super T> destination`: 隐藏元素类型是 T 或父类型，写入 T 在类型上安全。

- `<T> void copy(...)`: 声明一个方法类型参数，把来源与目的地的数据流联系起来。

**按执行顺序理解**

1. 读取侧用 extends，写入侧用 super。

2. 先在目标放入非 Integer 值。

3. 无需未检查转换，逐项转移源值。

**跟踪状态的变化**

| 步骤 | 目标 |
| --- | --- |
| initial | [2.5] |
| copy 3 | [2.5,3] |
| copy 7 | [2.5,3,7] |

**逐步读懂代码**

源能提供可读作 T 的值，目标能安全接收 T。目标已有较宽类型值不必为 Integer。

方法只读取源，因此源不变。目标按源顺序增长。这里假设目标具体实现支持 add。

<a id="lab-wildcards-java-wildcard-mutation-limits"></a>

### 区分类型知识与可变性

通过 extends 视图移除，通过 super 视图写入。

```java
import java.util.*;

public class Main {

    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>(Arrays.asList(3, 7));
        List<? extends Number> source = numbers;
        Number first = source.get(0);
        source.remove(0);
        List<Object> mixed = new ArrayList<>();
        mixed.add("note");
        List<? super Integer> destination = mixed;
        destination.add(9);
        Object earlier = destination.get(0);
        System.out.println("read number=" + first);
        System.out.println("after removal=" + numbers);
        System.out.println("earlier=" + earlier);
        System.out.println("destination=" + mixed);
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
read number=3
after removal=[7]
earlier=note
destination=[note, 9]
```

**按执行顺序理解**

1. 通过 extends 通配符读取，不把整个列表冒充 List<Number>。

2. 通过共享视图移除元素。

3. 通过下界写 Integer，并把已有内容读作 Object。

**逐步读懂代码**

extends 视图允许读取 Number，也允许受支持的移除，但不允许加入任意 Number。两个引用到达同一列表，因此 numbers 看得到移除。

super 视图能向 List<Object> 安全加入整数九。已有 String 说明读取只能承诺 Object。这是静态类型保证，不是不可变视图。

### Leon 的进一步思考

**按当前方法描述数据方向**

copy 中，源产生 T，目标接收 T。同一列表在另一方法中可能角色不同。PECS 描述本次用途，不是对象永久类别。

**可变性属于另一个契约**

通配符签名可保证 add 类型安全，但不可修改目标仍会拒绝。类型兼容不承诺具体容器支持每种操作。

**注意这个边界:** 不要把 ? extends T 说成完全只读，或把 ? super T 说成只写。这些标签近似描述元素类型保证，不描述全部操作或底层集合可变性。

<details>
<summary>检查自己的理解</summary>

为什么 List<Object> 可传给 List<? super Integer> 参数，却不能传给 List<Integer>？

Object 是 Integer 父类型，所以保证接受 Integer 写入是安全的。不变性阻止把可能含任意 Object 的列表当成每项都是 Integer 的列表。

</details>

<a id="iteration-memory"></a>

## 迭代器、别名与可达性

### 01 / 从一个故事开始

两位参观者各拿一张美术馆路线卡。每张卡记录个人当前位置；若共享同一张卡，一个人的前进就会意外改变另一个人的进度。展馆像可迭代结构，路线卡像迭代器。再想象旧展室所有公共入口都被移除。里面物品仍可能互相指向，但从仍在使用的建筑已经无法到达。第二个比喻解释垃圾回收：即使对象互相引用，只要循环整体不可达，也可能被回收。

### 02 / 理解原理

Iterable<T> 提供迭代器，Iterator<T> 通过 hasNext 和 next 表达遍历状态。增强 for 对 Iterable 对象使用该协议，数组另有语言支持。独立迭代器应保存独立游标。耗尽后 next 必须抛 NoSuchElementException，不能返回编造值。遍历期间的结构修改取决于集合契约。常见快速失败迭代器可抛 ConcurrentModificationException，但这不是同步策略。引用也决定对象可达性。垃圾回收可回收从活动根不再可达的对象，包括不可达循环。只把一个变量设为 null 不够，其他路径可能仍存在。回收时机不确定，也不能代替外部资源的明确清理。

### 03 / 逐项读懂公式

```text
Iterable<T>.iterator() → 独立遍历状态
hasNext() 报告是否还有元素；next() 返回元素并推进
可回收候选：活动根到对象之间没有强引用路径
```

T 是元素类型。独立遍历状态指需要独立遍历时使用独立游标，不表示复制整个集合。活动根包括活动执行上下文与静态引用；强路径由普通可达链接组成。最后一行只概念性描述资格，不是立即删除指令。不可达循环中的对象即使被内部引用，也可能没有活动根路径。看似不再使用、却仍被静态集合保存的对象，仍可能可达。

### 04 / 先看一个完整例子

列表为 ["oak", "pine", "elm"]。迭代器 a 读取 "oak"，下一步面向 "pine"。独立创建的 b 第一次 next 仍返回 "oak"。把 a 推进到结束，不应改变 b 的游标。若两个迭代器与列表都不可达，且没有其他引用，列表及其私有节点便可能可回收。若静态缓存仍保存列表，它就仍可达。通过受支持的迭代器操作删除元素，与遍历期间任意修改同一列表，是不同情况。

### 先认识这些词

- **迭代器:** 保存一次遍历游标与操作的对象。

- **可达性:** 是否仍有活跃引用链到达对象。

- **根:** 可达性搜索开始处的执行引用或静态引用。

### 一步步想清楚

**两个迭代器为何第一次都返回 oak？**

每个迭代器有自己的游标。共享列表不等于共享遍历位置。

**遍历中为什么使用 iterator.remove？**

它是迭代器支持的删除最后返回元素方式。随意通过列表改结构可能使遍历假设失效。

**把一个变量设为 null 能证明垃圾回收发生吗？**

不能。其他强引用路径可能仍在。即使没有路径，也只表示有资格回收，不承诺立即回收。

<a id="lab-iteration-memory-java-independent-iterators"></a>

### 为每次遍历提供独立游标

耗尽一个迭代器，而不推进另一个。

```java
import java.util.*;

public class Main {

    public static void main(String[] args) {
        List<String> trees = Arrays.asList("oak", "pine", "elm");
        Iterator<String> a = trees.iterator(), b = trees.iterator();
        System.out.println("a=" + a.next());
        System.out.println("a=" + a.next());
        System.out.println("b=" + b.next());
        System.out.println("a=" + a.next());
        System.out.println("a has next=" + a.hasNext());
        try {
            a.next();
        } catch (NoSuchElementException ex) {
            System.out.println("a exhausted");
        }
        System.out.println("b=" + b.next());
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
a=oak
a=pine
b=oak
a=elm
a has next=false
a exhausted
b=pine
```

**读懂这里的语法**

- `Iterator<String> a = values.iterator()`: 向可迭代集合索取保存独立遍历位置的迭代器。

- `a.hasNext()`: 检查是否还有元素，不消费下一项。

- `a.next()`: 返回下一元素并推进游标，耗尽时抛异常。

**按执行顺序理解**

1. 创建两个迭代器，而不是让两个变量指向同一个。

2. 交错调用 next，观察独立位置。

3. 检查耗尽，并处理刻意进行的无效 next 调用。

**跟踪状态的变化**

| 调用后 | a 下一索引 | b 下一索引 |
| --- | --- | --- |
| create | 0 | 0 |
| a.next twice | 2 | 0 |
| b.next | 2 | 1 |
| a.next | 3 | 1 |

**逐步读懂代码**

两次 iterator 调用在同一列表上创建独立遍历状态。a 已到末尾时，b 仍在开头附近。

耗尽后调用 next 会抛规定异常。它不能编造默认元素，否则无法区分结束与真实数据。

<a id="lab-iteration-memory-java-iterator-remove-alias"></a>

### 安全删除并观察可达性

原变量清空后，第二个别名仍存在。

```java
import java.util.*;

public class Main {

    public static void main(String[] args) {
        List<Integer> values = new ArrayList<>(Arrays.asList(1, 2, 3, 4));
        List<Integer> alias = values;
        Iterator<Integer> iterator = values.iterator();
        while (iterator.hasNext()) {
            if (iterator.next() % 2 != 0)
                iterator.remove();
        }
        values = null;
        System.out.println("remaining through alias=" + alias);
        System.out.println("original variable null=" + (values == null));
        System.out.println("alias size=" + alias.size());
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
remaining through alias=[2, 4]
original variable null=true
alias size=2
```

**按执行顺序理解**

1. 刻意创建指向同一可变列表的别名。

2. 每次 next 后，通过迭代器删除奇数项。

3. 清空一个引用，再观察另一个仍可访问。

**逐步读懂代码**

next 前进并返回一个元素，remove 按迭代器支持的协议删除刚返回元素，最后仅保留偶数。

把 values 设为 null 不改变 alias。打印列表展示仍存活的强引用路径，不对其他对象何时回收作任何声称。

### Leon 的进一步思考

**不要把垃圾回收时点当作测试要求**

确定性的所有权示例可以展示另一个别名仍可达对象，不应等待 System.gc，也不应声称 Java 未保证的回收期限。

**快速失败不是同步**

ConcurrentModificationException 能暴露无效遍历模式，但只是尽力而为的诊断，不能替代锁、快照或明确并发容器契约。

**注意这个边界:** 不要让所有迭代器共用存于集合中的一个游标。不要假设 System.gc 强制立即清理，也不要认为垃圾回收会在正确时间关闭每个文件。

<details>
<summary>检查自己的理解</summary>

两个节点互相引用，但没有活动根能到达它们，这个循环必须永远保留吗？

不必。基于可达性的回收可以回收该循环。内部引用不会自动建立从活动根出发的路径。

</details>

<a id="exceptions"></a>

## 把异常作为明确的失败路径

### 01 / 从一个故事开始

配送流程有普通路线，也有清楚标出的故障路线。地址无法辨认时，需要有能力纠正的人作决定。假装包裹已经送达，会破坏记录。分拣设备损坏又不同于缺少门牌号。异常让方法报告正常承诺无法完成，并把控制交给合适处理者。比喻也提醒我们：无论走哪条路线，装货门仍要关好，临时设备仍要释放。

### 02 / 理解原理

抛出异常会离开当前正常路径，向外寻找匹配 catch，必要时展开调用栈。受检查异常必须被捕获，或用 throws 声明。RuntimeException 与 Error 的子类不受检查。这是编译规则，不是严重程度的完美排序。应选择能解释契约违反或失败原因的异常，在能够恢复或转换含义的边界捕获。throws 声明并没有处理失败。较具体 catch 要写在较宽泛 catch 前。finally 在普通及异常退出时执行；受管理资源优先使用 try-with-resources。异常不全是调用者违反前置条件：输入合法，输入输出操作也可能失败。

### 03 / 逐项读懂公式

```text
正常返回 ⇒ 文档规定的结果与后置条件
throw E ⇒ 向外寻找兼容 catch
受检查 E ⇒ 捕获 E，或声明合适的 throws 类型
```

E 表示携带失败信息的异常类型及实例。兼容 catch 可以处理 E 或其父类型。后置条件描述正常返回后的承诺。异常退出可有单独状态保证，例如对象保持不变。蕴含不表示所有程序自动履行契约，而是实现和测试必须验证的设计责任，包括失败后保留什么状态。

### 04 / 先看一个完整例子

包裹柜容量 6，目前放着 5 件。add(2) 先检查剩余容量，放不下时抛 IllegalArgumentException。因为先验证，捕获异常后仍为 5 件。另一个情况是从不存在的文件加载柜子数据：即使路径语法正确，也可能抛 IOException。命令行边界可报告文件缺失，请使用者选择不同路径。若毫无说明返回空清单，就会把操作失败错误伪装成有效领域数据。

### 先认识这些词

- **受检查异常:** 编译器要求调用者捕获或声明的异常。

- **调用栈展开:** 寻找匹配处理器时退出活动方法调用的过程。

- **异常保证:** 操作失败时状态仍必须满足的保证。

### 一步步想清楚

**throws 会执行恢复吗？**

不会。它声明允许传播。合适边界上的 catch 决定恢复、报告或再次抛出。

**为什么相加前先验证？**

这样拒绝操作可保持旧状态不变，成为调用者可依赖的明确失败契约。

**异常路径为什么也要关闭资源？**

失败不会消除所有权责任。资源型 try 在离开块时安排 close。

<a id="lab-exceptions-java-exception-state-guarantee"></a>

### 修改状态之前拒绝

调用者捕获失败后仍可检查对象。

```java
public class Main {
    static final class Store {
        private final int capacity;
        private int size;
        Store(int capacity, int size) {
            if (capacity < 0 || size < 0 || size > capacity)
                throw new IllegalArgumentException("invalid state");
            this.capacity = capacity;
            this.size = size;
        }
        void add(int amount) {
            if (amount < 0 || amount > capacity - size)
                throw new IllegalArgumentException("capacity exceeded");
            size += amount;
        }
        int size() {
            return size;
        }
    }
    public static void main(String[] args) {
        Store store = new Store(6, 5);
        try {
            store.add(2);
        } catch (IllegalArgumentException ex) {
            System.out.println("rejected: " + ex.getMessage());
        }
        System.out.println("after failure=" + store.size());
        store.add(1);
        System.out.println("after success=" + store.size());
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
rejected: capacity exceeded
after failure=5
after success=6
```

**读懂这里的语法**

- `try { ... } catch (IllegalArgumentException ex) { ... }`: 运行正常代码块，匹配失败转交 catch，处理器可以查看 ex。

- `throw new IllegalArgumentException("capacity")`: 报告请求被拒绝，在修改状态前离开正常路径。

- `amount > capacity - count`: 加法前与剩余容量比较，在不变式成立时避免和溢出。

**按执行顺序理解**

1. 在构造器建立有效初态。

2. 请求超过剩余容量时在修改前抛出。

3. 检查状态未变，再执行成功调用。

**跟踪状态的变化**

| 操作 | 数量 |
| --- | --- |
| initial | 5 |
| add(2) throws | 5 |
| add(1) | 6 |

**逐步读懂代码**

失败 add 在写 size 前退出。捕获改变控制流，但无需撤销部分更新。之后合法请求仍可成功。

异常消息解释边界失败。示例捕获预期具体类型，而不隐藏所有可能的编程缺陷。

<a id="lab-exceptions-java-resource-close-order"></a>

### 观察异常传播时的清理

小型自有资源报告关闭顺序，不操作文件。

```java
public class Main {
    static final class Resource implements AutoCloseable {
        private final String name;
        Resource(String name) {
            this.name = name;
            System.out.println("open=" + name);
        }
        void use() {
            System.out.println("use=" + name);
        }
        @Override
        public void close() {
            System.out.println("close=" + name);
        }
    }
    public static void main(String[] args) {
        try (Resource first = new Resource("first");
             Resource second = new Resource("second")) {
            first.use();
            second.use();
            throw new IllegalStateException("work failed");
        } catch (IllegalStateException ex) {
            System.out.println("caught=" + ex.getMessage());
        }
        System.out.println("continued");
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
open=first
open=second
use=first
use=second
close=second
close=first
caught=work failed
continued
```

**按执行顺序理解**

1. 在同一 try 头部获取两个本地 AutoCloseable 资源。

2. 使用资源，再刻意从主体抛异常。

3. 观察进入外层处理器与继续执行前的逆序清理。

**逐步读懂代码**

资源从左到右打开，循环体抛异常后按相反顺序关闭。两次 close 后才进入 catch，体现嵌套所有权。

示例的 close 不会失败。真实清理失败可能产生受抑制异常，应检查它们，不要误以为原始失败消失。

### Leon 的进一步思考

**不要把失败变成看似可信的数据**

错误记录后返回零可能与真实零无法区分。应保留失败信号，或使用明确表示失败的结果类型。

**清理与恢复不同**

关闭资源释放所有权，却不会让失败操作成功。应在能作有效决定处捕获，资源清理则留给本地所有者。

**注意这个边界:** 避免 catch(Exception e) 后放空主体。这会隐藏原因，也可能留下部分修改的状态。避免从 finally 返回，它可能压制异常或先前结果。

<details>
<summary>检查自己的理解</summary>

方法声明 throws IOException，是否已从文件失败中恢复？

没有。它只声明失败可能向上传播。调用者或更高边界仍需决定恢复、报告、转换或继续传播。

</details>

<a id="enums-state"></a>

## 枚举与有限状态模型

### 01 / 从一个故事开始

包裹可以等待、运输中或已送达。用便签随意写状态，容易拼错或创造“差不多算送到”等标签。预先印好的正式标记可以收窄词汇。但词汇正确不保证流程合理：从已送达直接回到等待，仍可能违反流程。enum 提供有限标签集合，转移规则控制标签之间如何移动。同时看清两者，才能避免把类型安全状态名误当完整正确的状态机。

### 02 / 理解原理

enum 定义该枚举类型中固定的一组命名实例。它在编译时阻止混用无关标签，也可包含字段、方法和构造器。每个常量甚至可提供专门行为。枚举实例可用 == 比较，因为每个声明常量对应唯一实例。优先使用有意义的方法或明确转移表，避免分散的字符串比较。状态机结合当前状态、事件和转移规则，决定下一状态或拒绝事件。枚举让状态空间明确，却不会自动约束所有转移。不要把 ordinal 持久保存为长期标识，因为常量重排会改变编号。名称或明确稳定代码通常更清楚。

### 03 / 逐项读懂公式

```text
δ(state, event) = 下一状态或拒绝
δ(QUEUED, dispatch) = IN_TRANSIT
δ(IN_TRANSIT, confirm) = DELIVERED
δ(DELIVERED, dispatch) = 拒绝
```

δ 是转移函数。state 属于枚举定义的有限集合，event 是应用接收的动作。拒绝表示该状态事件对在本模型中没有允许转移。模型必须说明如何报告拒绝，以及是否保留状态。这些规则描述所选包裹流程，不是普遍领域规则。枚举常量只是状态值，不是保存包裹身份等数据的完整对象。

### 04 / 先看一个完整例子

包裹 P7 从 QUEUED 开始。dispatch 使其进入 IN_TRANSIT。第二次 dispatch 被拒绝，因为已离开队列，状态保持 IN_TRANSIT。confirm 再使其进入 DELIVERED。后来的 dispatch 再次被拒绝。测试要检查三个允许状态，以及契约中重要的所有不支持状态事件组合。若加入 RETURNED，应重新检查转移模型与测试。仅增加枚举常量，不会自动告诉现有操作退回包裹应如何行动。

### 先认识这些词

- **枚举常量:** 枚举定义的固定集合中的一个命名实例。

- **状态转换:** 把当前状态和事件变为下一状态或拒绝的规则。

- **稳定编码:** 不依赖声明位置的明确外部标识。

### 一步步想清楚

**枚举能阻止所有无效转换吗？**

不能。它只限制状态名称。必须由独立操作拒绝不支持的状态事件组合。

**枚举值为何可用 == 比较？**

每个声明常量都是固定实例，身份就代表这个枚举值。

**为什么避免持久保存 ordinal？**

ordinal 是声明位置。插入或重排常量会改变它，显式编码则可保持格式契约。

<a id="lab-enums-state-java-explicit-state-transitions"></a>

### 接受与拒绝流程事件

只有获得合法下一状态后才赋值。

```java
public class Main {
    enum State { QUEUED, RUNNING, DONE }
    static State next(State state, String event) {
        if (state == State.QUEUED && event.equals("start"))
            return State.RUNNING;
        if (state == State.RUNNING && event.equals("finish"))
            return State.DONE;
        throw new IllegalStateException("invalid transition");
    }
    public static void main(String[] args) {
        State state = State.QUEUED;
        for (String event : new String[] {"start", "start", "finish", "start"}) {
            try {
                state = next(state, event);
                System.out.println(event + " -> " + state);
            } catch (IllegalStateException ex) {
                System.out.println(event + " rejected at " + state);
            }
        }
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
start -> RUNNING
start rejected at RUNNING
finish -> DONE
start rejected at DONE
```

**读懂这里的语法**

- `enum State { QUEUED, RUNNING, DONE }`: 声明有限类型的三个命名实例，逗号分隔常量。

- `state == State.QUEUED`: 与唯一命名枚举实例比较身份，这适合枚举值。

- `return State.RUNNING`: 返回建议下一状态，由调用者明确决定是否保存。

**按执行顺序理解**

1. 从命名枚举状态开始。

2. 把每个事件交给明确的转换函数。

3. 捕获拒绝事件并显示保留状态。

**跟踪状态的变化**

| 事件 | 之前 | 之后 |
| --- | --- | --- |
| start | QUEUED | RUNNING |
| start | RUNNING | RUNNING |
| finish | RUNNING | DONE |

**逐步读懂代码**

next 是无修改的转换函数。只有正常返回才发生赋值，所以重复 start 不会破坏当前状态。

事件使用字符串值相等，状态使用枚举身份相等。这个模型刻意只允许两个转换，不是普遍流程规则。

<a id="lab-enums-state-java-enum-stable-codes"></a>

### 解码明确的枚举编码

区分持久编码与常量 ordinal。

```java
public class Main {
    enum Mode {
        IDLE(10),
        ACTIVE(20),
        STOPPED(30);
        private final int code;
        Mode(int code) {
            this.code = code;
        }
        int code() {
            return code;
        }
        static Mode fromCode(int code) {
            for (Mode mode : values())
                if (mode.code == code)
                    return mode;
            throw new IllegalArgumentException("unknown code");
        }
    }
    public static void main(String[] args) {
        for (Mode mode : Mode.values())
            System.out.println(mode + ": code=" + mode.code());
        System.out.println("decoded=" + Mode.fromCode(20));
        try {
            Mode.fromCode(99);
        } catch (IllegalArgumentException ex) {
            System.out.println("unknown code rejected");
        }
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
IDLE: code=10
ACTIVE: code=20
STOPPED: code=30
decoded=ACTIVE
unknown code rejected
```

**按执行顺序理解**

1. 在枚举构造中分配稳定编码。

2. 按明确字段匹配解码。

3. 拒绝未知编码，不把它当作 ordinal。

**逐步读懂代码**

每个常量保存明确外部编码。fromCode 搜索编码，而不是数组位置。重排声明会改变 values 遍历顺序，却不会改变这些映射。

未知编码明确失败。大型枚举可使用带重复编码检查的映射，这里的小型线性搜索使契约清楚可见。

### Leon 的进一步思考

**拒绝转换应保持状态**

赋值前先计算或验证下一状态。被拒事件就不会改变当前状态，简化调用与转换测试。

**增加状态时重审整个模型**

新枚举标签不会自动说明如何进入或离开。应一致地更新转换规则、显示、解析与相关测试。

**注意这个边界:** 不要把 enum ordinal 用作永久领域标识。不要以为 enum 会自动防止非法转移，应由领域操作强制执行。

<details>
<summary>检查自己的理解</summary>

为什么 enum 用 == 比较合适，而任意 String 内容通常不能用 ==？

每个枚举常量都是唯一固定实例，因此身份代表该命名值。不同 String 对象可能包含相同文本，内容比较需要 equals。

</details>

<a id="testing-contracts"></a>

## 断言、测试与有用边界

### 01 / 从一个故事开始

电梯检查员不会只在安静时从二楼坐到三楼。他们检查底层、顶层、空载、容量上限和非法请求，因为边界会暴露普通行程隐藏的错误。软件测试同理。维修面板中的警示灯像语言断言：适合检测内部假设，却不适合作为阻止乘客选择不可能目的地的唯一保护。无论诊断断言是否开启，公开输入验证都必须在正常运行时有效。

### 02 / 理解原理

测试把观察行为与期望结果比较。根据契约选择正常情况、边界、非法输入及相关状态转移。黑盒测试关注公开行为；白盒推理可找出未测试分支，但不应让测试依赖每个实现细节。Java assert 可以被关闭，适合内部一致性检查，却不能作为公开输入唯一验证。JUnit 等测试框架的断言在测试中执行，不依赖 Java assert 开关。每个测试都应清晰且确定，既检查正常返回，也检查异常行为。通过测试会提升已覆盖情况的信心，却不能证明没有其他缺陷。

### 03 / 逐项读懂公式

```text
准备 Arrange → 执行 Act → 验证 Assert
合法整数范围 1…m 的边界集合：{0, 1, m, m+1}
期望结果包含值、状态变化与异常行为
```

Arrange 建立初态，Act 调用待测行为，Assert 将观察与契约比较。m 是简单连续整数范围的最大合法输入。边界集合采样两侧边界及紧邻非法值；若逻辑还有分支，仍需其他值。期望行为可包含拒绝后的状态不变。覆盖率统计执行到哪里，不衡量期望判断的质量。

### 04 / 先看一个完整例子

预约对象有 8 个位置，当前占用 3 个。测试 reserve(2)，期望变为 5。在独立初态中测试 reserve(5)，期望为 8。测试 reserve(6)，应拒绝并保持 3。也测试负请求，并依据契约决定零是否合法。这覆盖一般成功、恰好满额、超容量与非法符号。若随意复用已修改对象，第二个测试会依赖第一个，因此每个案例都应建立自己的初态。

### 先认识这些词

- **测试初始环境:** 单个测试案例准备的对象与输入。

- **边界案例:** 位于规则接受边界上或附近的输入。

- **回归测试:** 保留用于发现已理解缺陷再次出现的测试。

### 一步步想清楚

**为什么每个预留测试都新建对象？**

否则一个测试的修改会改变下一个初态。独立环境让每项预期结果可解释。

**公共输入验证为何不能只靠 Java assert？**

assert 可以关闭。普通条件与异常才能始终落实输入契约。

**分支覆盖能证明预期值正确吗？**

不能。它记录的是执行。测试仍需把有意义观察与独立推导的预期比较。

<a id="lab-testing-contracts-java-boundary-test-harness"></a>

### 无需测试依赖运行边界测试

本地 check 始终执行，不受 Java assert 开关影响。

```java
public class Main {
    static final class Seats {
        private int reserved = 3;
        void reserve(int amount) {
            if (amount < 0 || amount > 8 - reserved)
                throw new IllegalArgumentException();
            reserved += amount;
        }
    }
    static void check(boolean condition) {
        if (!condition)
            throw new AssertionError("contract failed");
    }
    static void testSuccess(int amount, int expected) {
        Seats seats = new Seats();
        seats.reserve(amount);
        check(seats.reserved == expected);
    }
    static void testReject(int amount) {
        Seats seats = new Seats();
        boolean rejected = false;
        try {
            seats.reserve(amount);
        } catch (IllegalArgumentException ex) {
            rejected = true;
        }
        check(rejected);
        check(seats.reserved == 3);
    }
    public static void main(String[] args) {
        testSuccess(2, 5);
        testSuccess(5, 8);
        testReject(6);
        testReject(-1);
        System.out.println("four contract cases passed");
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
four contract cases passed
```

**读懂这里的语法**

- `new Seats(8, 3)`: 创建容量八、初始占用三的全新测试对象。

- `if (!condition) throw new AssertionError(message)`: 使用始终启用的检查。! 取布尔反值，这不是可关闭的 assert 语句。

- `catch (IllegalArgumentException expected)`: 捕获测试要求的失败，变量名 expected 表达其作用。

**按执行顺序理解**

1. 在每个测试辅助方法内安排新环境。

2. 分别执行普通、精确边界或无效请求。

3. 检查结果值与拒绝后的状态保证。

**跟踪状态的变化**

| 请求 | 预期结果 |
| --- | --- |
| 2 | reserved=5 |
| 5 | reserved=8 |
| 6 | reject, reserved=3 |
| -1 | reject, reserved=3 |

**逐步读懂代码**

每个辅助函数新建初始预留数为三的对象。成功案例检查普通请求与恰好满容量。拒绝案例同时检查异常和状态不变。

预期失败时，测试框架抛 AssertionError。这是原创小型测试程序，不是 Java assert 语句，也不替代完整测试框架的所有功能。

<a id="lab-testing-contracts-java-average-regression-check"></a>

### 保留有针对性的算术回归测试

说明小数预期为何能识别整数除法。

```java
public class Main {
    static double average(int[] values) {
        if (values.length == 0)
            throw new IllegalArgumentException("empty input");
        long sum = 0;
        for (int value : values)
            sum += value;
        return (double)sum / values.length;
    }
    public static void main(String[] args) {
        int[] values = {3, 6};
        double actual = average(values);
        if (actual != 4.5)
            throw new AssertionError("expected 4.5");
        boolean rejected = false;
        try {
            average(new int[0]);
        } catch (IllegalArgumentException ex) {
            rejected = true;
        }
        if (!rejected)
            throw new AssertionError("empty input must fail");
        System.out.println("fractional average=" + actual);
        System.out.println("empty input rejected=" + rejected);
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
fractional average=4.5
empty input rejected=true
```

**按执行顺序理解**

1. 选择正确答案不是整数的案例。

2. 独立于实现的中间算术检查返回值。

3. 独立测试空输入拒绝。

**逐步读懂代码**

期望 4.5 来自独立计算 (3+6)/2。除法前转换 sum 保留小数。这里的二分之一可精确表示，所以直接相等可用，但不是所有浮点测试的通则。

空输入检查另一契约分支。方法假设数组非 null，并用 long 扩宽累加。

### Leon 的进一步思考

**也测试拒绝后的保证**

异常类型可能正确，但对象已经被错误修改。因此捕获预期失败后，还要检查承诺保留的状态。

**使用能区分对错的数值案例**

[2,4] 的均值是整数，因此整数除法缺陷可能通过。[3,6] 期望 4.5，用极小且易懂的案例暴露错误。

**注意这个边界:** 仅重复实现算式的测试，可能重复同一个错误。应根据契约、简单独立例子或已知不变式选择期望。

<details>
<summary>检查自己的理解</summary>

为什么 assert amount > 0 不足以验证外部提供的移除数量？

Java 断言可能关闭。程序必须用普通条件语句与合适异常或错误响应，可靠约束公开输入。

</details>

<a id="recursion-memoization"></a>

## 递归、调用树与结果复用

### 01 / 从一个故事开始

嵌套文件夹可以这样计数：询问各子文件夹大小，再加上自身的一。每次请求处理更小部分，空文件夹停止下探。若多个路径又请求同一昂贵计算，文员可以第一次记下答案，以后复用。但记录必须对应相同输入与假设。这两幅图区分递归与记忆化。递归把问题拆成更小调用；记忆化避免重复等价工作。

### 02 / 理解原理

递归方法需要基本情况，以及不断接近基本情况的递归调用。每个活动调用都有自己的局部变量，并增加调用栈深度。正确性可这样论证：假设较小实例正确，再说明它们的结果如何组成当前答案。递归遍历对象结构，还需处理循环与重复引用的规则；任意对象图不自动是一棵树。记忆化按决定结果的输入缓存答案。重复子问题多时有用，但消耗内存；相关状态改变而未失效缓存，会产生错误。即使数学算法终止，深递归仍可能使 Java 栈溢出。深度可能很大时，应考虑迭代替代。

### 03 / 逐项读懂公式

```text
ways(0) = 1；ways(1) = 1
ways(n) = ways(n−1) + ways(n−2)，n ≥ 2
此递推的缓存状态数 n+1；时间 O(n)，存储 O(n)
```

ways(n) 统计用一步或两步恰好走完 n 阶的移动序列，n 为非负整数。零阶有一种空序列。时间界假设每个缓存状态只算一次，且算术成本为常数；只有结果仍放得进所选定宽类型时，该算术假设才适用。递归记忆化最坏还需要 O(n) 调用栈。大规模精确答案需要更宽或任意精度类型，会改变算术成本模型。

### 04 / 先看一个完整例子

四阶楼梯中，ways(2) = 1 + 1 = 2，ways(3) = 2 + 1 = 3，ways(4) = 3 + 2 = 5。五种模式为 1111、112、121、211、22。直接递归会在多个分支重算 ways(2)。使用缓存时，ways(0) 到 ways(4) 各算一次并复用。负 n 应被拒绝，或明确设计另一契约。n 大到让 int 溢出时，即使递归逻辑正确，数值答案仍会错误，除非改变表示。

### 先认识这些词

- **基本情况:** 无需继续递归即可回答的情况。

- **记忆化:** 按决定结果的输入缓存计算结果。

- **调用深度:** 同时活动的嵌套调用数量。

### 一步步想清楚

**ways(0) 为什么为一？**

覆盖零阶有一种空移动序列。这个基本情况让递推正确统计组合。

**记忆化消除了什么？**

消除同一状态的重复计算，不一定缩短最长依赖链，也不一定避免深栈失败。

**缓存键为什么必须包含允许步长规则？**

允许移动规则改变后，同一 n 的答案可能改变。只按 n 缓存仅在规则固定时有效。

<a id="lab-recursion-memoization-java-memoized-staircase"></a>

### 每个台阶状态只缓存一次

统计不同状态的计算次数，再重复同一查询。

```java
import java.util.*;

public class Main {
    static long ways(int n, Map<Integer, Long> memo, int[] computed) {
        if (n < 0 || n > 91)
            throw new IllegalArgumentException("supported range 0..91");
        Long cached = memo.get(n);
        if (cached != null)
            return cached;
        computed[0]++;
        long result = n < 2 ? 1L
                            : Math.addExact(ways(n - 1, memo, computed),
                                            ways(n - 2, memo, computed));
        memo.put(n, result);
        return result;
    }
    public static void main(String[] args) {
        Map<Integer, Long> memo = new HashMap<>();
        int[] computed = {0};
        System.out.println("ways(4)=" + ways(4, memo, computed));
        System.out.println("computed states=" + computed[0]);
        System.out.println("repeat=" + ways(4, memo, computed));
        System.out.println("computed after repeat=" + computed[0]);
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
ways(4)=5
computed states=5
repeat=5
computed after repeat=5
```

**读懂这里的语法**

- `Map<Integer, Long> memo`: 将每个输入状态关联到已算结果，包装类型可作泛型实参。

- `cached != null`: 在本缓存表示中，区分已存在结果与缺失映射。

- `Math.addExact(left, right)`: 相加 long 值，溢出时抛异常，而不默默回绕。

**按执行顺序理解**

1. 递归前检查输入契约与缓存。

2. 组合较小状态并缓存结果。

3. 重复查询，验证复用而非重复计算。

**跟踪状态的变化**

| 状态 | 结果 |
| --- | --- |
| 0 | 1 |
| 1 | 1 |
| 2 | 2 |
| 3 | 3 |
| 4 | 5 |

**逐步读懂代码**

计数之前先查缓存。每个缺失状态计算后存入答案，包括两个基本情况。首次查询到达零至四，共五个不同状态。

重复查询直接返回缓存，不增加次数。明确范围保证答案可放入 long；受检查加法仍能暴露意外溢出。

<a id="lab-recursion-memoization-java-iterative-big-staircase"></a>

### 用迭代精确计数替代深递归

用 BigInteger 与两个滚动状态处理较大输入。

```java
public class Main {
    static java.math.BigInteger ways(int n) {
        if (n < 0 || n > 1000)
            throw new IllegalArgumentException("demo range 0..1000");
        java.math.BigInteger previous = java.math.BigInteger.ONE;
        java.math.BigInteger current = java.math.BigInteger.ONE;
        for (int i = 2; i <= n; i++) {
            java.math.BigInteger next = previous.add(current);
            previous = current;
            current = next;
        }
        return current;
    }
    public static void main(String[] args) {
        System.out.println("ways(0)=" + ways(0));
        System.out.println("ways(4)=" + ways(4));
        System.out.println("ways(100)=" + ways(100));
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
ways(0)=1
ways(4)=5
ways(100)=573147844013817084101
```

**按执行顺序理解**

1. 把两个基本递推值初始化为一。

2. 先算 next，再移动滚动状态对。

3. 打印小型边界检查与一个较大精确结果。

**逐步读懂代码**

每次循环前，previous 与 current 保存前两个递推值。临时 next 避免二者用完前覆盖旧值。程序不再递归。

BigInteger 在超过 long 范围后仍保持精确。只保留两个递推值，但其存储随位数增长。明确演示上限保证执行有界。

### Leon 的进一步思考

**优化前先数子问题**

该递推只需要零到 n 的状态。缓存让每个状态只算一次，而迭代递推还可只保留前两个值。

**数值大小会改变复杂度**

BigInteger 避免定宽溢出，但数值增大后加法更贵。递推步数可以是线性，每步位运算成本却不是常数。

**注意这个边界:** 缓存键必须包含影响结果的全部输入。若可配置规则改变允许步长，仅按 n 缓存就不正确。

<details>
<summary>检查自己的理解</summary>

为什么记忆化不自动解决 StackOverflowError？

它减少重复计算，却可能不改变最长递归调用链。迭代动态规划可以避免深调用链。

</details>

<a id="lambdas"></a>

## 匿名类、lambda 与捕获上下文

### 01 / 从一个故事开始

咖啡馆需要一条小规则，决定哪些订单收到提醒。每条规则都请一位有名字的专家未必必要，传入一张短说明往往更清楚。但说明仍需已知格式：接收什么输入，返回什么答案。lambda 填充这样的单操作契约。匿名类更像临时员工，有自己的身份，也可能有额外内部状态。二者都避免独立命名类，却不会产生完全相同的作用域与身份行为。

### 02 / 理解原理

匿名类表达式创建一个在当前位置声明的类的实例，扩展一个类或实现一个接口。lambda 为函数式接口提供实现；完整判断需考虑继承及 Object 方法规则，其抽象方法契约只对应一个函数操作。default 和 static 方法本身不增加抽象义务。参数及返回类型必须匹配目标接口。lambda 只能捕获 final 或事实上的 final 局部变量。捕获引用不会冻结所指对象。lambda 中 this 指外围实例，匿名类中 this 指匿名类实例。若行为复杂到局部难以阅读，应使用命名方法或命名类。

### 03 / 逐项读懂公式

```text
Predicate<T>：T → boolean
Function<T,R>：T → R
示例：length -> length >= minimum
捕获的局部变量必须为 final 或事实上的 final
```

T 是输入引用类型，R 是结果类型。箭头描述函数形状的契约，不承诺实现纯净或没有副作用。minimum 是外围方法中的局部变量，捕获后不能重新赋值。代码块 lambda 若从语句返回值，需要明确 return。接口决定目标类型；lambda 不是脱离类型独立存在的通用函数。

### 04 / 先看一个完整例子

提醒系统使用 Predicate<Integer> overdue = days -> days >= 3。对 1、3、5 测试，依次得到 false、true、true。若阈值来自 int minimum = 3，只要此局部变量以后不重新赋值，lambda 就能捕获它。final List<String> log 也能捕获；log.add 仍能修改列表，因为 final 约束引用，不约束内容。若要有意改变规则，用新阈值构造新谓词。除非外围设计明确需要，不要把不断变化的领域状态隐藏在捕获的可变对象里。

### 先认识这些词

- **函数式接口:** 只有一个抽象方法、为 lambda 提供目标类型的接口。

- **捕获:** 在 lambda 内使用外围作用域的值或引用。

- **事实上的 final:** 初始化后不再重新赋值的局部变量。

### 一步步想清楚

**lambda 会冻结捕获对象吗？**

不会。被捕获的局部引用不能重新赋值，但所指对象仍可变化。

**lambda 主体何时执行？**

调用函数式方法时执行，不是仅在创建 lambda 时执行。

**lambda 内的 this 表示什么？**

沿用外围实例上下文的含义；匿名类则引入自己的实例。

<a id="lab-lambdas-java-lambda-capture"></a>

### 捕获阈值与可变日志

区分固定局部引用与被冻结的对象。

```java
public class Main {

    public static void main(String[] args) {
        int threshold = 3;
        java.util.function.Predicate<String> longEnough = s -> s.length() >= threshold;
        java.util.List<String> log = new java.util.ArrayList<>();
        Runnable report = () -> System.out.println("log size=" + log.size());
        System.out.println("oak accepted=" + longEnough.test("oak"));
        System.out.println("ox accepted=" + longEnough.test("ox"));
        report.run();
        log.add("visited");
        report.run();
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
oak accepted=true
ox accepted=false
log size=0
log size=1
```

**读懂这里的语法**

- `s -> s.length() >= threshold`: 命名参数 s，箭头后的表达式提供布尔结果。

- `Predicate<String>`: 使用标准函数式接口，其 test 接收 String 并返回 boolean。

- `() -> System.out.println(...)`: 空括号表示没有参数。只有调用函数式方法时主体才执行。

**按执行顺序理解**

1. 用未重新赋值的阈值创建谓词。

2. 用两个明确输入调用 test。

3. 修改捕获的列表，再次调用同一个 report。

**跟踪状态的变化**

| 时刻 | 列表大小 |
| --- | --- |
| 添加前 | 0 |
| 添加后 | 1 |

**逐步读懂代码**

threshold 局部变量没有重新赋值，因此可以捕获。调用 test 才提供待检查的 String 输入。仅创建函数不会检查任何字符串。

report 捕获 log 引用。两次调用访问同一个列表，而列表内容在期间改变。事实上的 final 限制局部赋值，不会让列表不可变。

<a id="lab-lambdas-java-lambda-this"></a>

### 观察 this 命名哪个对象

从同一个外围实例运行 lambda 和匿名 Runnable。

```java
public class Main {
    static final class Presenter {
        private final String name = "outer";
        void show() {
            Runnable lambda = () -> System.out.println("lambda=" + this.name);
            Runnable anonymous = new Runnable() {
                private final String name = "inner";
                public void run() {
                    System.out.println("anonymous=" + this.name);
                    System.out.println("enclosing=" + Presenter.this.name);
                }
            };
            lambda.run();
            anonymous.run();
        }
    }
    public static void main(String[] args) {
        new Presenter().show();
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
lambda=outer
anonymous=inner
enclosing=outer
```

**按执行顺序理解**

1. 创建外围 Presenter 实例。

2. 运行 lambda，在外围上下文解析 this。

3. 运行匿名对象，区分它自己的字段与外围字段。

**逐步读懂代码**

lambda 使用 Presenter.show 的词法上下文，因此 this.name 找到 Presenter 字段。匿名对象有自己的 name 字段和自己的 this。

Presenter.this 在匿名类中明确选取外围实例。转换回调写法时要注意：语法缩短可能改变未限定 this 的所指。

### Leon 的进一步思考

**行为跨越边界时明确数据**

捕获可变集合，会让后续行为依赖后续状态。延迟操作若必须表达创建时刻，应传入不可变快照。

**简短主体也有契约**

Predicate 通常应回答问题而不产生意外修改。筛选谓词的副作用使复用、测试和并行执行更难。

**注意这个边界:** lambda 不只是更短的匿名类。二者 this 绑定不同；lambda 需要函数式接口目标，不能以任意抽象类为目标。

<details>
<summary>检查自己的理解</summary>

含一个抽象方法和两个 default 方法的接口可以作 lambda 目标吗？

可以，前提是满足完整函数式接口规则。default 方法已有实现，不增加必须实现的抽象操作。

</details>

<a id="debugging-documentation"></a>

## 依据证据调试，记录明确承诺

### 01 / 从一个故事开始

修车师傅调查自行车每转一圈响一次的故障。一次更换全部零件可能消除声音，却很难理解原因。师傅应先复现，观察从哪里开始，提出狭窄解释，再检验。调试也需要这种纪律。文档像维修卡，说明自行车应做什么、在什么条件下工作。可复现观察与明确契约结合，才能区分实现缺陷和对预期行为的误解。

### 02 / 理解原理

先区分编译错误、运行时异常与错误结果。用最小但有用的输入复现，再在适当边界比较期望与实际。调试器可在断点暂停，查看局部变量和调用栈，单步进入或跨过方法，找出最早偏差。编译时保留调试信息可改善检查，具体命令取决于工具。修复原因，并在适当时保留聚焦回归测试。Javadoc 使用描述与 @param、@return、@throws 等标签记录公开契约。应说明假设、副作用、单位和异常行为。仅复述方法名字，不如精确说明调用者可以依赖什么。

### 03 / 逐项读懂公式

```text
复现 → 观察 → 假设 → 定向检查 → 修正
契约 = 接受的输入 + 承诺的输出 + 副作用 + 失败行为
```

箭头表示调查循环。假设失败，就回到观察。接受输入包含范围、null 处理和单位。输出承诺在相关时包含顺序或状态保证。副作用说明修改及外部输入输出。失败行为说明异常，以及失败后仍成立什么。这些是概念类别，不是数学方程。文档与测试应遵循同一契约，让失败测试具有可解释含义。

### 04 / 先看一个完整例子

计算平均时长的方法对 [3, 6] 返回 4.0，期望却是 4.5。只用这两个值复现。在断点查看 sum = 9、count = 2，二者都正确。单步到除法，发现 9 / 2 先按 int 求值，再赋给 double。精确修复是在除法前转换一个操作数。增加非整数平均值测试，并记录空输入行为。仅测试 [2, 4] 会漏掉故障，因为整数除法恰好得到期望整数。

### 先认识这些词

- **复现用例:** 能可靠暴露故障的小型输入与程序。

- **假设:** 能够预测可观察结果的具体解释。

- **前置条件:** 调用方法时必须成立的要求。

### 一步步想清楚

**为什么改公式前要查看操作数类型？**

纸面公式可能正确，但语言可能在不合适的数值类型中执行中间运算。

**文档应该承诺什么？**

承诺可观察行为：接受的输入、返回含义、状态影响与预期失败。不要承诺偶然的实现细节。

**打印语句是证明吗？**

它是一次执行的证据。用它检验假设，再保留聚焦的回归检查。

<a id="lab-debugging-documentation-java-debug-intermediate-types"></a>

### 定位第一个错误中间值

比较同一算术中两个不同转换位置。

```java
public class Main {

    public static void main(String[] args) {
        int sum = 9, count = 2;
        int quotient = sum / count;
        double late = (double)quotient;
        double early = (double)sum / count;
        System.out.println("integer quotient=" + quotient);
        System.out.println("late conversion=" + late);
        System.out.println("early conversion=" + early);
        if (early != 4.5)
            throw new AssertionError("fraction lost");
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
integer quotient=4
late conversion=4.0
early conversion=4.5
```

**读懂这里的语法**

- `(double) quotient`: 转换已经算出的整数，丢失的小数信息无法回来。

- `(double) sum / count`: 除法前转换一个操作数，使除法使用浮点运算。

- `if (early != 4.5)`: 与独立期望结果检查不相等。这里的二分之一可精确表示。

**按执行顺序理解**

1. 写下独立已知的数学答案。

2. 检查整数除法后的即时值。

3. 把转换移到除法之前并保留回归检查。

**跟踪状态的变化**

| 表达式 | 类型 | 值 |
| --- | --- | --- |
| sum/count | int | 4 |
| (double)(sum/count) | double | 4.0 |
| (double)sum/count | double | 4.5 |

**逐步读懂代码**

先写出已知答案 9/2 = 4.5。整数商已经是 4，所以信息在转换前丢失。转换结果无法找回被舍弃的小数。

先转换一个操作数，会使除法采用浮点运算。这个微型测试把类型错误与输入输出代码分开，并保留对该精确可表示结果的检查。

<a id="lab-debugging-documentation-java-documented-distance-contract"></a>

### 把文档转为可执行检查

记录一个小方法的契约，检查接受与拒绝的输入。

```java
public class Main {
    /**
     * Returns the nonnegative distance between two nonnegative positions.
     * @throws IllegalArgumentException if either position is negative
     */
    static long distance(int left, int right) {
        if (left < 0 || right < 0)
            throw new IllegalArgumentException("negative position");
        return Math.abs((long)left - right);
    }
    public static void main(String[] args) {
        System.out.println("distance=" + distance(2, 9));
        try {
            distance(-1, 9);
        } catch (IllegalArgumentException ex) {
            System.out.println("rejected negative position");
        }
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
distance=7
rejected negative position
```

**按执行顺序理解**

1. 先读契约再预测输出。

2. 检查有效位置对及返回距离。

3. 检查文档规定的负数边界拒绝。

**逐步读懂代码**

注释描述接受范围、返回含义与拒绝条件，没有规定局部变量名或分支顺序。

检查先执行，保证算术输入符合范围。扩大左操作数让减法使用 long，因此日后扩展范围时也不会发生 int 减法溢出。范围改变仍需要新测试与新文档。

### Leon 的进一步思考

**一次减少一个变量**

小型复现用例把算术与文件输入、图形及无关状态分开。若故障消失，恢复最后移除的条件并研究其作用。

**让注释解释原因**

有用注释记录检查或转换为什么必要。逐句复述代码只增加维护工作，无法解释契约。

**注意这个边界:** 检验假设前，不要同时改变多个无关条件。不要文档要求非 null，却在一条路径默默接受 null，在另一条路径不可预测崩溃。

<details>
<summary>检查自己的理解</summary>

为什么仅在错误输出前设断点，有时太晚而无法理解缺陷？

错误状态可能早已产生。向前追踪不变式首次变假的位置，再观察造成第一次偏差的操作。

</details>

<a id="application-design"></a>

## 从对象模型到小型事件驱动应用

### 01 / 从一个故事开始

社区温室有传感器、控制策略和墙上显示屏。若显示屏自己决定全部浇水规则，修改画面可能意外影响植物照料。更好的职责分配是：模型存当前条件，策略更新状态，显示屏展示快照。按钮动作转换成模型请求。这也便于画图：方框表示职责，连线表示协作。图只有帮助解释行为时才有价值，单纯列出全部字段和方法，却不说明部件为什么组合，作用有限。

### 02 / 理解原理

小应用也需要明确职责。领域状态与规则留在模型对象中；输入解析和事件处理放在边界；显示层读取状态，不拥有无关领域决定。UML 类图可概括字段、操作、可见性与关系。泛化箭头表示继承，实现关系表示实现接口，关联表示协作。组合表示较强所有权，不是每个引用字段都算组合。包组织名称及可访问性。构建工具自动编译、解析依赖并执行可重复检查。事件驱动程序中，处理器把事件转成模型操作，显示过程读取结果。将逻辑更新与显示时机分开，可在不开图形窗口时测试相同行为。

### 03 / 逐项读懂公式

```text
事件 → 已验证命令 → 模型转移 → 显示状态
命令后状态 = update(先前状态, 命令)
设计检查：组件职责清楚；协作明确
```

事件是按钮点击等原始交互。已验证命令是输入经过检查的领域请求。update 表示模型转移逻辑，可以修改对象，也可返回新状态，只要契约明确。render 把状态转换为视图，不应偷偷推进无关领域规则。职责原则是设计启发，不是数值定律，也不要求每个类只能有一个方法。

### 04 / 先看一个完整例子

设计温室面板：Bed 模型存湿度，WateringPolicy 决定是否允许浇水，Dashboard 显示当前值。按钮事件为某个 Bed 请求 addWater(2)。处理器确定目标，模型验证数量并更新湿度，显示从新状态刷新。在以湿度单位计量的简化模型中，单元测试从 4 开始，加 2，检查 6，全程不开窗口。类图显示 Dashboard 关联 Bed 和策略接口，具体策略实现该接口。同样协作可先通过无界面命令接口练习，再添加图形视图。

### 先认识这些词

- **模型:** 表示应用领域数据与规则的部分。

- **事件边界:** 外部动作转换为经过验证的领域请求的位置。

- **依赖注入:** 把协作对象传入对象，而不是在内部固定创建它。

### 一步步想清楚

**为什么把规则放在按钮回调之外？**

同一规则可在不开窗口时测试与复用。回调只负责把事件转换为请求。

**谁决定是否允许浇水？**

应由模型或明确策略强制执行规则。只禁用按钮无法约束其他调用者。

**分离层次一定需要很多类吗？**

不一定。先区分职责与方法契约；当所有权或可替换行为需要时再增加类。

**为什么先计算结果，再压入历史？**

操作可能拒绝输入。若先修改历史，撤销时可能取回完全相同的快照，看起来像没反应。应先校验，再提交状态变化。

<a id="lab-application-design-java-headless-event-flow"></a>

### 追踪命令流经小型应用

把输入转换、领域状态与显示拆成独立方法。

```java
public class Main {
    static final class Bed {
        private int moisture;
        Bed(int initial) {
            if (initial < 0 || initial > 10)
                throw new IllegalArgumentException("range");
            moisture = initial;
        }
        void water(int amount) {
            if (amount < 0 || amount > 10 - moisture)
                throw new IllegalArgumentException("range");
            moisture += amount;
        }
    }
    static String handle(Bed bed, String command) {
        String[] parts = command.split(" ");
        if (parts.length != 2 || !parts[0].equals("water"))
            return "unknown command";
        try {
            bed.water(Integer.parseInt(parts[1]));
            return "accepted";
        } catch (IllegalArgumentException ex) {
            return "rejected";
        }
    }
    static String render(Bed bed) {
        return "moisture=" + bed.moisture;
    }
    public static void main(String[] args) {
        Bed bed = new Bed(4);
        for (String command : new String[] {"water 2", "water 8", "water many"}) {
            System.out.println(command + " -> " + handle(bed, command));
            System.out.println(render(bed));
        }
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
water 2 -> accepted
moisture=6
water 8 -> rejected
moisture=6
water many -> rejected
moisture=6
```

**读懂这里的语法**

- `String[] parts = command.split(" ")`: 按单个空格把这个刻意简化的命令语法拆成字段数组。

- `Integer.parseInt(parts[1])`: 把数字文本字段转成 int，拒绝格式错误或超范围文本。

- `return "moisture=" + bed.moisture`: 用拼接构建显示字符串。读取字段不会推进模型。

**按执行顺序理解**

1. 把每条命令转换为一个经过验证的模型请求。

2. 由模型强制执行容量规则。

3. 成功与失败后都显示状态，验证状态是否保留。

**跟踪状态的变化**

| 请求 | 结果 | 湿度 |
| --- | --- | --- |
| water 2 | 接受 | 6 |
| water 8 | 拒绝 | 6 |
| water many | 拒绝 | 6 |

**逐步读懂代码**

命令处理器识别一个小语法并转换数字参数。Bed 拥有范围规则，验证后才改变状态。render 读取结果而不修改状态。

过量请求与非法数字通过同一个用户边界被拒绝，并让湿度保持六。这个无界面序列无需图形工具包即可检查协作。

<a id="lab-application-design-java-injected-watering-policy"></a>

### 替换策略而不重写控制器

传入目标规则行为，并独立执行模型边界检查。

```java
public class Main {
    static int apply(int current, java.util.function.IntUnaryOperator policy) {
        if (current < 0 || current > 10)
            throw new IllegalArgumentException("initial range");
        int amount = policy.applyAsInt(current);
        if (amount < 0 || amount > 10 - current)
            throw new IllegalArgumentException("amount range");
        return current + amount;
    }
    public static void main(String[] args) {
        java.util.function.IntUnaryOperator gentle =
            current -> Math.max(0, 6 - current);
        java.util.function.IntUnaryOperator dryDay =
            current -> Math.max(0, 8 - current);
        System.out.println("gentle=" + apply(4, gentle));
        System.out.println("dry day=" + apply(4, dryDay));
        try {
            apply(4, current -> 20);
        } catch (IllegalArgumentException ex) {
            System.out.println("unsafe policy rejected");
        }
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
gentle=6
dry day=8
unsafe policy rejected
```

**按执行顺序理解**

1. 把每个目标规则表示为 IntUnaryOperator。

2. 把选中的策略传给同一个应用函数。

3. 拒绝会违反模型边界的策略结果。

**逐步读懂代码**

两种策略实现同一契约：查看当前湿度并建议添加量。共用 apply 方法询问所选策略，在计算新状态前验证建议。

注入的协作对象也可能出错。最后的策略故意建议二十单位，独立范围检查会拒绝它。策略灵活性不能替代不变式约束。

<a id="lab-application-design-undoable-notebook"></a>

### 做一个可以撤销的小编辑器

米娜为校园展览编写标签。她追加一个词，发现不合适，想点击撤销。只保存最新文本就找不回上一步。她把文档、编辑动作分开，让编辑器用栈保存旧快照，同时规定被拒绝的动作不能进入历史。这样还没有制作按钮，就能测试编辑器。

```java
import java.util.ArrayDeque;
import java.util.Deque;
public class Main {
    interface Edit { String apply(String text); }
    static final class Append implements Edit {
        private final String suffix;
        Append(String suffix) { this.suffix = suffix; }
        @Override public String apply(String text) {
            if (suffix == null || suffix.isEmpty())
                throw new IllegalArgumentException("empty edit");
            return text + suffix;
        }
    }
    static final class Editor {
        private String text = "";
        private final Deque<String> history = new ArrayDeque<>();
        void execute(Edit edit) {
            String next = edit.apply(text);
            if (next == null) throw new IllegalArgumentException("null result");
            history.push(text);
            text = next;
        }
        boolean undo() {
            if (history.isEmpty()) return false;
            text = history.pop();
            return true;
        }
        String text() { return text; }
    }
    public static void main(String[] args) {
        Editor editor = new Editor();
        assert !editor.undo();
        editor.execute(new Append("Hello"));
        editor.execute(new Append(" Python"));
        System.out.println(editor.text());
        try { editor.execute(new Append("")); }
        catch (IllegalArgumentException error) { System.out.println("rejected without history"); }
        if (!editor.undo()) throw new AssertionError("missing history");
        assert editor.text().equals("Hello");
        System.out.println("undo: " + editor.text());
    }
}
```

**在本地运行**

```sh
javac -encoding UTF-8 Main.java
java -ea Main
```

**预期输出**

```text
Hello Python
rejected without history
undo: Hello
```

**读懂这里的语法**

- `interface Edit`: 声明编辑器需要的行为。

- `Deque<String>`: 双端队列；本例把它用作快照栈。

- `throw new IllegalArgumentException`: 在提交新状态之前拒绝无效操作。

**按执行顺序理解**

1. Edit 描述操作，Append 实现一种操作；Editor 无须知道新文本如何生成。

2. 先计算并校验 next，再修改历史和正文。普通校验失败不会改变二者。

3. push 记录旧文本，pop 取回最近快照，体现后进先出。

4. 测试空历史撤销、两次成功操作、一次失败操作，再撤销。失败操作不能占用撤销步骤。

**逐步读懂代码**

状态转移是 text_next = edit(text_current)，历史保存之前的 text_current。String 不可变，因此保存引用就能保留旧快照，无须在每次入栈时复制字符。不过生成拼接后的新字符串仍需要与长度相关的时间和空间。大量长快照会占用较多内存。

分四步构建：定义有效文档，加入一种操作，加入历史，最后连接界面。按钮应调用 execute，而不是再维护另一份正文。这样测试不依赖屏幕事件。

这里约定 Edit 只计算文本，不产生外部副作用。撤销字符串无法撤回邮件或文件写入。这个小编辑器没有重做、持久化或并发编辑。文档很大时，可以先测量需求，再考虑保存逆操作或使用持久化文本结构。

### Leon 的进一步思考

**无界面测试缩短反馈循环**

直接测试模型可使故障快速复现。另用小型集成测试检查事件转换与显示是否正确连接。

**先注入策略，再考虑到处加分支**

不同环境若需要不同浇水规则，应把变化集中表示成策略。即使策略推荐某动作，模型仍要检查不变式。

**注意这个边界:** 避免一个应用类同时负责解析、存储、绘图与全部领域规则，也不要增加没有实际澄清职责的层次。

<details>
<summary>检查自己的理解</summary>

显示代码只读取模型、不拥有领域规则，会让什么更容易？

规则可独立于界面测试和复用。命令行视图或另一图形视图能调用相同模型操作，无需复制逻辑。

</details>

## 继续探索

- [Oracle Java Tutorials: Learning the Java Language](https://docs.oracle.com/javase/tutorial/java/index.html)

- [Oracle Java Tutorials: Overriding and Hiding Methods](https://docs.oracle.com/javase/tutorial/java/IandI/override.html)

- [Java SE 21 Object API: equals and hashCode contracts](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Object.html)

- [Oracle Java Tutorials: Guidelines for Wildcard Use](https://docs.oracle.com/javase/tutorial/java/generics/wildcardGuidelines.html)

- [Java SE 21 Language Specification: Classes and Interfaces](https://docs.oracle.com/javase/specs/jls/se21/html/index.html)

- [JUnit 4 official documentation](https://junit.org/junit4/)

- [Java SE 21 DataOutput API: primitive binary formats](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/io/DataOutput.html)

- [Java SE 8 Language Specification: Types, Values, and Variables](https://docs.oracle.com/javase/specs/jls/se8/html/jls-4.html)

- [Java SE 8 Language Specification: Expressions, method invocation and lambdas](https://docs.oracle.com/javase/specs/jls/se8/html/jls-15.html)

- [Java SE 8 Object API: equality, hashing and shallow copying](https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html)

- [Java SE 8 DataOutput API: primitive binary encoding](https://docs.oracle.com/javase/8/docs/api/java/io/DataOutput.html)


---
Leon
