Reading 1: 静态检查(Static Checking)
第二部分:分讲学习笔记(Reading 1–29)
以下按阅读材料编号顺序呈现全部 29 讲。每讲严格遵循统一结构: 概述 → 核心概念与设计原则详解 → 代码示例与对比分析 → 与其他设计原则的关联 → 关键要点 → 常见陷阱与注意事项 → 思考题(带答案)。
每讲开头的说明块会指出该讲在 sp22(TypeScript 原版)与 sp21(Java 版)之间的语言对应关系。
Reading 1: 静态检查(Static Checking)
说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。
概述
本讲包含两个主题:静态类型(static typing) 与 好软件的三大性质(the big three properties of good software),并以冰雹序列(hailstone sequence) 作为贯穿全讲的运行示例。核心问题是:怎样让错误尽可能早地、尽可能自动地被发现?答案是把手动检查变成编译期(compile time) 的机器检查,也就是静态检查。这一思想直接服务于课程的第一个目标「Safe from bugs」(在程序运行之前就拦住一整类错误),同时通过把类型、final、前置条件等假设显式写进代码而服务于「Easy to understand」,并通过让编译器指出所有需要同步修改的位置而服务于「Ready for change」。
核心概念与设计原则详解
冰雹序列(Hailstone Sequence)
- 定义与目的:从正整数 n 出发,若 n 为偶数则下一项为 n/2,若 n 为奇数则下一项为 3n+1,序列在到达 1 时结束。例:3, 10, 5, 16, 8, 4, 2, 1。它是本讲的「小白鼠」:一个足够简单、可以完整读懂的循环,却同时暴露出类型声明、整数运算、数组越界、参数前置条件等一连串软件构造问题。
- 直观解释(”它是什么?”):因为奇数规则 3n+1 会把数「弹上去」,序列会先上下跳动,最后落回地面——就像云中的冰雹反复上下翻滚,直到足够重才落地。数学上至今没有人能证明所有起点最终都会落到 1(Collatz 猜想仍未解决),这个「连数学家都证明不了它一定停机」的性质,正好说明为什么我们不能靠推理替代测试(见 Reading 03)。
- 关键规则与最佳实践:
- 用整数运算写循环时,先想清楚「这个表达式是在 int 域还是 double 域里求值」,
n / 2与n / 2.0完全不同。 - 序列长度不可预测(n=27 需要 112 项才能降到 1),因此绝不能用固定长度的容器去装它。
- 规格说明里必须写清前置条件,冰雹序列要求
n > 0;n = 0或n < 0会造成无限循环(0 永远是偶数,-5 → -14 → -7 → -20 → -10 → -5 形成死循环)。 - 循环体内修改参数会让规格说明中「n 是起始值」的假设失效,应使用独立的循环变量。
- 用整数运算写循环时,先想清楚「这个表达式是在 int 域还是 double 域里求值」,
类型(Type)
- 定义与目的:类型是一组值的集合,以及可以在这些值上执行的操作。类型的作用是让编译器知道哪些操作是合法的,从而把「把操作施加到错误类型的参数上」这一类 bug 彻底挡在编译期之外。它服务的首要目标是 Safe from bugs,其次是 Easy to understand(类型就是写在代码里的假设)。
- 直观解释(”它是什么?”):把类型想成「容器上的标签」。标着
int的盒子里只装 32 位整数,标着String的盒子里装字符序列。你在String盒子上按「乘法」按钮时,编译器会在你按下运行按钮之前就告诉你:「这个按钮在这个盒子上不存在」。 - 关键规则与最佳实践:
- Java 的原始类型(primitive types):
int(约 ±2³¹)、long(约 ±2⁶³)、boolean、double、char,一律小写。 - Java 的对象类型(object types):
String(字符序列)、BigInteger(任意精度整数)、以及你自定义的类,按约定首字母大写。 - 每个原始类型都有对应的包装类型(wrapper type):
int/Integer、long/Long、double/Double,泛型参数必须用包装类型。 - 变量声明要「能写就写」:把类型写下来既是给编译器的承诺,也是给未来读者的文档。
- Java 的原始类型(primitive types):
操作的三种书写形式与重载(Overloading)
- 定义与目的:操作(operation)是「吃进输入、吐出输出、有时还会改变自身值」的函数,无论语法怎么变,我们都把它当函数看。识别这三种形式有助于读懂 API 文档,也是理解重载的前提。
- 直观解释(”它是什么?”):同一个「加法」概念,可以写成中缀运算符
a + b、对象方法bigint1.add(bigint2)、或者类里的静态函数Math.sin(theta)。它们在语法上长得完全不同,在语义上都是「函数」。 - 关键规则与最佳实践:
- 运算符形式:
a + b调用的是+ : int × int → int,箭头前是输入类型,箭头后是输出类型。 - 对象方法形式:
bigint1.add(bigint2)调用add : BigInteger × BigInteger → BigInteger,接收者对象是隐含的第一个参数(Java 中叫this,Python 中叫self)。 - 静态函数形式:
Math.sin(theta)调用sin : double → double;此时Math不是对象,而是包含该函数的类。 - 属性/实例变量形式:数组用
a.length(无括号,因为是实例变量),字符串用str.length()(有括号,因为是方法调用),二者语法不同,别记混。 - 重载(overloading):同一个名字对应不同参数类型的多个函数。Java 中
+ - * /对数值原始类型重载,方法的参数列表不同也可以重载。重载让代码更易读(+对字符串就是拼接),但也是==在对象上语义突变的根源(见 Reading 02)。
- 运算符形式:
静态类型(Static Typing)
- 定义与目的:静态类型语言在程序运行之前就知道所有变量的类型,因而编译器能推断出所有表达式的类型。它消灭的是一大类 bug:把操作施加到错误类型的参数上。这直接对应 Safe from bugs。
- 直观解释(”它是什么?”):想象一位在你身边盯着你打字的助教。你刚写完
"5" * "6",他就说:「字符串不能相乘。」你还没运行程序,错误就已经被拦住了。Eclipse / VS Code 在你打字时就在做这件事。 - 关键规则与最佳实践:
- 静态检查能抓:语法错误(多余的标点、错字)、拼错的名字(
Math.sine(2))、参数个数错误(Math.sin(30, 20))、参数类型错误(Math.sin("30"))、返回值类型错误(声明返回int却return "30";)。 - 静态检查不能抓:与具体值有关的错误,比如除零、下标越界、非法转换(
Integer.valueOf("hello"))。因为编译器只知道「y 是 int」,不知道「y 恰好是 0」。 - 静态类型保证「变量一定持有该类型中的某个值」,但不保证是哪一个值;值相关的检查属于动态检查。
- 类型越精确(如用
List<Integer>而不是裸List),编译器能替你抓的错误就越多。
- 静态检查能抓:语法错误(多余的标点、错字)、拼错的名字(
静态检查、动态检查、不检查(Static Checking, Dynamic Checking, No Checking)
- 定义与目的:语言可以提供的三种自动检查档次——静态检查(运行前发现)、动态检查(运行时发现)、不检查(语言完全不管,你自己盯着,否则就得到错误答案)。三者优先级为:静态优于动态,动态优于不检查。这三档构成了本讲最重要的判断框架。
- 直观解释(”它是什么?”):把它想成机场安检的三道关:静态检查是登机前的行李检查(还没上飞机就被拦下),动态检查是飞行中的警报(出问题立刻报警并迫降),不检查是根本没有报警器(飞机安静地飞向错误的方向,直到撞山)。
- 关键规则与最佳实践:
- 写代码时反复问自己:「这个错误会在编译期被抓住、运行期被抓住、还是完全抓不住?」抓不住的错误要有额外的防御(断言、显式检查、更安全的类型)。
- 静态检查面向类型,动态检查面向具体值;这是一条极为有用的心智分界线。
- Java 做了不少动态检查(数组越界抛
ArrayIndexOutOfBoundsException、除零抛ArithmeticException、null上调用方法抛NullPointerException),这比 C/C++ 安全得多——C/C++ 的数组越界是不检查的,正是缓冲区溢出漏洞与网络蠕虫的温床。 - TypeScript 在运行期的行为完全由 JavaScript 提供,而 JavaScript 在很多场合选择「不检查」:越界返回
undefined,除零返回Infinity。错误值会一路传播,直到离原始错误很远的地方才爆发——这让调试变难。
原始类型不是真正的数(Primitive Types Are Not True Numbers)
- 定义与目的:Java 的数值原始类型有一些不符合直觉的角落,导致某些本该被动态检查的错误变成了「静默的错误答案」。理解这些角落是避免「数值 bug」的关键。
- 直观解释(”它是什么?”):
int是一条首尾相接的环形跑道,跑到最右端会突然跳回最左端;double是一个只有 53 位有效二进制数字的标尺,太长的小数只能被截断。 - 关键规则与最佳实践:
- 整数除法:
5/2得到2而不是2.5,小数部分被直接丢掉(截断),不报错。 - 整数溢出(overflow):
int/long是有限集合,超出范围会静默回绕,得到一个合法范围内的错误数字。 - 浮点特殊值:
double有NaN(Not a Number)、POSITIVE_INFINITY、NEGATIVE_INFINITY。除零或对负数开平方不会抛异常,而是得到这些特殊值,继续算下去会得到错误的最终结果。 - 精度限制:
double只能精确表示 2⁵³ 以内的整数,超过之后相邻可表示整数之间的间隔大于 1,x + 1可能等于x(这正是 sp22 中 TypeScript 的number陷阱)。 - Java 补充说明:
Math.abs(Integer.MIN_VALUE)返回的仍是Integer.MIN_VALUE(一个负数!),因为 -2³¹ 的相反数超出了int的范围。
- 整数除法:
数组与集合(Arrays and Collections)
- 定义与目的:把冰雹序列存起来而不是打印出来,需要数据结构。Java 提供定长数组和可变长 List 两类「列表状」容器;选择哪一个直接影响安全性。
- 直观解释(”它是什么?”):数组是一排固定编号的储物柜,造好之后柜子数量就定死了;
List是一根可以随时加长缩短的伸缩杆。 - 关键规则与最佳实践:
- 数组声明与构造:
int[] a = new int[100];,一旦创建长度不可变;操作有下标a[2]、赋值a[2] = 0、长度a.length。 - List 声明与构造:
List<Integer> list = new ArrayList<Integer>();,操作有list.get(2)、list.set(2, 0)、list.size()、list.add(n)。 - 为什么左边写
List右边写ArrayList:List是接口(interface),只规定必须提供哪些操作;ArrayList是具体类,提供实现。声明变量与返回类型时优先用接口,代码更通用、更灵活。 - 为什么不能写
List<int>:泛型参数必须是对象类型,所以要用包装类型Integer。Java 会在int与Integer之间自动装箱/拆箱,所以Integer i = 5;合法。 - 定长数组 + 魔法数字是经典的 bug 来源:冰雹序列长度不可预测,
new int[100]对 n=27(需要 112 项)就会越界。这种错误叫缓冲区溢出(buffer overflow),在 C/C++ 中是安全灾难,在 Java 中会被动态检查为异常。
- 数组声明与构造:
迭代(Iterating)
- 定义与目的:for-each 循环让「遍历容器中的每个元素」这一最常见任务变得简洁、不易出错。
- 直观解释(”它是什么?”):
for (int x : list)读作「对 list 中的每一个 x」,你不需要自己维护下标计数器,也就没有机会把<=写成<、把起始值写成 1。 - 关键规则与最佳实践:
- Java 写法:
for (int x : list) { max = Math.max(x, max); },数组和List通用。 - 循环变量优先用原始类型
int而不是Integer,因为原始类型的==更简单、更不易出错(见 Reading 02)。 - 只有在确实需要下标时才写
for (int i = 0; i < list.size(); i++),这种写法冗长且藏 bug 的地方更多。 - 绝不要在迭代过程中修改正在遍历的集合(增、删、替换),这会破坏迭代器,甚至让程序崩溃。
- Java 写法:
方法与规格说明(Methods and Specifications)
- 定义与目的:Java 中语句必须放在方法里,方法必须放在类里。方法上方的
/** ... */注释是规格说明(specification),它描述操作的输入与输出,是模块与客户之间的契约。 - 直观解释(”它是什么?”):规格说明是「说明书」,不是「实现日记」。说明书只写调用者需要知道的事,不写内部怎么实现。
- 关键规则与最佳实践:
public表示任何代码都可以引用;private等访问修饰符用来获得更强的安全性,并保证不可变类型的不可变性(Reading 08 会展开)。static表示这个方法不带隐含的this参数,用类名调用:Hailstone.hailstoneSequence(83)。- 规格说明要简洁、清晰、精确,只写类型声明没说的事:不必写「返回一个整数列表」(
List<Integer>已经说了),但必须写「序列以 n 开始、以 1 结束」以及「要求 n > 0」。 - 有前置条件(precondition)时一定要写出来,并用防御性检查兜底;注释是给人看的,编译器不看注释。
改变值 vs. 重新赋值变量(Mutating Values vs. Reassigning Variables)
- 定义与目的:这是两个必须严格区分的概念:改变变量指向哪里(重新赋值)与改变值本身的内容(修改)。不可变性(immutability) —— 有意禁止某些东西在运行时改变 —— 是本课程的核心设计原则。
- 直观解释(”它是什么?”):在快照图(snapshot diagram,Reading 02 详述)里,变量是一支箭头。赋值是让箭头指向别处;修改是让箭头指向的那个气泡内部发生变化。
- 关键规则与最佳实践:
- 不可变类型(immutable type):值一旦创建就不能改变。
String在 Java 和 Python 中都是不可变的。 - 不可重新赋值的引用(unreassignable reference):用
final声明,变量只被赋值一次。若编译器无法确信它只被赋值一次,就报编译错误——所以final提供的是静态检查。 - 好习惯是对方法参数和尽可能多的局部变量加
final:这些声明既是文档,又被编译器静态检查。 - 注意两个容易混淆的组合:
final引用可以指向可变对象(final StringBuilder sb = new StringBuilder("a"); sb.append("b");合法);非final引用也可以指向不可变对象(String s = "a"; s = "ab";合法,只是箭头改指了)。
- 不可变类型(immutable type):值一旦创建就不能改变。
记录假设(Documenting Assumptions)
- 定义与目的:写下一个变量的类型,就是在记录一条假设:「这个变量永远指向一个整数」。Java 在编译期检查这条假设。写下
final是另一条假设:「这个变量初始化后不会再被赋值」,Java 同样静态检查。 - 直观解释(”它是什么?”):程序里满是假设,而人的记忆不可靠。假设不写下来,三个月后的你(以及接手你代码的同事)只能靠猜。
- 关键规则与最佳实践:
- 能自动检查的假设优先用语言机制表达(类型、
final),因为机器检查永不疲倦。 - 不能自动检查的假设(例如
n必须为正)必须写进规格说明,必要时用运行期检查兜底。 - 程序要同时服务两个目标:与计算机沟通(先让编译器相信程序语法与类型正确,再让逻辑在运行时给出正确结果)和与人沟通(让程序易于理解,以便将来有人能修好它、改进它、改造它)。
- 能自动检查的假设优先用语言机制表达(类型、
黑客式编程 vs. 工程化编程(Hacking vs. Engineering)
- 定义与目的:本讲写的冰雹代码其实相当「黑」。区分「黑」与「工程」的判断标准,是乐观主义与悲观主义的分野。
- 直观解释(”它是什么?”):黑客相信「一次就能写对、bug 一眼就能找到」;工程师相信「一定会错,所以要提前布防」。
- 关键规则与最佳实践:
- 坏:写一大堆代码才第一次测试;把所有细节记在脑子里;假设 bug 不存在或很容易修。
- 好:一次写一点、边写边测(Reading 03 的测试优先编程);把代码依赖的假设写下来;用静态检查替自己防御「愚蠢的错误」——尤其是自己的。
好软件的三大目标(The Big Three)
- 定义与目的:本课程的全部内容都围绕三个性质展开,每个语言特性、每种编程实践、每个设计模式都要问「它如何服务于这三点」。
- 直观解释(”它是什么?”):把三大目标当成三把尺子,任何设计决策都拿它们量一量。
- 关键规则与最佳实践:
- Safe from bugs(免受 bug 之害):正确性(现在行为正确)与防御性(将来行为也正确)。静态检查、
final、动态检查都有贡献。 - Easy to understand(易于理解):代码要与未来的程序员沟通,那个人很可能是几个月后的你自己。显式类型、写明假设的注释都有贡献。
- Ready for change(为变化做好准备):软件永远在变。静态检查在你改名字或改类型时,会立刻在所有使用处显示错误,提醒你同步修改;函数与接口把可变的部分隔离起来。
- 还有其他重要性质(性能、可用性、安全性),它们可能与三大目标冲突,但三大目标是 6.031 最优先考虑的。
- Safe from bugs(免受 bug 之害):正确性(现在行为正确)与防御性(将来行为也正确)。静态检查、
为什么这门课用静态类型语言(以及 TypeScript 与 Java 的取舍)
- 定义与目的:sp22 选择 TypeScript、sp21 选择 Java,理由高度一致:安全性与普遍性。
- 直观解释(”它是什么?”):静态检查把安全性「调高」,让你先在一个安全的、被静态检查的语言里学会好的工程习惯,再迁移到动态语言。
- 关键规则与最佳实践:
- 动态类型语言也在向静态化靠拢:Python 3.5+ 的类型注解 + Mypy,JavaScript + 类型声明 = TypeScript。这反映了工程界的普遍信念:静态类型是构建与维护大型系统的必需品。
- 渐进类型(gradual typing):同一份代码中,一部分有静态类型声明,另一部分没有——这让小原型可以平滑地长成大型可维护系统。
- sp22 认为 TypeScript 相比 Java 类型系统更丰富、样板代码更少、更适合现代 Web 界面;Java 的优点则在于生态成熟、
List/Set/Map等 API 稳定、工具链完善。 - 双方共同的缺点:语言都很大、背负历史包袱(
switch语句、原始类型),且都有内部不一致之处(如 Java 的final在不同上下文中含义不同,static关键字与静态检查毫无关系)。 - 最重要的一点:本课程教的不是语言特性,而是可迁移的能力——安全、清晰、抽象、工程直觉。
代码示例与对比分析
场景 1:把「值相关的错误」误当成「类型相关的错误」——大整数溢出
❌ 错误代码
// 错误:以为把两个 int 相乘就能得到 40 亿
public class Overflow {
public static void main(String[] args) {
int big = 200000; // 200,000
big = big * big; // 期望 40,000,000,000
System.out.println(big); // 实际打印 1345294336 —— 完全错误的答案
}
}
【错误代码的问题】
- 编译期没有任何错误:
int * int类型完全正确,静态检查帮不上忙。 - 运行期也没有任何错误:Java 对整数溢出不做动态检查,结果静默回绕成一个仍落在
int范围内的错误数字。 - 错误值会继续参与后续计算,直到某个完全无关的地方才暴露成怪现象,调试成本极高。
- 如果在真实系统中这段代码用来算金额、速度、坐标,错误答案可能造成难以挽回的后果(见下文 Ariane 5 的故事)。
✅ 正确代码
// 正确:先想清楚取值范围,再选足够宽的类型
import java.math.BigInteger;
public class OverflowFixed {
public static void main(String[] args) {
long big = 200000L; // 用 long 容纳 4e10
big = big * big;
System.out.println(big); // 40000000000
// 需要任意精度时用 BigInteger(对应 Python 的 int)
BigInteger b = BigInteger.valueOf(200000);
System.out.println(b.multiply(b)); // 40000000000
}
}
【为什么这样更好】 这里的关键变化不是「写对了一个运算符」,而是先把假设写下来:200000² ≈ 4×10¹⁰,远超 int 的 2.1×10⁹。long 把可表示范围扩大到这个假设成立;BigInteger 则让假设「永不溢出」彻底成立。类型选择本身就是一条可被读者检验的文档。
【代码对比解说】 注意 200000L 里的 L:如果写成 long big = 200000; big = big * big;,Java 会先把 big 提升为 long 再做乘法,结果是正确的;但若写成 long big = 200000 * 200000;,右边两个 int 会先按 int 相乘溢出,再把已经错误的 1345294336 提升为 long——这是极其经典的陷阱。规则是:溢出发生在表达式求值的那一步,而不是赋值的那一步。
【设计原则透视】 对应 sp22 中的 Number.MAX_SAFE_INTEGER 陷阱:类型只界定「值的集合」,该集合的边界是表示不变量(representation invariant, RI)的一部分。静态类型系统保证「值属于某个类型」,但不保证「值落在有意义的范围内」。这正是「静态检查面向类型、动态检查面向具体值」这条分界线的具体体现——而当语言连动态检查都不做时,责任就落回程序员的设计上。在 Reading 11(抽象函数与表示不变量)中,我们会把「取值范围」正式写成 RI,并用测试去覆盖边界(Reading 03)。
场景 2:整数除法——最容易被静态检查放过的一类错误
❌ 错误代码
// 错误:5/9 在 int 域里求值,结果是 0
public class Celsius {
public static double fahrenheitToCelsius(double f) {
return (f - 32) * (5 / 9); // 括号让它看起来像「先算比例」,实际先算出了 0
}
public static void main(String[] args) {
System.out.println(fahrenheitToCelsius(212.0)); // 打印 0.0,而不是 100.0
}
}
【错误代码的问题】
- 编译期无错误:
(double) * (int/int)类型合法。 - 运行期无异常:整数除法
5/9 == 0是语言的正常行为,不抛错。 - 结果为「看起来很像正确答案」的 0.0——比崩溃更危险,因为它可能被当成「温度本来就是 0 度」而流向生产环境。
- 这个 bug 只会被某些输入触发;任何只用冻结温度(f = 32)的测试都无法发现它。
✅ 正确代码
// 正确:把常量写成 double,强制在浮点域求值
public class CelsiusFixed {
public static double fahrenheitToCelsius(double f) {
return (f - 32) * (5.0 / 9); // 5.0/9 == 0.5555...
}
public static void main(String[] args) {
System.out.println(fahrenheitToCelsius(212.0)); // 100.0
}
}
【为什么这样更好】 5.0 是一个 double 字面量,因此 5.0 / 9 触发二元数值提升,在浮点域求值得到 0.555…,整条表达式随后都在 double 域中进行。更稳妥的写法还有 (f - 32) * 5 / 9(乘法先行,天然保持 double),但显式写 5.0 / 9 的可读性最好,因为它把「这是一个比例」这一意图直接写在了代码里。
【代码对比解说】 两种写法在字符上的差别只有两个字符(5 改成 5.0),语义差别却是「100 度」与「0 度」。这类 bug 的可怕之处在于它同时躲过了静态检查与动态检查:类型没错,值也没「非法」。唯一的防线是测试(Reading 03):只要针对「非冻结温度」这一输入子域取一个测试用例,212.0 → 100.0 立刻失败。
【设计原则透视】 这是「原始类型不是真正的数」的教科书案例:int 上的 / 与数学上的除法不是同一个函数。从设计原则看,它同时违反了三目标中的两个——不安全(错误答案)且不易理解(5/9 的读者必须自己推断求值域)。修复它的手段(写 5.0)本质上是在代码里显式记录求值域的假设,这正是本讲「记录假设」的实践。
场景 3:固定长度数组造成缓冲区溢出——用 List 替代
❌ 错误代码
import java.util.List;
import java.util.ArrayList;
// 错误:用魔法数字 100 装一个长度不可预测的序列
public class HailstoneBuggy {
public static int[] hailstoneSequence(int n) {
int[] a = new int[100]; // <==== DANGER, WILL ROBINSON!
int i = 0;
while (n != 1) {
a[i] = n;
i++;
if (n % 2 == 0) {
n = n / 2;
} else {
n = 3 * n + 1;
}
}
a[i] = n;
i++;
return a; // 返回长度恒为 100 的数组,有效元素只有 i 个
}
}
【错误代码的问题】
100是魔法数字:hailstoneSequence(27)需要 112 项,立刻抛出ArrayIndexOutOfBoundsException——一个只在特定输入下才发生的运行期错误。- 返回值的契约模糊:数组长度恒为 100,调用者无法判断哪些元素有效;一旦调用者用
a.length去遍历,就会读到一堆无意义的 0。 - 在 C/C++ 里这不再是异常而是缓冲区溢出:越界写入会破坏相邻内存,历史上造成过大量网络安全事件与网络蠕虫。
- 代码把「序列有多长」这一假设硬编码进来,任何关于序列长度的新事实(例如数学上的新结果)都需要改动多处。
✅ 正确代码
import java.util.List;
import java.util.ArrayList;
// 正确:用自动扩容的 List,长度由数据自己决定
public class Hailstone {
/**
* Compute a hailstone sequence.
* @param n starting number for sequence; requires n > 0.
* @return hailstone sequence starting with n and ending with 1.
*/
public static List<Integer> hailstoneSequence(final int n) {
final List<Integer> list = new ArrayList<Integer>();
int current = n;
while (current != 1) {
list.add(current);
if (current % 2 == 0) {
current = current / 2;
} else {
current = 3 * current + 1;
}
}
list.add(current);
return list;
}
}
【为什么这样更好】 List 会自动随元素增加而扩容(直到内存耗尽),因此「序列长度」这一假设彻底从代码中消失,也就没有了 ArrayIndexOutOfBoundsException。返回类型 List<Integer> 的 size() 精确等于有效元素个数,调用者不再需要猜。同时规格说明明确写出了前置条件(requires n > 0)与后置条件(以 n 开始、以 1 结束),把类型表达不了的假设写了下来。
【代码对比解说】 这里有三处修改值得逐条体会:第一,容器从定长数组改为可变长 List,把「长度」这个不可控因素交给容器管理;第二,声明类型用接口 List 而不是实现类 ArrayList,让实现可以随时替换而不影响客户代码(对应 Reading 12 的接口与实现分离);第三,在规格说明中显式写出 n > 0。第三点尤其重要:Java 编译器不会检查 n > 0,它是一条纯文档假设,但是它的缺失会导致 n = 0 时无限循环(0 是偶数,永远除以 2 还是 0),n = -5 时陷入 -5 → -14 → -7 → -20 → -10 → -5 的死循环。
【设计原则透视】 数组版本暴露的是表示不变量被破坏:0 <= i < a.length 是这段代码必须始终成立的 RI,而代码没有任何机制保证它(见 Reading 11)。List 版本把这个不变量交给容器自身承担,从而在抽象边界上把「不可能出错」变成结构性事实。此外,接口类型 List 让客户不依赖具体表示,这是「为变化做好准备」的直接体现:把 ArrayList 换成 LinkedList 时,客户代码无需改动。
场景 4:final 把「不重新赋值」的假设交给编译器
❌ 错误代码
import java.util.List;
import java.util.ArrayList;
// 错误:循环体反复重写参数,规格说明中的假设在循环里悄悄失效
public class HailstoneMutating {
/**
* @param n starting number for sequence; requires n > 0.
* @return hailstone sequence starting with n and ending with 1.
*/
public static List<Integer> hailstoneSequence(int n) {
List<Integer> list = new ArrayList<Integer>(); // 从未被重新赋值,却不是 final
while (n != 1) {
list.add(n);
if (n % 2 == 0) {
n = n / 2; // 参数被重新赋值
} else {
n = 3 * n + 1; // 参数被重新赋值
}
}
list.add(n);
return list;
}
}
【错误代码的问题】
- 参数
n在循环中被反复改写,规格说明里「n 是序列的起始值」这一说法在循环体内部不再成立,读者必须时刻跟踪它的当前含义。 list明明从未被重新赋值,却声明为可变引用:读者要通读整个方法体才能确认这一点,编译器也无法替他确认。- 这类「顺手复用参数」的习惯在方法变长以后极易演变成真正的 bug,例如某次改动把循环后的
n当成起始值使用。 - 假设没有被写下来,也就没有被检查——一旦有人后来往循环里加了一行改写
n的代码,没有任何提示。
✅ 正确代码
import java.util.List;
import java.util.ArrayList;
// 正确:参数只读,循环用独立的局部变量;能加 final 的都加上
public class HailstoneFinal {
/**
* @param start starting number for sequence; requires start > 0.
* @return hailstone sequence starting with start and ending with 1.
*/
public static List<Integer> hailstoneSequence(final int start) {
final List<Integer> list = new ArrayList<Integer>();
int current = start; // 唯一的可变变量,职责清晰
while (current != 1) {
list.add(current);
if (current % 2 == 0) {
current = current / 2;
} else {
current = 3 * current + 1;
}
}
list.add(current);
return list;
}
}
【为什么这样更好】 参数 start 与容器 list 都加了 final:编译器会静态检查它们从不被重新赋值,这条「不会变」的假设从此不需要读者去验证。方法里只剩一个可变量 current,它的含义在整个方法内保持稳定(「当前正在处理的冰雹数」),读者可以放心地只看它的名字。若将来有人误在循环里写 start = ...,编译器会立刻报错,把 bug 挡在编译期。
【代码对比解说】 变量数量没有变(仍然是三个),但职责边界变清晰了:start 是只读输入,list 是只读引用(内容可变),current 是唯一的状态载体。final 的代价几乎为零(多打六个字母),收益是编译器替你守护一条不变量。注意 final List<Integer> list 只保证引用不被重赋,list.add(...) 完全合法——这正是「不可重新赋值的引用」与「不可变的值」之间的区别,Reading 02 会用快照图把这一点画清楚,Reading 08 会讨论如何用 Collections.unmodifiableList 让值本身也不可变。
【设计原则透视】 final 是本讲「记录假设」原则的语言化表达:类型记录「值属于哪个集合」,final 记录「引用不再改变」,规格说明记录「值必须满足什么条件」。三者构成一个从强到弱的假设梯度:类型由编译器完全检查,final 由编译器完全检查,前置条件必须靠文档加运行期检查。一个成熟的工程师会把尽可能多的假设推到「编译器能检查」的那一端。
场景 5:把「值相关的错误」写进规格说明并用防御性检查兜底
❌ 错误代码
// 错误:规格说明里什么都不说,调用者只能靠猜
public class Average {
public static double average(int sum, int n) {
return sum / n; // n == 0 时抛 ArithmeticException
}
}
【错误代码的问题】
- 方法契约完全没有说明
n不能为 0,调用者无法从签名或注释中得知这个约束。 - 一旦传入 0,运行期抛出
ArithmeticException;如果传入的是double类型的 0,则连异常都没有,直接得到NaN或Infinity并继续传播。 - 错误在离调用点很远的地方才暴露,定位成本高。
- 规格说明缺失会让测试无从下手:不知道哪些输入是「合法的」,也就无法写出正确的测试套件(Reading 03 强调「测试套件必须是规格说明的合法客户」)。
✅ 正确代码
// 正确:把前置条件写进规格说明,并在运行期显式检查
public class Average {
/**
* @param sum the sum of the values being averaged
* @param n how many values were summed; requires n > 0
* @return the arithmetic mean sum / n
* @throws IllegalArgumentException if n <= 0
*/
public static double average(int sum, int n) {
if (n <= 0) {
throw new IllegalArgumentException("n must be positive, but was " + n);
}
return (double) sum / n;
}
}
【为什么这样更好】 规格说明把「n > 0」这条编译器无法检查的假设写了下来,调用者读一眼就知道自己的责任。运行期检查把「不检查」变成「动态检查」:错误在出错的那一行立刻以清晰的异常信息暴露,而不是变成一个 NaN 传染给整个程序。注意 (double) sum / n:显式转换避免了整数除法带来的截断,这是场景 2 的教训在这里的复现。
【代码对比解说】 关键转变是把一条隐式假设变成三层显式声明:注释(给人看)、异常声明(给调用者看)、运行期检查(给运行时看)。有人会认为运行期检查「浪费性能」,但对于一个 n <= 0 就完全无意义的函数,这正是「工程师是悲观主义者」的具体体现——防御性检查的成本是几纳秒,收益是不必在半夜排查一个 NaN。
【设计原则透视】 这段代码展示了本讲全部三条主线的交汇:类型(int sum, int n)由编译器静态检查,前置条件(n > 0)只能靠文档与运行期检查,而「把假设写下来」直接服务于 Easy to understand。到了 Reading 06(规格说明)我们会把前置条件、后置条件、异常行为形式化成更严格的契约;到了 Reading 07(设计规格说明)我们会讨论「前置条件该由谁负责检查」这一经典权衡。
(sp22 原版 TypeScript 写法对照)
// sp22 原版 TypeScript 写法:const 提供「不可重新赋值」的静态检查
const n: number = 5; // 终生指向 5
let m: number = 5; // 可重新赋值
m = m + 1; // 合法
// n = n + 1; // 静态错误:Cannot assign to 'n' because it is a constant.
// const 只固定引用,不冻结值:数组内容仍可变
const array: Array<number> = [];
array.push(5); // 合法:引用没变,值变了
// TypeScript 的静态类型在编译后会被丢弃,生成的是纯 JavaScript:
// function hello(name: string): string { return 'Hi, ' + name; }
// 编译为
// function hello(name) { return 'Hi, ' + name; }
// 所以运行期的一切行为仍由 JavaScript 的(常常不做检查的)语义决定。
这组 TypeScript 写法与 Java 的 final 形成精确对应:const ↔ final,都只约束引用而不冻结值;而 TypeScript 编译后丢弃类型信息这一点,提醒我们「静态检查只存在于编译期」,运行期的安全仍需靠设计与测试(Reading 03)来保证。
与其他设计原则的关联
- 与 Reading 02(基本 Java / Basic Java):本讲只把类型当作「值的集合 + 操作」来介绍,Reading 02 会把它落到具体语法上——原始类型与对象类型的区别、
==与.equals()、List/Set/Map、final与可变对象的组合,并用快照图把「重新赋值 vs. 改变值」可视化。本讲场景 3 中的List<Integer>在 Reading 02 会展开为完整的集合 API。 - 与 Reading 03(测试 / Testing):本讲反复强调「静态检查抓不到与具体值相关的错误」。如何抓住它们?答案是系统化测试。Reading 03 的输入空间划分与边界值正对应本讲中的整数溢出、
Integer.MIN_VALUE、空字符串、空集合——本讲场景 2 的整数除法 bug 只有靠一个「非边界温度」的测试用例才能发现。 - 与 Reading 04(代码审查 / Code Review):本讲说「写一点点、测一点点」「把假设写下来」,代码审查是把这两条落到团队流程上的实践:让另一个人用本讲的三把尺子(安全、易理解、可修改)来读你的代码。
- 与 Reading 06(规格说明 / Specifications) 和 Reading 07(设计规格说明 / Designing Specifications):本讲第一次出现了「规格说明」与「前置条件」,正式的契约式设计在那里展开。
- 与 Reading 08(不可变性 / Immutability):本讲区分了「不可重新赋值的引用」与「不可变的值」,Reading 08 会系统化不可变性的设计原则,并说明它为什么是「Ready for change」的核心武器。
- 与 Reading 09(避免调试 / Avoiding Debugging):本讲提出的「工程师是悲观主义者」在 Reading 09 中被系统化成一系列构造性建议(如
assert、快速失败、把假设变成检查)。 - 与 Reading 11(抽象函数与表示不变量 / Abstraction Functions & Rep Invariants):本讲场景 3 中的
0 <= i < a.length是一条尚未命名的表示不变量,Reading 11 会给出 RI/AF 的正式框架。 - 与 Reading 21–23(并发 / Concurrency、线程安全 / Thread Safety、锁 / Locks):本讲「避免改变」的直觉在并发中变成硬性要求——共享可变状态是线程安全问题的根源。
关键要点
- 把检查尽量往左移:静态检查优于动态检查,动态检查优于不检查。写每一行代码时都问自己「这个错误会在哪一档被抓住」,抓不住的地方必须额外布防。
- 区分「面向类型」与「面向值」的错误:编译器能保证变量持有某个合法值,但永远不知道是哪一值。除零、越界、非法转换、溢出、精度丢失都只能靠测试与设计来防。
- 假设必须写下来:类型和
final交给编译器检查;前置条件写进 Javadoc,并用运行期检查兜底。没写下来的假设,未来一定会被忘记。 - 优先选择「不变量由结构保证」的设计:用自动扩容的
List代替int[100],永远不要让魔法数字承担正确性责任。 - 用三大目标检验每一个设计决策:Safe from bugs / Easy to understand / Ready for change。
常见陷阱与注意事项
- 以为「编译通过」就等于「正确」:
big = big * big;与(f - 32) * (5 / 9)都能顺利编译。→ 后果:静默的错误答案,且会继续向程序下游传播。 - 把
int当成数学上的整数:忘记5/2 == 2、超出 ±2³¹ 会回绕、double超过 2⁵³ 后x + 1 == x。→ 后果:数值结果错误,且错误只在特定数值范围内出现,测试容易被「恰好选中安全值」而漏过。 - 用
==比较对象(本讲铺垫,Reading 02 详述):==在 Java 中对对象重载为「是否指向同一对象」。→ 后果:字符串内容相同却返回false,或因为字符串驻留(interning)而「偶尔正确」,成为最难查的一类 bug。 - 把参数当作可随意改写的临时变量:在循环里反复修改参数。→ 后果:规格说明中「参数是起始值」的假设失效,后续维护者读到循环后的参数值时会得到错误结论;也无法对它加
final。 - 忽略前置条件:
hailstoneSequence(0)或hailstoneSequence(-5)会无限循环;average(sum, 0)会抛异常。→ 后果:程序挂死或崩溃,而且发生在与错误源头无关的位置。 - 依赖 C/C++ 式的「数组越界无所谓」直觉:Java 会抛
ArrayIndexOutOfBoundsException,但不代表可以靠它兜底。→ 后果:把本该在编译期或设计期解决的问题留到生产环境的运行期。
思考题(带答案)
问题 1:下面这段 Java 代码在编译期、运行期分别会出什么问题?如果没有问题,结果是否等于 8.0?
double x = 16.0;
double y = 2;
double z = x / y; // 期望 8.0
int w = 5 / 2; // 期望 2.5
答案:x / y 没有任何问题:x 是 double,二元数值提升让 y 也变成 double,结果是 8.0。int w = 5 / 2; 编译通过、运行通过,但结果是 2 而不是 2.5——两个 int 之间的 / 是整数除法,小数部分被截断,这是「不检查」的典型例子:既不是静态错误,也不是动态错误,只是错误答案。这正对应本讲判断框架中的第三档。
问题 2:为什么 Math.sin("30") 是静态错误,而 Integer.valueOf("8000000000") 是动态错误?请用「类型 vs. 具体值」这条分界线解释。
答案:Math.sin 的签名是 sin(double),参数类型是 double,而 "30" 的类型是 String。这种不匹配在编译期就完全可见,编译器不需要知道任何运行期的值就能判定它非法,因此是静态错误。相反,Integer.valueOf(String) 的参数类型就是 String,类型完全正确;错误只发生在这个具体字符串无法被解析成 int 范围内的十进制整数时——"8000000000" 是 80 亿,超出了 int 的合法范围。编译器无法预知运行时收到哪个字符串,只能由运行期抛出 NumberFormatException。这条分界线可以概括为:静态检查面向类型(值的集合),动态检查面向具体值。
问题 3:本讲的 hailstoneSequence 规格说明写了 requires n > 0。为什么不能像类型那样让编译器检查它?如果不写这条前置条件,可能出现什么后果?你能想出两种让这条假设被强制检查的手段吗?
答案:n > 0 是一个关于值的约束,而编译器只知道 n 是 int;int 的合法值集合包含负数和 0,因此「正整数」不是一个可以用 Java 类型表达的集合(除非定义一个 PositiveInt 类,把约束编码进构造过程——这是 Reading 11 中表示不变量的思路)。不写这条前置条件,调用者可能传入 0(0 是偶数,0/2 == 0 永远循环)或 -5(进入 -5 → -14 → -7 → -20 → -10 → -5 的环),程序静默挂死。两种强制检查手段:其一,在方法开头写运行期检查 if (n <= 0) throw new IllegalArgumentException(...),把「不检查」升级为「动态检查」;其二,在规格说明中把它变成调用者的义务,并由测试套件保证只使用合法输入(这个「让违反前置条件的调用成为调用者的 bug」的立场将在 Reading 06/07 中正式讨论)。
