Reading 2: Java 基础(sp22 原版为 Basic TypeScript)

目录 · ← l1 · l3 →

Reading 2: Java 基础(sp22 原版为 Basic TypeScript)

说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。文中凡涉及本讲之外、属于 Java 生态的额外知识,均以「补充说明」标注。

概述

本讲的目标是从 Python 平滑过渡到静态类型语言的日常写法:学会基本语法与语义,掌握快照图(snapshot diagram) 这一描述运行时状态的通用工具,并建立「原始类型 vs. 对象类型」「重新赋值 vs. 改变值」「可变 vs. 不可变」三组贯穿全课程的核心区分。它同时服务于三大目标:泛型与静态类型让集合操作免受 bug 之害(Safe from bugs),快照图与 final 让「谁在什么时候改了什么东西」变得易于理解(Easy to understand),而「声明接口类型、选择实现类」的习惯让代码为变化做好准备(Ready for change)。

核心概念与设计原则详解

快照图(Snapshot Diagram)

  • 定义与目的:快照图是程序在某一时刻的运行时内部状态的图示,包含栈(stack)(正在进行的方法及其局部变量)与堆(heap)(当前存在的对象)。它让我们能用图而不是用嘴来讨论「不可变的值 vs. 不可重新赋值的引用」「指针别名(aliasing)」「栈 vs. 堆」「抽象 vs. 具体表示」这些微妙问题。
  • 直观解释(”它是什么?”):把变量画成一支持有箭头的标签,箭头指向一个值;原始类型的值直接写在箭头旁边,对象类型的值是堆上的一个圆圈(气泡),圆圈里可以写字段名与字段值。给引用加双箭头表示「不可重新赋值」(final),给对象圆圈加双边框表示「不可变值」(如 String)。
  • 关键规则与最佳实践
    • 语法是灵活的,不必画出全部细节:只画与当前讨论相关的部分。讨论字符串内容时画字符,讨论对象身份时只画一个圆圈。
    • 赋值x = ...)改变箭头的指向;修改list.add(...)sb.append(...))改变箭头所指气泡的内部。
    • 双线(双箭头 / 双边框)只在需要强调「不可变」时使用;当可变性显而易见或不相关时,保持单线,避免图画得太吵。
    • 快照图的记法适用于任何现代语言(Python、Java、C++、Ruby),并在后续课程中泛化为对象模型。
    • 在 6.031 中,快照图是团队沟通工具:课堂讨论、结对编程、代码评审时都可以画。

原始类型与对象类型(Primitive Types vs. Object Types)

  • 定义与目的:Java 把值分成两类,理解这个区分是理解 ==、参数传递、内存布局的前提。它首先服务于 Safe from bugs(选错类型会导致错误的相等性判断)。
  • 直观解释(”它是什么?”):原始类型的值就是值本身(一个小方块里写着 5);对象类型的值是一个「指针」,指向堆上的一个气泡。因此原始类型没有内部结构,而对象类型可以有内部结构(引用其他原始值或对象)。
  • 关键规则与最佳实践
    • 原始类型:intlongbooleandoublefloatchar 等,约定全部小写,占用固定的小块内存。
    • 对象类型:StringBigIntegerListTurtle 以及自定义类,约定首字母大写,占用可变且可能很大的内存。
    • 判别规则(来自本讲练习):原始类型并不只是「数值」,char 是原始类型而 String 不是;判别的可靠依据是约定的大小写是否有内部结构是否占用固定小内存
    • 每个原始类型都有包装类型(wrapper type)int/Integerlong/Longdouble/Double。Java 会在两者之间自动装箱(boxing)与拆箱(unboxing),所以 Integer i = 5; 合法。
    • 泛型参数只能是对象类型,因此写 List<Integer> 而不是 List<int>;遍历时优先用原始类型 int num,因为原始类型的 == 更简单可靠。

改变值 vs. 重新赋值变量(Mutating Values vs. Reassigning Variables)

  • 定义与目的:这是本讲最重要的一组区分。混淆它们会直接导致共享可变状态引发的 bug,也会让人误以为 final 就意味着「不可变」。
  • 直观解释(”它是什么?”)重新赋值是把变量的箭头指向另一个气泡;改变值是让同一个气泡里的内容发生变化。前者改变「谁」,后者改变「什么」。
  • 关键规则与最佳实践
    • String s = "a"; s = s + "b";重新赋值s 从指向 "a" 改为指向新对象 "ab"String 不可变,原对象没变)。
    • StringBuilder sb = new StringBuilder("a"); sb.append("b");改变值:箭头没动,气泡内部从 "a" 变成 "ab"
    • 参数传递永远是「按值传递引用」:方法内对参数重新赋值不影响调用者;对参数所指对象修改会影响调用者。因此 void f(String s, StringBuilder sb) { s.concat("b"); s += "c"; sb.append("d"); } 在调用 f(t, tb) 之后,t 仍是 "a"s += "c" 只是让局部变量 s 改指新对象,s.concat("b") 的返回值被丢弃),而 tb 变成 "ad"
    • 只读方法(如 concat)返回新对象而非修改原对象;修改方法(如 append)返回 this 并改变内部状态。API 文档会写明是哪一种。

不可变值与不可重新赋值的引用(Immutable Values vs. Unreassignable References)

  • 定义与目的:Java 提供两种「不变」的保证,分别作用于引用。理解它们正交组合出的四种情况,是安全设计的基础。
  • 直观解释(”它是什么?”)不可变类型(immutable type):值一旦创建就永远不变(String)。不可重新赋值的引用:变量只被赋值一次(final)。二者相互独立,可以任意组合。
  • 关键规则与最佳实践
    • final int n = 5;n 终生指向 5,重新赋值会产生编译错误——final 提供的是静态检查。
    • final 引用可以指向可变对象final StringBuilder sb = new StringBuilder("a"); sb.append("b"); 完全合法。
    • final 引用可以指向不可变值String s = "a"; s = "ab"; 合法,只是箭头改指了。
    • 优先用 final 声明方法参数与尽可能多的局部变量:它既是给读者的文档,也被编译器检查。
    • 不可变性是 Safe from bugs 的基石:不可变对象可以被自由共享(别名不再是威胁),也天然线程安全(见 Reading 21)。

==.equals():两种相等性

  • 定义与目的:Java 对原始类型与对象类型使用不同的相等性测试,用错会得到「内容相同却不相等」的结果。这是本讲最直接对应 Safe from bugs 的一条规则,也是 Python 背景学生最容易踩的坑。
  • 直观解释(”它是什么?”)== 对原始类型比较5 == 5true'a' == 'a'true);对对象类型比较身份(两个箭头是否指向同一个气泡),在 Python 中对应的是 is.equals() 对对象比较内容"abc".equals("abc")true)。
  • 关键规则与最佳实践
    • 比较原始类型(intchardouble)用 ==;比较对象(List、数组、String、其他对象)用 .equals()
    • 在原始类型上调用方法(如 5.equals(5))是静态错误,因为 Java 不允许在原始类型上调用方法——这算是幸运的,错误很容易被发现。
    • 反过来,在对象上用 == 不会报错,只会安静地给出错误答案,这才是真正危险的方向。
    • 危险点还在于「优化会掩盖 bug」:String s = "foobar"; String t = "foobar";s == t 很可能返回 true,因为 Java 复用了同一个字符串对象(字符串驻留)。这会让写错的代码在测试中「碰巧通过」。
    • char 是原始类型,字面量用单引号'a');String 是对象,字面量用双引号"abc"""),二者不可混用。

集合:List、Set 与 Map(Java Collections)

  • 定义与目的:三门「日常数据结构」,分别对应有序可重复序列、无序不重复集合、键值映射。它们的共同价值是:把「容器大小管理」这件容易出错的事交给库去承担(回忆 Reading 01 的 int[100] 缓冲区溢出)。
  • 直观解释(”它是什么?”)List 像 Python 的 list(有顺序、可重复);Map 像 Python 的 dict(键必须可哈希);Set 像数学集合(要不就在里面,要不就不在)。
  • 关键规则与最佳实践
    • List:lst.size()lst.add(e)lst.isEmpty()lst.contains(e)lst.get(i)lst.set(i, e)
    • Map:map.put(k, v)map.get(k)map.containsKey(k)map.remove(k)
    • Set:s1.contains(e)s1.containsAll(s2)(判断 s1 ⊇ s2)、s1.removeAll(s2)
    • 快照图记法:List 画成带下标字段的对象,Map 画成含键/值对的对象,Set 画成一组无名字段的对象。
    • 键必须可哈希:Map 的键与 Set 的元素必须是「适合做键的类型」;在 Java 中这意味着它们必须正确实现 hashCodeequals(Reading 15 会详细展开)。

字面量(Literals)与不可变集合

  • 定义与目的:快速构造集合的语法糖,同时引入「不可变集合」这一安全默认值。
  • 直观解释(”它是什么?”):Java 没有 Python 那样的 list/dict 字面量,但有数组字面量,以及 List.of / Set.of / Map.of 这些静态工厂方法。
  • 关键规则与最佳实践
    • 数组字面量:String[] arr = { "a", "b", "c" };——注意这创建的是数组,不是 List
    • List.of("a", "b", "c") 创建不可变 List:不能添加、删除或替换元素。
    • Set.of("a", "b", "c")Map.of("apple", 5, "banana", 7) 同理,创建不可变集合。
    • 需要「已初始化但可变」的 List 时,用拷贝构造:new ArrayList<>(List.of("Huey", "Dewey", "Louie"))。这比连续调用多次 add() 简洁得多。
    • 在测试中优先使用不可变字面量,可以防止测试用例之间通过共享集合互相污染。

泛型(Generics):声明集合变量

  • 定义与目的:泛型让集合限制元素的类型,从而让编译器静态检查「只加入正确类型的元素」,并在取出元素时保证类型正是所期望的。这是 Reading 01「静态检查」在容器上的直接应用。
  • 直观解释(”它是什么?”)List<String> 读作「装着 String 的 List」。它是给容器贴标签,而不是给元素贴标签。
  • 关键规则与最佳实践
    • 声明形式:List<String> cities;Set<Integer> numbers;Map<String,Turtle> turtles;
    • 不能创建原始类型的集合:Set<int> 非法,必须写 Set<Integer>
    • 自动装箱/拆箱让包装类型几乎无感:sequence.add(5); 把 5 包装成 Integerint second = sequence.get(1); 自动拆箱为 int
    • 构造时的菱形语法省略右侧参数:List<String> names = new ArrayList<>();
    • 补充说明:raw type(裸类型,如 List numbers)会关闭泛型检查,只在维护遗留代码时才会遇到;新代码一律使用泛型。

接口与实现类:ArrayList、LinkedList、HashSet、HashMap

  • 定义与目的:Java 帮我们区分规格说明(这个类型做什么)与实现(代码长什么样)。ListSetMap 都是接口(interface),只规定行为,不提供实现;使用者可以在不同场景选择不同实现。
  • 直观解释(”它是什么?”):接口是「插座标准」,实现类是「具体的插座」。你的电器(客户代码)只依赖标准,因此可以随时换插座。
  • 关键规则与最佳实践
    • 创建:List<String> firstNames = new ArrayList<>();List<String> lastNames = new LinkedList<>();
    • ArrayListLinkedList 都完整提供 List 的全部操作,行为完全一致,只是性能不同;互换它们不会破坏代码。
    • 在 6.031 中:拿不准就用 ArrayListSet 默认用 HashSet(需要有序时用 TreeSet);Map 默认用 HashMap
    • 声明变量与返回类型时永远写接口类型List 而非 ArrayList),这样实现可以随需求变化而替换。
    • 用另一个集合初始化:new ArrayList<>(Set.of("Duck"))

迭代(Iteration)

  • 定义与目的:遍历集合是最常见的任务,for-each 语法让这件事简洁且不易出错。
  • 直观解释(”它是什么?”)for (String city : cities) 读作「对 cities 中的每个 city」。底层使用 Iterator 设计模式(本课程后段会展开)。
  • 关键规则与最佳实践
    • List 与 Set 都可以直接 for-each;Map 不能直接 for-each,需要遍历 turtles.keySet()(或 values()entrySet())。
    • 循环变量写原始类型:for (int num : numbers) 而不是 Integer num,因为原始类型的相等性判断更简单、更不易出错。
    • 绝不要在迭代过程中修改正在遍历的集合:添加、删除或替换元素会破坏迭代,甚至让程序崩溃(ConcurrentModificationException)。这条警告对 Python 同样成立。
    • 需要下标时才写 for (int ii = 0; ii < cities.size(); ii++);这种写法冗长,且藏 bug 的地方更多(起始值写成 1、用 <= 而不是 <、某处变量名写错等)。

变量声明与作用域(Variable Declarations and Scope)

  • 定义与目的:Java 的局部变量作用域是它所在的那一对花括号,这与 Python「函数级作用域」不同,会导致一类「在分支里声明、在分支外使用」的编译错误。
  • 直观解释(”它是什么?”):花括号是一道墙,墙内声明的变量出了墙就不存在。
  • 关键规则与最佳实践
    • int b = 2; 写在 if 块里,块外的 b *= 3; 就是静态错误(cannot find symbol)。
    • 正确的修法是在分支之前声明并初始化int b = 0; if (...) { b = 2; } else { b = 4; } b *= 3;。注意 Java 要求局部变量在使用前确定被赋值,所以如果注释掉 else 分支,编译器会在最后一行报「变量可能未初始化」——这是编译期发现的错误,而不是运行期。
    • TypeScript 对照(sp22 原版写法):TypeScript 用 let(可重新赋值)与 const(不可重新赋值)声明局部变量,const 提供不可重新赋值的静态检查let/const 的作用域同样是所在花括号,而老式的 var 是函数级作用域,已被强烈不推荐。Java 中最接近 const 的机制是 final,其作用域规则本质上与 let 相同(都在花括号内),没有 var 那样的函数级作用域问题。
    • 补充说明:Java 10+ 提供 var 关键字做局部变量类型推断(var x = 5;),但它仍是静态类型——类型在编译期被推断出来,与 JavaScript 的 var 完全无关,不要因同名而混淆。

枚举(Enumerations)

  • 定义与目的:当一个类型只有小的、有限的一组不可变取值时(月份、星期、罗盘方位、可选颜色),枚举让这些取值成为命名常量,并且是一个全新的类型,因而可以被静态检查。
  • 直观解释(”它是什么?”):枚举把「一堆散落的常量」升级为一个类型。以往用 int 或字符串常量表示时,任何整数或任何字符串都能被塞进去;枚举则只接受它自己列出的那几个值。
  • 关键规则与最佳实践
    • 声明:public enum PenColor { BLACK, GRAY, RED, PINK, ORANGE, YELLOW, GREEN, CYAN, BLUE, MAGENTA; }
    • 使用:PenColor drawingColor; drawingColor = PenColor.RED;——像命名静态常量一样引用。
    • 枚举比数值常量更类型安全int month = TUESDAY; 不会报错(如果用的是整数常量),而 Month month = DayOfWeek.TUESDAY;静态错误
    • 枚举能抓住拼写错误:String color = "REd"; 不会报错,而 PenColor drawingColor = PenColor.REd;静态错误
    • Python 3 也有枚举,但不做静态类型检查——这正是 Java 枚举的优势。

API 文档与规格说明(API Documentation and Specifications)

  • 定义与目的:API(application programming interface)是别人提供的、你可以编程调用的方法与类。API 文档中的详细描述就是规格说明(specification),它让你无需阅读实现代码就能正确使用 StringMapBufferedReader
  • 直观解释(”它是什么?”):文档是「黑盒说明书」,实现是「白盒内部」。规格说明的存在,正是抽象边界得以成立的前提。
  • 关键规则与最佳实践
    • 读文档的顺序:类描述 → 构造器摘要 → 方法摘要 → 点击具体方法看方法签名(返回类型、方法名、参数、可能抛出的异常)、完整描述、参数说明、返回值说明。
    • 类层次与方法摘要能帮你发现「HashMapMap 的实现」这类关系。
    • 用搜索框直接跳到类、接口或方法,是查阅 API 的最快路径。
    • TypeScript 对照(sp22 原版写法):TypeScript 的三处文档来源是 MDN JavaScript 参考(通用特性与浏览器 API)、Node.js 参考(非浏览器 API,如 fs.readFileSyncos.homedir)以及 TypeScript Handbook(TypeScript 特有特性)。Java 的对应物是 Java API 文档中的 java.lang.Stringjava.util.Listjava.util.Mapjava.io.BufferedReader 等。

TypeScript 与 Java 语法对照速查(本讲 sp22 内容的 Java 对应写法)

sp22 原版写法(TypeScript)本笔记的 Java 写法说明
let n: number = 3;int n = 3;TypeScript 的 number 同时表示整数与浮点;Java 区分 int/long/double
const n: number = 5;final int n = 5;二者都提供「不可重新赋值」的静态检查
Array<string>string[]List<String>String[]Java 的 List 可变长,数组定长
let cities = new Array<string>();List<String> cities = new ArrayList<>();Java 用接口类型声明、实现类构造
new Map([["apple", 5]])Map.of("apple", 5) / new HashMap<>()Map.of 返回不可变 Map
new Set<number>()new HashSet<>()元素必须可哈希
for (const city of cities)for (String city : cities)都用 for-each;TypeScript 慎用 for...in(遍历的是下标)
s.has(e) / s.add(e) / s.sizes.contains(e) / s.add(e) / s.size()Java 的方法调用一律带括号
arr.push(x) / arr.lengthlist.add(x) / list.size()Java 数组用 arr.length(无括号),List 用 size()
enum PenColor { RED, GREEN }public enum PenColor { RED, GREEN; }语义几乎一致
{ x: 5, y: -2 }(record type)没有直接对应,需定义类补充说明:Java 16+ 的 record Point(int x, int y) {} 最接近 TypeScript 的 record type
const { quotient, remainder } = f(23, 7);无析构语法补充说明:Java 需逐个 result.quotient() 取值

代码示例与对比分析

(sp22 原版 TypeScript 写法对照:变量声明、作用域与 record type)

// sp22 原版 TypeScript 写法
let a: number = 5;              // 可重新赋值     ↔ Java: int a = 5;
const b: number = 5;            // 不可重新赋值   ↔ Java: final int b = 5;

if (a > 10) {
  let c: number = 2;            // 作用域仅限这一对花括号   ↔ Java: int c = 2;
} else {
  // c 在这里不可见:TypeScript 与 Java 一样是块级作用域
  // c = 4;                     // 静态错误:Cannot find name 'c'
}

// 对象字面量与 record type
let point: { x: number, y: number } = { x: 5, y: 2 };
// Java 没有对象字面量;最接近的是 Java 16+ 的 record:
//   record Point(int x, int y) { }
//   Point point = new Point(5, 2);

// 解构赋值(Java 没有对应语法)
// const { quotient, remainder } = integerDivision(23, 7);

两者的块级作用域规则是一致的,所以「在 if 分支里声明、在分支外使用」在两种语言里都是编译错误;差别在于 Java 没有对象字面量、record type 与解构赋值,需要写一个显式的类(或使用 record)来打包多个返回值。

场景 1:用 == 比较对象内容——最典型的「不报错的 bug」

❌ 错误代码

// 错误:用 == 比较字符串内容
public class NameCheckBuggy {
    public static void main(String[] args) {
        String s = "foobar";
        String t = "foobar";
        System.out.println(s == t);            // 打印 true —— 靠的是字符串驻留,纯属运气

        String u = new String("foobar");
        System.out.println(s == u);            // 打印 false —— 内容一样却「不相等」
    }
}

【错误代码的问题】

  1. s == t 返回 true 不是因为内容相同,而是因为 Java 复用了同一个字符串对象(字符串驻留)。这个「优化」会让写错的代码在测试中碰巧通过。
  2. s == u 返回 false:内容完全相同却判定不相等,逻辑随之走错分支。
  3. 正确性依赖于「字符串从哪里来」这一与语义无关的实现细节,属于典型的不可靠代码:把字面量换成从文件或网络读入的字符串,行为立刻改变。
  4. 编译期毫无提示,运行期也不抛异常,错误会静默传播到很远的地方。

✅ 正确代码

// 正确:对象比内容用 equals(),原始类型比值用 ==
public class NameCheck {
    public static void main(String[] args) {
        String s = "foobar";
        String t = "foobar";
        String u = new String("foobar");

        System.out.println(s.equals(t));       // true —— 内容相同
        System.out.println(s.equals(u));       // true —— 内容相同,与对象身份无关

        int i1 = 5;
        int i2 = 5;
        System.out.println(i1 == i2);          // true —— 原始类型比较值

        char c1 = 'a';
        char c2 = 'a';
        System.out.println(c1 == c2);          // true —— char 是原始类型
    }
}

【为什么这样更好】 equals() 问的是「内容是否相同」,这正是几乎所有业务逻辑真正关心的问题;它给出的答案不依赖对象从哪来、是否被驻留、是否被缓存,因此行为稳定、可预测。而 == 只在原始类型上表达「值相同」,语义清晰无歧义。

【代码对比解说】 这段代码的教学价值在于它把一条规则拆成了两个方向:== 用于原始类型(intchardouble),equals() 用于对象(StringList、数组、自定义对象)。注意两个方向的「可发现性」完全不同:在原始类型上调 .equals()静态错误5.equals(5) 无法编译),错误立刻暴露;而在对象上用 == 完全合法,只是答案错——所以真正需要肌肉记忆的是后者。此外还要注意 s1 == i1Stringint)是静态错误,因为二者类型不可比较。

【设计原则透视】 这条规则直接对应「Safe from bugs」:== 在 Java 中是重载的(对原始类型比值、对对象比身份),重载带来的歧义正是 bug 的温床。更深的层面,equals() 的正确语义依赖对象类型对 equals/hashCode 的正确实现——这就是 Reading 15(相等性)的主题,而 Map/Set 的键必须可哈希正是本讲已经埋下的伏笔。在 Reading 11(抽象函数)中我们会看到,equals 的语义本质上由抽象函数决定。


场景 2:在迭代过程中修改集合

❌ 错误代码

import java.util.List;
import java.util.ArrayList;

// 错误:一边 for-each 遍历,一边删除元素
public class WordFilterBuggy {
    public static List<String> removeShortWords(List<String> words) {
        for (String w : words) {
            if (w.length() < 3) {
                words.remove(w);        // 破坏迭代器 → ConcurrentModificationException
            }
        }
        return words;
    }
}

【错误代码的问题】

  1. 运行期抛出 ConcurrentModificationException(若恰好删到最后一个元素附近,也可能不抛异常而是静默漏删,更难查)。
  2. 编译期无任何错误:语法与类型完全正确。
  3. 这个 bug 的行为依赖集合的具体实现与元素分布,因此「本地测不出来、线上偶发」。
  4. 同样的错误在 Python 中也存在(for num in numbers: numbers.remove(num)),从 Python 迁过来时极容易原样照搬。

✅ 正确代码

import java.util.List;
import java.util.ArrayList;
import java.util.Iterator;

// 正确一:用集合自己的批量操作,由库保证迭代安全
public class WordFilter {
    public static List<String> removeShortWords(List<String> words) {
        words.removeIf(w -> w.length() < 3);
        return words;
    }

    // 正确二:需要更复杂的条件时,显式使用 Iterator 的 remove()
    public static List<String> removeShortWordsWithIterator(List<String> words) {
        Iterator<String> iter = words.iterator();
        while (iter.hasNext()) {
            String w = iter.next();
            if (w.length() < 3) {
                iter.remove();          // 通过迭代器删除,迭代状态保持一致
            }
        }
        return words;
    }
}

【为什么这样更好】 removeIf 把「遍历 + 条件删除」交给集合自己实现,库内部使用的正是 Iterator.remove(),因此迭代状态始终一致。当条件复杂到 removeIf 表达不了时,显式使用 Iterator 是标准做法:iter.next()iter.remove() 配合,保证「读一个、判一个、删一个」的节奏不会被打乱。若删除逻辑很复杂,还有一种更朴素的策略:先把要删的元素收集到另一个列表,遍历结束后再统一删除。

【代码对比解说】 关键区别在于谁掌握迭代状态。for-each 把迭代状态藏起来了(这正是它简洁的原因),同时也把「不允许中途修改」变成了一条隐性契约;一旦违反,库只能通过 modCount 检查在下次 next() 时抛异常来「报警」。显式 Iterator 把状态交回你手上,于是你有了合法修改的入口。这三段代码在功能上等价,在安全性上差别巨大——这正是「用库提供的正确抽象,而不是自己拼装」这一工程直觉的体现。

【设计原则透视】 这组对比体现了本讲「可变 vs. 不可变」的实用推论:共享可变状态是 bug 之源。迭代器与集合之间共享着一个可变的状态(当前位置),外部修改破坏了迭代器依赖的不变量(类似于 Reading 11 中的表示不变量被外部代码破坏)。最彻底的解法是避免可变性——例如让方法返回一个 List 而不是原地修改(filter 式的函数式风格),这也预告了 Reading 16(map/filter/reduce)的主题。


场景 3:用字符串常量表示有限取值 vs. 用枚举

❌ 错误代码

// 错误:用字符串常量表示有限的颜色集合
public class PenBuggy {
    public static final String RED = "RED";
    public static final String GREEN = "GREEN";

    private String drawingColor = "REd";      // 拼写错误,编译器不报

    public void setDrawingColor(String color) {
        this.drawingColor = color;            // 可以传入 "purple"、""、null……
    }

    public static void main(String[] args) {
        PenBuggy pen = new PenBuggy();
        pen.setDrawingColor("bleu");          // 静态检查完全帮不上忙
        System.out.println(pen.drawingColor);
    }
}

【错误代码的问题】

  1. 拼写错误无法被发现:"REd""RED" 都是合法字符串,错误要等到运行期比较失败时才暴露。
  2. 类型太宽:任何字符串都能通过 setDrawingColor,包括拼错、空串甚至 null
  3. 语义不可读:setDrawingColor("3") 这样的调用看不出意图,读者需要去查常量表。
  4. 「有限集合」这一重要的设计约束完全没有被代码表达出来,也就无法被编译器守护。

✅ 正确代码

// PenColor.java —— 枚举是一个独立的新类型
public enum PenColor {
    BLACK, GRAY, RED, PINK, ORANGE,
    YELLOW, GREEN, CYAN, BLUE, MAGENTA;
}
// Pen.java
public class Pen {
    private PenColor drawingColor = PenColor.RED;

    public void setDrawingColor(PenColor color) {
        this.drawingColor = color;
    }

    public PenColor getDrawingColor() {
        return drawingColor;
    }

    public static void main(String[] args) {
        Pen pen = new Pen();
        pen.setDrawingColor(PenColor.RED);
        // pen.setDrawingColor("bleu");    // 静态错误:String 不能转换为 PenColor
        // pen.setDrawingColor(PenColor.REd); // 静态错误:找不到符号 REd
    }
}

【为什么这样更好】 枚举把「这个类型只有这十种合法取值」这条设计约束编码进了类型系统,于是拼写错误、类型不匹配、传入域外值全部变成编译错误。这正是 Reading 01 中「把假设交给编译器检查」的最佳实践:能静态检查的假设,绝不留到运行期。

【代码对比解说】 三种表示方式的安全性依次递增:int 常量(int month = TUESDAY; 不报错,且数值含义不可读)→ 字符串常量(能读懂,但拼写不受保护、取值域不受限制)→ 枚举(拼写受保护、取值域受保护、语义可读)。选择哪种取决于约束的强度:如果确实会出现「域外值」,就应当重新审视设计,而不是放宽类型。

【设计原则透视】 枚举是「用类型表达约束」的典范,属于三大目标中的两项之和:Safe from bugs(编译期拦截非法取值)与 Easy to understand(PenColor.RED2"red" 可读得多)。它也为 Reading 12(接口、泛型与枚举)打下基础——在那里我们会看到枚举可以有字段与方法。此外,枚举天然是不可变的有限值集合,因此可以作为 Map 的键与 Set 的元素安全使用。


场景 4:放弃泛型——用裸类型装不同类型

❌ 错误代码

import java.util.List;
import java.util.ArrayList;

// 错误:使用裸类型(raw type),等于主动放弃静态检查
public class RawListBuggy {
    public static void main(String[] args) {
        List numbers = new ArrayList();       // 没有类型参数
        numbers.add(5);
        numbers.add("six");                   // 编译通过 —— 灾难
        int first = (Integer) numbers.get(0); // 需要显式强转
        int second = (Integer) numbers.get(1);// 运行期 ClassCastException
    }
}

【错误代码的问题】

  1. 编译器不再检查元素类型,"six" 能混进一个「数字列表」,错误被推迟到运行期。
  2. 取出元素必须显式强转 (Integer),每处强转都是一个潜在崩溃点。
  3. 强转错误抛出的 ClassCastException 发生在离错误源头很远的地方(写入点在第 7 行,崩溃点在第 10 行),排查成本高。
  4. 代码读起来无法判断这个列表装的是什么,读者必须通读所有 add 调用。

✅ 正确代码

import java.util.List;
import java.util.ArrayList;

// 正确:用泛型把元素类型写进类型,交给编译器检查
public class TypedList {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        numbers.add(5);                       // 自动装箱:int → Integer
        numbers.add(6);
        // numbers.add("six");                // 静态错误:不兼容的类型

        int second = numbers.get(1);          // 自动拆箱:Integer → int,无需强转
        System.out.println(second);           // 6

        // 不允许 List<int>:泛型参数必须是对象类型
        // List<int> bad = new ArrayList<>();  // 静态错误
    }
}

【为什么这样更好】 泛型把「这个容器里装的是什么」写进类型,于是错误在写入的那一行就被编译器拦下,而不是等到读取时崩溃。取出元素时类型已知,不再需要强转,代码既更安全也更简洁。

【代码对比解说】 注意 Java 的自动装箱/拆箱:numbers.add(5) 会自动把 int 包装成 Integerint second = numbers.get(1) 会自动拆箱,因此泛型带来的语法负担几乎为零。但要注意拆箱的风险:补充说明,如果 List<Integer> 中某个元素是 null,拆箱会抛 NullPointerException。另外要区分「编译期类型」与「运行期类型」:泛型参数在运行期被擦除(type erasure),所以 List<Integer>List<String> 在运行期是同一个类——这正是为什么不能写 new T[]、也为什么不能重载 f(List<Integer>)f(List<String>)(后者是静态错误)。

【设计原则透视】 泛型是本讲所有内容中最纯粹地体现「静态检查」价值的一处:它把一条本来只能靠文档与约定维持的假设(「这个列表里都是整数」)变成了机器可验证的类型声明。它与 Reading 01 的 Array<number>List<int> 讨论一脉相承,也直接连接到 Reading 12(泛型)与 Reading 10(抽象数据类型)——在那里我们关心的不再是「容器里装什么」,而是「这个数据类型的抽象行为是什么」。


场景 5:final 引用不等于不可变值——泄漏内部表示

❌ 错误代码

import java.util.List;
import java.util.ArrayList;

// 错误:以为 final 就万事大吉,把内部可变列表直接交出去
public class PlaylistBuggy {
    private final List<String> songs = new ArrayList<>();

    public void add(String song) {
        songs.add(song);
    }

    public List<String> getSongs() {
        return songs;                  // 泄漏内部表示:调用者拿到的是同一个可变对象
    }

    public static void main(String[] args) {
        PlaylistBuggy p = new PlaylistBuggy();
        p.add("Yesterday");
        List<String> leaked = p.getSongs();
        leaked.clear();                // 从外部把「封装」好的状态清空了
        System.out.println(p.getSongs().size());   // 0 —— 内部状态被悄悄破坏
    }
}

【错误代码的问题】

  1. final 只保证引用不被重新赋值,songs 指向的列表内容仍可被任何人修改。
  2. getSongs() 把内部表示直接暴露出去(表示泄漏,representation exposure),封装形同虚设。
  3. 外部代码可以在毫无提示的情况下破坏对象的不变量(例如「歌单里至少有一首歌」),这类 bug 极难定位。
  4. 若将来把内部表示从 List 换成别的结构,所有依赖「拿到的是可变列表」的客户代码都会失效——违反 Ready for change。

✅ 正确代码

import java.util.List;
import java.util.ArrayList;
import java.util.Collections;

// 正确:引用不可重新赋值 + 不把可变内部表示交出去
public class Playlist {
    private final List<String> songs = new ArrayList<>();

    public void add(String song) {
        songs.add(song);
    }

    /** @return an unmodifiable view of the songs in this playlist */
    public List<String> getSongs() {
        return Collections.unmodifiableList(songs);   // 补充说明:Java 标准库 API
    }

    /** @return a snapshot copy of the songs in this playlist */
    public List<String> snapshot() {
        return List.copyOf(songs);                    // 补充说明:Java 10+ API
    }

    public static void main(String[] args) {
        Playlist p = new Playlist();
        p.add("Yesterday");
        p.getSongs().clear();          // 运行期抛 UnsupportedOperationException
    }
}

【为什么这样更好】 返回 Collections.unmodifiableList(songs) 让调用者拿到的引用不能修改:任何写操作(addclearset)都会抛出 UnsupportedOperationException,从而把「不得从外部修改」这条规则变运行期为显式错误,而不是静默破坏。若调用者需要一份可以自由改动的数据,就给他 List.copyOf(songs)(一份独立的快照),这样他怎么改都不会影响原始对象。

【代码对比解说】 两种保护手段的差别值得记牢:Collections.unmodifiableList 返回的是一个视图(view)——它不复制数据,因此开销小,但它会随原列表的变化而变化p.add(...) 之后视图里也能看到新元素);List.copyOf 返回的是一个副本,与原列表彻底解耦。选择哪一个取决于语义:想表达「这是当前状态的只读视图」就用视图,想表达「这是此刻的快照」就用副本。不要为了「不可变」而盲目复制大集合,那是无谓的性能损失。

【设计原则透视】 这组对比精准地示范了本讲的核心区分:final 管的是「箭头不动」,不可变性管的是「气泡不变」,而封装管的是「气泡不许别人碰」。真正的安全来自三者配合:private 限制访问,final 固定引用,不可变返回类型切断外部修改路径。这正是 Reading 08(不可变性)的主题,而「内部表示不得泄漏」在 Reading 11(抽象函数与表示不变量)中会作为 RI 的一部分被正式定义:只要内部表示可能被外部代码随意改动,我们就根本无法为它写出任何有意义的 RI。

与其他设计原则的关联

  • Reading 01(静态检查 / Static Checking):本讲是 Reading 01 的语法落地。final 对应 constList<Integer> 对应 Array<number>,泛型把 Reading 01 中「静态检查面向类型」的结论变成日常写法;int[100] 的缓冲区溢出教训则直接解释了为什么要用 List
  • Reading 03(测试 / Testing):本讲场景 1 的 == 陷阱与场景 2 的迭代修改陷阱,都是「单元测试必须覆盖的划分」。互不相等的字符串(字面量 vs. new String)正是「字符串相等性」这一输入子域的边界情形。
  • Reading 06(规格说明 / Specifications)Reading 07(设计规格说明 / Designing Specifications)Collections.unmodifiableList 的注释其实就是一条后置条件;「前置条件该不该检查」「返回类型该不该用接口」都是规格设计问题。
  • Reading 08(不可变性 / Immutability):本讲场景 5 提出的「表示泄漏」在那里得到系统解决;不可变类型如何带来安全性与线程安全性,是 Reading 08 的核心。
  • Reading 10(抽象数据类型 / Abstract Data Types):本讲的 List/Set/Map 就是 Java 库中的 ADT;Reading 10 会解释「为什么用接口声明、用实现类构造」这一习惯背后的抽象思想。
  • Reading 11(抽象函数与表示不变量 / Abstraction Functions & Rep Invariants):本讲的快照图是 RI/AF 的图示语言;private final 字段与「不泄漏表示」是维持 RI 的必要条件。
  • Reading 12(接口、泛型与枚举 / Interfaces, Generics, Enums):本讲只介绍了这三者的基本用法,Reading 12 会把它们整合成完整的抽象与参数化设计工具。
  • Reading 15(相等性 / Equality):本讲 .equals() 的正确语义、Map 键必须可哈希,都在 Reading 15 中展开为 equals/hashCode 契约。
  • Reading 21–23(并发 / Concurrency、线程安全 / Thread Safety、锁 / Locks):本讲「共享可变状态是 bug 之源」的直觉,在并发中成为硬约束——不可变对象天生线程安全,可变对象必须加锁。

关键要点

  • 对象比内容用 equals(),原始类型比值用 ==== 用在对象上不会报错,只会安静地给出错误答案,因此必须靠习惯而不是靠编译器来防。
  • 区分四个正交概念:引用是否可重新赋值(final)、值是否可变(String vs StringBuilder)、变量作用域(花括号)、参数传递(按值传递引用)。
  • 声明用接口,构造用实现List<String> x = new ArrayList<>();,让实现可替换。
  • 把类型信息完整写出来:用 List<String> 而非裸 List,用 enum 而非字符串常量,让编译器替你守护尽可能多的假设。
  • 不要泄漏可变内部表示:返回只读视图或快照副本;final 不解决封装问题。

常见陷阱与注意事项

  1. ==.equals() 混用:字符串字面量的驻留让你「测出来是对的」。→ 后果:一旦换成从文件、网络或 substring 得到的字符串,逻辑立刻走错分支。
  2. 以为 final 就等于不可变final List<String> l = new ArrayList<>(); l.add("x"); 是合法的。→ 后果:误以为对象受到保护,实际内部状态仍可被任意修改。
  3. 在 for-each 中增删元素ConcurrentModificationException,或者更糟——静默漏删。→ 后果:行为随集合实现与数据分布而变,难以复现。
  4. 忘记原始类型集合的限制:写 List<int>Set<int> 会编译失败;用 List<Integer> 时若元素为 null,拆箱抛 NullPointerException。→ 后果:编译期报错(尚可接受)或运行期空指针(危险)。
  5. 混淆 TypeScript 的 let 与 Java 的 var:sp22 中的 let 对应 Java 的「声明 + 可重新赋值」,而 Java 10+ 的 var 只是类型推断,是静态类型。→ 后果:把 var 当成动态类型使用,写出难以理解的代码。
  6. 把 API 文档当摆设:不查 assertEquals 的参数顺序、不查 Map.put 返回的是旧值还是新值、不查 replace 返回 boolean 还是旧值。→ 后果:写出语义相反的测试或逻辑,且自己看不出来(详见 Reading 03 与 Reading 06)。

思考题(带答案)

问题 1:运行下面的代码,s 最终指向的内容是什么?sb 最终指向的内容是什么?请分别说明是「重新赋值」还是「改变值」。

public class Params {
    static void f(String s, StringBuilder sb) {
        s.concat("b");
        s += "c";
        sb.append("d");
    }

    public static void main(String[] args) {
        String t = "a";
        StringBuilder tb = new StringBuilder("a");
        f(t, tb);
        System.out.println(t + " / " + tb);
    }
}

答案:输出 a / adt 仍是 "a":方法内 s.concat("b") 返回新字符串但返回值被丢弃(String 不可变,没有任何东西被改变);s += "c"局部变量 s 重新指向新对象 "ac",这属于「重新赋值」,只影响方法内的那个箭头,方法返回后 t 的箭头毫无变化。tb 变成 "ad"sb.append("d") 是「改变值」——局部变量 sb 与调用者的 tb 指向同一个对象,因此对该对象内部状态的修改对外可见。这组对比说明:Java 的参数传递总是按值传递引用;能影响调用者的只有「对共享对象的修改」,而绝不是「对参数重新赋值」。

问题 2:下面三个声明中,哪些在编译期就会失败?为什么?

List<int> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
Integer c = 5;

答案:只有第一个失败。List<int> 是静态错误,因为泛型类型参数必须是对象类型(引用类型),不能是原始类型;必须写作 List<Integer>。第二个合法(菱形语法推断出 ArrayList<Integer>)。第三个也合法:Java 会自动装箱,把 int 字面量 5 包装成 Integer 对象。补充说明Integer c = null; int d = c; 会在运行期抛 NullPointerException——自动拆箱不检查 null,这是包装类型相对于原始类型多出的风险点。

问题 3:一位同学写了 private final List<String> names = new ArrayList<>();,并认为「加了 final,所以 names 是不可变的,外部无法修改」。请指出这句话的两处错误,并给出修正方案。

答案:第一处错误:final 约束的是引用,不是names 这个箭头不能再指向别的列表,但 names.add(...)names.clear() 完全合法,列表内容随时可变。第二处错误:即使字段是 private,只要把内部列表本身作为返回值交出去(return names;),外部代码就能通过这个引用修改它——封装被绕过,表示被泄漏。修正方案分两点:(1)对外返回只读视图 Collections.unmodifiableList(names) 或快照副本 List.copyOf(names);(2)如果语义上确实要求「集合内容也不可变」,就改用 List.of(...) 构造,或把所有修改方法收拢到类自己的接口内。补充说明Collections.unmodifiableList 返回的是视图,原列表变化时视图也会变化;List.copyOf 返回的是独立副本。选视图还是副本,取决于你想表达「只读视图」还是「当下快照」。