跳到内容

第 1 卷 / 第 7 章 / 第 6 课

7.6 实验:对象不变量与常见陷阱 ​

前置知识:第7章对象模型、构造、封装和 static

预计时间:40 分钟

练习环境:JDK 17+ 与 Bash;完整实验写入临时目录

完成标志:能为对象状态写出不变量、失败策略和别名测试,并完成精确金额账户练习

先排查四类对象故障 ​

前两个片段沿用封装一讲的 Book;每组单独运行,避免把不同版本的类混在一起。

1. 赋值产生别名,不是对象副本 ​

java
Book first = new Book("ISBN-DEMO-001", "Java 学习笔记", 1000);
Book second = first;
second.rename("新版 Java 核心技术");
System.out.println(first.title());

两个变量指向同一对象。若需要副本,先定义复制语义,再由复制构造、静态工厂或专门 mapper 创建;不要假设所有字段都应深复制。

2. 对象参数仍按值传递 ​

java
static void replace(Book book) {
    book = new Book("ISBN-DEMO-002", "局部新书", 1000);
}

给形参重新赋值不会替换调用方变量。通过形参调用 book.rename(...) 则会修改共享对象。

3. 构造方法调用可重写方法 ​

java
class Parent {
    Parent() {
        printLength();
    }

    void printLength() {}
}

class Child extends Parent {
    private String value = "ready";

    @Override
    void printLength() {
        System.out.println(value.length());
    }
}

new Child() 先进入父类构造,但动态分派会调用子类覆盖版本。此时 value 仍是默认值 null,因而抛 NullPointerException。构造期间只调用 private、final、static 或明确不会被覆盖的逻辑,并保持其简单。

4. getter 泄漏内部可变对象 ​

java
public java.util.List<String> tags() {
    return java.util.List.copyOf(tags);
}

快照阻止调用者修改内部列表结构。若元素可变,还要决定是否复制元素;若选择只读视图,还要说明视图是否随内部变化。

热身:Student 不变量 ​

实现 Student:

  • name 创建后不可为空或空白;
  • age 在 0 到 150 之间;
  • score 有限且在 0 到 100 之间;
  • 只有成绩允许通过 updateScore 修改;
  • static 计数只统计成功构造的对象,并注明示例非线程安全。

参考实现先写入独立文件。准备实验目录:

bash
chapter7_lab_root=$(mktemp -d)
readonly chapter7_lab_root
mkdir -p "$chapter7_lab_root/out"
cd "$chapter7_lab_root"

保存为 Student.java:

java
final class Student {
    private static int count;

    private final String name;
    private final int age;
    private double score;

    Student(String name, int age, double score) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("blank name");
        }
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("invalid age");
        }
        this.name = name;
        this.age = age;
        updateScore(score);
        count++;
    }

    void updateScore(double score) {
        if (!Double.isFinite(score) || score < 0.0 || score > 100.0) {
            throw new IllegalArgumentException("invalid score");
        }
        this.score = score;
    }

    String name() { return name; }
    int age() { return age; }
    double score() { return score; }
    static int count() { return count; }
}

测试空白名、年龄 -1/151、成绩 NaN/Infinity/-1/101,以及失败构造是否错误增加计数。

挑战:使用整数分的 BankAccount ​

本文用 long 表示最小货币单位“分”,不在示例中处理币种兑换、舍入、利息或并发事务。

保存为 BankAccount.java:

java
import java.util.Objects;

final class BankAccount {
    private final String accountNumber;
    private final String ownerName;
    private long balanceCents;

    BankAccount(String accountNumber,
                String ownerName,
                long initialCents) {
        this.accountNumber = requireText(accountNumber, "accountNumber");
        this.ownerName = requireText(ownerName, "ownerName");
        if (initialCents < 0) {
            throw new IllegalArgumentException("negative initial balance");
        }
        balanceCents = initialCents;
    }

    long balanceCents() {
        return balanceCents;
    }

    void deposit(long cents) {
        requirePositive(cents);
        balanceCents = Math.addExact(balanceCents, cents);
    }

    boolean withdraw(long cents) {
        requirePositive(cents);
        if (cents > balanceCents) {
            return false;
        }
        balanceCents -= cents;
        return true;
    }

    boolean transferTo(BankAccount target, long cents) {
        Objects.requireNonNull(target, "target");
        requirePositive(cents);
        if (target == this) {
            throw new IllegalArgumentException("same account");
        }
        if (cents > balanceCents) {
            return false;
        }

        long targetAfter = Math.addExact(target.balanceCents, cents);
        long sourceAfter = balanceCents - cents;
        target.balanceCents = targetAfter;
        balanceCents = sourceAfter;
        return true;
    }

    private static void requirePositive(long cents) {
        if (cents <= 0) {
            throw new IllegalArgumentException("amount must be positive");
        }
    }

    private static String requireText(String value, String name) {
        Objects.requireNonNull(value, name);
        if (value.isBlank()) {
            throw new IllegalArgumentException("blank " + name);
        }
        return value;
    }
}

转账先计算目标余额,确认不会溢出后才写两个对象;因此目标加法失败时,源账户没有先被扣款。这只保证单线程调用中的写入顺序,不是并发事务。真实转账需要锁定顺序、持久化事务、幂等键和审计。

把边界放进同一个测试入口 ​

保存为 OopLab.java。这里的 () -> ... 把一次操作交给测试辅助方法执行;Lambda 和 Runnable 会在下一章展开,当前先观察“调用后应抛哪种异常”的契约:

java
public class OopLab {
    public static void main(String[] args) {
        boolean assertionsEnabled = false;
        assert assertionsEnabled = true;
        if (!assertionsEnabled) {
            throw new IllegalStateException("run with -ea");
        }
        int countBeforeFailures = Student.count();
        expectIllegal(() -> new Student(" ", 20, 80));
        expectIllegal(() -> new Student("一名开发者", -1, 80));
        expectIllegal(() -> new Student("一名开发者", 151, 80));
        expectIllegal(() -> new Student("一名开发者", 20, Double.NaN));
        expectIllegal(() -> new Student("一名开发者", 20,
                Double.POSITIVE_INFINITY));
        expectIllegal(() -> new Student("一名开发者", 20, -1));
        expectIllegal(() -> new Student("一名开发者", 20, 101));
        assert Student.count() == countBeforeFailures;

        Student student = new Student("一名开发者", 20, 80);
        student.updateScore(92.5);
        assert student.score() == 92.5;
        assert Student.count() == countBeforeFailures + 1;

        BankAccount source = new BankAccount("A", "甲", 10_000);
        BankAccount target = new BankAccount("B", "乙", 2_000);
        source.deposit(500);
        assert source.withdraw(500);
        assert source.transferTo(target, 3_000);
        assert source.balanceCents() == 7_000;
        assert target.balanceCents() == 5_000;
        assert !source.withdraw(8_000);
        expectIllegal(() -> source.transferTo(source, 1));

        BankAccount overflowSource =
                new BankAccount("C", "丙", 100);
        BankAccount overflowTarget =
                new BankAccount("D", "丁", Long.MAX_VALUE);
        expectArithmetic(() ->
                overflowSource.transferTo(overflowTarget, 1));
        assert overflowSource.balanceCents() == 100;
        assert overflowTarget.balanceCents() == Long.MAX_VALUE;

        System.out.println("OOP lab passed");
    }

    private static void expectIllegal(Runnable action) {
        try {
            action.run();
            throw new AssertionError("expected IllegalArgumentException");
        } catch (IllegalArgumentException expected) {
            // 本用例只关心契约是否拒绝输入。
        }
    }

    private static void expectArithmetic(Runnable action) {
        try {
            action.run();
            throw new AssertionError("expected ArithmeticException");
        } catch (ArithmeticException expected) {
            // 溢出必须发生在两个账户写入之前。
        }
    }
}

编译三个文件,并显式开启断言:

bash
javac --release 17 -encoding UTF-8 -Xlint:all -d out \
  Student.java BankAccount.java OopLab.java
java -ea -cp out OopLab

预期只输出 OOP lab passed。assert 在这里属于测试夹具,所以用 -ea 开启;入口也会检查它已开启,避免漏写参数却输出“通过”。生产校验仍由领域方法自身完成。

保存需要的实验记录后清理:

bash
cd
ls -ld -- "$chapter7_lab_root"
rm -r -- "$chapter7_lab_root"

离开这一章前 ​

对象图要能解释别名与形参引用副本;失败构造不能增加 Student.count。账户测试至少覆盖存款、余额不足、同账户、目标溢出与正常转账,并确认目标溢出时源余额未先扣除。这个写入顺序只在当前单线程调用内成立,不能当作数据库事务或并发原子性保证。

下一章继续讨论继承、组合与多态。

→ 下一步:继承、组合与多态

Built with VitePress | Software Systems Atlas