Skip to content

元数据卡

  • 前置知识:Vol 1 编程基础;会用 Git 基本操作
  • 预计时间:45 分钟
  • 核心难度:入门
  • 完成标志:能在代码中应用静态检查、测试优先和不变式;能写出防御式代码

你的进度

你走出了数据堡垒,带着一包袱代码经验来到了工匠之都。这里的铁匠铺不像变量村的家庭式作坊——墙上挂着规范的图纸,地上摆着精密量具,锻造台前有学徒在写测试用例。你意识到前几年的“能跑就行”在这里行不通了。工匠之都的第一课:软件构造不是写代码,是建工程。 你的任务

"能跑"的代码和"能放心交给别人维护"的代码之间,隔着四条基本功:静态检查提前卡掉低级错误,测试优先让你在动手前想清楚规格,不变式守住数据不乱跑,防御式编程让调用者没法搞破坏。这章的目标很实际——让你写的每一行代码都经得起队友的 code review。

本章分层

  • 必读:静态检查、测试优先、不变式、防御式编程
  • 选读:形式化验证简介
  • 进阶:Design by Contract 的 Java 实现

本章不会要求你掌握

  • 快速排序或任何算法
  • 设计模式

破局 · 溯源

你的代码总是能跑。但队友总能在里面翻出问题——少了个 null 检查、数组越界没处理、某个变量突然变成负数了。这些 bug 不是因为你不懂编程,是因为你的代码缺少了"工程防护层"。

先看一个问题。你写了一个函数,计算两个日期之间的天数差:

java
// ch01/Snippet01.java
public static int daysBetween(Date start, Date end) {
    long diff = end.getTime() - start.getTime();
    return (int) (diff / (1000 * 60 * 60 * 24));
}

这段代码有几个问题?

  • 如果 startend 之后,返回负数。
  • 如果时间差超过 int 范围,溢出。
  • 如果任何一个参数是 null,NullPointerException。
  • 如果传入的不是纯日期(带了时间),精度误差。

你当然可以说"调用者自己注意"。但在工匠之都,这句话说不出口——所有走到生产环境的代码,默认调用者会犯所有能犯的错误。

第一层:静态检查——把 bug 杀死在编译前

你其实已经在用一类工具:编译器。Java 编译器能拦截类型错误、未捕获异常、语法错误。但编译器只能覆盖很小一片。

静态检查工具(静态分析)在编译器的肩膀上走得更远。它们在不运行代码的前提下,检查代码中的潜在问题。你用的是哪个?列表不短:SpotBugs(FindBugs 的继任者)、Checkstyle、PMD、Error Prone,还有 IDE 自带的 inspections。

看一眼 Error Prone 抓到的真实例子。你写:

java
// 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 枚举:

java
// 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 的语法示意):

java
// SpotBugs 规则示意(不是你写的,是工具内置的)
if (node instanceof NullCheck && 
    ((NullCheck)node).getChecked() instanceof MethodCall) {
    report("NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE");
}

在项目里集成静态检查比你想象得简单。Gradle 接入 Error Prone:

groovy
// 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),返回数组中最大值。

先写测试:

java
// 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]));
    }
}

然后写实现:

java
// 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)是一段需要在程序的整个生命周期(或某个操作前后)保持为真的条件。

最常见的例子:循环不变式。

java
// 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):

java
// 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 里类似:

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

第四层:防御式编程——假设调用者会搞乱一切

防御式编程的核心思路:不相信任何来自外部的输入。函数的参数、文件、网络数据、环境变量——全部视为不可信。

基于这个前提,你在函数开头做三件事:

  1. 检查参数合法性(前置条件)
  2. 使用不可变对象(别人改不了)
  3. 保护性拷贝(别人拿不到你的内部引用)
java
// 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 里类似的做法:

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)。你正在修改一个对象,走到一半抛异常了——对象应该保持修改前的状态,而不是半残状态。

java
// 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)

java
// 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 + ")";
    }
}

这是测试先行的验证——先写测试覆盖边界和非法输入:

java
// 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 每秒被调用十万次,可以考虑用不可变对象替代保护性拷贝。

java
// 替代方案:用不可变对象
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。

旅人笔记

软件构造的四个基本功——静态检查提前发现错误,测试优先帮你想清楚"什么是对的",不变式约束内聚性,防御式编程防止调用者意外破坏——这四条共同织成了一张安全网,让你的代码从"能跑"走向"别人敢用"。


下一站预告

"代码写得对"只是起点。工匠之都真正的秘密是:什么样的数据结构既能保护数据又能灵活扩展?下一章讲抽象数据类型——从实现细节中抽身,只看到接口的那个世界。

Built with VitePress | Software Systems Atlas