学会定义类、创建对象和编写构造器之后,我们已经能把一组数据和相关方法放进同一个类型。真正进入稍大的程序,问题会换一种形式出现:某个字段应该属于每个对象,还是属于整个类?方法收到对象后,为什么能改到对象,却换不掉调用者的变量?两个字段完全相同的对象,究竟算不算“同一个”?复制、组合和资源释放又该由谁负责?
这些问题表面上分散,背后都在讨论同一件事:对象的身份、状态、所有权和生命周期应该怎样被 API 清楚表达。如果边界含糊,static 会变成隐蔽的全局状态,equals 会破坏集合行为,所谓“复制”仍然共享内部对象,垃圾回收也会被误当成资源关闭工具。
这一章会从类级成员开始,依次处理对象参数与返回值、this、toString、equals/hashCode、复制策略、聚合与组合、枚举状态、可达性与资源释放,最后把这些能力放进一个完整设计中。示例以 Java SE 26 的正式语义为准;带 public class 的完整代码块应保存为同名 .java 文件。
进阶类设计不是堆更多关键字,而是让调用者从方法名、参数、返回值和类型约束中看出:谁拥有数据、谁可以修改、什么值算相等、对象何时仍然可用。
初学时,我们容易从“这个类需要哪些字段”开始写。更稳妥的顺序是先写出类要维护的事实,再决定字段和方法。例如一个礼品卡账户至少有这些约束:卡号创建后不变,余额不能为负,充值金额必须为正,外部代码不能绕过规则直接改余额。
import java.math.BigDecimal;
import java.util.Objects;
public final class GiftCard {
private final String number;
private BigDecimal balance;
public GiftCard(String number, BigDecimal openingBalance) {
this.number = Objects.requireNonNull(number, "number");
Objects.requireNonNull(openingBalance, "openingBalance");
if (openingBalance.signum() < 0) {
throw new IllegalArgumentException("初始余额不能为负数");
}
this.balance = openingBalance;
}
public void recharge(BigDecimal amount) {
Objects.requireNonNull(amount, "amount");
if (amount.signum() <= 0) {
throw new IllegalArgumentException("充值金额必须为正数");
}
balance = balance.add(amount);
}
public String number() {
return number;
}
public BigDecimal balance() {
return balance;
}
}这里的 private 不只是“隐藏字段”。它把所有余额变化收口到类内部,调用者只能走 recharge 这条经过校验的路径。final 字段 number 表达卡号一旦构造就不能重新指向另一个字符串;类本身声明为 final,也让后面定义值相等规则时少掉继承带来的额外变量。
讨论一个对象时,可以把信息分成三层:
余额可以变化,卡号不变;日志字符串可以调整格式,但不应该改变卡片状态。把这三层混在一起,后面的 equals、复制和 toString 就很容易写错。
balance() 只是读取;recharge() 会改变当前对象。方法命名无法替代文档,但好的命名能让副作用更容易被看见。对于会修改对象的方法,还要明确失败时是否保持原状态。上面的 recharge 先完成所有校验,再赋值,因此参数非法时余额不会改到一半。
实例字段属于具体对象;static 字段在一个类的某次初始化中只有一份,不管已经创建零个、一个还是很多实例。最典型的例子是类级常量、无状态工具方法和所有实例共同使用的配置。
public final class Distance {
private Distance() {
// 工具类不需要实例
}
public static final double METERS_PER_KILOMETER = 1_000.0;
public static double kilometersToMeters(double kilometers) {
return kilometers * METERS_PER_KILOMETER;
}
}调用时写 Distance.kilometersToMeters(2.5),类名直接说明方法不依赖某个 Distance 对象。即使 Java 允许通过实例引用访问静态成员,也应该用类名,因为成员的归属不会因点号左边写了哪个对象而改变。
实例方法执行时有一个当前对象,可以使用 this,也能直接读取当前对象的实例字段。静态方法没有接收者,所以不能直接访问实例字段,也不能使用 this:
public class Visit {
private String visitorName;
private static int totalVisits;
public Visit(String visitorName) {
this.visitorName = visitorName;
totalVisits++;
}
public static int totalVisits() {
return totalVisits;
}
public String summary() {
return visitorName + ",累计访问数=" + totalVisits;
}
}实例方法 summary 能同时看到自己的 visitorName 和全类共享的 totalVisits。静态方法 totalVisits() 只能直接处理静态上下文中的成员。它若真的需要某个对象,应让调用者把对象作为参数传入,而不是猜测“当前对象”。
类第一次被主动使用前会初始化。直接代码中常见触发包括:创建该类实例、调用该类声明的静态方法、给该类声明的静态字段赋值,以及读取该类声明的非编译期常量静态字段。只声明一个值为 null 的引用,不会因此创建对象或执行该类的静态初始化。
初始化前,静态字段先在准备阶段得到默认值;进入初始化后,静态字段初始化器和 static {} 块按源码中的文本顺序执行:
public final class RuntimeConfig {
private static int step = mark("字段初始化", 1);
static {
step = mark("静态块", step + 1);
}
private static int mark(String label, int value) {
System.out.println(label + " -> " + value);
return value;
}
public static int step() {
return step;
}
}正常情况下,同一个类加载器定义的类只成功初始化一次。初始化代码若抛出未处理异常,本次主动使用会失败,之后再次使用该类也可能得到初始化失败相关错误。因此不要把不稳定的网络请求、用户输入或庞大业务流程塞进静态初始化。

static 成员属于类级共享区,实例成员属于各自对象。类首次主动使用时,先准备默认值,再按文本顺序初始化。
static final 表示字段引用不能重新赋值,不等于引用对象不可变:
public static final java.util.List<String> TAGS = new java.util.ArrayList<>();外部代码仍能执行 TAGS.add("紧急")。如果要公开只读数据,应优先暴露不可变值,或返回防御性副本。另一个容易混淆的点是编译期常量:由常量表达式初始化的基本类型或 String 常量可能被调用方编译进自己的字节码,读取它不一定触发声明类初始化。new BigDecimal("0.13") 虽然可放进 static final 字段,却不是编译期常量表达式。
static int totalVisits 适合教学计数,但真实并发程序里,totalVisits++ 不是原子操作;测试之间也会共享残留值。类级缓存、当前用户、全局开关若能被随意修改,调用顺序会悄悄影响结果。使用静态可变字段前,应先问:它是否真的是整个类共享的概念?能否改成不可变配置、显式依赖或由专门对象管理?
Java 的参数传递始终是值传递。参数是基本类型时,复制数值;参数是引用类型时,复制引用值。被调方法和调用者因此可以暂时持有指向同一对象的两个引用。
import java.util.Objects;
final class Customer {
private String name;
Customer(String name) {
this.name = Objects.requireNonNull(name, "name");
}
String name() {
return name;
}
void renameTo(String name) {
this.name = Objects.requireNonNull(name, "name");
}
}
public class CustomerDemo {
static void rename(Customer customer) {
customer.renameTo("新名字");
}
static void replace(Customer customer) {
customer = new Customer("另一个对象");
}
public static void main(String[] args) {
Customer original = new Customer("小林");
rename(original);
System.out.println(original.name()); // 新名字
replace(original);
System.out.println(original.name()); // 仍是新名字
}
}rename 通过复制来的引用找到同一个对象,所以能调用方法修改它。replace 只让自己的局部参数改指向新对象,没有权限重写 main 中变量 original 保存的引用。这不是“对象按引用传递”,而是“引用值也按值复制”。
设计对象参数时,可以先明确方法角色:
static BigDecimal totalOf(Order order) { ... } // 查询
static void applyDiscount(Order order) { ... } // 命令
static Order discountedCopy(Order order) { ... } // 转换名字不能百分之百保证行为,但它让调用者有机会在调用前判断影响范围。一个叫 calculateTotal 的方法如果顺便清空购物车,就违反了大多数读者的合理预期。
返回类型只告诉调用者“得到什么类型”,不会自动说明对象关系。下面三种设计都合法,却有完全不同的所有权含义:
public Address address() { return address; } // 暴露内部对象
public Address addressCopy() { return new Address(address); } // 返回副本
public Customer renameTo(String name) { ...; return this; } // 返回当前对象若 Address 可变,第一种写法让调用者绕过 Customer 直接改内部状态。可选方案包括让 Address 本身不可变、返回复制对象,或只暴露真正需要的只读数据。不要机械地“所有返回都复制”;大型不可变对象安全共享通常更合理。关键是契约明确且前后一致。
如果参数不允许为 null,应尽早拒绝,而不是等到方法深处偶然触发空指针异常:
import java.util.Objects;
public void changeAddress(Address next) {
this.address = new Address(
Objects.requireNonNull(next, "next")
);
}Objects.requireNonNull 返回同一个非空引用,因此可以直接嵌入赋值或构造表达式。若业务上允许“没有地址”,应把这个状态设计清楚,不要让有时拒绝 null、有时默默接受的行为散落在各处。
实例方法每次执行都有接收者,this 就是当前接收者的引用。平时写 balance 或 recharge(amount),编译器能在上下文明确时把它们理解成 this.balance 和 this.recharge(amount)。
最常见的显式用法,是解决参数与字段同名:
import java.util.Objects;
public final class Label {
private String text;
public Label(String text) {
this.text = Objects.requireNonNull(text, "text");
}
}右边的 text 是参数,左边的 this.text 是当前对象字段。使用相同的领域名称通常比编造 theText、newTextValue 更清楚。
实例方法可以把当前对象传给另一个方法:
public void validateWith(LabelPolicy policy) {
Objects.requireNonNull(policy, "policy").validate(this);
}也可以返回当前对象,形成链式 API:
import java.util.Objects;
public final class MessageBuilder {
private final StringBuilder content = new StringBuilder();
public MessageBuilder append(String part) {
content.append(Objects.requireNonNull(part, "part"));
return this;
}
public String build() {
return content.toString();
}
public static void main(String[] args) {
String message = new MessageBuilder()
.append("订单")
.append("已创建")
.build();
System.out.println(message);
}
}链式写法本身不表示不可变。这里每次 append 都修改同一个 MessageBuilder,只是把 this 返回给下一次调用。若不可变类型采用链式风格,它通常会每次返回一个新对象。阅读 API 时要看契约,不能只看点号是否连在一起。
构造器中的 this(...) 是对本类另一个构造器的显式调用,并且必须位于允许位置;return this 是普通实例方法返回当前引用。两者拼写相近,生命周期阶段完全不同。静态上下文没有当前实例,因此静态方法和静态初始化块都不能使用 this。
构造器把 this 注册到全局集合、启动回调或交给另一个线程,会让外部代码看到一个尚未完成初始化的对象。即使单线程示例偶尔“看起来能跑”,也会破坏构造完成后不变量成立的假设。更稳妥的做法是先完成构造,再由工厂方法或调用者执行注册。
所有普通类最终都拥有 Object 定义的 toString()。默认结果通常形如“运行时类名@哈希码的十六进制表示”,能区分对象,却几乎不包含领域信息。重写后,日志、调试器和字符串拼接可以显示更有用的状态:
@Override
public String toString() {
return "GiftCard{" +
"number='" + mask(number) + '\'' +
", balance=" + balance +
'}';
}一个实用的 toString 应返回非 null、简洁且对人可读的文本。它最好只读取已有状态,不执行网络请求、不修改计数器,也不要因为某个可选字段缺失就抛异常。调试输出越可靠,定位问题越省力。
对象可能保存密码、令牌、身份证号、完整卡号或私密消息。toString 经常被日志框架隐式调用,把这些字段原样拼进去,会让敏感数据扩散到日志、告警和错误报告。可以只输出安全标识、掩码值和必要状态。
private static String mask(String number) {
int visible = Math.min(4, number.length());
return "****" + number.substring(number.length() - visible);
}toString 的人类可读格式不保证跨版本稳定。用它生成数据库键、网络协议、CSV 或可再次解析的配置,会让一次普通的调试格式调整变成兼容性事故。需要稳定交换格式时,应定义专门的序列化方法或 DTO。
Java SE 26 的 Objects.toIdentityString(object) 可以得到类似未重写 toString 与 hashCode 时的身份文本,适合诊断“这两个日志项是不是同一实例”。它仍不是稳定 ID,也不应暴露给业务协议。
两个对象可以 equals 为真,但 toString 包含的展示字段不同;反过来,两个对象显示完全相同,也不代表它们相等。toString 服务观察,equals 服务等价关系,不要用字符串比较替代对象相等判断。
对引用类型使用 ==,比较的是两个引用值是否指向同一对象。equals 则可以由类定义“领域上什么叫相等”。如果类没有重写 equals,继承自 Object 的默认实现仍然采用身份相等。
String first = new String("JAVA");
String second = new String("JAVA");
System.out.println(first == second); // false:不同实例
System.out.println(first.equals(second)); // true:字符序列相同null 没有可调用的方法。已知左边可能为空时,可以使用 Objects.equals(a, b):两个参数都为 null 时返回 true,只有一个为 null 时返回 false,否则调用第一个参数的 equals。
商品编码适合按规范化后的编码相等。为了避免继承对相等关系的干扰,这里把类声明为 final:
import java.util.Locale;
import java.util.Objects;
public final class Sku {
private final String code;
public Sku(String code) {
String normalized = Objects.requireNonNull(code, "code")
.trim()
.toUpperCase(Locale.ROOT);
if (normalized.isEmpty()) {
throw new IllegalArgumentException("商品编码不能为空");
}
this.code = normalized;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof Sku that)) {
return false;
}
return code.equals(that.code);
}
@Override
public int hashCode() {
return code.hashCode();
}
@Override
public String toString() {
return code;
}
}这段实现先处理同一引用,再拒绝不兼容类型,最后比较真正定义身份的字段。构造时完成规范化并保存为不可变字段,可以保证对象进入集合后,相等依据不会突然改变。

两个身份不同但字段值相同的对象,可以由 equals 判定为值相等;相等对象的 hashCode 必须相同,但同哈希不代表相等。
对所有非空引用,合理的 equals 应满足:
x.equals(x) 为真。x.equals(y) 与 y.equals(x) 结果相同。x 等于 y,y 等于 z,则 x 等于 z。null 不相等。这些规则不是形式主义。集合查找、去重、缓存和测试断言都假定“相等”能稳定分组。把随机数、当前时间或可随意变化的字段放进 equals,会直接破坏这种分组。
哈希契约要求:两个对象只要 equals 为真,hashCode 就必须相同。反方向不成立,不相等对象允许产生相同哈希码;哈希冲突只会影响效率,不应改变正确性。
若只重写 equals 而保留身份型哈希码,HashSet、HashMap 等哈希集合可能把逻辑相等的对象放在不同位置,随后出现“明明相等却找不到”的现象。Objects.hash(field1, field2) 可以帮助组合多个字段,但它也不会替你决定哪些字段属于身份。
对象加入哈希集合后,如果参与 equals/hashCode 的字段发生变化,集合仍按旧哈希位置保存它,新的查找值却会去另一个位置。最简单的规避方式是用不可变值对象做键,或至少确保身份字段在对象进入集合期间不变。
引用赋值不会复制对象:
Profile a = new Profile("小林", new Address("杭州"));
Profile b = a;
b.address().renameCity("上海");a 和 b 指向同一个 Profile,谈不上副本。真正创建新 Profile 后,还要继续问:新旧对象内部是否允许共享同一个 Address?这就是浅复制与深复制的分界。
若复制对象的基本类型字段按值复制,而引用字段仍指向原来的嵌套对象,就是浅复制:
public Profile(Profile other) {
this.name = other.name;
this.address = other.address;
}两个 Profile 是不同实例,但修改任意一边的可变 Address,另一边都能观察到。浅复制并非一定错误。若嵌套对象不可变,或者业务上本就要共享同一个 Address,共享引用既安全又节省成本。
如果每个 Profile 应独占自己的可变地址,复制构造器应继续创建地址副本:
public Profile(Profile other) {
Objects.requireNonNull(other, "other");
this.name = other.name;
this.address = new Address(other.address);
}深复制不是“把对象图中遇到的一切都复制”。数据库连接、线程池、共享商品目录往往不该复制;对象图还可能有环。正确问题是:哪些部分由新对象独占,哪些部分可以安全共享?复制深度应该沿着这个所有权边界决定。

别名只复制引用值;浅复制建立新的外层对象但继续共享嵌套对象;深复制沿独占所有权边界建立独立对象。复制多深,取决于谁拥有可变状态。
常见选择有:
Profile copy = new Profile(original); // 复制构造器
Profile copy = Profile.copyOf(original); // 静态工厂
Profile renamed = original.withName("阿青"); // 不可变对象返回变体这些 API 可以用名称和文档说明复制策略,也能执行校验。不可变类型的 copyOf 甚至可以安全返回原实例,但必须明确这一点。
Object.clone() 不应被当成默认深复制方案。Cloneable 只是标记接口,本身不声明公开 clone 方法;Object.clone() 是 protected,默认按字段赋值执行浅复制,类还要处理 CloneNotSupportedException 和嵌套可变对象。数组的 clone() 使用方便,但对象数组复制的仍只是元素引用。除非既有 API 已建立清楚的克隆契约,新代码通常更适合复制构造器或工厂。
只比较复制后两边初始值相同还不够。测试应修改副本的嵌套可变对象,再检查原对象是否按契约保持不变。这个“复制后再修改”的实验,比打印两个对象的地址更能暴露意外共享。
真实模型很少只有一个对象。订单包含订单行,订单行引用商品,团队拥有成员,播放器使用音频设备。Java 没有 composition 关键字;聚合与组合体现为字段关系、构造方式、修改权限和生命周期约定。
可以用一个实用问题区分两种倾向:整体消失后,部分是否仍然有独立意义和其他所有者?
Product 可以同时被许多订单行引用,不属于某一张订单。这不是硬性语法分类。同一个类在不同系统中可能采用不同所有权策略,关键是让构造、复制和返回值都遵守同一约定。
import java.util.ArrayList;
import java.util.List;
public final class Order {
private final List<OrderLine> lines = new ArrayList<>();
public void addLine(Product product, int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("数量必须为正数");
}
lines.add(new OrderLine(product, quantity));
}
public List<OrderLine> lines() {
return List.copyOf(lines);
}
}如果直接返回 lines,调用者就能执行 clear(),绕过订单规则。List.copyOf 返回一个不可修改列表快照,阻止调用者增删内部列表。它不会深复制每个 OrderLine;因此最好让 OrderLine 自身不可变,或者返回专用只读数据对象。
一个类收到外部集合时,应明确是复制内容还是长期持有调用者给的同一个集合:
public Playlist(List<Song> songs) {
this.songs = new ArrayList<>(Objects.requireNonNull(songs, "songs"));
}这里复制列表结构,调用者以后对原列表 add/remove 不会改变播放列表;元素 Song 仍被共享。如果 Song 不可变,这通常就是合适边界。若类故意持有外部列表,则应把这种联动写进契约,不能让调用者猜。
从订单列表删除一个订单行,只是移除一条引用。若还有日志、缓存或局部变量指向它,对象仍然可达。对象何时可被回收取决于整个引用图,而不是某个容器是否执行了 remove。业务上的“已删除”也不等于 JVM 内存中的“对象立刻消失”。
用整数或任意字符串表示状态,很容易写出不存在的值:status = 97、status = "已支福" 都可能混进程序。枚举把合法值限制为一组命名实例,让编译器参与检查。
public enum OrderStatus {
DRAFT("草稿", false),
SUBMITTED("已提交", false),
PAID("已支付", false),
CANCELLED("已取消", true),
COMPLETED("已完成", true);
private final String label;
private final boolean terminal;
OrderStatus(String label, boolean terminal) {
this.label = label;
this.terminal = terminal;
}
public String label() {
return label;
}
public boolean isTerminal() {
return terminal;
}
}枚举是一种受限类。每个枚举常量都是该枚举类的命名实例,可以拥有字段和方法;构造器不能由外部公开调用,也不能用 new OrderStatus(...) 创建额外状态。嵌套枚举隐式具有静态性质,不依赖外部类实例。
与其让多个调用者重复写 status == CANCELLED || status == COMPLETED,可以像上面那样提供 isTerminal()。更复杂的固定行为也可以由常量分别实现:
public enum FeePolicy {
STANDARD {
@Override
public int feeFor(int cents) {
return cents / 20;
}
},
VIP {
@Override
public int feeFor(int cents) {
return 0;
}
};
public abstract int feeFor(int cents);
}新增常量时必须提供抽象方法实现,能减少在多个 switch 中漏加分支的风险。若行为依赖大量外部数据或频繁变化,把所有逻辑塞进枚举也会过重;枚举适合固定状态空间和紧密相关的小型行为。
OrderStatus.values() 按声明顺序返回所有常量;OrderStatus.valueOf("PAID") 按声明名精确查找,大小写或空格不匹配会抛出 IllegalArgumentException。面向用户输入时,应先规范化并处理错误。
name() 返回声明名,ordinal() 返回声明位置。不要把 ordinal 持久化为数据库业务值,因为重新排序或插入常量就会改变位置。需要稳定外部编码时,显式增加不可变字段,例如 private final String code。
枚举常量每个只有一个实例,因此同一枚举类型的常量可以安全使用 == 比较;Enum.equals 本身也是身份比较。这个特例不能推广到普通值对象。
对象创建在堆上后,只要还能从活动线程、静态字段等根引用沿着引用链访问到,它就是可达的。一个对象可以被多个变量、数组和集合共同引用;只有当它不再能从任何根引用到达时,才有资格被垃圾回收。
Node first = new Node();
Node second = new Node();
first.next = second;
second.next = first;
first = null;
second = null;两个节点互相引用形成环,但外部根已经无法到达它们,因此这个环仍然可以被回收。相反,把对象放进长期存活的静态集合,即使局部变量离开作用域,对象仍可能一直可达,形成逻辑上的内存泄漏。

垃圾收集器依据根可达性管理内存,但回收时机不确定;文件、连接等外部资源应通过 try-with-resources 或显式 close() 在确定时刻释放。
赋值为 null 只是切断某个变量的一条引用,不保证对象已经不可达,更不保证回收立即发生。垃圾收集器选择何时运行,程序不能依赖具体时间。System.gc() 也只是请求,不是“现在立刻清理所有对象”的命令。
因此,不要用“把变量设为 null”代替正常的作用域设计。局部变量离开作用域后,运行时本就能判断后续是否仍需使用引用;更应该警惕的是静态集合、监听器和缓存长期保留已经不用的对象。
文件描述符、套接字、数据库连接和本地内存通常数量有限,需要及时释放。等待对象未来某个不确定时刻被回收,可能先耗尽这些资源。实现 AutoCloseable 的资源应使用 try-with-resources:
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class FirstLineReader {
public static String readFirstLine(Path path) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(path)) {
return reader.readLine();
}
}
}无论正常返回还是抛出异常,离开资源块时都会调用 close()。自定义资源类的 close 最好支持安全重复调用:先把内部状态标记为已关闭,再释放底层资源,并让关闭后的其他方法明确拒绝继续工作。
Object.finalize() 在 Java SE 26 中仍标记为已废弃且准备移除。它存在安全、性能和可靠性问题,虚拟机可能禁用终结机制;即使启用,调用也可能无限期延迟。新代码不应通过重写 finalize 释放资源。
Cleaner 可以为极少数封装本地资源的类提供兜底清理,但它仍然不承诺及时运行。最有效的路径是显式调用清理动作;注册的清理任务还不能反向持有被清理对象,否则对象永远到不了幻象可达状态。常规业务代码优先采用 AutoCloseable 和 try-with-resources,把 Cleaner 视为安全网,不是主流程。
下面的设计把本章概念放在同一条业务链里:Sku 是不可变值对象,负责相等与哈希;OrderStatus 限制状态;OrderLine 是订单拥有的不可变组成部分;Order 用静态计数器生成内部 ID,用 this 返回链式调用,并只返回订单行快照。
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong;
public class AdvancedOrderDemo {
public static void main(String[] args) {
Order order = new Order()
.add(new Sku(" java-101 "), 2)
.add(new Sku("DB-20"), 1)
.submit();
System.out.println(order);
System.out.println(order.lines());
}
}
enum OrderStatus {
DRAFT, SUBMITTED, CANCELLED
}
final class Sku {
private final String code;
Sku(String code) {
this.code = Objects.requireNonNull(code, "code")
.trim()
.toUpperCase(Locale.ROOT);
if (this.code.isEmpty()) {
throw new IllegalArgumentException("商品编码不能为空");
}
}
@Override
public boolean equals(Object other) {
return this == other
|| other instanceof Sku that && code.equals(that.code);
}
@Override
public int hashCode() {
return code.hashCode();
}
@Override
public String toString() {
return code;
}
}
final class OrderLine {
private final Sku sku;
private final int quantity;
OrderLine(Sku sku, int quantity) {
this.sku = Objects.requireNonNull(sku, "sku");
if (quantity <= 0) {
throw new IllegalArgumentException("数量必须为正数");
}
this.quantity = quantity;
}
@Override
public String toString() {
return sku + " × " + quantity;
}
}
final class Order {
private static final AtomicLong NEXT_ID = new AtomicLong(1);
private final long id = NEXT_ID.getAndIncrement();
private final List<OrderLine> lines = new ArrayList<>();
private OrderStatus status = OrderStatus.DRAFT;
Order add(Sku sku, int quantity) {
requireDraft();
lines.add(new OrderLine(sku, quantity));
return this;
}
Order submit() {
requireDraft();
if (lines.isEmpty()) {
throw new IllegalStateException("空订单不能提交");
}
status = OrderStatus.SUBMITTED;
return this;
}
List<OrderLine> lines() {
return List.copyOf(lines);
}
private void requireDraft() {
if (status != OrderStatus.DRAFT) {
throw new IllegalStateException("只有草稿订单可以修改");
}
}
@Override
public boolean equals(Object other) {
return this == other
|| other instanceof Order that && id == that.id;
}
@Override
public int hashCode() {
return Long.hashCode(id);
}
@Override
public String toString() {
return "Order{id=" + id +
", status=" + status +
", lineCount=" + lines.size() + '}';
}
}NEXT_ID 是类级状态,所有订单共享一个分配器;id 是实例状态,每个订单固定一份。AtomicLong 让并发获取 ID 的单次自增保持原子性,但真实系统仍应使用数据库或分布式 ID 方案保证跨进程唯一性。
Sku 构造时规范化,后续不变,因此适合定义 equals/hashCode。OrderLine 不复制 Sku,因为不可变值可以安全共享。Order.lines() 保护列表结构。submit() 先检查状态和订单行,再改变状态,失败时订单保持原样。
Order.equals 只按不可变 id 判断,订单状态和行项目变化不会让它在哈希集合中“失踪”。toString 只输出安全的诊断摘要,不把全部订单行拼成稳定协议。
这个示例没有假装覆盖所有业务。取消是否允许从已提交状态发生?相同 SKU 能否重复添加?ID 是否需要跨重启稳定?订单行是否要保存下单时价格快照?这些答案必须来自需求,并落实到状态转换、所有权和测试中。类设计的完成标准不是字段齐全,而是重要决定已经变成可执行约束。
类进阶内容最适合用“关系测试”,不能只跑一条正常路径。我们需要验证静态状态是否隔离、相等关系是否成组、复制后是否独立、组合对象是否泄漏、状态转换失败时是否保持原状态。
不依赖测试框架,也能先写最小断言辅助方法:
static void check(boolean condition, String message) {
if (!condition) {
throw new AssertionError(message);
}
}
static void equalityContract() {
Sku x = new Sku("java-101");
Sku y = new Sku(" JAVA-101 ");
Sku z = new Sku("Java-101");
check(x.equals(x), "应满足自反性");
check(x.equals(y) && y.equals(x), "应满足对称性");
check(x.equals(y) && y.equals(z) && x.equals(z), "应满足传递性");
check(x.hashCode() == y.hashCode(), "相等对象必须有相同哈希码");
check(!x.equals(null), "非空对象不等于 null");
}只断言“抛出了异常”还不完整。假设 order.submit() 因为空订单失败,还要检查 status 仍是 DRAFT;资源关闭过程中某一步失败,也要检查对象是否已标记关闭,避免下一次调用继续使用半释放资源。失败路径是对象不变量最容易破裂的地方。
请设计一个 LibraryLoan 借阅类,要求如下:
BookId 值对象保存规范化书号,并正确实现 equals/hashCode/toString。LoanStatus 枚举限制 ACTIVE、RETURNED、LOST 三种状态,并把“是否结束”行为放进枚举。LibraryLoan 使用类级安全计数器分配当前 JVM 内的编号;借阅人和书号创建后不能替换。markReturned() 只允许从 ACTIVE 转换,重复归还要明确拒绝;失败后状态不能变化。toString。先写对象不变量和所有权说明,再写字段。完成后做一次“谁能修改谁”的走查:每个公开方法都标记为查询、命令或转换;每个引用字段都说明是独占、共享还是不可变值。做到这一步,类的边界才真正可维护。