跳到内容

第 6 卷 / 第 1 章 / 第 1 课

1.1 把正确性变成快速反馈 ​

软件构造的第一项专业能力,是让错误尽早、稳定且低成本地暴露。编译器、静态分析、测试、代码审查和生产观测构成不同时间尺度的反馈环;前一层越可靠,后一层越少替它收拾低级错误。

从一个含糊函数开始 ​

“计算两个日期相差几天”看似简单,规格难点是:

  • 输入是日期还是带时区的瞬间?
  • 允许结束日期早于开始日期吗?
  • 返回有符号天数还是绝对值?
  • 对 null 怎么处理?

本节使用 Java 21 语言级别。先把下面的完整类保存为 src/main/java/StayLength.java;JUnit 练习环境在后面的测试段给出,其余短片段会标明依赖上下文。

若业务谈的是日历日期,Java 应优先使用 LocalDate,不要把 Date 的毫秒差除以 86,400,000。跨夏令时的“一天”未必等于固定 24 小时。

java
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Objects;

public final class StayLength {
    private StayLength() {}

    /** 返回 [arrival, departure) 覆盖的自然日数量。 */
    public static long days(LocalDate arrival, LocalDate departure) {
        Objects.requireNonNull(arrival, "arrival");
        Objects.requireNonNull(departure, "departure");
        if (departure.isBefore(arrival)) {
            throw new IllegalArgumentException("departure must not precede arrival");
        }
        return ChronoUnit.DAYS.between(arrival, departure);
    }
}

类型选择已经消掉了时区与毫秒换算问题;方法名、区间约定和异常又把剩余语义固定下来。

反馈阶梯 ​

层次擅长发现发现时间局限
编译器类型、语法、可达性、部分穷尽性编辑/编译不理解多数业务规则
静态分析已知 bug pattern、资源泄露、错误 API 用法本地/CI有误报,依赖规则覆盖
单元与性质测试可执行行为、边界与代数性质秒到分钟只能覆盖生成/编写的输入
集成测试数据库、序列化、进程边界分钟环境和诊断成本更高
Review可读性、设计、风险和遗漏合并前人会疲劳,不能稳定模拟执行
生产观测真实分布、容量和未知故障发布后代价最高,不能替代前置质量

这些层互相补充,让每类错误尽量在成本最低的阶段被捕获。

用类型排除非法状态 ​

下面是依赖尚未定义的 transfer 方法的调用示意;三个字符串很难暴露参数角色:

java
transfer("account-1", "account-2", "100.00");

值对象让编译器区分标识符与金额。把以下两个 public record 分别保存为 AccountId.java、Money.java,与前面的类放在同一源码目录,BigDecimal 的 import 属于 Money 文件:

java
import java.math.BigDecimal;

public record AccountId(String value) {
    public AccountId {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("account id is blank");
        }
    }
}

public record Money(BigDecimal amount, String currency) {
    public Money {
        if (amount == null || currency == null) {
            throw new IllegalArgumentException("amount and currency are required");
        }
        if (amount.signum() < 0) {
            throw new IllegalArgumentException("amount must be non-negative");
        }
    }
}

这仍不能阻止两个 AccountId 参数互换:来源和目标若都是同一类型,编译器看不出业务方向。可以使用带角色名称的命令对象,或在确有风险时区分来源/目标类型,并用测试固定转账方向。

Money 仍不是完整货币库:还需定义小数位、舍入和币种合法性。但它至少阻止“任意字符串金额”进入核心接口。

Optional 适合作为“可能没有返回值”的返回类型,不应无差别用于实体字段和所有参数。对集合返回空集合通常比返回 null 更容易组合。

静态分析检查的是模式,不是证明 ​

Java 中 == 对引用比较对象身份,.equals() 表达值等价;枚举与显式 sentinel 等场景又确实需要身份比较。Error Prone 的 ReferenceEquality 等规则会在它能识别的易错场景报警,不代表任何 == 都是错。

java
if (Objects.equals(expectedName, actualName)) {
    // null-safe value equality
}

枚举 switch 也要区分 statement 与 expression。以下方法放在业务类中,并假设同一作用域定义 enum Status { PENDING, RUNNING, DONE }。Java 21 的 switch expression 要求穷尽:

java
static int priority(Status status) {
    return switch (status) {
        case PENDING -> 10;
        case RUNNING -> 20;
        case DONE -> 30;
    };
}

增加枚举值并重新编译这段代码后,编译器会迫使这里作出决定。穷尽性也不代表可接受 null;本例传入 null 会抛 NullPointerException。随手写 default 虽能编译,却可能遮住未来新增分支;只有确有合理兜底语义时才使用。

静态分析规则应:

  • 固定在构建配置中,开发机与 CI 一致;
  • 新增规则先评估现存告警与误报;
  • suppression 写在最小范围并说明理由;
  • 把实际 bug pattern 与纯格式规则分开治理;
  • 版本升级后阅读规则变化,不复制过时插件号。

测试先固定行为边界 ​

在独立练习目录创建下面的 pom.xml,使用 JDK 21+、Maven 3.9+。版本用于固定本次练习,并非声称它们是最新版本。Maven 首次运行需能取得这些依赖。

xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>example</groupId><artifactId>contracts-lab</artifactId><version>1.0</version>
  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId>
      <version>5.12.2</version><scope>test</scope>
    </dependency>
  </dependencies>
  <build><plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId>
      <version>3.14.0</version>
    </plugin>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId>
      <version>3.5.3</version>
    </plugin>
  </plugins></build>
</project>

把测试保存为 src/test/java/StayLengthTest.java;这里沿用默认包以减少练习配置,实际项目应使用命名包。

java
import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;

class StayLengthTest {
    @Test
    void sameDayHasZeroNights() {
        var day = LocalDate.of(2026, 8, 3);
        assertEquals(0, StayLength.days(day, day));
    }

    @Test
    void countsCalendarDaysAcrossMonthBoundary() {
        assertEquals(2, StayLength.days(
            LocalDate.of(2026, 7, 31),
            LocalDate.of(2026, 8, 2)));
    }

    @Test
    void countsLeapDay() {
        assertEquals(2, StayLength.days(
            LocalDate.of(2024, 2, 28), LocalDate.of(2024, 3, 1)));
    }

    @Test
    void rejectsMissingDates() {
        var day = LocalDate.of(2026, 8, 3);
        assertThrows(NullPointerException.class, () -> StayLength.days(null, day));
        assertThrows(NullPointerException.class, () -> StayLength.days(day, null));
    }

    @Test
    void rejectsReverseRange() {
        assertThrows(IllegalArgumentException.class, () ->
            StayLength.days(
                LocalDate.of(2026, 8, 4),
                LocalDate.of(2026, 8, 3)));
    }
}

在含 pom.xml 的目录执行 mvn test,应发现 5 个测试,失败、错误和跳过数均为 0。这里只验证日历日期数量;如果需求改成“已持续满多少小时”,应重新定义瞬间与时区语义。JUnit 5.12.2

测试优先的价值是迫使接口在实现前接受例子检验,不是要求每一行代码都机械遵循同一仪式。修复缺陷时先写能复现缺陷的测试;探索未知 API 时可以先做 spike,再丢弃试验并从稳定契约重写。

小批量构造让反馈可解释 ​

一次变更同时改接口、重写模块、升级依赖和格式化全仓时,即使测试失败也难以定位。可审查的批次通常满足:

text
one behavior change
+ its tests
+ necessary refactor
+ explicit migration/compatibility note

每批都应能编译、运行相关测试,并说明失败模式。小步提交让因果关系更清楚,回退也更安全。

本节检查点 ​

  • 能用领域类型和穷尽分支消除一类非法状态。
  • 能说明编译器、静态分析、测试和 review 各自的证据边界。
  • 能把含糊需求变成可执行例子和明确失败语义。

参考 ​

下一节把这些反馈落到对象内部:前置条件、后置条件和表示不变式怎样在失败中仍保持成立。

Built with VitePress | Software Systems Atlas