元数据卡
- 前置知识:Vol 1 编程基础;会用 Git 基本操作
- 预计时间:45 分钟
- 核心难度:入门
- 完成标志:能在代码中应用静态检查、测试优先和不变式;能写出防御式代码
你的进度
你走出了数据堡垒,带着一包袱代码经验来到了工匠之都。这里的铁匠铺不像变量村的家庭式作坊——墙上挂着规范的图纸,地上摆着精密量具,锻造台前有学徒在写测试用例。你意识到前几年的“能跑就行”在这里行不通了。工匠之都的第一课:软件构造不是写代码,是建工程。 你的任务
"能跑"的代码和"能放心交给别人维护"的代码之间,隔着四条基本功:静态检查提前卡掉低级错误,测试优先让你在动手前想清楚规格,不变式守住数据不乱跑,防御式编程让调用者没法搞破坏。这章的目标很实际——让你写的每一行代码都经得起队友的 code review。
本章分层
- 必读:静态检查、测试优先、不变式、防御式编程
- 选读:形式化验证简介
- 进阶:Design by Contract 的 Java 实现
本章不会要求你掌握
- 快速排序或任何算法
- 设计模式
破局 · 溯源
你的代码总是能跑。但队友总能在里面翻出问题——少了个 null 检查、数组越界没处理、某个变量突然变成负数了。这些 bug 不是因为你不懂编程,是因为你的代码缺少了"工程防护层"。
先看一个问题。你写了一个函数,计算两个日期之间的天数差:
// ch01/Snippet01.java
public static int daysBetween(Date start, Date end) {
long diff = end.getTime() - start.getTime();
return (int) (diff / (1000 * 60 * 60 * 24));
}这段代码有几个问题?
- 如果
start在end之后,返回负数。 - 如果时间差超过
int范围,溢出。 - 如果任何一个参数是
null,NullPointerException。 - 如果传入的不是纯日期(带了时间),精度误差。
你当然可以说"调用者自己注意"。但在工匠之都,这句话说不出口——所有走到生产环境的代码,默认调用者会犯所有能犯的错误。
第一层:静态检查——把 bug 杀死在编译前
你其实已经在用一类工具:编译器。Java 编译器能拦截类型错误、未捕获异常、语法错误。但编译器只能覆盖很小一片。
静态检查工具(静态分析)在编译器的肩膀上走得更远。它们在不运行代码的前提下,检查代码中的潜在问题。你用的是哪个?列表不短:SpotBugs(FindBugs 的继任者)、Checkstyle、PMD、Error Prone,还有 IDE 自带的 inspections。
看一眼 Error Prone 抓到的真实例子。你写:
// ch01/Snippet02.java
if (a == b) { // Error Prone: ReferenceEquality
...
}== 比较对象引用而不是内容。在 Java 里,Integer 对象用 == 比较,值在 -128 到 127 之间可能 true(缓存),之外就是 false——一个运行时才炸的坑。
Error Prone 会在编译时就报:
[ErrorProne] ReferenceEquality: Comparison using reference equality instead of value equality你修掉它只需换成 .equals()。
再看一个场景。你有一个 switch 枚举:
// ch01/Snippet03.java
enum Status { PENDING, RUNNING, DONE }
switch (status) {
case PENDING: ...
case RUNNING: ...
// 忘了 DONE
}编译器不会报错——语法没问题。但如果有人新增了一个 FAILED 枚举值,你的 switch 会静默跳过。这就是穷尽性检查的问题。静态工具可以做到:
[ErrorProne] MissingCasesInEnumSwitch: Switch statement does not cover all enum values你不用等到测试发现"咦,FAILED 的日志去哪了"才知道漏了分支。
静态检查的工作方式很简单:解析 AST(抽象语法树),然后运行一组检查器。每个检查器是一个模式匹配规则。这是最基本的一个(用 SpotBugs 的语法示意):
// SpotBugs 规则示意(不是你写的,是工具内置的)
if (node instanceof NullCheck &&
((NullCheck)node).getChecked() instanceof MethodCall) {
report("NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE");
}在项目里集成静态检查比你想象得简单。Gradle 接入 Error Prone:
// build.gradle
plugins {
id 'net.ltgt.errorprone' version '3.0.1'
}
dependencies {
errorprone 'com.google.errorprone:error_prone_core:2.23.0'
}
tasks.withType(JavaCompile).configureEach {
options.errorprone.enabled = true
}从今以后,./gradlew build 不只是编译,还会顺带检查数百种潜在的 bug 模式。
第二层:测试优先——还没写代码就写测试
你习惯的流程:写代码 -> 手动跑一下 -> 看起来没问题 -> 提交。但在工匠之都的作坊里,测试不是事后验证,而是先想清楚"什么是对的"。
这叫测试优先(Test-First),但不是 TDD。TDD 是红-绿-重构的微观循环,测试优先只是"写实现前先写测试"的思想。
具体到你手上:你要实现一个函数 max(int[] arr),返回数组中最大值。
先写测试:
// ch01/MaxTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class MaxTest {
@Test
void returnsMaxForNormalArray() {
int[] arr = {3, 7, 2, 9, 1};
assertEquals(9, Max.max(arr));
}
@Test
void handlesSingleElement() {
int[] arr = {5};
assertEquals(5, Max.max(arr));
}
@Test
void handlesAllNegative() {
int[] arr = {-5, -2, -8, -1};
assertEquals(-1, Max.max(arr));
}
@Test
void throwsOnEmptyArray() {
assertThrows(IllegalArgumentException.class,
() -> Max.max(new int[0]));
}
}然后写实现:
// ch01/Max.java
public class Max {
public static int max(int[] arr) {
if (arr.length == 0) {
throw new IllegalArgumentException("arr must not be empty");
}
int max = arr[0];
for (int i = 1; i < arr.length; i++) {
if (arr[i] > max) {
max = arr[i];
}
}
return max;
}
}测试通过的瞬间,你不仅有了代码,还有了契约——任何人看了测试就知道这个函数的行为边界。改代码也不怕,跑一遍测试就知道了。
每次你写一个新函数,先问自己:"如果我的函数写对了,输入 X 会输出什么?输入 Y 应该报错吗?边界值是什么?"把这些写下来,就是测试。
第三层:不变式——"不会变"的东西才是安全边界
工匠之都的铁匠铺有一个规矩:每一件装备都要有不变式。一副盔甲,无论你怎么锤打,前胸甲永远是前胸甲,不会变成肩甲——这是设计决定的。
代码里的不变式(invariant)是一段需要在程序的整个生命周期(或某个操作前后)保持为真的条件。
最常见的例子:循环不变式。
// ch01/FindMax.java
int max = arr[0];
// 不变式:max 始终是 arr[0..i-1] 中的最大值
for (int i = 1; i < arr.length; i++) {
if (arr[i] > max) {
max = arr[i];
}
// 循环结束时:max 是 arr[0..i] 的最大值
}
// 循环结束后:max 是整个数组的最大值但不变式不只在循环里。数据结构的内部状态也依赖不变式。比如一个"平衡"的二叉树,每一次插入和删除后,树必须保持平衡——这是红黑树的结构不变式。
在你的日常代码中,不变式表现为断言(assertion):
// ch01/BankAccount.java
class BankAccount {
private double balance;
public void withdraw(double amount) {
assert amount > 0 : "取款金额必须为正";
double oldBalance = balance;
balance -= amount;
// 不变式:余额不能为负(透支场景除外)
assert balance >= 0 : "余额异常: " + balance;
}
}Java 里 assert 默认不开启。要在运行时打开,加 JVM 参数 -ea。在生产环境通常关闭断言——所以依赖断言做业务验证是不安全的。断言是用来调试内部逻辑错误的,不是用来验证用户输入的。
在 Python 里类似:
# ch01/bank_account.py
def withdraw(self, amount: float) -> None:
assert amount > 0, "取款金额必须为正"
old_balance = self.balance
self.balance -= amount
assert self.balance >= 0, f"余额异常: {self.balance}"第四层:防御式编程——假设调用者会搞乱一切
防御式编程的核心思路:不相信任何来自外部的输入。函数的参数、文件、网络数据、环境变量——全部视为不可信。
基于这个前提,你在函数开头做三件事:
- 检查参数合法性(前置条件)
- 使用不可变对象(别人改不了)
- 保护性拷贝(别人拿不到你的内部引用)
// ch01/DefensiveCoding.java
import java.util.Date;
import java.util.List;
import java.util.ArrayList;
import java.util.Collections;
public class Event {
private final String name;
private final Date startTime;
private final List<String> attendees;
public Event(String name, Date startTime, List<String> attendees) {
// 前置条件:参数校验
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name must not be null or blank");
}
if (startTime == null) {
throw new IllegalArgumentException("startTime must not be null");
}
if (attendees == null) {
throw new IllegalArgumentException("attendees must not be null");
}
this.name = name;
// 保护性拷贝:Date 是可变对象
this.startTime = new Date(startTime.getTime());
// 保护性拷贝:List 可能被外部修改
this.attendees = new ArrayList<>(attendees);
}
public Date getStartTime() {
// 返回时也做保护性拷贝
return new Date(startTime.getTime());
}
public List<String> getAttendees() {
return Collections.unmodifiableList(attendees);
}
public String getName() {
return name; // String 不可变,安全
}
}这个模式的精妙之处在于:调用者传一个 Date 对象给你,然后在自己手里改了它——你这边看到的时间也变了。这不是调用者的恶意,是 Java Date 的可变性造成的陷阱。保护性拷贝消灭了这个隐患。
Python 里类似的做法:
# ch01/defensive_coding.py
from typing import List
from datetime import datetime
class Event:
def __init__(self, name: str, start_time: datetime, attendees: List[str]):
if not name or not name.strip():
raise ValueError("name must not be null or blank")
if start_time is None:
raise ValueError("start_time must not be null")
if attendees is None:
raise ValueError("attendees must not be null")
self._name = name
# 保护性拷贝
self._start_time = start_time.replace()
self._attendees = list(attendees)
@property
def start_time(self) -> datetime:
return self._start_time.replace()
@property
def attendees(self) -> List[str]:
return list(self._attendees)
@property
def name(self) -> str:
return self._name防御式编程还有一个常见手法:失败原子性(failure atomicity)。你正在修改一个对象,走到一半抛异常了——对象应该保持修改前的状态,而不是半残状态。
// ch01/FailureAtomic.java
class EventManager {
private List<Event> events = new ArrayList<>();
public void addEvent(Event event) {
// 先生成一个新列表
List<Event> newList = new ArrayList<>(events);
newList.add(event);
// 只有到这一步才真正修改状态
events = newList;
}
}把对象的状态修改操作拆成"构造新状态"和"替换旧状态"两步,中间失败了旧状态不受影响。这是函数式编程的常用思想,也是防御式编程的重要实践。
综合实战
你把四条基本功串起来,写一个工具类 Range,表示一个左闭右开区间 [low, high):
// ch01/Range.java
import java.util.Objects;
/**
* 一个不可变的整数区间 [low, high),左闭右开。
* 不变式:low <= high
*/
public final class Range {
private final int low;
private final int high;
// 防御式:校验参数
public Range(int low, int high) {
if (low > high) {
throw new IllegalArgumentException(
"low (" + low + ") must be <= high (" + high + ")");
}
this.low = low;
this.high = high;
}
// 防御式:返回不可变对象
public int getLow() { return low; }
public int getHigh() { return high; }
public boolean contains(int value) {
return low <= value && value < high;
}
public Range intersect(Range other) {
Objects.requireNonNull(other, "other must not be null");
int newLow = Math.max(this.low, other.low);
int newHigh = Math.min(this.high, other.high);
if (newLow >= newHigh) {
return null; // 没有交集
}
return new Range(newLow, newHigh);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Range)) return false;
Range range = (Range) o;
return low == range.low && high == range.high;
}
@Override
public int hashCode() {
return Objects.hash(low, high);
}
@Override
public String toString() {
return "[" + low + ", " + high + ")";
}
}这是测试先行的验证——先写测试覆盖边界和非法输入:
// ch01/RangeTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class RangeTest {
@Test
void validRangeCreated() {
Range r = new Range(1, 5);
assertEquals(1, r.getLow());
assertEquals(5, r.getHigh());
}
@Test
void lowEqualsHighIsValid() {
Range r = new Range(3, 3);
assertTrue(r.contains(3) == false); // [3,3) 空区间
}
@Test
void lowGreaterThanHighThrows() {
assertThrows(IllegalArgumentException.class,
() -> new Range(5, 1));
}
@Test
void containsCheck() {
Range r = new Range(1, 5);
assertTrue(r.contains(3));
assertFalse(r.contains(5)); // 右开
assertTrue(r.contains(1)); // 左闭
assertFalse(r.contains(0));
}
@Test
void intersectOverlap() {
Range a = new Range(1, 5);
Range b = new Range(3, 7);
Range result = a.intersect(b);
assertNotNull(result);
assertEquals(3, result.getLow());
assertEquals(5, result.getHigh());
}
@Test
void intersectDisjoint() {
Range a = new Range(1, 3);
Range b = new Range(5, 7);
assertNull(a.intersect(b));
}
}你看,做完这些,这个 Range 类从"能跑"变成了"别人敢用"。静态检查在编译阶段拦截了引用比较和缺失分支;测试明确了所有边界;不变式约定了 low <= high 永远真;防御式编程确保了类不会被外部破坏。
常见陷阱
陷阱一:断言当作业务验证。 断言在生产环境默认关闭。不要写 assert user != null : "用户不存在" 来替代优雅的异常处理。断言管的是"这里理论上不该走到",不是"这里可能出错"。
陷阱二:过度防御式编程。 不是每个参数都需要校验。私有的内部方法,调用方是同一个类的其他方法——你不需要在每一步都重新检查一遍参数。防御式编程的边界在公有方法。
陷阱三:保护性拷贝的性能陷阱。 每次 getter 都 new Date() 是有代价的。如果你的 getter 每秒被调用十万次,可以考虑用不可变对象替代保护性拷贝。
// 替代方案:用不可变对象
public record Event(String name, Instant startTime, List<String> attendees) {}Java 16+ 的 record 默认是不可变的,天然满足防御式编程的要求。
陷阱四:测试优先变成测试最后。 "先写测试再写代码"听起来美好,但你在紧张的项目进度下会自然退化到"先写代码,最后补测试"。要想真正坚持测试优先,需要练习到形成肌肉记忆——这需要几个月。
通关挑战
- 热身:在
Range类上加上union(Range other)方法,先写测试再写实现。考虑不连续的情况——两个区间不相交时返回什么? - 挑战:写一个
TimeSpan类,表示时间段[start, end)。使用java.time.Instant(不可变,不需要保护性拷贝),加上防御式编程和测试覆盖所有边界情况。 - 观察:在你的项目上跑一次静态检查工具(IntelliJ 菜单 Analyze -> Inspect Code,或者集成 Error Prone),看看工具能抓到哪类 bug。
旅人笔记
软件构造的四个基本功——静态检查提前发现错误,测试优先帮你想清楚"什么是对的",不变式约束内聚性,防御式编程防止调用者意外破坏——这四条共同织成了一张安全网,让你的代码从"能跑"走向"别人敢用"。
下一站预告
"代码写得对"只是起点。工匠之都真正的秘密是:什么样的数据结构既能保护数据又能灵活扩展?下一章讲抽象数据类型——从实现细节中抽身,只看到接口的那个世界。