Reading 2: Java 基础(sp22 原版为 Basic TypeScript)
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);对象类型的值是一个「指针」,指向堆上的一个气泡。因此原始类型没有内部结构,而对象类型可以有内部结构(引用其他原始值或对象)。
- 关键规则与最佳实践:
- 原始类型:
int、long、boolean、double、float、char等,约定全部小写,占用固定的小块内存。 - 对象类型:
String、BigInteger、List、Turtle以及自定义类,约定首字母大写,占用可变且可能很大的内存。 - 判别规则(来自本讲练习):原始类型并不只是「数值」,
char是原始类型而String不是;判别的可靠依据是约定的大小写、是否有内部结构、是否占用固定小内存。 - 每个原始类型都有包装类型(wrapper type):
int/Integer、long/Long、double/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 == 5为true,'a' == 'a'为true);对对象类型比较身份(两个箭头是否指向同一个气泡),在 Python 中对应的是is。.equals()对对象比较内容("abc".equals("abc")为true)。 - 关键规则与最佳实践:
- 比较原始类型(
int、char、double)用==;比较对象(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 中这意味着它们必须正确实现hashCode与equals(Reading 15 会详细展开)。
- List:
字面量(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 包装成Integer;int second = sequence.get(1);自动拆箱为int。 - 构造时的菱形语法省略右侧参数:
List<String> names = new ArrayList<>();。 - 补充说明:raw type(裸类型,如
List numbers)会关闭泛型检查,只在维护遗留代码时才会遇到;新代码一律使用泛型。
- 声明形式:
接口与实现类:ArrayList、LinkedList、HashSet、HashMap
- 定义与目的:Java 帮我们区分规格说明(这个类型做什么)与实现(代码长什么样)。
List、Set、Map都是接口(interface),只规定行为,不提供实现;使用者可以在不同场景选择不同实现。 - 直观解释(”它是什么?”):接口是「插座标准」,实现类是「具体的插座」。你的电器(客户代码)只依赖标准,因此可以随时换插座。
- 关键规则与最佳实践:
- 创建:
List<String> firstNames = new ArrayList<>();、List<String> lastNames = new LinkedList<>();。 ArrayList与LinkedList都完整提供List的全部操作,行为完全一致,只是性能不同;互换它们不会破坏代码。- 在 6.031 中:拿不准就用
ArrayList;Set默认用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、用<=而不是<、某处变量名写错等)。
- List 与 Set 都可以直接 for-each;
变量声明与作用域(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),它让你无需阅读实现代码就能正确使用
String、Map、BufferedReader。 - 直观解释(”它是什么?”):文档是「黑盒说明书」,实现是「白盒内部」。规格说明的存在,正是抽象边界得以成立的前提。
- 关键规则与最佳实践:
- 读文档的顺序:类描述 → 构造器摘要 → 方法摘要 → 点击具体方法看方法签名(返回类型、方法名、参数、可能抛出的异常)、完整描述、参数说明、返回值说明。
- 类层次与方法摘要能帮你发现「
HashMap是Map的实现」这类关系。 - 用搜索框直接跳到类、接口或方法,是查阅 API 的最快路径。
- TypeScript 对照(sp22 原版写法):TypeScript 的三处文档来源是 MDN JavaScript 参考(通用特性与浏览器 API)、Node.js 参考(非浏览器 API,如
fs.readFileSync、os.homedir)以及 TypeScript Handbook(TypeScript 特有特性)。Java 的对应物是 Java API 文档中的java.lang.String、java.util.List、java.util.Map、java.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.size | s.contains(e) / s.add(e) / s.size() | Java 的方法调用一律带括号 |
arr.push(x) / arr.length | list.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 —— 内容一样却「不相等」
}
}
【错误代码的问题】
s == t返回true不是因为内容相同,而是因为 Java 复用了同一个字符串对象(字符串驻留)。这个「优化」会让写错的代码在测试中碰巧通过。s == u返回false:内容完全相同却判定不相等,逻辑随之走错分支。- 正确性依赖于「字符串从哪里来」这一与语义无关的实现细节,属于典型的不可靠代码:把字面量换成从文件或网络读入的字符串,行为立刻改变。
- 编译期毫无提示,运行期也不抛异常,错误会静默传播到很远的地方。
✅ 正确代码
// 正确:对象比内容用 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() 问的是「内容是否相同」,这正是几乎所有业务逻辑真正关心的问题;它给出的答案不依赖对象从哪来、是否被驻留、是否被缓存,因此行为稳定、可预测。而 == 只在原始类型上表达「值相同」,语义清晰无歧义。
【代码对比解说】 这段代码的教学价值在于它把一条规则拆成了两个方向:== 用于原始类型(int、char、double),equals() 用于对象(String、List、数组、自定义对象)。注意两个方向的「可发现性」完全不同:在原始类型上调 .equals() 是静态错误(5.equals(5) 无法编译),错误立刻暴露;而在对象上用 == 完全合法,只是答案错——所以真正需要肌肉记忆的是后者。此外还要注意 s1 == i1(String 与 int)是静态错误,因为二者类型不可比较。
【设计原则透视】 这条规则直接对应「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;
}
}
【错误代码的问题】
- 运行期抛出
ConcurrentModificationException(若恰好删到最后一个元素附近,也可能不抛异常而是静默漏删,更难查)。 - 编译期无任何错误:语法与类型完全正确。
- 这个 bug 的行为依赖集合的具体实现与元素分布,因此「本地测不出来、线上偶发」。
- 同样的错误在 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);
}
}
【错误代码的问题】
- 拼写错误无法被发现:
"REd"与"RED"都是合法字符串,错误要等到运行期比较失败时才暴露。 - 类型太宽:任何字符串都能通过
setDrawingColor,包括拼错、空串甚至null。 - 语义不可读:
setDrawingColor("3")这样的调用看不出意图,读者需要去查常量表。 - 「有限集合」这一重要的设计约束完全没有被代码表达出来,也就无法被编译器守护。
✅ 正确代码
// 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.RED 比 2 或 "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
}
}
【错误代码的问题】
- 编译器不再检查元素类型,
"six"能混进一个「数字列表」,错误被推迟到运行期。 - 取出元素必须显式强转
(Integer),每处强转都是一个潜在崩溃点。 - 强转错误抛出的
ClassCastException发生在离错误源头很远的地方(写入点在第 7 行,崩溃点在第 10 行),排查成本高。 - 代码读起来无法判断这个列表装的是什么,读者必须通读所有
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 包装成 Integer,int 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 —— 内部状态被悄悄破坏
}
}
【错误代码的问题】
final只保证引用不被重新赋值,songs指向的列表内容仍可被任何人修改。getSongs()把内部表示直接暴露出去(表示泄漏,representation exposure),封装形同虚设。- 外部代码可以在毫无提示的情况下破坏对象的不变量(例如「歌单里至少有一首歌」),这类 bug 极难定位。
- 若将来把内部表示从
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) 让调用者拿到的引用不能修改:任何写操作(add、clear、set)都会抛出 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对应const,List<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)、值是否可变(StringvsStringBuilder)、变量作用域(花括号)、参数传递(按值传递引用)。 - 声明用接口,构造用实现:
List<String> x = new ArrayList<>();,让实现可替换。 - 把类型信息完整写出来:用
List<String>而非裸List,用enum而非字符串常量,让编译器替你守护尽可能多的假设。 - 不要泄漏可变内部表示:返回只读视图或快照副本;
final不解决封装问题。
常见陷阱与注意事项
==与.equals()混用:字符串字面量的驻留让你「测出来是对的」。→ 后果:一旦换成从文件、网络或substring得到的字符串,逻辑立刻走错分支。- 以为
final就等于不可变:final List<String> l = new ArrayList<>(); l.add("x");是合法的。→ 后果:误以为对象受到保护,实际内部状态仍可被任意修改。 - 在 for-each 中增删元素:
ConcurrentModificationException,或者更糟——静默漏删。→ 后果:行为随集合实现与数据分布而变,难以复现。 - 忘记原始类型集合的限制:写
List<int>或Set<int>会编译失败;用List<Integer>时若元素为null,拆箱抛NullPointerException。→ 后果:编译期报错(尚可接受)或运行期空指针(危险)。 - 混淆 TypeScript 的
let与 Java 的var:sp22 中的let对应 Java 的「声明 + 可重新赋值」,而 Java 10+ 的var只是类型推断,是静态类型。→ 后果:把var当成动态类型使用,写出难以理解的代码。 - 把 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 / ad。t 仍是 "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 返回的是独立副本。选视图还是副本,取决于你想表达「只读视图」还是「当下快照」。
