7.4 封装与访问边界
前置知识:字段、方法、构造方法和对象不变量
预计时间:30 分钟
完成标志:能隐藏表示细节,只公开维持不变量所需的查询和业务操作
1. private 字段阻止调用者绕过规则
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public final class Book {
private final String isbn;
private String title;
private long priceCents;
private boolean borrowed;
private final List<String> tags = new ArrayList<>();
public Book(String isbn, String title, long priceCents) {
this.isbn = requireText(isbn, "isbn");
rename(title);
changePrice(priceCents);
}
public String isbn() {
return isbn;
}
public String title() {
return title;
}
public long priceCents() {
return priceCents;
}
public boolean isBorrowed() {
return borrowed;
}
public void rename(String newTitle) {
title = requireText(newTitle, "title");
}
public void changePrice(long newPriceCents) {
if (newPriceCents < 0) {
throw new IllegalArgumentException("negative price");
}
priceCents = newPriceCents;
}
public boolean borrow() {
if (borrowed) return false;
borrowed = true;
return true;
}
public boolean returnBook() {
if (!borrowed) return false;
borrowed = false;
return true;
}
public void addTag(String tag) {
tags.add(requireText(tag, "tag"));
}
public List<String> tags() {
return List.copyOf(tags);
}
private static String requireText(String value, String name) {
Objects.requireNonNull(value, name);
if (value.isBlank()) {
throw new IllegalArgumentException("blank " + name);
}
return value;
}
}完整示例中的 Book 声明为 final,因此构造期间调用的 rename 和 changePrice 不会被子类替换;继承留到下一章设计。它只做非空文本校验,ISBN 格式与唯一性另属业务约束。
这里没有为每个字段机械生成 setter:
- ISBN 创建后不允许改变,因此只有查询方法。
rename与changePrice表达业务意图并集中校验。- 借阅状态只能通过
borrow和returnBook转换。 - 价格改用 long 保存最小货币单位,避免二进制浮点金额误差;币种与舍入仍需业务契约。
封装不是“private 字段 + 一组能写回任意值的 public setter”。核心是让对象自己维护有效状态,并保留改变内部表示的自由。
2. 返回可变字段时要防止外部反向修改
若 tags() 直接返回内部 ArrayList,调用者就能绕过 addTag 执行清空或加入非法值。List.copyOf(tags) 返回当前元素的不可修改快照,因此后续内部变化不会改变旧快照。
这是浅快照:若列表元素本身是可变对象,那些对象仍可能共享。List.copyOf 还拒绝 null 元素。防御策略要与元素类型和 API 契约一起设计。
另一种选择是返回只读视图。视图不能由调用者修改,却可能随内部列表变化。快照与视图没有绝对优劣,区别在于时间语义。
3. 信息隐藏允许内部实现演进
只要 public 契约不变,类内部可以:
- 把价格从 long 改成值对象;
- 为规范化书名增加缓存;
- 在公开契约本来就要求标签唯一时,把内部列表改成保持插入顺序的集合;
- 增加审计信息或状态机。
调用者依赖的是方法契约,不是字段布局。与此同时,修改返回类型、异常策略或线程安全保证仍可能破坏兼容性,不能用“封装了”掩盖契约变化。
4. 四种成员访问级别
| 修饰符 | 同一顶级类嵌套范围 | 同包 | 跨包子类 | 其他代码 |
|---|---|---|---|---|
private | 是 | 否 | 否 | 否 |
| 无修饰符(包级) | 是 | 是 | 否 | 否 |
protected | 是 | 是 | 有条件 | 否 |
public | 是 | 是 | 是 | 是 |
跨包子类对 protected 实例成员的访问还有“通过哪个接收者访问”的限制,继承章节再结合代码说明。public 也可能受 Java 模块导出规则约束。
顶级类只能声明为 public 或包级;private 和 protected 可用于成员类,不能直接修饰顶级类。
5. 包负责命名与访问边界
package com.atlas.library.model;
public final class Book {
// ...
}包声明位于注释和空白之后、import 与类型声明之前。常见构建工具要求目录与包名对应,例如:
src/main/java/com/atlas/library/model/Book.java使用方可以导入类型:
import com.atlas.library.model.Book;也可写全限定名。import 只是让源代码使用短名,不会复制类,也不是运行时“加载命令”。默认包不适合正式项目,因为其中类型不能被具名包正常导入。
6. JavaBeans 是约定,不是封装定义
getName()、setName(...) 和布尔 isActive() 是常见 JavaBeans 属性命名。某些框架会使用这些约定,但具体支持构造器、字段、记录类或注解的方式取决于框架版本和配置。
领域对象不必为了看起来像 Bean 而暴露无参构造和所有 setter。先满足对象不变量,再为确实使用的序列化、ORM 或界面绑定工具配置边界。
专业速查
| 设计问题 | 优先答案 |
|---|---|
| 字段是否公开 | 默认 private |
| 是否需要 setter | 只有业务允许任意替换时才提供 |
| 身份字段 | 构造注入 + final + 只读查询 |
| 可变集合 getter | 快照、只读视图或领域查询 |
| 同包协作 | 包级成员,谨慎控制包规模 |
| 扩展点 | protected 需要继承契约,不是“半公开” |
离开这一页前
为 ISBN、书名、价格、借阅状态和标签分别决定查询与修改入口;若答案只是“每项加 getter/setter”,对象不变量仍可被绕过。对标签集合还要明确返回快照还是只读视图,并说明元素可变时为何仍是浅层边界。最后区分访问修饰符、包声明与 import 各自负责什么。
规范入口:JLS §6.6:访问控制。