7.6 实验:对象不变量与常见陷阱
前置知识:第7章对象模型、构造、封装和 static
预计时间:40 分钟
练习环境:JDK 17+ 与 Bash;完整实验写入临时目录
完成标志:能为对象状态写出不变量、失败策略和别名测试,并完成精确金额账户练习
先排查四类对象故障
前两个片段沿用封装一讲的 Book;每组单独运行,避免把不同版本的类混在一起。
1. 赋值产生别名,不是对象副本
Book first = new Book("ISBN-DEMO-001", "Java 学习笔记", 1000);
Book second = first;
second.rename("新版 Java 核心技术");
System.out.println(first.title());两个变量指向同一对象。若需要副本,先定义复制语义,再由复制构造、静态工厂或专门 mapper 创建;不要假设所有字段都应深复制。
2. 对象参数仍按值传递
static void replace(Book book) {
book = new Book("ISBN-DEMO-002", "局部新书", 1000);
}给形参重新赋值不会替换调用方变量。通过形参调用 book.rename(...) 则会修改共享对象。
3. 构造方法调用可重写方法
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 泄漏内部可变对象
public java.util.List<String> tags() {
return java.util.List.copyOf(tags);
}快照阻止调用者修改内部列表结构。若元素可变,还要决定是否复制元素;若选择只读视图,还要说明视图是否随内部变化。
热身:Student 不变量
实现 Student:
name创建后不可为空或空白;age在 0 到 150 之间;score有限且在 0 到 100 之间;- 只有成绩允许通过
updateScore修改; - static 计数只统计成功构造的对象,并注明示例非线程安全。
参考实现先写入独立文件。准备实验目录:
chapter7_lab_root=$(mktemp -d)
readonly chapter7_lab_root
mkdir -p "$chapter7_lab_root/out"
cd "$chapter7_lab_root"保存为 Student.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:
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 会在下一章展开,当前先观察“调用后应抛哪种异常”的契约:
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) {
// 溢出必须发生在两个账户写入之前。
}
}
}编译三个文件,并显式开启断言:
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 开启;入口也会检查它已开启,避免漏写参数却输出“通过”。生产校验仍由领域方法自身完成。
保存需要的实验记录后清理:
cd
ls -ld -- "$chapter7_lab_root"
rm -r -- "$chapter7_lab_root"离开这一章前
对象图要能解释别名与形参引用副本;失败构造不能增加 Student.count。账户测试至少覆盖存款、余额不足、同账户、目标溢出与正常转账,并确认目标溢出时源余额未先扣除。这个写入顺序只在当前单线程调用内成立,不能当作数据库事务或并发原子性保证。
下一章继续讨论继承、组合与多态。