跳到内容

第 1 卷 / 第 7 章 / 第 4 课

7.4 封装与访问边界 ​

前置知识:字段、方法、构造方法和对象不变量

预计时间:30 分钟

完成标志:能隐藏表示细节,只公开维持不变量所需的查询和业务操作

1. private 字段阻止调用者绕过规则 ​

java
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. 包负责命名与访问边界 ​

java
package com.atlas.library.model;

public final class Book {
    // ...
}

包声明位于注释和空白之后、import 与类型声明之前。常见构建工具要求目录与包名对应,例如:

text
src/main/java/com/atlas/library/model/Book.java

使用方可以导入类型:

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:访问控制。

Built with VitePress | Software Systems Atlas