LWLeon.
LEON / INFO1113

Java 面向对象编程

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

23 个主题 · 中英双语 · 49 份可运行代码

LEON / LEARNING MAP

先看学习路线

按箭头顺序学习。点击一个阶段进入第一节;完整知识框架列出了每一个主题。

  1. 01

    追踪程序执行

    先理解表达式、分支和引用,再设计较大的对象。

    5 个主题 ↗
  2. 02

    建立有契约的对象

    明确状态归谁所有,让每个操作遵守文档规定的契约。

    6 个主题 ↗
  3. 03

    安全组织数据

    选择能表达预期数据流的容器与类型关系。

    5 个主题 ↗
  4. 04

    处理变化与失败

    输入、操作或资源访问失败时,仍保留有效状态。

    4 个主题 ↗
  5. 05

    连接与检查应用

    把可复用行为组合成应用,再测试和解释其工作方式。

    3 个主题 ↗
LEON / FROM IDEAS TO IMPLEMENTATION

继续深入:完整案例

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

  1. 01

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

    沿构造器和 getter 跟踪数据所有权。

    故事 · 代码 · 推理
    先读对应章节: 类、构造器与受保护状态
  2. 02

    用值对象作为 Map 的键

    区分引用身份、值相等与哈希冲突。

    故事 · 代码 · 推理
    先读对应章节: 身份、值相等与哈希契约
  3. 03

    做一个可以撤销的小编辑器

    把对象模型变成可测试的小功能。

    故事 · 代码 · 推理
    先读对应章节: 从对象模型到小型事件驱动应用
阅读字号
下载全部 Python、Java 和 C 示例 ↓
第一次运行这些示例?

JDK 是 Java Development Kit,即 Java 开发工具包。使用 JDK 8 或更新版本。它包含 javac 编译器和 java 启动命令。JVM 是 Java 虚拟机,负责执行编译后的字节码。

每个可下载示例都是独立的 Main.java。将它保存在单独文件夹中,保持文件名为 Main.java。在这个文件夹中打开终端,再输入下方两行命令。终端是输入命令、查看文字结果的窗口。

javac 把源代码编译成 .class 文件;java 启动程序。-encoding UTF-8 指定源文件编码;-ea 启用 assert 检查。把结果与页面的预期输出比较。

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

Java 官方入门与安装指引 ↗

开始学习之前

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

开始前需要了解

  • 基本编程知识:变量、条件与函数
  • 愿意手工跟踪短程序的执行过程
  • 用于实践的 Java 开发工具包

你将学到什么

  • 准确追踪值、引用与方法调用
  • 设计能保护自身不变式的类
  • 有意识地选择接口、继承与组合
  • 安全使用集合和泛型,识别隐藏假设
  • 解释异常行为并测试有意义的边界
  • 阅读并调试不熟悉的小型 Java 应用

把知识连接起来

按阶段循序渐进。也可以点击任一主题,阅读故事、方法与例子。

INFO1113 / 01Leon

从源代码到有类型的值

01 / 从一个故事开始

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

02 / 理解原理

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

先认识这些词

基本类型值
直接表示数值或布尔值,而非对象引用。
强制类型转换
显式请求转换数值,可能丢失信息。
溢出
数学结果超出类型可表示的范围。

03 / 逐项读懂公式

源代码 → 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,说明编译成功,而数值表达式需要修改。

一步步想清楚

  1. 为什么把整数除法结果赋给 double 不能恢复小数?

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

  2. 为什么 50000 乘 50000 要使用 long?

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

  3. 能解析成功是否代表所有整数都可接受?

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

运行一个完整示例 · 01

观察转换发生的时点

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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);
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 / 23
(double) 7 / 23.5
(long) 50000 * 500002500000000

逐步读懂代码

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

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

运行一个完整示例 · 02

分开解析与有效范围

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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");
            }
        }
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 能拒绝格式错误或超范围文本。另一个范围检查落实任务含义。区分两类失败有助于提供有效错误信息。

注意这个边界. 不要声称所有基本类型值都在栈上,也不要把编译成功当作正确性证明。存储取决于上下文与实现;类型正确只是正确性的一层。
检查自己的理解: (double) (9 / 4) 得到什么?怎样得到 2.25?

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

↑ 返回知识框架
INFO1113 / 02Leon

条件、循环与不变式

01 / 从一个故事开始

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

02 / 理解原理

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

先认识这些词

循环不变式
每次完整迭代前后都成立的事实。
短路求值
第一个布尔操作数已决定结果时,不求第二个。
进展度量
朝终止方向变化、且有界的量。

03 / 逐项读懂公式

第 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 并失败。若数组为空,原循环不执行任何一轮,正确返回零。

一步步想清楚

  1. 为什么总和从零开始?

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

  2. 什么保证循环结束?

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

  3. 为什么 text != null 要放在 text.length() 前?

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

运行一个完整示例 · 01

追踪已处理前缀

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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);
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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. 相加后打印,再在空输入上运行同样的累加思路。

跟踪状态的变化
已处理项总和
00
14
24
311

逐步读懂代码

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

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

Leon 的进一步思考

区分安全与进展

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

空情况属于设计的一部分

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

注意这个边界. 对零起始数组使用 <= length 会多访问一格。把 null 检查写在方法调用之后,也无法发挥短路保护作用。
检查自己的理解: 为什么循环能保持不变式,却仍不结束?

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

↑ 返回知识框架
INFO1113 / 03Leon

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

01 / 从一个故事开始

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

02 / 理解原理

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

先认识这些词

实参
调用方法时提供的值。
形参
用实参值的副本初始化的新变量。
别名
指向同一对象的另一个引用。

03 / 逐项读懂公式

形参值 ← 实参值的副本
调用者 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 为按引用传递。

将这个短例子另存为 ReferenceDemo.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]
    }
}
javac -encoding UTF-8 ReferenceDemo.java
java -ea ReferenceDemo

一步步想清楚

  1. 哪种赋值效果可跨越方法边界?

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

  2. 调用者怎样采用新数组?

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

  3. 为什么递增 Integer 不改变调用者变量?

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

运行一个完整示例 · 01

比较修改与重新赋值

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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));
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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]。方法内与返回后分别打印,能展示两种状态,而不把引用误当成可见数值地址。

运行一个完整示例 · 02

明确返回替代引用

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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));
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 按值传递引用值。对象修改可以跨越方法边界;形参重新赋值不能。
检查自己的理解: 方法对 Integer 形参执行 x++,调用者的 Integer 变量会改变吗?

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

↑ 返回知识框架
INFO1113 / 04Leon

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

01 / 从一个故事开始

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

02 / 理解原理

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

先认识这些词

不规则数组
各行长度不必相同的数组。
浅复制
新外层容器仍共享其引用的内部对象。
元素类型
数组允许存入的元素类型。

03 / 逐项读懂公式

有效下标: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 行。若行内存放可变对象,还要进一步决定是否复制这些对象。

一步步想清楚

  1. {{2,5},{9}} 有几个单元?

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

  2. outer.clone 会隔离行内元素吗?

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

  3. 为什么 Object[] 别名不能向 String[] 写任意值?

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

运行一个完整示例 · 01

追踪浅复制与逐行复制

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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));
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 行,一般复制器需要检查。

运行一个完整示例 · 02

处理 null 行与受检查的存储

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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]);
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 会递归复制整个对象图。
检查自己的理解: 为什么 Object[] values = new String[2] 能编译,而 values[0] = 5 在运行时失败?

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

↑ 返回知识框架
INFO1113 / 05Leon

文本值、相等性与构建器

01 / 从一个故事开始

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

02 / 理解原理

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

先认识这些词

不可变
对象构造后,其逻辑值不能改变。
UTF-16 编码单元
Java 字符串索引使用的一个 16 位单元。
可变字符串构建器
创建 String 前用于组合文本的可变缓冲区。

03 / 逐项读懂公式

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" 时,从空构建器开始。仅当编号大于一时追加分隔符,再追加标签。最后转换一次,得到没有开头逗号的完整文本。

一步步想清楚

  1. 两个新字符串为何 equals 真而 == 假?

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

  2. 忽略 toUpperCase 的返回值会怎样?

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

  3. 一个可见符号能占两个 char 吗?

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

运行一个完整示例 · 01

区分文本值与对象身份

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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);
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 的值
initialharbor
ignored conversionharbor
assigned conversionHARBOR

逐步读懂代码

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

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

运行一个完整示例 · 02

构建标签并统计 Unicode 单位

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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()));
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 本身可变,不应随意共享。
检查自己的理解: 为什么 s.replace("a", "b") 看起来没有效果?

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

↑ 返回知识框架
INFO1113 / 06Leon

类、构造器与受保护状态

01 / 从一个故事开始

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

02 / 理解原理

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

先认识这些词

对象不变式
每个可用对象状态都必须满足的规则。
构造器
建立新实例初始状态的代码。
防御性复制
为防止外部别名改变受保护状态而创建的副本。

03 / 逐项读懂公式

不变式: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 只会把形参赋给自身,字段仍停留在默认值。

一步步想清楚

  1. 为什么改变 level 前先检查?

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

  2. 为什么比较 q 与 capacity−level?

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

  3. final int[] 代表元素不可变吗?

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

  4. 为什么 private final 数组仍然不够?

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

运行一个完整示例 · 01

保护容量不变式

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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());
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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

读懂这里的语法

private int level
在类访问边界内保存每对象可变状态。private 阻止外部直接访问。
this.capacity = capacity
左侧是当前接收者字段,右侧是构造器参数。
throw new IllegalArgumentException(...)
输入违反方法契约时,创建并抛出异常,跳过后续正常语句。

按执行顺序理解

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

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

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

跟踪状态的变化
请求a.levelb.level
construct40
fill(5)90
fill(4) rejected90

逐步读懂代码

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

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

运行一个完整示例 · 02

保护内部数组

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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()));
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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

按执行顺序理解

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

  2. 暴露值时返回副本。

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

逐步读懂代码

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

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

运行一个完整示例 · 03

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

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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;
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 直接暴露可变列表。返回防御性副本,或提供合适的不可修改视图,并明确说明共享关系。
检查自己的理解: final List 字段还能添加元素吗?

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

↑ 返回知识框架
INFO1113 / 07Leon

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

01 / 从一个故事开始

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

02 / 理解原理

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

先认识这些词

词法项
按读取器分隔规则划分的一个输入单位。
行边界
逻辑输入行的结尾,其处理方式与数字词法项不同。
资源所有权
使用后负责关闭资源的责任。

03 / 逐项读懂公式

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

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

04 / 先看一个完整例子

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

一步步想清楚

  1. nextLine 为什么返回空的剩余内容?

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

  2. 为什么区分解析错误与负计数?

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

  3. 这里为什么读取字符串而非外部文件?

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

运行一个完整示例 · 01

观察数字项之后的行

全部输入来自内存。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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);
        }
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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。这个程序刻意展示数字项与行混读;一致的逐行格式通常能避免该边界问题。

运行一个完整示例 · 02

验证完整设备记录

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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);
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 包装器则可能使用进程共享输入,其关闭责任需要由调用设计决定。

注意这个边界. 不要把所有输入错误当作文件结束,也不要默默代入默认值。要区分缺失、格式损坏,以及格式正确但违反领域规则的数据。
检查自己的理解: nextInt 读到 12 后,为什么 nextLine 可能返回空字符串?

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

↑ 返回知识框架
INFO1113 / 08Leon

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

01 / 从一个故事开始

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

02 / 理解原理

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

先认识这些词

二进制布局
存储字段约定的顺序、宽度与表示方式。
字节序
高位与低位字节写出的顺序。
截断记录
所需字段字节尚未完整到达就结束的记录。

03 / 逐项读懂公式

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 不完整,必须触发读取失败。先在内存中测试往返,再引入文件系统行为,并独立测试缺失和截断文件。

将这个短例子另存为 BinaryDemo.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
        }
    }
}
javac -encoding UTF-8 BinaryDemo.java
java -ea BinaryDemo

一步步想清楚

  1. 传感器记录为何是十二字节?

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

  2. 机器本地字节序会改变 writeInt 吗?

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

  3. 缺失字节能当作零吗?

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

运行一个完整示例 · 01

写入再读取传感器记录

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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));
        }
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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. 读取匹配类型并确认无剩余字节。

跟踪状态的变化
字段起始偏移字节数
stationId04
temperature48
end120

逐步读懂代码

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

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

运行一个完整示例 · 02

检查精确字节并拒绝截断

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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");
        }
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 文本解析与二进制基本类型布局混用,也不要假设所有写入方法采用一种通用字符编码。
检查自己的理解: 一个记录包含两个 int 和一个 double,在文件头之前共需多少字节?

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

↑ 返回知识框架
INFO1113 / 09Leon

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

01 / 从一个故事开始

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

02 / 理解原理

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

先认识这些词

列表
可重复包含相等元素的有序序列。
集合
每种相等值最多保留一个成员的容器。
映射
每个键对应一个当前关联值的结构。

03 / 逐项读懂公式

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 合适。若主要任务是反复从队列两端添加和删除,应考虑队列抽象及适合该访问方式的实现。

一步步想清楚

  1. 三次到达为何只有两个集合成员?

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

  2. 替换映射值会增加键吗?

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

  3. 队列为何适合使用 Deque?

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

运行一个完整示例 · 01

表示事件、唯一值与计数

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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());
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 保存增加后的计数。以后写入相等键只是替换值,不产生新逻辑键。

运行一个完整示例 · 02

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

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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());
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 的遍历顺序当作有序流程表。
检查自己的理解: 哪种集合适合保存用户名及最近登录时间?同名插入两次会怎样?

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

↑ 返回知识框架
INFO1113 / 10Leon

身份、值相等与哈希契约

01 / 从一个故事开始

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

02 / 理解原理

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

先认识这些词

对象身份
两个引用是否表示完全同一个对象。
值相等
类定义的规则,判断两个对象是否表示相同逻辑值。
哈希冲突
不相等值拥有相同哈希码,这是合法情况。

03 / 逐项读懂公式

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 可在编译时暴露错误签名。

将这个短例子另存为 EqualityDemo.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
    }
}
javac -encoding UTF-8 EqualityDemo.java
java -ea EqualityDemo

一步步想清楚

  1. 为何同时重写 equals 与 hashCode?

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

  2. 哈希相同就表示值相等吗?

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

  3. 为什么把键字段设为 final?

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

  4. 发生哈希冲突,就一定会丢失一条说明吗?

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

运行一个完整示例 · 01

用不可变座位作为值键

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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());
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 one1
B7 instance two1
B82

逐步读懂代码

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

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

运行一个完整示例 · 03

用值对象作为 Map 的键

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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());
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 推断相等。键仍在哈希集合中时,不要修改参与相等判断的字段。先移除、修改、重新插入,或直接设计不可变键。
检查自己的理解: 两个不相等对象合法地返回哈希值 42,可以吗?

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

↑ 返回知识框架
INFO1113 / 11Leon

继承、组合与可替换性

01 / 从一个故事开始

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

02 / 理解原理

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

先认识这些词

子类型
保持父类型行为承诺、可通过父类型使用的类型。
组合
对象持有协作者,并把工作委托给它。
可替换性
用子类型替换实现后,调用者的有效预期仍成立。

03 / 逐项读懂公式

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

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

04 / 先看一个完整例子

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

一步步想清楚

  1. 为什么批量报告器持有 Sink,而不是本身作为 Sink?

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

  2. 子类构造器为什么调用 super?

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

  3. 子类型能拒绝父类型承诺接受的输入吗?

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

运行一个完整示例 · 01

注入记录型协作者

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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);
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

[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。记录型实现让本地测试看到顺序。

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

运行一个完整示例 · 02

保持父类契约

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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());
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

samples=5
negative rejected
count=5

按执行顺序理解

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

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

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

逐步读懂代码

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

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

Leon 的进一步思考

替换协作者以测试边界

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

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

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

注意这个边界. 不要只为方便访问代码使用继承。拥有关系、不稳定实现依赖或更严格输入限制,通常需要组合或更合适的抽象。
检查自己的理解: 只读集合为什么很难作为“保证 add 成功”的契约的子类型?

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

↑ 返回知识框架
INFO1113 / 12Leon

重载选择签名,重写选择行为

01 / 从一个故事开始

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

02 / 理解原理

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

先认识这些词

重载
方法同名,但参数列表不同。
重写
子类型为继承的实例方法提供实现。
接收者
实例方法被调用时作用的对象。

03 / 逐项读懂公式

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 控制重载选择。这样可把签名选择与运行时接收者分派分别观察。

将这个短例子另存为 DispatchDemo.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
    }
}
javac -encoding UTF-8 DispatchDemo.java
java -ea DispatchDemo

一步步想清楚

  1. 为什么 Object 变量选择 show(Object)?

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

  2. 之后什么选择 FancyPrinter 的实现?

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

  3. 只改返回类型能建立重载吗?

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

运行一个完整示例 · 01

追踪先重载、后重写

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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"));
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 做运行时检查或转换。两个输出区分编译器选择与运行时接收者行为。

运行一个完整示例 · 02

区分类方法与实例方法

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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());
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 注解与语言关键字。
检查自己的理解: 子类的 String describe() 可以重写 Object describe() 吗?

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

↑ 返回知识框架
INFO1113 / 13Leon

抽象类、接口与默认行为

01 / 从一个故事开始

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

02 / 理解原理

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

先认识这些词

抽象方法
声明了操作,但实现需由具体子类型提供。
接口
不同类都可实现的命名能力契约。
默认方法
由接口提供、可继承的实例方法实现。

03 / 逐项读懂公式

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

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

04 / 先看一个完整例子

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

一步步想清楚

  1. 抽象类构造器能运行吗?

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

  2. 为什么必须解决冲突默认方法?

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

  3. 接口字段是每个实例的可变数据吗?

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

运行一个完整示例 · 01

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

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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());
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

panel: area=12

读懂这里的语法

abstract class Shape
定义可共享状态与代码、但不能直接实例化的类。
abstract int area();
声明没有主体的操作,具体子类必须提供实现。
super(name)
调用父类构造器,先初始化继承部分,再继续子类主体。

按执行顺序理解

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

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

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

跟踪状态的变化
步骤状态或结果
Shape constructorname=panel
Rectangle constructorwidth=3, height=4
area()12

逐步读懂代码

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

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

运行一个完整示例 · 02

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

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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());
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

left+right
description provider

按执行顺序理解

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

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

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

逐步读懂代码

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

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

Leon 的进一步思考

能力与共享状态

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

本版程序保持 Java 8 兼容

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

注意这个边界. 接口不只包含没有主体的方法;接口常量也不是普通实例字段。调用者只需要一种能力时,不要制造庞大抽象父类。
检查自己的理解: 抽象类不能直接实例化,为什么还能有构造器?

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

↑ 返回知识框架
INFO1113 / 14Leon

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

01 / 从一个故事开始

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

02 / 理解原理

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

先认识这些词

类型参数
由泛型类或方法声明的类型占位符。
类型界限
限制哪些类型能替换类型参数的要求。
类型擦除
编译移除许多泛型类型信息,同时保留所需检查与转换。

03 / 逐项读懂公式

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 特有行为。

一步步想清楚

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

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

  2. 静态方法返回类型前为何声明类型参数?

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

  3. 类型安全保证列表有首项吗?

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

运行一个完整示例 · 01

通过容器与方法保留类型

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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");
        }
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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])Stringred
first([8,13])Integer8
first([])Stringrejection

逐步读懂代码

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

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

运行一个完整示例 · 02

用 Number 界限进行明确转换

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

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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()));
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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[] 转换。
检查自己的理解: T extends Number 允许用 + 相加两个 T 吗?

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

↑ 返回知识框架
INFO1113 / 15Leon

通配符与数据流方向

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。这是相对于方法的数据流。通配符视图不一定不可变,底层列表仍可能支持删除和清空。

先认识这些词

不变性
虽然元素类型有继承关系,List<Integer> 仍不是 List<Number> 子类型。
生产者
当前方法从中读取值的参数。
消费者
当前方法向其中写入值的参数。

03 / 逐项读懂公式

生产者: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。

将这个短例子另存为 WildcardDemo.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]
    }
}
javac -encoding UTF-8 WildcardDemo.java
java -ea WildcardDemo

一步步想清楚

  1. 为什么 extends 视图不能接收 Double?

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

  2. 从 ? super Integer 能安全读成什么类型?

    Object。列表可能含先前加入的更宽类型值。写 Integer 安全,但并非所有存储值都是 Integer。

  3. ? extends Number 禁止 clear 或 remove 吗?

    不禁止。通配符限制元素类型操作,不限制所有修改。具体列表决定支持哪些修改方法。

运行一个完整示例 · 01

从生产者复制到消费者

把 Integer 值转移到也能接受 Double 的列表。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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);
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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。

运行一个完整示例 · 02

区分类型知识与可变性

通过 extends 视图移除,通过 super 视图写入。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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);
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 说成只写。这些标签近似描述元素类型保证,不描述全部操作或底层集合可变性。
检查自己的理解: 为什么 List<Object> 可传给 List<? super Integer> 参数,却不能传给 List<Integer>?

Object 是 Integer 父类型,所以保证接受 Integer 写入是安全的。不变性阻止把可能含任意 Object 的列表当成每项都是 Integer 的列表。

↑ 返回知识框架
INFO1113 / 16Leon

迭代器、别名与可达性

01 / 从一个故事开始

两位参观者各拿一张美术馆路线卡。每张卡记录个人当前位置;若共享同一张卡,一个人的前进就会意外改变另一个人的进度。展馆像可迭代结构,路线卡像迭代器。再想象旧展室所有公共入口都被移除。里面物品仍可能互相指向,但从仍在使用的建筑已经无法到达。第二个比喻解释垃圾回收:即使对象互相引用,只要循环整体不可达,也可能被回收。

02 / 理解原理

Iterable<T> 提供迭代器,Iterator<T> 通过 hasNext 和 next 表达遍历状态。增强 for 对 Iterable 对象使用该协议,数组另有语言支持。独立迭代器应保存独立游标。耗尽后 next 必须抛 NoSuchElementException,不能返回编造值。遍历期间的结构修改取决于集合契约。常见快速失败迭代器可抛 ConcurrentModificationException,但这不是同步策略。引用也决定对象可达性。垃圾回收可回收从活动根不再可达的对象,包括不可达循环。只把一个变量设为 null 不够,其他路径可能仍存在。回收时机不确定,也不能代替外部资源的明确清理。

先认识这些词

迭代器
保存一次遍历游标与操作的对象。
可达性
是否仍有活跃引用链到达对象。
根
可达性搜索开始处的执行引用或静态引用。

03 / 逐项读懂公式

Iterable<T>.iterator() → 独立遍历状态
hasNext() 报告是否还有元素;next() 返回元素并推进
可回收候选:活动根到对象之间没有强引用路径

T 是元素类型。独立遍历状态指需要独立遍历时使用独立游标,不表示复制整个集合。活动根包括活动执行上下文与静态引用;强路径由普通可达链接组成。最后一行只概念性描述资格,不是立即删除指令。不可达循环中的对象即使被内部引用,也可能没有活动根路径。看似不再使用、却仍被静态集合保存的对象,仍可能可达。

04 / 先看一个完整例子

列表为 ["oak", "pine", "elm"]。迭代器 a 读取 "oak",下一步面向 "pine"。独立创建的 b 第一次 next 仍返回 "oak"。把 a 推进到结束,不应改变 b 的游标。若两个迭代器与列表都不可达,且没有其他引用,列表及其私有节点便可能可回收。若静态缓存仍保存列表,它就仍可达。通过受支持的迭代器操作删除元素,与遍历期间任意修改同一列表,是不同情况。

一步步想清楚

  1. 两个迭代器为何第一次都返回 oak?

    每个迭代器有自己的游标。共享列表不等于共享遍历位置。

  2. 遍历中为什么使用 iterator.remove?

    它是迭代器支持的删除最后返回元素方式。随意通过列表改结构可能使遍历假设失效。

  3. 把一个变量设为 null 能证明垃圾回收发生吗?

    不能。其他强引用路径可能仍在。即使没有路径,也只表示有资格回收,不承诺立即回收。

运行一个完整示例 · 01

为每次遍历提供独立游标

耗尽一个迭代器,而不推进另一个。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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());
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 下一索引
create00
a.next twice20
b.next21
a.next31

逐步读懂代码

两次 iterator 调用在同一列表上创建独立遍历状态。a 已到末尾时,b 仍在开头附近。

耗尽后调用 next 会抛规定异常。它不能编造默认元素,否则无法区分结束与真实数据。

运行一个完整示例 · 02

安全删除并观察可达性

原变量清空后,第二个别名仍存在。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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());
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 强制立即清理,也不要认为垃圾回收会在正确时间关闭每个文件。
检查自己的理解: 两个节点互相引用,但没有活动根能到达它们,这个循环必须永远保留吗?

不必。基于可达性的回收可以回收该循环。内部引用不会自动建立从活动根出发的路径。

↑ 返回知识框架
INFO1113 / 17Leon

把异常作为明确的失败路径

01 / 从一个故事开始

配送流程有普通路线,也有清楚标出的故障路线。地址无法辨认时,需要有能力纠正的人作决定。假装包裹已经送达,会破坏记录。分拣设备损坏又不同于缺少门牌号。异常让方法报告正常承诺无法完成,并把控制交给合适处理者。比喻也提醒我们:无论走哪条路线,装货门仍要关好,临时设备仍要释放。

02 / 理解原理

抛出异常会离开当前正常路径,向外寻找匹配 catch,必要时展开调用栈。受检查异常必须被捕获,或用 throws 声明。RuntimeException 与 Error 的子类不受检查。这是编译规则,不是严重程度的完美排序。应选择能解释契约违反或失败原因的异常,在能够恢复或转换含义的边界捕获。throws 声明并没有处理失败。较具体 catch 要写在较宽泛 catch 前。finally 在普通及异常退出时执行;受管理资源优先使用 try-with-resources。异常不全是调用者违反前置条件:输入合法,输入输出操作也可能失败。

先认识这些词

受检查异常
编译器要求调用者捕获或声明的异常。
调用栈展开
寻找匹配处理器时退出活动方法调用的过程。
异常保证
操作失败时状态仍必须满足的保证。

03 / 逐项读懂公式

正常返回 ⇒ 文档规定的结果与后置条件
throw E ⇒ 向外寻找兼容 catch
受检查 E ⇒ 捕获 E,或声明合适的 throws 类型

E 表示携带失败信息的异常类型及实例。兼容 catch 可以处理 E 或其父类型。后置条件描述正常返回后的承诺。异常退出可有单独状态保证,例如对象保持不变。蕴含不表示所有程序自动履行契约,而是实现和测试必须验证的设计责任,包括失败后保留什么状态。

04 / 先看一个完整例子

包裹柜容量 6,目前放着 5 件。add(2) 先检查剩余容量,放不下时抛 IllegalArgumentException。因为先验证,捕获异常后仍为 5 件。另一个情况是从不存在的文件加载柜子数据:即使路径语法正确,也可能抛 IOException。命令行边界可报告文件缺失,请使用者选择不同路径。若毫无说明返回空清单,就会把操作失败错误伪装成有效领域数据。

一步步想清楚

  1. throws 会执行恢复吗?

    不会。它声明允许传播。合适边界上的 catch 决定恢复、报告或再次抛出。

  2. 为什么相加前先验证?

    这样拒绝操作可保持旧状态不变,成为调用者可依赖的明确失败契约。

  3. 异常路径为什么也要关闭资源?

    失败不会消除所有权责任。资源型 try 在离开块时安排 close。

运行一个完整示例 · 01

修改状态之前拒绝

调用者捕获失败后仍可检查对象。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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());
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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. 检查状态未变,再执行成功调用。

跟踪状态的变化
操作数量
initial5
add(2) throws5
add(1)6

逐步读懂代码

失败 add 在写 size 前退出。捕获改变控制流,但无需撤销部分更新。之后合法请求仍可成功。

异常消息解释边界失败。示例捕获预期具体类型,而不隐藏所有可能的编程缺陷。

运行一个完整示例 · 02

观察异常传播时的清理

小型自有资源报告关闭顺序,不操作文件。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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");
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 返回,它可能压制异常或先前结果。
检查自己的理解: 方法声明 throws IOException,是否已从文件失败中恢复?

没有。它只声明失败可能向上传播。调用者或更高边界仍需决定恢复、报告、转换或继续传播。

↑ 返回知识框架
INFO1113 / 18Leon

枚举与有限状态模型

01 / 从一个故事开始

包裹可以等待、运输中或已送达。用便签随意写状态,容易拼错或创造“差不多算送到”等标签。预先印好的正式标记可以收窄词汇。但词汇正确不保证流程合理:从已送达直接回到等待,仍可能违反流程。enum 提供有限标签集合,转移规则控制标签之间如何移动。同时看清两者,才能避免把类型安全状态名误当完整正确的状态机。

02 / 理解原理

enum 定义该枚举类型中固定的一组命名实例。它在编译时阻止混用无关标签,也可包含字段、方法和构造器。每个常量甚至可提供专门行为。枚举实例可用 == 比较,因为每个声明常量对应唯一实例。优先使用有意义的方法或明确转移表,避免分散的字符串比较。状态机结合当前状态、事件和转移规则,决定下一状态或拒绝事件。枚举让状态空间明确,却不会自动约束所有转移。不要把 ordinal 持久保存为长期标识,因为常量重排会改变编号。名称或明确稳定代码通常更清楚。

先认识这些词

枚举常量
枚举定义的固定集合中的一个命名实例。
状态转换
把当前状态和事件变为下一状态或拒绝的规则。
稳定编码
不依赖声明位置的明确外部标识。

03 / 逐项读懂公式

δ(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,应重新检查转移模型与测试。仅增加枚举常量,不会自动告诉现有操作退回包裹应如何行动。

一步步想清楚

  1. 枚举能阻止所有无效转换吗?

    不能。它只限制状态名称。必须由独立操作拒绝不支持的状态事件组合。

  2. 枚举值为何可用 == 比较?

    每个声明常量都是固定实例,身份就代表这个枚举值。

  3. 为什么避免持久保存 ordinal?

    ordinal 是声明位置。插入或重排常量会改变它,显式编码则可保持格式契约。

运行一个完整示例 · 01

接受与拒绝流程事件

只有获得合法下一状态后才赋值。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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);
            }
        }
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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. 捕获拒绝事件并显示保留状态。

跟踪状态的变化
事件之前之后
startQUEUEDRUNNING
startRUNNINGRUNNING
finishRUNNINGDONE

逐步读懂代码

next 是无修改的转换函数。只有正常返回才发生赋值,所以重复 start 不会破坏当前状态。

事件使用字符串值相等,状态使用枚举身份相等。这个模型刻意只允许两个转换,不是普遍流程规则。

运行一个完整示例 · 02

解码明确的枚举编码

区分持久编码与常量 ordinal。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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");
        }
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

IDLE: code=10
ACTIVE: code=20
STOPPED: code=30
decoded=ACTIVE
unknown code rejected

按执行顺序理解

  1. 在枚举构造中分配稳定编码。

  2. 按明确字段匹配解码。

  3. 拒绝未知编码,不把它当作 ordinal。

逐步读懂代码

每个常量保存明确外部编码。fromCode 搜索编码,而不是数组位置。重排声明会改变 values 遍历顺序,却不会改变这些映射。

未知编码明确失败。大型枚举可使用带重复编码检查的映射,这里的小型线性搜索使契约清楚可见。

Leon 的进一步思考

拒绝转换应保持状态

赋值前先计算或验证下一状态。被拒事件就不会改变当前状态,简化调用与转换测试。

增加状态时重审整个模型

新枚举标签不会自动说明如何进入或离开。应一致地更新转换规则、显示、解析与相关测试。

注意这个边界. 不要把 enum ordinal 用作永久领域标识。不要以为 enum 会自动防止非法转移,应由领域操作强制执行。
检查自己的理解: 为什么 enum 用 == 比较合适,而任意 String 内容通常不能用 ==?

每个枚举常量都是唯一固定实例,因此身份代表该命名值。不同 String 对象可能包含相同文本,内容比较需要 equals。

↑ 返回知识框架
INFO1113 / 19Leon

断言、测试与有用边界

01 / 从一个故事开始

电梯检查员不会只在安静时从二楼坐到三楼。他们检查底层、顶层、空载、容量上限和非法请求,因为边界会暴露普通行程隐藏的错误。软件测试同理。维修面板中的警示灯像语言断言:适合检测内部假设,却不适合作为阻止乘客选择不可能目的地的唯一保护。无论诊断断言是否开启,公开输入验证都必须在正常运行时有效。

02 / 理解原理

测试把观察行为与期望结果比较。根据契约选择正常情况、边界、非法输入及相关状态转移。黑盒测试关注公开行为;白盒推理可找出未测试分支,但不应让测试依赖每个实现细节。Java assert 可以被关闭,适合内部一致性检查,却不能作为公开输入唯一验证。JUnit 等测试框架的断言在测试中执行,不依赖 Java assert 开关。每个测试都应清晰且确定,既检查正常返回,也检查异常行为。通过测试会提升已覆盖情况的信心,却不能证明没有其他缺陷。

先认识这些词

测试初始环境
单个测试案例准备的对象与输入。
边界案例
位于规则接受边界上或附近的输入。
回归测试
保留用于发现已理解缺陷再次出现的测试。

03 / 逐项读懂公式

准备 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。也测试负请求,并依据契约决定零是否合法。这覆盖一般成功、恰好满额、超容量与非法符号。若随意复用已修改对象,第二个测试会依赖第一个,因此每个案例都应建立自己的初态。

一步步想清楚

  1. 为什么每个预留测试都新建对象?

    否则一个测试的修改会改变下一个初态。独立环境让每项预期结果可解释。

  2. 公共输入验证为何不能只靠 Java assert?

    assert 可以关闭。普通条件与异常才能始终落实输入契约。

  3. 分支覆盖能证明预期值正确吗?

    不能。它记录的是执行。测试仍需把有意义观察与独立推导的预期比较。

运行一个完整示例 · 01

无需测试依赖运行边界测试

本地 check 始终执行,不受 Java assert 开关影响。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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");
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

four contract cases passed

读懂这里的语法

new Seats(8, 3)
创建容量八、初始占用三的全新测试对象。
if (!condition) throw new AssertionError(message)
使用始终启用的检查。! 取布尔反值,这不是可关闭的 assert 语句。
catch (IllegalArgumentException expected)
捕获测试要求的失败,变量名 expected 表达其作用。

按执行顺序理解

  1. 在每个测试辅助方法内安排新环境。

  2. 分别执行普通、精确边界或无效请求。

  3. 检查结果值与拒绝后的状态保证。

跟踪状态的变化
请求预期结果
2reserved=5
5reserved=8
6reject, reserved=3
-1reject, reserved=3

逐步读懂代码

每个辅助函数新建初始预留数为三的对象。成功案例检查普通请求与恰好满容量。拒绝案例同时检查异常和状态不变。

预期失败时,测试框架抛 AssertionError。这是原创小型测试程序,不是 Java assert 语句,也不替代完整测试框架的所有功能。

运行一个完整示例 · 02

保留有针对性的算术回归测试

说明小数预期为何能识别整数除法。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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);
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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,用极小且易懂的案例暴露错误。

注意这个边界. 仅重复实现算式的测试,可能重复同一个错误。应根据契约、简单独立例子或已知不变式选择期望。
检查自己的理解: 为什么 assert amount > 0 不足以验证外部提供的移除数量?

Java 断言可能关闭。程序必须用普通条件语句与合适异常或错误响应,可靠约束公开输入。

↑ 返回知识框架
INFO1113 / 20Leon

递归、调用树与结果复用

01 / 从一个故事开始

嵌套文件夹可以这样计数:询问各子文件夹大小,再加上自身的一。每次请求处理更小部分,空文件夹停止下探。若多个路径又请求同一昂贵计算,文员可以第一次记下答案,以后复用。但记录必须对应相同输入与假设。这两幅图区分递归与记忆化。递归把问题拆成更小调用;记忆化避免重复等价工作。

02 / 理解原理

递归方法需要基本情况,以及不断接近基本情况的递归调用。每个活动调用都有自己的局部变量,并增加调用栈深度。正确性可这样论证:假设较小实例正确,再说明它们的结果如何组成当前答案。递归遍历对象结构,还需处理循环与重复引用的规则;任意对象图不自动是一棵树。记忆化按决定结果的输入缓存答案。重复子问题多时有用,但消耗内存;相关状态改变而未失效缓存,会产生错误。即使数学算法终止,深递归仍可能使 Java 栈溢出。深度可能很大时,应考虑迭代替代。

先认识这些词

基本情况
无需继续递归即可回答的情况。
记忆化
按决定结果的输入缓存计算结果。
调用深度
同时活动的嵌套调用数量。

03 / 逐项读懂公式

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 溢出时,即使递归逻辑正确,数值答案仍会错误,除非改变表示。

一步步想清楚

  1. ways(0) 为什么为一?

    覆盖零阶有一种空移动序列。这个基本情况让递推正确统计组合。

  2. 记忆化消除了什么?

    消除同一状态的重复计算,不一定缩短最长依赖链,也不一定避免深栈失败。

  3. 缓存键为什么必须包含允许步长规则?

    允许移动规则改变后,同一 n 的答案可能改变。只按 n 缓存仅在规则固定时有效。

运行一个完整示例 · 01

每个台阶状态只缓存一次

统计不同状态的计算次数,再重复同一查询。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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]);
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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. 重复查询,验证复用而非重复计算。

跟踪状态的变化
状态结果
01
11
22
33
45

逐步读懂代码

计数之前先查缓存。每个缺失状态计算后存入答案,包括两个基本情况。首次查询到达零至四,共五个不同状态。

重复查询直接返回缓存,不增加次数。明确范围保证答案可放入 long;受检查加法仍能暴露意外溢出。

运行一个完整示例 · 02

用迭代精确计数替代深递归

用 BigInteger 与两个滚动状态处理较大输入。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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));
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

ways(0)=1
ways(4)=5
ways(100)=573147844013817084101

按执行顺序理解

  1. 把两个基本递推值初始化为一。

  2. 先算 next,再移动滚动状态对。

  3. 打印小型边界检查与一个较大精确结果。

逐步读懂代码

每次循环前,previous 与 current 保存前两个递推值。临时 next 避免二者用完前覆盖旧值。程序不再递归。

BigInteger 在超过 long 范围后仍保持精确。只保留两个递推值,但其存储随位数增长。明确演示上限保证执行有界。

Leon 的进一步思考

优化前先数子问题

该递推只需要零到 n 的状态。缓存让每个状态只算一次,而迭代递推还可只保留前两个值。

数值大小会改变复杂度

BigInteger 避免定宽溢出,但数值增大后加法更贵。递推步数可以是线性,每步位运算成本却不是常数。

注意这个边界. 缓存键必须包含影响结果的全部输入。若可配置规则改变允许步长,仅按 n 缓存就不正确。
检查自己的理解: 为什么记忆化不自动解决 StackOverflowError?

它减少重复计算,却可能不改变最长递归调用链。迭代动态规划可以避免深调用链。

↑ 返回知识框架
INFO1113 / 21Leon

匿名类、lambda 与捕获上下文

01 / 从一个故事开始

咖啡馆需要一条小规则,决定哪些订单收到提醒。每条规则都请一位有名字的专家未必必要,传入一张短说明往往更清楚。但说明仍需已知格式:接收什么输入,返回什么答案。lambda 填充这样的单操作契约。匿名类更像临时员工,有自己的身份,也可能有额外内部状态。二者都避免独立命名类,却不会产生完全相同的作用域与身份行为。

02 / 理解原理

匿名类表达式创建一个在当前位置声明的类的实例,扩展一个类或实现一个接口。lambda 为函数式接口提供实现;完整判断需考虑继承及 Object 方法规则,其抽象方法契约只对应一个函数操作。default 和 static 方法本身不增加抽象义务。参数及返回类型必须匹配目标接口。lambda 只能捕获 final 或事实上的 final 局部变量。捕获引用不会冻结所指对象。lambda 中 this 指外围实例,匿名类中 this 指匿名类实例。若行为复杂到局部难以阅读,应使用命名方法或命名类。

先认识这些词

函数式接口
只有一个抽象方法、为 lambda 提供目标类型的接口。
捕获
在 lambda 内使用外围作用域的值或引用。
事实上的 final
初始化后不再重新赋值的局部变量。

03 / 逐项读懂公式

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 约束引用,不约束内容。若要有意改变规则,用新阈值构造新谓词。除非外围设计明确需要,不要把不断变化的领域状态隐藏在捕获的可变对象里。

一步步想清楚

  1. lambda 会冻结捕获对象吗?

    不会。被捕获的局部引用不能重新赋值,但所指对象仍可变化。

  2. lambda 主体何时执行?

    调用函数式方法时执行,不是仅在创建 lambda 时执行。

  3. lambda 内的 this 表示什么?

    沿用外围实例上下文的含义;匿名类则引入自己的实例。

运行一个完整示例 · 01

捕获阈值与可变日志

区分固定局部引用与被冻结的对象。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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();
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 限制局部赋值,不会让列表不可变。

运行一个完整示例 · 02

观察 this 命名哪个对象

从同一个外围实例运行 lambda 和匿名 Runnable。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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();
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 需要函数式接口目标,不能以任意抽象类为目标。
检查自己的理解: 含一个抽象方法和两个 default 方法的接口可以作 lambda 目标吗?

可以,前提是满足完整函数式接口规则。default 方法已有实现,不增加必须实现的抽象操作。

↑ 返回知识框架
INFO1113 / 22Leon

依据证据调试,记录明确承诺

01 / 从一个故事开始

修车师傅调查自行车每转一圈响一次的故障。一次更换全部零件可能消除声音,却很难理解原因。师傅应先复现,观察从哪里开始,提出狭窄解释,再检验。调试也需要这种纪律。文档像维修卡,说明自行车应做什么、在什么条件下工作。可复现观察与明确契约结合,才能区分实现缺陷和对预期行为的误解。

02 / 理解原理

先区分编译错误、运行时异常与错误结果。用最小但有用的输入复现,再在适当边界比较期望与实际。调试器可在断点暂停,查看局部变量和调用栈,单步进入或跨过方法,找出最早偏差。编译时保留调试信息可改善检查,具体命令取决于工具。修复原因,并在适当时保留聚焦回归测试。Javadoc 使用描述与 @param、@return、@throws 等标签记录公开契约。应说明假设、副作用、单位和异常行为。仅复述方法名字,不如精确说明调用者可以依赖什么。

先认识这些词

复现用例
能可靠暴露故障的小型输入与程序。
假设
能够预测可观察结果的具体解释。
前置条件
调用方法时必须成立的要求。

03 / 逐项读懂公式

复现 → 观察 → 假设 → 定向检查 → 修正
契约 = 接受的输入 + 承诺的输出 + 副作用 + 失败行为

箭头表示调查循环。假设失败,就回到观察。接受输入包含范围、null 处理和单位。输出承诺在相关时包含顺序或状态保证。副作用说明修改及外部输入输出。失败行为说明异常,以及失败后仍成立什么。这些是概念类别,不是数学方程。文档与测试应遵循同一契约,让失败测试具有可解释含义。

04 / 先看一个完整例子

计算平均时长的方法对 [3, 6] 返回 4.0,期望却是 4.5。只用这两个值复现。在断点查看 sum = 9、count = 2,二者都正确。单步到除法,发现 9 / 2 先按 int 求值,再赋给 double。精确修复是在除法前转换一个操作数。增加非整数平均值测试,并记录空输入行为。仅测试 [2, 4] 会漏掉故障,因为整数除法恰好得到期望整数。

一步步想清楚

  1. 为什么改公式前要查看操作数类型?

    纸面公式可能正确,但语言可能在不合适的数值类型中执行中间运算。

  2. 文档应该承诺什么?

    承诺可观察行为:接受的输入、返回含义、状态影响与预期失败。不要承诺偶然的实现细节。

  3. 打印语句是证明吗?

    它是一次执行的证据。用它检验假设,再保留聚焦的回归检查。

运行一个完整示例 · 01

定位第一个错误中间值

比较同一算术中两个不同转换位置。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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");
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

integer quotient=4
late conversion=4.0
early conversion=4.5

读懂这里的语法

(double) quotient
转换已经算出的整数,丢失的小数信息无法回来。
(double) sum / count
除法前转换一个操作数,使除法使用浮点运算。
if (early != 4.5)
与独立期望结果检查不相等。这里的二分之一可精确表示。

按执行顺序理解

  1. 写下独立已知的数学答案。

  2. 检查整数除法后的即时值。

  3. 把转换移到除法之前并保留回归检查。

跟踪状态的变化
表达式类型值
sum/countint4
(double)(sum/count)double4.0
(double)sum/countdouble4.5

逐步读懂代码

先写出已知答案 9/2 = 4.5。整数商已经是 4,所以信息在转换前丢失。转换结果无法找回被舍弃的小数。

先转换一个操作数,会使除法采用浮点运算。这个微型测试把类型错误与输入输出代码分开,并保留对该精确可表示结果的检查。

运行一个完整示例 · 02

把文档转为可执行检查

记录一个小方法的契约,检查接受与拒绝的输入。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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");
        }
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

distance=7
rejected negative position

按执行顺序理解

  1. 先读契约再预测输出。

  2. 检查有效位置对及返回距离。

  3. 检查文档规定的负数边界拒绝。

逐步读懂代码

注释描述接受范围、返回含义与拒绝条件,没有规定局部变量名或分支顺序。

检查先执行,保证算术输入符合范围。扩大左操作数让减法使用 long,因此日后扩展范围时也不会发生 int 减法溢出。范围改变仍需要新测试与新文档。

Leon 的进一步思考

一次减少一个变量

小型复现用例把算术与文件输入、图形及无关状态分开。若故障消失,恢复最后移除的条件并研究其作用。

让注释解释原因

有用注释记录检查或转换为什么必要。逐句复述代码只增加维护工作,无法解释契约。

注意这个边界. 检验假设前,不要同时改变多个无关条件。不要文档要求非 null,却在一条路径默默接受 null,在另一条路径不可预测崩溃。
检查自己的理解: 为什么仅在错误输出前设断点,有时太晚而无法理解缺陷?

错误状态可能早已产生。向前追踪不变式首次变假的位置,再观察造成第一次偏差的操作。

↑ 返回知识框架
INFO1113 / 23Leon

从对象模型到小型事件驱动应用

01 / 从一个故事开始

社区温室有传感器、控制策略和墙上显示屏。若显示屏自己决定全部浇水规则,修改画面可能意外影响植物照料。更好的职责分配是:模型存当前条件,策略更新状态,显示屏展示快照。按钮动作转换成模型请求。这也便于画图:方框表示职责,连线表示协作。图只有帮助解释行为时才有价值,单纯列出全部字段和方法,却不说明部件为什么组合,作用有限。

02 / 理解原理

小应用也需要明确职责。领域状态与规则留在模型对象中;输入解析和事件处理放在边界;显示层读取状态,不拥有无关领域决定。UML 类图可概括字段、操作、可见性与关系。泛化箭头表示继承,实现关系表示实现接口,关联表示协作。组合表示较强所有权,不是每个引用字段都算组合。包组织名称及可访问性。构建工具自动编译、解析依赖并执行可重复检查。事件驱动程序中,处理器把事件转成模型操作,显示过程读取结果。将逻辑更新与显示时机分开,可在不开图形窗口时测试相同行为。

先认识这些词

模型
表示应用领域数据与规则的部分。
事件边界
外部动作转换为经过验证的领域请求的位置。
依赖注入
把协作对象传入对象,而不是在内部固定创建它。

03 / 逐项读懂公式

事件 → 已验证命令 → 模型转移 → 显示状态
命令后状态 = update(先前状态, 命令)
设计检查:组件职责清楚;协作明确

事件是按钮点击等原始交互。已验证命令是输入经过检查的领域请求。update 表示模型转移逻辑,可以修改对象,也可返回新状态,只要契约明确。render 把状态转换为视图,不应偷偷推进无关领域规则。职责原则是设计启发,不是数值定律,也不要求每个类只能有一个方法。

04 / 先看一个完整例子

设计温室面板:Bed 模型存湿度,WateringPolicy 决定是否允许浇水,Dashboard 显示当前值。按钮事件为某个 Bed 请求 addWater(2)。处理器确定目标,模型验证数量并更新湿度,显示从新状态刷新。在以湿度单位计量的简化模型中,单元测试从 4 开始,加 2,检查 6,全程不开窗口。类图显示 Dashboard 关联 Bed 和策略接口,具体策略实现该接口。同样协作可先通过无界面命令接口练习,再添加图形视图。

一步步想清楚

  1. 为什么把规则放在按钮回调之外?

    同一规则可在不开窗口时测试与复用。回调只负责把事件转换为请求。

  2. 谁决定是否允许浇水?

    应由模型或明确策略强制执行规则。只禁用按钮无法约束其他调用者。

  3. 分离层次一定需要很多类吗?

    不一定。先区分职责与方法契约;当所有权或可替换行为需要时再增加类。

  4. 为什么先计算结果,再压入历史?

    操作可能拒绝输入。若先修改历史,撤销时可能取回完全相同的快照,看起来像没反应。应先校验,再提交状态变化。

运行一个完整示例 · 01

追踪命令流经小型应用

把输入转换、领域状态与显示拆成独立方法。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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));
        }
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 读取结果而不修改状态。

过量请求与非法数字通过同一个用户边界被拒绝,并让湿度保持六。这个无界面序列无需图形工具包即可检查协作。

运行一个完整示例 · 02

替换策略而不重写控制器

传入目标规则行为,并独立执行模型边界检查。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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");
        }
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

gentle=6
dry day=8
unsafe policy rejected

按执行顺序理解

  1. 把每个目标规则表示为 IntUnaryOperator。

  2. 把选中的策略传给同一个应用函数。

  3. 拒绝会违反模型边界的策略结果。

逐步读懂代码

两种策略实现同一契约:查看当前湿度并建议添加量。共用 apply 方法询问所选策略,在计算新状态前验证建议。

注入的协作对象也可能出错。最后的策略故意建议二十单位,独立范围检查会拒绝它。策略灵活性不能替代不变式约束。

运行一个完整示例 · 03

做一个可以撤销的小编辑器

米娜为校园展览编写标签。她追加一个词,发现不合适,想点击撤销。只保存最新文本就找不回上一步。她把文档、编辑动作分开,让编辑器用栈保存旧快照,同时规定被拒绝的动作不能进入历史。这样还没有制作按钮,就能测试编辑器。

Java 8+下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

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());
    }
}
在本地运行
javac -encoding UTF-8 Main.java
java -ea Main

预期输出

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 的进一步思考

无界面测试缩短反馈循环

直接测试模型可使故障快速复现。另用小型集成测试检查事件转换与显示是否正确连接。

先注入策略,再考虑到处加分支

不同环境若需要不同浇水规则,应把变化集中表示成策略。即使策略推荐某动作,模型仍要检查不变式。

注意这个边界. 避免一个应用类同时负责解析、存储、绘图与全部领域规则,也不要增加没有实际澄清职责的层次。
检查自己的理解: 显示代码只读取模型、不拥有领域规则,会让什么更容易?

规则可独立于界面测试和复用。命令行视图或另一图形视图能调用相同模型操作,无需复制逻辑。

↑ 返回知识框架

继续探索

故事、概念讲解和推导例题由 Leon 原创编写。课程代号标识独立学习路径。参考资料属于各自作者。