Reading 1: 静态检查(Static Checking)

目录 · ← l0 · l2 →

第二部分:分讲学习笔记(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 / 2n / 2.0 完全不同。
    • 序列长度不可预测(n=27 需要 112 项才能降到 1),因此绝不能用固定长度的容器去装它。
    • 规格说明里必须写清前置条件,冰雹序列要求 n > 0n = 0n < 0 会造成无限循环(0 永远是偶数,-5 → -14 → -7 → -20 → -10 → -5 形成死循环)。
    • 循环体内修改参数会让规格说明中「n 是起始值」的假设失效,应使用独立的循环变量。

类型(Type)

  • 定义与目的类型是一组值的集合,以及可以在这些值上执行的操作。类型的作用是让编译器知道哪些操作是合法的,从而把「把操作施加到错误类型的参数上」这一类 bug 彻底挡在编译期之外。它服务的首要目标是 Safe from bugs,其次是 Easy to understand(类型就是写在代码里的假设)。
  • 直观解释(”它是什么?”):把类型想成「容器上的标签」。标着 int 的盒子里只装 32 位整数,标着 String 的盒子里装字符序列。你在 String 盒子上按「乘法」按钮时,编译器会在你按下运行按钮之前就告诉你:「这个按钮在这个盒子上不存在」。
  • 关键规则与最佳实践
    • Java 的原始类型(primitive types)int(约 ±2³¹)、long(约 ±2⁶³)、booleandoublechar,一律小写。
    • Java 的对象类型(object types)String(字符序列)、BigInteger(任意精度整数)、以及你自定义的类,按约定首字母大写。
    • 每个原始类型都有对应的包装类型(wrapper type)int/Integerlong/Longdouble/Double,泛型参数必须用包装类型。
    • 变量声明要「能写就写」:把类型写下来既是给编译器的承诺,也是给未来读者的文档。

操作的三种书写形式与重载(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"))、返回值类型错误(声明返回 intreturn "30";)。
    • 静态检查不能抓:与具体值有关的错误,比如除零、下标越界、非法转换(Integer.valueOf("hello"))。因为编译器只知道「y 是 int」,不知道「y 恰好是 0」。
    • 静态类型保证「变量一定持有该类型中的某个值」,但不保证是哪一个值;值相关的检查属于动态检查。
    • 类型越精确(如用 List<Integer> 而不是裸 List),编译器能替你抓的错误就越多。

静态检查、动态检查、不检查(Static Checking, Dynamic Checking, No Checking)

  • 定义与目的:语言可以提供的三种自动检查档次——静态检查(运行前发现)、动态检查(运行时发现)、不检查(语言完全不管,你自己盯着,否则就得到错误答案)。三者优先级为:静态优于动态,动态优于不检查。这三档构成了本讲最重要的判断框架。
  • 直观解释(”它是什么?”):把它想成机场安检的三道关:静态检查是登机前的行李检查(还没上飞机就被拦下),动态检查是飞行中的警报(出问题立刻报警并迫降),不检查是根本没有报警器(飞机安静地飞向错误的方向,直到撞山)。
  • 关键规则与最佳实践
    • 写代码时反复问自己:「这个错误会在编译期被抓住、运行期被抓住、还是完全抓不住?」抓不住的错误要有额外的防御(断言、显式检查、更安全的类型)。
    • 静态检查面向类型,动态检查面向具体值;这是一条极为有用的心智分界线。
    • Java 做了不少动态检查(数组越界抛 ArrayIndexOutOfBoundsException、除零抛 ArithmeticExceptionnull 上调用方法抛 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 是有限集合,超出范围会静默回绕,得到一个合法范围内的错误数字。
    • 浮点特殊值doubleNaN(Not a Number)、POSITIVE_INFINITYNEGATIVE_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 右边写 ArrayListList接口(interface),只规定必须提供哪些操作;ArrayList具体类,提供实现。声明变量与返回类型时优先用接口,代码更通用、更灵活。
    • 为什么不能写 List<int>:泛型参数必须是对象类型,所以要用包装类型 Integer。Java 会在 intInteger 之间自动装箱/拆箱,所以 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 的地方更多。
    • 绝不要在迭代过程中修改正在遍历的集合(增、删、替换),这会破坏迭代器,甚至让程序崩溃。

方法与规格说明(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"; 合法,只是箭头改指了)。

记录假设(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 最优先考虑的。

为什么这门课用静态类型语言(以及 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 —— 完全错误的答案
    }
}

【错误代码的问题】

  1. 编译期没有任何错误:int * int 类型完全正确,静态检查帮不上忙。
  2. 运行期也没有任何错误:Java 对整数溢出不做动态检查,结果静默回绕成一个仍落在 int 范围内的错误数字。
  3. 错误值会继续参与后续计算,直到某个完全无关的地方才暴露成怪现象,调试成本极高。
  4. 如果在真实系统中这段代码用来算金额、速度、坐标,错误答案可能造成难以挽回的后果(见下文 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
    }
}

【错误代码的问题】

  1. 编译期无错误:(double) * (int/int) 类型合法。
  2. 运行期无异常:整数除法 5/9 == 0 是语言的正常行为,不抛错。
  3. 结果为「看起来很像正确答案」的 0.0——比崩溃更危险,因为它可能被当成「温度本来就是 0 度」而流向生产环境。
  4. 这个 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 个
    }
}

【错误代码的问题】

  1. 100 是魔法数字:hailstoneSequence(27) 需要 112 项,立刻抛出 ArrayIndexOutOfBoundsException——一个只在特定输入下才发生的运行期错误。
  2. 返回值的契约模糊:数组长度恒为 100,调用者无法判断哪些元素有效;一旦调用者用 a.length 去遍历,就会读到一堆无意义的 0。
  3. 在 C/C++ 里这不再是异常而是缓冲区溢出:越界写入会破坏相邻内存,历史上造成过大量网络安全事件与网络蠕虫。
  4. 代码把「序列有多长」这一假设硬编码进来,任何关于序列长度的新事实(例如数学上的新结果)都需要改动多处。

✅ 正确代码

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;
    }
}

【错误代码的问题】

  1. 参数 n 在循环中被反复改写,规格说明里「n 是序列的起始值」这一说法在循环体内部不再成立,读者必须时刻跟踪它的当前含义。
  2. list 明明从未被重新赋值,却声明为可变引用:读者要通读整个方法体才能确认这一点,编译器也无法替他确认。
  3. 这类「顺手复用参数」的习惯在方法变长以后极易演变成真正的 bug,例如某次改动把循环后的 n 当成起始值使用。
  4. 假设没有被写下来,也就没有被检查——一旦有人后来往循环里加了一行改写 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
    }
}

【错误代码的问题】

  1. 方法契约完全没有说明 n 不能为 0,调用者无法从签名或注释中得知这个约束。
  2. 一旦传入 0,运行期抛出 ArithmeticException;如果传入的是 double 类型的 0,则连异常都没有,直接得到 NaNInfinity 并继续传播。
  3. 错误在离调用点很远的地方才暴露,定位成本高。
  4. 规格说明缺失会让测试无从下手:不知道哪些输入是「合法的」,也就无法写出正确的测试套件(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 形成精确对应:constfinal,都只约束引用而不冻结;而 TypeScript 编译后丢弃类型信息这一点,提醒我们「静态检查只存在于编译期」,运行期的安全仍需靠设计与测试(Reading 03)来保证。

与其他设计原则的关联

  • Reading 02(基本 Java / Basic Java):本讲只把类型当作「值的集合 + 操作」来介绍,Reading 02 会把它落到具体语法上——原始类型与对象类型的区别、==.equals()List/Set/Mapfinal 与可变对象的组合,并用快照图把「重新赋值 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。

常见陷阱与注意事项

  1. 以为「编译通过」就等于「正确」big = big * big;(f - 32) * (5 / 9) 都能顺利编译。→ 后果:静默的错误答案,且会继续向程序下游传播。
  2. int 当成数学上的整数:忘记 5/2 == 2、超出 ±2³¹ 会回绕、double 超过 2⁵³ 后 x + 1 == x。→ 后果:数值结果错误,且错误只在特定数值范围内出现,测试容易被「恰好选中安全值」而漏过。
  3. == 比较对象(本讲铺垫,Reading 02 详述):== 在 Java 中对对象重载为「是否指向同一对象」。→ 后果:字符串内容相同却返回 false,或因为字符串驻留(interning)而「偶尔正确」,成为最难查的一类 bug。
  4. 把参数当作可随意改写的临时变量:在循环里反复修改参数。→ 后果:规格说明中「参数是起始值」的假设失效,后续维护者读到循环后的参数值时会得到错误结论;也无法对它加 final
  5. 忽略前置条件hailstoneSequence(0)hailstoneSequence(-5) 会无限循环;average(sum, 0) 会抛异常。→ 后果:程序挂死或崩溃,而且发生在与错误源头无关的位置。
  6. 依赖 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 没有任何问题:xdouble,二元数值提升让 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 是一个关于的约束,而编译器只知道 nintint 的合法值集合包含负数和 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 中正式讨论)。