Reading 8: 可变性与不可变性(Mutability & Immutability)
Reading 8: 可变性与不可变性(Mutability & Immutability)
说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。
概述
本讲要回答的问题是:既然可变对象看起来「能力更强」,为什么我们仍然应当尽量使用不可变对象和不可重赋值的引用? 6.031 的答案是:不可变类型 更不容易出 bug(safe from bugs)、更容易理解(easy to understand)、更适应变化(ready for change);而可变性的真正危险来源不是「对象能改」,而是别名(aliasing)——同一个可变对象被程序的多处引用,任何一处都能在不通知其他人的情况下改变它,契约因此从「调用点附近的局部约定」退化为「贯穿整个程序生命周期的全局约定」。
围绕这一命题,本讲依次讨论:可变对象在「传入参数」与「返回结果」两条路径上引发的经典 bug(List 与 Date)、防御性拷贝(defensive copy)及其代价、修改型方法必须在规格中声明 Modifies、迭代器与「迭代中修改」导致的下标错位、不可变类的设计规则、Collections.unmodifiableList 这类不可修改视图的局限,以及不可变性在共享与性能上的真实优势。它上承 Reading 7(Designing Specifications)的契约语言,下启 Reading 10、11 的抽象数据类型与表示不变量、Reading 15 的相等性与 hashCode,并为 Reading 21、23 的并发安全打基础。
核心概念与设计原则详解
可变对象与不可变对象(Mutable vs. Immutable Objects)
- 定义与目的:不可变对象(immutable object)一旦创建就永远表示同一个值,不存在能改变其值的方法;可变对象(mutable object)提供会改变自身值的方法。这一区分是 Safe from bugs 的第一道防线。
- 直观解释(”它是什么?”):Java 的
String是不可变的——想「在末尾追加字符」必须创建一个新String(s = s.concat("b"));StringBuilder是可变的——sb.append("b")直接改变对象本身。两者最终都能表示"ab",差别只在当存在多个引用时才显现:String t = s; t = t + "c";不会影响s,而StringBuilder tb = sb; tb.append("c");会连带改变sb所指向的内容。 - 关键规则与最佳实践:默认选择不可变类型;只有「局部、单一引用」的场景才放心使用可变对象;把「对象不可变」与「引用不可重赋」严格区分(见下一条);在快照图(snapshot diagram)中用双线边框表示不可变对象、双线箭头表示不可重赋值的引用,用来在脑内验证代码行为。
不可重赋值的引用与不可变对象(Unreassignable References vs. Immutable Objects)
- 定义与目的:
final(Java)/const、readonly(TypeScript)只保证引用本身不能被重新赋值,完全不保证被指向的对象不可变。区分这两件事是避免「我明明写了 final,为什么它还是变了」这类误解的关键。 - 直观解释(”它是什么?”):
final List<String> roster = new ArrayList<>();中的roster永远指向同一个列表对象,但这个列表里的元素可以被自由add、remove(因此MyIterator的list字段声明为final,表示迭代器一生都盯着同一个集合)。这与String那种「对象内部都改不了」的不可变性是两个层次。 - 关键规则与最佳实践:把
final用在所有能用的地方——方法参数、局部变量、字段,它既是给读者的文档(「这里不会重新指向别的对象」),也是编译器静态检查的对象;但它不能替代不可变性设计;final字段是构造不可变类的必要条件而非充分条件(若字段指向可变对象,仍可能通过该对象泄漏可变性)。
别名(Aliasing):可变性风险的根源
- 定义与目的:别名指多个引用指向同一个对象。只有在存在别名时,可变性才会伤害他人;因此理解别名是理解本讲所有 bug 案例的统一钥匙(Safe from bugs)。
- 直观解释(”它是什么?”):如果某个可变对象从头到尾只有一个引用,且完全活在某个方法内部,那么随意修改它是安全的。问题出在两个程序员(或同一程序员的两个模块)各自持有一个别名:一个认为「我可以改」,另一个认为「它不会变」,后者就输了。
- 关键规则与最佳实践:写代码时先问「这个对象现在有几个引用?谁能看见它?」;跨模块传递可变对象前,先决定是「明确共享」还是「隔离」;用快照图把别名画出来(先在纸上画,最终目标是能在脑中画);一旦发现某个可变对象被两个以上模块持有,就要评估是否应该改成不可变或在边界上拷贝。
风险一:传入可变值(Risky Example #1: Passing Mutable Values)
- 定义与目的:把可变对象作为参数传入方法,等于把「它可能被改」的权利交给被调方;即使被调方动机良好(复用、性能),也可能改变调用方的数据(Safe from bugs、Easy to understand)。
- 直观解释(”它是什么?”):
sumAbsolute(list)为了复用sum(list),先把列表里每个元素改成绝对值再求和——从实现者角度这是 DRY 加性能双赢,但调用方main手里的myData被悄悄改成了正数,之后sum(myData)得到 10 而不是 -10。 - 关键规则与最佳实践:如果方法不打算修改参数,就绝不修改它(并且在规格里不写
Modifies);确实需要修改时,必须在规格中明确声明,让客户自己决定是否接受;怀疑有副作用时,优先构造新对象而不是原地修改;把「参数被修改」当作潜伏 bug:它不会立刻报错,而是在某次看似无关的改动后爆发。
风险二:返回可变值(Risky Example #2: Returning Mutable Values)
- 定义与目的:返回可变对象同样会制造别名:调用方持有返回对象,实现者可能把它存进缓存,两边互相踩踏(Safe from bugs、Ready for change)。
- 直观解释(”它是什么?”):
startOfSpring()先返回askGroundhog()的结果;后来实现者为了少打扰土拨鼠加了缓存(private static Date groundhogAnswer);同时某个客户为了把派对推迟一个月写了partyDate.setMonth(partyDate.getMonth() + 1)——两处独立改动一交互,缓存里的「春天第一天」被永久改成一个月后,而最先发现 bug 的往往是与这两处都无关的第三个调用者(Ready for change 的反例:两个看起来独立的改动叠加出严重 bug)。 - 关键规则与最佳实践:优先返回不可变类型(用
java.time.LocalDate、Instant而不是Date);若必须返回可变对象,就在返回处做防御性拷贝;反过来,接收可变参数并保存为字段时也要拷贝(copy-in);记住「不可变性让 bug 在设计上不可能发生」——这比事后打补丁更可靠。
防御性拷贝(Defensive Copying)
- 定义与目的:在「可变对象跨越模块边界」的两个方向上各拷贝一份(copy-in:构造器/设值方法入口拷贝;copy-out:返回前拷贝),从而切断别名。它是可变对象与不可变契约之间的折中方案。
- 直观解释(”它是什么?”):
return new Date(groundhogAnswer.getTime());让每个调用方拿到自己的副本,partyPlanning想怎么改都不影响缓存。代价是每个客户都要付一次拷贝的时间与空间,哪怕 99% 的客户从不变更返回值。 - 关键规则与最佳实践:拷贝必须在所有跨越抽象边界的方向上做,漏一处就前功尽弃;拷贝是浅拷贝还是深拷贝取决于内部结构(若元素本身可变,浅拷贝不够);把「用拷贝换安全」与「用不可变类型换安全」作对比——不可变类型永远不需要防御性拷贝,因此常常更快也更省内存;防御性拷贝是可用的工程手段,但优先考虑不可变设计。
修改型方法的规格(Specifications for Mutating Methods)
- 定义与目的:如果方法会修改对象(参数或
this),必须在规格中显式声明(Modifies:子句或effects中的描述)。这是 Reading 7 契约结构在可变性下的必然要求(Safe from bugs、Easy to understand)。 - 直观解释(”它是什么?”):
static void sort(List<String> list)的effects写「把 list 排成升序」,客户一看就知道自己传进去的列表会被改;而static List<String> toLowerCase(List<String> list)的effects写「返回一个新列表 t,其中 t[i] = list[i].toLowerCase()」,客户就知道原列表安然无恙。 - 关键规则与最佳实践:effects 里没有声明修改,就等于禁止修改——「惊喜式修改(surprise mutation)」是 bug 的温床;对
this的修改同样要写(例如next()的Modifies: this iterator);写测试时按规格断言「未被声明修改的对象不应改变」,例如对toLowerCase断言入参列表保持不变。
迭代器与「迭代中修改」(Iterators and Mutation During Iteration)
- 定义与目的:迭代器(iterator)是「依次取元素」的可变对象,
next()既返回元素又推进自身位置——它是修改型方法的典型例子。理解它与集合之间的别名关系,可以解释一大类难查的 bug(Safe from bugs)。 - 直观解释(”它是什么?”):Java 的
for (String s : subjects)会被编译器改写成Iterator循环;迭代器内部记着一个下标。如果循环体里删除了集合中的元素,后面的元素会整体前移,而迭代器的下标不调整,于是跳过某些元素。例如dropCourse6(["6.045", "6.031", "6.036"])期望得到空列表,实际却留下["6.031"]——第一轮删掉下标 0 后,原本在下标 1 的"6.031"移到下标 0,而迭代器已经推进到下标 1,永远不会再看它。 - 关键规则与最佳实践:迭代过程中禁止修改被迭代的集合;需要边遍历边过滤时,改用「收集到新列表再替换/清空」的方式,或用
Iterator.remove()(迭代器会自行调整位置)、removeIf、stream().filter(...);注意ArrayList的迭代器会主动检测这种修改并抛出ConcurrentModificationException(快速失败,见 Reading 9),而自写的MyIterator不会,只会默默给出错误结果;即使Iterator.remove()也只解决了「只有一个迭代器」的情形——多迭代器并发、或做了排序之类的复杂修改时依然不安全。
不可变性的三大好处(The Three Benefits of Immutability)
- 定义与目的:不可变性是本讲的核心设计原则,它同时改进课程的三大质量目标。
- 直观解释(”它是什么?”):Safe from bugs——不可变对象不受别名 bug 影响,不可重赋值的变量永远指向同一个对象;Easy to understand——读者不必追踪「这个对象在整个程序里可能被谁改过」,因为答案恒为「没人能改」;Ready for change——既然运行期不可能变,依赖它的一切代码在程序演化时都不必跟着改。
- 关键规则与最佳实践:把「不可变对象 + 不可重赋值引用」当作默认选择,可变性当作需要理由的例外;在代码评审中把「未经声明就修改参数」当作必须修复的问题;用不可变性把「全局推理」变成「局部推理」——这是它最深远的价值。
不可变类的设计规则(Design Rules for Immutable Classes)
- 定义与目的:如何真正写出一个不可变类?需要同时堵住三条泄漏通道,否则类会「看起来不可变、实际上可被改」(Safe from bugs)。
- 直观解释(”它是什么?”):三条规则分别是:(1)所有字段用
final并在构造器中初始化——引用不再重赋;(2)不提供任何修改字段的方法,也不要把可变字段暴露出去(不要返回内部可变对象,也不要在构造器中保存外部传入的可变对象,即 copy-in/copy-out);(3)内部若持有可变对象,要么让它是私有的且从不外泄,要么它本身就是不可变的。注意「所有字段都是 final」并不意味着类是安全的:final List<String> animals指向的列表仍可被add,所以还要配合(2)。 - 关键规则与最佳实践:用
private final声明字段;构造器对可变参数做防御性拷贝;观察器(getter)返回不可变视图或副本,绝不直接返回内部可变对象;类本身可以声明为final以防子类破坏不可变性;写完类后自查一句:类外是否存在任何能改变本类实例可观察状态的代码路径? 如果存在,它就不是不可变类。
Collections.unmodifiableList 的局限(The Limitation of Unmodifiable Views)
- 定义与目的:Java 的
Collections.unmodifiableList/Set/Map返回的是不可修改视图(unmodifiable view)——一个包在底层集合外面的包装器,任何通过包装器的修改都会抛出UnsupportedOperationException。但它不是不可变集合:底层集合若仍有别名存在,依旧可以被改,视图会跟着变。 - 直观解释(”它是什么?”):
List<String> view = Collections.unmodifiableList(roster);之后,roster.add("bob")合法,并且view立刻显示["alice", "bob"]——包装器挡住的只是「通过它自己」的修改。要真正隔离,必须同时抛弃对底层集合的引用(让持有roster的局部变量离开作用域),或者直接使用List.copyOf(...)得到一份不可修改的(浅)拷贝,或者用List.of(...)构造不可变集合。TypeScript 中对应的ReadonlyArray也是同样性质:它只是一个界面(interface),没有构造器,必须先有Array再赋给它,因此无法保证不可变。 - 关键规则与最佳实践:把
unmodifiableList当「防止客户误改的护栏」,而不是「不可变保证」;真正需要不可变性时用List.of/List.copyOf(copyOf产生不可修改的浅拷贝);Collections.emptyList()等不可变空集合可以放心使用(否则会出现「你确定很空的列表突然不空了」的诡异 bug);返回视图的同时保留底层引用,是典型的半吊子封装。
不可变性与性能(Immutability and Performance)
- 定义与目的:很多人选择可变类型是为了性能,但 6.031 明确指出:可变类型并不总是比不可变类型高效。关键在于「能共享多少」与「必须拷贝多少」。
- 直观解释(”它是什么?”):不可变值可以被程序各处安全共享,因此在需要跨越边界传递时,往往只需传引用而无需防御性拷贝;相反,可变值在同样的场景下被迫反复拷贝,既费时间又费空间。反过来,若一个值需要被「大量小幅修改」,可变对象更省——例如用
StringBuilder循环append替代String的+拼接,可把 O(n²) 的拷贝降为线性(Java 的+在循环里每次都生成新字符串,第一个字符会被反复拷贝 n 次)。此外,不可变值可以借助内部共享(structural sharing)降低拷贝成本:编辑一个百万字符字符串中间的一个字符,聪明的实现可以复用编辑点前后的未改动区域。Git 的对象图也是同一原理——因为提交(commit)不可变,后续提交可以放心指向父提交而不担心它被改写。 - 关键规则与最佳实践:先问「这个值会被共享吗?会被跨界传递吗?」——会,就用不可变类型;再问「它会被频繁小幅修改吗?」——会,且在单一引用范围内,可考虑可变类型(如
StringBuilder);永远不要在循环里用不可变类型做累积拼接;记住「不可变类型从不需要防御性拷贝」这一条本质上就是性能优势。
常用不可变类型清单(Useful Immutable Types in Java)
- 定义与目的:把「尽量不可变」落实为可执行的选型习惯(Safe from bugs、Ready for change)。
- 直观解释(”它是什么?”):Java 中:基本类型及其包装类(
Integer、Double…)全部不可变;大数运算用不可变的BigInteger、BigDecimal;不要使用可变的java.util.Date,改用java.time包中按精度需求选择的LocalDate、LocalDateTime、Instant等(它们的规格保证不可变);用List.of、Set.of、Map.of从已知值构造不可变集合;Collections.unmodifiableList/Set/Map提供不可修改视图,List.copyOf等提供不可修改的浅拷贝;Collections.emptyList()等提供不可变空集合。 - 关键规则与最佳实践:需要日期时间时默认选
java.time;需要「传出去不会被改」的集合时优先List.copyOf或List.of;看到Date、Calendar、StringBuilder、ArrayList出现在方法签名里,就要停下来问「这里真的需要可变性吗?」;把「不可变优先」写进团队约定,而不是靠个人记忆。
代码示例与对比分析
场景 1:为了 DRY 与性能而修改传入的列表
❌ 错误代码
// 错误:sumAbsolute 为了实现复用而原地修改参数列表,
// 调用方手里的 myData 被悄悄改成绝对值。
public static int sum(List<Integer> list) {
int sum = 0;
for (int x : list) {
sum += x;
}
return sum;
}
public static int sumAbsolute(List<Integer> list) {
// 复用 sum(),先就地取绝对值——看起来既 DRY 又高效
for (int i = 0; i < list.size(); ++i) {
list.set(i, Math.abs(list.get(i)));
}
return sum(list);
}
public static void main(String[] args) {
List<Integer> myData = new ArrayList<>(Arrays.asList(-5, -3, -2));
System.out.println(sumAbsolute(myData)); // 10
System.out.println(sum(myData)); // 期望 -10,实际 10
}
【错误代码的问题】
- 潜伏 bug:程序不会报错,只会给出错误结果;真正的破坏发生在「另一个程序员本以为数据没变」的地方。
- 规格违约:
sumAbsolute的文档只承诺「返回绝对值之和」,并未声明会修改list,因此修改属于未声明副作用(违反 Reading 7 的契约结构)。 - 极难理解:读
main的人无法从调用形式看出myData会被改动,必须跳进实现里逐个检查,违背 Easy to understand。 - 放大风险:一旦有多个别名(例如
myData也被别的模块持有),bug 会扩散到与sumAbsolute完全无关的代码。
✅ 正确代码
// 正确:不修改参数;既保留 DRY 精神(借用 sum 的求和思路),又不产生任何副作用。
/**
* @param list 待求和的列表
* @return list 中各元素绝对值之和
*/
public static int sumAbsolute(List<Integer> list) {
int sum = 0;
for (int x : list) { // 只读遍历,不改列表
sum += Math.abs(x);
}
return sum;
}
public static void main(String[] args) {
List<Integer> myData = new ArrayList<>(Arrays.asList(-5, -3, -2));
System.out.println(sumAbsolute(myData)); // 10
System.out.println(sum(myData)); // -10,myData 未被修改
}
【为什么这样更好】 方法对参数是只读的,客户可以放心地把同一个列表传给多个方法而无需担心相互干扰;行为在调用点即可推断(sum(myData) 之后 myData 不变),无需阅读被调方实现;两条独立的修改(一个是 sumAbsolute 的实现,一个是 main 里增加一次 sum 调用)不再可能互相破坏。
【代码对比解说】 错误版本其实蕴含一个合理的直觉:「如果列表有百万个元素,就地修改能省下一次百万级分配」。这个性能考量是真实的,但代价是把契约从局部推理变成全局推理。若性能确实关键,正确的折中是把修改限制在局部:在方法内部新建一个列表(new ArrayList<>(list))再改,绝不改调用方的对象——即「把可变性关在方法里」。这也正是本讲的判断标准:可变对象局部使用是安全的,跨边界共享才是危险的。
【设计原则透视】 这是「别名 + 未声明修改」的典型组合。它把 Reading 7 的 Modifies 子句从纸面规则变成可观察的 bug:契约没有声明修改,客户就有权假定数据不变。同时它示范了抽象边界(abstraction boundary)的作用——方法边界是切断别名的天然位置,防御性拷贝通常就放在这里。
场景 2:返回可变的 Date,与内部缓存形成别名
❌ 错误代码
// 错误:对外返回内部缓存的 Date 对象,客户一改,缓存就跟着坏。
private static Date groundhogAnswer = null;
/**
* @return 今年春天的第一天
*/
public static Date startOfSpring() {
if (groundhogAnswer == null) {
groundhogAnswer = askGroundhog(); // 只问一次土拨鼠,之后走缓存
}
return groundhogAnswer; // 返回的是同一个 Date 对象
}
public static void partyPlanning() {
Date partyDate = startOfSpring();
partyDate.setMonth(partyDate.getMonth() + 1); // 派对推迟一个月
// …… 于是 startOfSpring() 的缓存也被推后了一个月
}
【错误代码的问题】
- 缓存被污染:
partyDate与groundhogAnswer是同一个对象的两个别名,客户端的合法调用修改了实现者的内部状态。 - bug 归属混乱:错误发生在
partyPlanning,但受害者是后续任何调用startOfSpring()的无关代码——「谁的错」在工程上难以仲裁。 - 契约变成终身契约:若想用规格补救,就只能写上「调用方永远不得修改返回的数组/对象」,这让契约的效力延续到程序余生的每一刻,客户再也无法只靠「调用前看前置、调用后看后置」推理。
Date本身设计糟糕:Date.setMonth的月份取值是 0–11,而且文档允许越界参数(如 1 月 32 日被解释为 2 月 1 日),本该是前置条件的地方变成了「自动纠正」,违反了 fail fast(见 Reading 9)。
✅ 正确代码
// 正确:改用 java.time 的不可变类型,缓存与客户永远不会互相干扰。
// 补充说明:java.time 的引入属于 Java 8 生态建议,6.031 sp21 原文也明确
// 建议“永远不要使用 Date,改用 java.time 中保证不可变的类型”。
private static LocalDate groundhogAnswer = null;
/**
* @return 今年春天的第一天
*/
public static LocalDate startOfSpring() {
if (groundhogAnswer == null) {
groundhogAnswer = askGroundhog(); // 返回 LocalDate,不可变
}
return groundhogAnswer; // 共享同一个不可变对象,安全
}
public static void partyPlanning() {
LocalDate partyDate = startOfSpring().plusMonths(1); // 产生新对象,不改原值
// ……
}
【为什么这样更好】 LocalDate 没有修改自身的方法:plusMonths(1) 返回新对象,缓存里的日期毫发无损。于是实现者获得了引入缓存的自由(性能改进),客户也获得了随意计算的自由,两者互不干扰——不需要任何一方仔细阅读规格注释,也不存在「终身契约」。
【代码对比解说】 如果因为历史原因必须沿用 Date,最低限度也要做防御性拷贝:return new Date(groundhogAnswer.getTime());。这样 partyPlanning 改的是自己的副本。但拷贝让每个客户都付出一次分配与复制的成本,哪怕 99% 的客户从不变更返回值;内存里还会散落大量「春天第一天」的副本。不可变类型则允许所有调用方安全共享同一个对象,拷贝次数为零——这就是「不可变性可以比可变性更高效」的具体含义。
【设计原则透视】 这条对比把「别名」从同一模块内提升到跨模块、跨开发者的尺度,并展示了 Reading 7 中「契约应当是局部可推理的」这一要点:一旦需要「终身契约」(调用方永远不得修改返回值),说明设计已经出了根本问题,正确做法是换用不可变类型而不只是加一条注释。它同时说明了后缀式 API(plusMonths)如何把可变性从设计里彻底移除。
场景 3:用 char[] 返回 9 位 MIT 学号
❌ 错误代码
// 错误:返回可变数组,客户端“打码”与实现者的缓存互相踩踏。
private static Map<String, char[]> cache = new HashMap<>();
/**
* @param username 要查询的用户名
* @return 含 9 位 MIT 学号的字符数组
* @throws NoSuchUserException 数据库中没有该用户时
*/
public static char[] getMitId(String username) throws NoSuchUserException {
if (cache.containsKey(username)) {
return cache.get(username); // 缓存里的数组被直接交出去
}
char[] id = lookupInDatabase(username);
cache.put(username, id); // 同时把同一个数组存进缓存
return id;
}
// 客户为了隐私,把前 5 位打码:
char[] id = getMitId("bitdiddle");
for (int i = 0; i < 5; ++i) {
id[i] = '*'; // 缓存里的学号变成了 "*****2033"
}
【错误代码的问题】
- 缓存被破坏:客户的打码操作写穿了缓存,此后所有调用者拿到的都是残缺学号。
- 责任无法界定:客户有义务不改返回值吗?实现者有权保留返回对象吗?双方各自都「合理」,冲突只能靠契约澄清——这就是共享可变对象带来的契约复杂化。
- 两种补救规格都不理想:「调用方永远不得修改返回数组」是终身契约;「返回一个新数组」虽然保证新鲜,但无法阻止实现者继续持有别名并在将来改写它。
- 性能反而受损:为了安全,实现者可能被迫在每次返回时拷贝数组,缓存省下的开销被抵消。
✅ 正确代码
// 正确:返回不可变的 String,客户与实现者都能自由行动。
private static Map<String, String> cache = new HashMap<>();
/**
* @param username 要查询的用户名
* @return 含 9 位 MIT 学号的字符串
* @throws NoSuchUserException 数据库中没有该用户时
*/
public static String getMitId(String username) throws NoSuchUserException {
if (cache.containsKey(username)) {
return cache.get(username); // String 不可变,直接共享是安全的
}
String id = lookupInDatabase(username);
cache.put(username, id);
return id;
}
// 客户想打码,只能构造新字符串:
String id = getMitId("bitdiddle");
String obscured = "*****" + id.substring(5); // 缓存中的学号不受影响
【为什么这样更好】 返回值类型本身提供了保证——String 不可变,所以「客户与实现者永远不会互相踩踏」这件事不依赖于任何人仔细阅读规格注释;同时实现者保留了引入缓存的自由(性能改进),客户也保留了任意加工返回值的自由。注意「返回新数组」那种规格做不到这一点:它只能保证数组是新鲜的,不能阻止实现者继续持有别名。
【代码对比解说】 三种规格写法可以排成一条优劣链:(a)「返回数组,调用方永不修改」——终身契约,最差;(b)「返回一个新数组」——保证新鲜但仍有别名风险,中等;(c)「返回 String」——从类型层面消灭问题,最好。这条链清楚说明:用正确的类型表达不可变性,优先于用文档约束人的行为。
【设计原则透视】 这是抽象边界上「返回值即契约」的经典案例:类型选择直接决定了契约的可表达性。它也预示了 Reading 11 中的表示暴露(rep exposure)问题——这里的缓存就是把内部表示直接交给了外部。同一个模式在并发场景下更危险:共享可变状态需要锁(Reading 23),而不可变对象天然线程安全(Reading 21)。
场景 4:构造器保存外部别名,观察器直接把内部集合交出去
❌ 错误代码
// 错误:构造器保存外部传入的列表引用,getter 又直接把内部列表返回,
// 类的“动物园”完全不受自己控制。
public class Zoo {
private List<String> animals;
public Zoo(List<String> animals) {
this.animals = animals; // 保存别名(copy-in 缺失)
}
public List<String> getAnimals() {
return this.animals; // 泄漏内部表示(copy-out 缺失)
}
}
// 客户端:
List<String> a = new ArrayList<>();
a.addAll(List.of("lion", "tiger", "bear"));
Zoo zoo = new Zoo(a);
a.add("zebra"); // 动物园凭空多了一只斑马
System.out.println(zoo.getAnimals()); // [lion, tiger, bear, zebra]
List<String> b = zoo.getAnimals();
b.add("flamingo"); // 客户绕过 Zoo 的规则直接改内部列表
System.out.println(a); // [lion, tiger, bear, zebra, flamingo]
【错误代码的问题】
- 构造器未做防御性拷贝:外部列表的任何后续修改都会「从后门」改变
Zoo的状态,类无法维护自己的不变量。 - 观察器泄漏表示(rep exposure):客户拿到内部列表后可以任意增删,类的全部规则形同虚设(若
Zoo有「容量上限」之类的不变量,立刻会被绕过)。 - 代码无法推理:
zoo.getAnimals()返回的列表在何时可能变化,取决于程序里所有持有别名的位置,局部推理彻底失效。 - 不可变性无法实现:即使把所有字段都声明为
final,这个类依然不是不可变的(final只锁住引用)。
✅ 正确代码
// 正确:copy-in + copy-out,并用不可修改视图封住出口。
// 这同时演示了不可变类的三条设计规则:final 字段、不暴露可变内部对象、
// 不保存外部传入的可变对象。
public final class Zoo {
private final List<String> animals; // 规则 1:字段 final
public Zoo(List<String> animals) {
this.animals = new ArrayList<>(animals); // 规则 3:copy-in,切断外部别名
}
/** @return 动物园中动物名的不可修改视图 */
public List<String> getAnimals() {
return Collections.unmodifiableList(animals); // 规则 2:不交出内部可变对象
}
/** 增加一只动物。要求 animals.size() < capacity。 */
public void add(String animal) {
animals.add(animal);
}
}
【为什么这样更好】 类的状态只由自己的方法改变,不变量随时可维护;外部列表后续怎么改都与 Zoo 无关;客户拿到的是不可修改视图,误写会立刻抛 UnsupportedOperationException 而不是悄悄破坏对象。配合 final 字段,这类「内部表示不泄漏」的类才可能进一步做成真正不可变的类。
【代码对比解说】 若确实希望类的实例不可变(没有 add),可把 getAnimals 改成 return List.copyOf(animals);(补充说明:List.copyOf 是 Java 10 引入的、产生不可修改浅拷贝的方法),或者用 List.of(...) 在构造时直接构造不可变列表。要注意 Collections.unmodifiableList 返回的是视图:只要还有对 animals 的别名,视图内容仍会被改变——在本例中 animals 是私有且只在内部使用,因此是安全的(详见场景 6)。
【设计原则透视】 这是表示不变量(rep invariant)与表示暴露(rep exposure)在 Reading 11 中正式定义的先声:只要类的字段可以经由构造器输入或观察器输出被外部改动,类的抽象函数就无法保证成立。copy-in/copy-out 是维护抽象边界的物理手段,而「不可变类型优先」是从根上免除拷贝的替代方案。
场景 5:迭代过程中修改被迭代的集合
❌ 错误代码
// 错误:边迭代边删除元素,迭代器的下标不再对应正确的元素。
/**
* 删除所有 Course 6 的课程。
* Modifies: subjects —— 删除以 "6." 开头的课程号。
* @param subjects MIT 课程号列表
*/
public static void dropCourse6(List<String> subjects) {
MyIterator iter = new MyIterator(subjects);
while (iter.hasNext()) {
String subject = iter.next();
if (subject.startsWith("6.")) {
subjects.remove(subject); // 通过另一个别名修改了正在被迭代的列表
}
}
}
// 测试用例:
// dropCourse6(["6.045", "6.031", "6.036"])
// 期望 [],实际 ["6.031"]
【错误代码的问题】
- 静默跳过元素:删掉下标 0 后,
"6.031"前移到下标 0,而迭代器已推进到下标 1,于是它被永久跳过——结果错误但不报错。 - 前置条件无人声明:迭代器的规格里根本没有写「迭代期间不得修改集合」(Java 集合类的文档也很难找到这条),于是责任在
Iterator、List、客户之间悬空。 - 契约退化成了全局性质:正确性不再取决于一次调用的前后置条件,而取决于「程序里是否有人恰好在此期间改了集合」,这类全局性质极难推理与测试。
- 不可泛化修复:就算换成
Iterator.remove(),也只能处理「只有一个迭代器、只做删除」的情形;排序之类更复杂的修改依然会错位。
✅ 正确代码
// 正确:把“遍历 + 过滤”与新列表构造分开,迭代期间不修改被迭代的集合。
public static void dropCourse6(List<String> subjects) {
final List<String> kept = new ArrayList<>();
for (String subject : subjects) { // 只读遍历
if (!subject.startsWith("6.")) {
kept.add(subject);
}
}
subjects.clear(); // 遍历结束后才修改
subjects.addAll(kept);
}
// 补充说明:Java 8+ 还提供等价的惯用法(不在 6.031 原文中出现):
// subjects.removeIf(subject -> subject.startsWith("6."));
【为什么这样更好】 「只读遍历 + 遍历后统一替换」把修改推迟到迭代结束,从根本上消除了下标错位;逻辑与结果都一目了然,测试用例 ["6.045","6.031","6.036"] => [] 能稳定通过。若确实需要原地删除,可改用 Iterator.remove():它由迭代器自己调整位置,因此安全且更高效(迭代器已经知道要删哪个元素,不必再搜索一次),但仍只适用于简单删除场景。
【代码对比解说】 三种做法可作对比:自写 MyIterator + list.remove 会静默出错;Java 集合的 for-each + list.remove 会抛 ConcurrentModificationException(快速失败,见 Reading 9,症状不同但同样是「迭代中修改」这一病根);「收集新列表」则完全绕开问题。这里也体现了「快速失败」的价值:抛异常比给出错误答案好得多。
【设计原则透视】 这条对比是「别名使契约复杂化」的最佳注解:正确性依赖的不再是某个方法的契约,而是一条全局性质(迭代期间集合不得变化),而这条性质在 Java API 文档里几乎无处安放。它也把本讲的结论与 Reading 9(Avoiding Debugging)连起来——把危险操作交给会快速失败的机制,而不是依赖程序员的自觉。
场景 6:把 Collections.unmodifiableList 当成不可变集合
❌ 错误代码
// 错误:只包了一层不可修改视图,却保留着底层集合的别名,
// 于是“不可变”的 view 依然会被改动。
public static List<String> roster(String username) {
List<String> roster = new ArrayList<>();
roster.add("alice");
List<String> view = Collections.unmodifiableList(roster);
// view 看起来已经不可变了…… 但 roster 还活着
roster.add("bob"); // 通过别名修改底层集合
System.out.println(view); // [alice, bob] —— 视图跟着变了
return view;
// 更糟的是:view.add("carol") 会抛 UnsupportedOperationException,
// 于是调用者以为“这里绝对改不了”,却没意识到内容仍会变。
}
【错误代码的问题】
- 错误的安全感:
UnsupportedOperationException让人以为对象不可变,但底层集合的任何别名都能继续改它。 - 半吊子封装:保存了底层引用却不打算共享,属于典型的信息泄漏;一旦将来有人在这个方法里复用
roster,行为会难以预测。 - 规格无法表述:返回类型是
List,无法在类型层面表达「永不改变」,只能靠注释——而注释挡不住代码。 - 并发下更糟:视图与底层集合并存会带来可见性与竞态问题(Reading 21、23)。
✅ 正确代码
// 正确:要么用不可修改的拷贝彻底切断联系,要么用 List.of 直接构造不可变集合。
public static List<String> rosterCopy(String username) {
List<String> roster = new ArrayList<>(List.of("alice", "bob"));
return List.copyOf(roster); // 不可修改的浅拷贝,与原列表无任何共享
}
public static List<String> rosterFixed(String username) {
return List.of("alice", "bob"); // 从一开始就是不可变集合
}
【为什么这样更好】 List.copyOf 生成的是与原集合没有共享的不可修改拷贝,因此无论原列表后来怎么改,返回值都稳定;List.of 更进一步,从构造那一刻起就不存在可变别名。客户可以安全共享这个返回值,也不需要任何防御性拷贝。
【代码对比解说】 三种手段的定位应当分清楚:Collections.unmodifiableList 是不可修改视图——适合在「内部代码希望防止外部误改、但自己仍需要继续修改底层集合」的场景(例如类内部维护可变列表、对外只暴露只读视图,如场景 4);List.copyOf 是不可修改的浅拷贝——适合跨模块传递、需要彻底隔离的场景;List.of 是不可变集合——适合常量数据。前者的关键词是「视图」,后者的关键词是「拷贝」。另外注意 Collections.emptyList() 之类的不可变空集合可以放心使用,它避免了「你确信很空的列表突然不空」的怪事。
【设计原则透视】 这里的关键区分是 unmodifiable ≠ immutable,与 TypeScript 中 ReadonlyArray 的局限完全对应(ReadonlyArray 只是接口、没有构造器,必须先有 Array 再赋值给它,因此不能保证不可变)。它又一次说明:不可变性应当由类型与所有权结构保证,而不是由「不要通过这条路径修改」的约定保证——后者只要存在第二条路径就会失效。
场景 7:在循环里用不可变 String 累积拼接(性能取向的可变性使用)
❌ 错误代码
// 错误(性能问题):循环里用 + 拼接不可变字符串,产生大量临时拷贝。
String s = "";
for (int i = 0; i < n; ++i) {
s = s + i; // 每次都新建一个 String,并整体复制已有内容
}
// 第一个数字被拷贝 n 次,第二个 n-1 次…… 总代价 O(n^2)
【错误代码的问题】
- O(n²) 时间:即使只拼接了 n 个元素,复制总量却是平方级,规模稍大就明显变慢。
- 大量垃圾对象:每一步都产生一个立即被丢弃的中间
String,给垃圾回收带来压力。 - 掩盖了真实意图:读者看到的是「拼接」,但代码实际表达的是「反复重建」,意图与实现不匹配。
- 隐含的性能回归风险:在热路径上使用这种写法,一旦数据量增长就会成为瓶颈,而它并不会「报错」,因此极难被发现。
✅ 正确代码
// 正确:使用可变的 StringBuilder 做累积,仅在最后生成一次不可变 String。
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; ++i) {
sb.append(String.valueOf(i)); // 原地追加,不复制已有内容
}
String s = sb.toString(); // 只在这里产生一个不可变结果
【为什么这样更好】 StringBuilder 内部用可变结构避免中途拷贝,把总代价降到线性(O(n));最终仍然产出不可变的 String,因此「可变对象只活在方法内部、只有单一引用」这一安全条件完全满足。这正是 6.031 认可的可变性用法:用可变类型做性能优化,但把它限制在局部。
【代码对比解说】 这一组与场景 1 形成互补:场景 1 说明「可变的跨界共享很危险」,这里说明「可变的局部使用很合理」。两者的分界线是 别名与作用域:StringBuilder 没有被传到任何地方,迭代结束后就只留下不可变的 String;而 sumAbsolute 修改的是调用方持有的对象。补充说明:Java 的 + 在编译期常被优化为 StringBuilder(String API 文档也有相关实现说明),但在循环中每次迭代仍可能创建新对象,因此累积拼接仍应显式使用 StringBuilder。
【设计原则透视】 这条对比把「不可变性与性能」的结论落到实处:不可变类型并不总是更快,但它从不需要防御性拷贝;当共享占主导时它更快,当频繁小幅修改占主导时可变类型更快。真实工程判断应同时考虑「共享量」「拷贝量」「作用域」三个因素,而不是笼统地认为「可变一定快」或「不可变一定安全」。
与其他设计原则的关联
- Reading 6(Specifications)与 Reading 7(Designing Specifications):本讲把契约语言用到了「对象会不会变」这件事上。修改型方法必须在规格中显式声明修改(
Modifies子句),否则默认禁止修改;而「返回易变对象 + 要求调用方永不修改」这种写法之所以坏,正是因为它把契约变成了覆盖程序余生的终身契约,违背了 Reading 7 中「前置条件在调用前检查、后置条件在调用后检查」的局部推理模型。 - Reading 9(Avoiding Debugging):不可变性是第一道防线「让 bug 不可能」的核心手段之一;
final/const声明不可重赋值的引用,是把假设变成可静态检查的文档;而ArrayList在迭代中修改时抛出的ConcurrentModificationException正是「快速失败」的样板。本讲的final规则与 Reading 9 的作用域最小化规则合在一起,构成「局部化 bug」的完整策略。 - Reading 10(Abstract Data Types)与 Reading 11(Abstraction Functions, Rep Invariants):本讲场景 4 的 copy-in/copy-out 在 Reading 11 中被系统化为「防止表示暴露(rep exposure)」;不可变类的三条设计规则正是维护表示不变量(RI)的前提。ADT 的实现几乎总是「可变 rep + 不可变抽象值」的组合,因此不理解别名与防御性拷贝就无法正确实现 ADT。
- Reading 12(Interfaces, Generics, Enums):匿名类、lambda 与枚举常用于实现不可变值(如
Comparator、枚举常量),而接口类型优先的原则(List而非ArrayList)也来自本讲「不要用具体可变实现类型写规格」的结论。 - Reading 15(Equality):不可变性与相等性密切相关——可变对象作为
HashMap的键时,一旦被修改就会「消失」;同时hashCode只有在对象不参与相等性比较的字段被修改时才稳定,因此 6.031 建议用作键的对象应当是(或至少行为上表现为)不可变的。 - Reading 21(Concurrency)与 Reading 23(Locks):不可变对象天然线程安全,可以自由共享;可变对象则需要锁来保护,而「谁拥有这个对象的别名」会直接决定死锁与竞态的风险。本讲关于「共享可变状态使契约复杂化」的分析,是并发章节的前提。
- Reading 4(Code Review):本讲的 DRY(
sumAbsolute为复用而修改参数)与 Reading 4 中dayOfYear的重复代码,是同一原则的两面:DRY 是好事,但为了 DRY 而破坏契约(修改参数、暴露内部状态)是更严重的错误。
关键要点
- 可变性的危险来源是别名,不是「能改」本身。 单一引用、完全局部的可变对象是安全且高效的;一旦跨越模块边界被多处引用,就需要显式声明、防御性拷贝,或干脆改成不可变类型。
- 始终区分「不可重赋值的引用」与「不可变对象」。
final/const/readonly只锁住引用(final List里的元素照样能增删),String、java.time类型才锁住值。 - 默认不可变,例外需要理由。 优先使用基本类型包装类、
BigInteger/BigDecimal、java.time类型、List.of/Set.of/Map.of、List.copyOf;避免java.util.Date、Calendar以及把ArrayList、StringBuilder放进方法签名。 - 跨界传递可变对象时,copy-in 与 copy-out 一个都不能少。 构造器要拷贝传入的可变参数,观察器要返回副本或不可修改视图;只做一半等于没做。
unmodifiable≠immutable。Collections.unmodifiableList是不可修改视图(底层集合仍可被别名修改),List.copyOf是不可修改拷贝,List.of是不可变集合——按「是否需要与原集合隔离」选型。- 迭代过程中禁止修改被迭代的集合。 需要过滤时用「收集新列表再替换」、
Iterator.remove()或removeIf;自写迭代器不会替你报错,只会静默给出错误结果。 - 性能上要比较「共享量」与「拷贝量」。 共享占主导时不可变更快(无需防御性拷贝),频繁小幅修改占主导时可变更快(
StringBuilder代替循环+拼接);不可变值还可以通过内部结构共享降低拷贝成本(git 的对象图即为例证)。
常见陷阱与注意事项
- 把
final当成不可变性的保证 → 例如private final List<String> items仍可被add/clear/被外部别名修改,类的状态照样会变。正确做法是final字段 + copy-in/copy-out(+ 必要时List.copyOf)。 - 只做 copy-in 或只做 copy-out → 只拷贝入口,外部仍能通过 getter 拿到内部集合直接改;只拷贝出口,外部仍能通过构造时传入的列表改内部状态。抽象边界两侧都必须封住。
- 用「调用方永远不得修改返回值」这类终身契约补救可变返回值 → 契约效力延伸到程序余生,任何一次疏忽都会破坏实现者缓存,而且责任的归属无法界定。应改用不可变返回类型(如
String、LocalDate)。 - 认为
Collections.unmodifiableList返回的就是不可变集合 → 通过它自身修改会抛UnsupportedOperationException,但底层集合的别名仍可自由修改,视图内容随之改变;在需要真正隔离时误用它,会得到「以为不会变却变了」的隐蔽 bug。 - 边遍历边增删集合元素 →
MyIterator这类自写迭代器会静默跳过元素并给出错误答案;Java 集合的for-each会抛ConcurrentModificationException;即使改用Iterator.remove()也只是在有多个迭代器、或做了排序等复杂修改时失效。安全策略是遍历期间只读。 - 为了复用或性能而在方法内修改传入的参数 → 未在规格中声明的副作用会污染调用方的数据,且错误结果往往在无关代码处显现;若要优化,就改动局部副本,或把修改写进规格让客户知情。
- 在循环中用
+累积拼接不可变字符串 → 形成 O(n²) 的拷贝与大量临时对象;应改用局部StringBuilder,最后一次性toString()。
思考题(带答案)
问题 1:Zoo 类中有 private final List<String> animals;,构造器执行 this.animals = animals;,并提供一个方法 public List<String> getAnimals() { return Collections.unmodifiableList(this.animals); }。请问这个类是否已经安全?为什么?
答案:不安全。 三个问题:(1)构造器保存了外部传入列表的别名,因此外部代码此后仍可通过自己手中的列表(如 a.add("zebra"))改动 Zoo 的内部状态,类的表示不变量无法保证——缺少 copy-in;(2)final 只保证 animals 这个引用不被重赋,不保证列表内容不变;(3)Collections.unmodifiableList 只返回不可修改视图,它确实挡住了客户通过返回值的修改(会抛 UnsupportedOperationException),但由于第一个问题中仍存在外部别名,视图内容依然可能在客户不知情时变化。正确做法是构造器中 this.animals = new ArrayList<>(animals); 做 copy-in,观察器返回 Collections.unmodifiableList(animals)(或若类本身要不可变,则返回 List.copyOf(animals))。这样两条泄漏通道同时封住,Zoo 才对自己的状态拥有完全控制权。
问题 2:有人在代码评审中提出:「我们干脆规定所有方法都不得修改自己的参数,需要修改时一律新建对象再返回。」这个规定会带来什么好处与什么代价?请至少各举一例。
答案:好处包括:(1)别名 bug 大幅减少——调用方可以假定数据在调用前后不变,从而只需局部推理(Safe from bugs、Easy to understand);(2)契约更简单,无需写 Modifies 子句,也无需「调用方不得修改返回值」这类终身契约;(3)方法变得像数学函数,易于测试与并行化,天然线程安全。代价包括:(1)性能与内存——如 String 循环拼接退化为 O(n²),List 的原地排序、原地去重等高效算法每次都要整体复制,此时应改用 StringBuilder、或提供显式的原地修改版本;(2)某些操作本质上就是修改语义,例如迭代器的 next()(必须推进自身位置)、缓存填充、removeIf 之类的批量操作,强行「不改」会让 API 变得别扭;(3)大的对象图(如项目作业中的 Graph)每次都复制可能不可接受。结论:这条规定是好默认值,但必须允许有明确理由的例外,并把例外写进规格;判断标准仍是「别名有多少、作用域有多大、性能代价是否可接受」。
问题 3:为什么说「不可变类型从不需要防御性拷贝」,这个结论在什么时候反而会让不可变类型不如可变类型高效?
答案:因为不可变对象一旦创建就无法改变,任何持有者都不可能通过它影响别人,因此跨边界传递时只需传引用,不必每次调用都复制一份——这正是 startOfSpring() 返回 LocalDate 可以安全共享同一对象、而返回 Date 时若不拷贝就会污染缓存的原因。反过来,当同一个值需要被频繁地小幅修改时,不可变类型的每次修改都要生成新对象并复制未改动部分,代价随值的大小线性增长,而可变类型可以原地修改,只付出常数代价;典型例子是循环中累积拼接字符串(String 的 + 与 StringBuilder 的 append)以及需要原地排序、原地更新的大数组。此外,防御性拷贝的开销只有在「跨界传递」时才发生,如果某段代码里的可变对象完全局部、只有单一引用,它连拷贝都不需要,因而在这种场景下比不可变类型更省。因此最终的判断依据是:这个值更多是被共享,还是更多被修改。
