Skip to content

封装 ​

S1-006必学入门约 60 分钟JDK 21编程语言基础 · 面向对象

1. 本节定位 ​

《类与对象》 末尾留了一个问题:字段为什么应该是 private。本节回答它,并把"封装"从一个语法要求升级成一种设计能力。

很多人对封装的理解停在"字段加 private、加 getter/setter"。但机械地加 getter/setter 等于没封装——真正的问题是:哪些东西该暴露、暴露成什么形式。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出封装要解决的三个问题(数据保护、隐藏实现、稳定接口);
  • 给字段加校验而不是裸暴露,并说清"什么时候需要 setter、什么时候不该有";
  • 解释为什么返回内部集合要用 List.copyOf(防御性拷贝);
  • 写出一个不可变类,并说出它的三个好处;
  • 用"问而不是要"的原则重构一段代码;
  • 说清四种访问修饰符的可见范围。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

看一段没有封装的代码会发生什么:

java
public class Wallet {
    public long balanceInCents;      // 谁都能改
    public List<String> records = new ArrayList<>();
}

// 别处代码
wallet.balanceInCents = -99999;      // 余额变负数,没人拦
wallet.records.clear();              // 流水被清空,无法追溯

三个后果:

  1. 业务规则失效:"余额不能为负""流水不可篡改"这些规则没有任何地方能保证;
  2. 改不动:将来要把 balanceInCents 从 long 改成 BigDecimal,所有直接访问它的代码都要改;
  3. 没人知道谁改的:字段被谁赋过值,无法追踪。

封装的目的就是把这三件事收回来:规则集中在类内部、实现可以自由替换、外部只能通过明确的方法交互。

5. 核心概念 ​

5.1 封装的三个层次 ​

层次做什么例子
数据保护私有字段 + 校验deposit 拒绝负数金额
隐藏实现暴露行为而非数据结构外部调 withdraw,不知道内部怎么记流水
稳定接口内部实现可变,方法签名不变流水从 ArrayList 换成数据库表,调用方无感

5.2 访问修饰符 ​

修饰符可见范围什么时候用
private仅本类默认选择,字段一律用它
默认(不写)同一个包包内协作的工具类
protected同包 + 子类只给子类用的钩子方法(《继承与重写》 会讨论它的风险)
public所有API 方法

经验法则:能小就小,需要时再放大。 从 private 开始,别人真的需要访问时再考虑放开——反过来(从 public 收紧)会破坏已存在的调用。

5.3 不是所有字段都该有 setter ​

java
// 机械式封装:加了 getter/setter,但等于没封装
private long balanceInCents;
public void setBalanceInCents(long value) { this.balanceInCents = value; }   // 规则呢?

// 有意义的封装:暴露"行为"
public void deposit(long amountInCents) {
    if (amountInCents <= 0) {
        throw new IllegalArgumentException("存款金额必须大于 0");
    }
    balanceInCents += amountInCents;
}

判断标准:这个字段的修改有业务规则吗?

  • 有规则(余额、状态、计数)→ 只暴露带校验的方法,不要 setter;
  • 纯粹的数据载体(DTO)→ 可以用 setter 或直接做成 record。

5.4 别把内部集合直接交出去 ​

java
private final List<String> records = new ArrayList<>();

// 危险:外部拿到的是内部列表本身
public List<String> badRecords() { return records; }          // 调用方 clear() 一下就完了

// 正确:返回只读副本
public List<String> records() { return List.copyOf(records); }

这叫防御性拷贝(defensive copy)。同样的道理适用于:

  • 构造方法接收集合时:this.records = new ArrayList<>(input);
  • 返回可变对象时:返回副本或不可变包装
  • 接收数组时:Arrays.copyOf(...)

5.5 不可变对象 ​

java
public final class Money {          // final:不允许被继承改写行为
    private final long amountInCents;
    private final String currency;

    public Money(long amountInCents, String currency) {
        this.amountInCents = amountInCents;
        this.currency = currency;
    }

    public Money plus(Money other) {          // 返回新对象,不修改自己
        return new Money(amountInCents + other.amountInCents, currency);
    }
}

不可变的三个好处:

  1. 天然线程安全(多线程可以自由共享,见 《线程与同步基础》);
  2. 可以安全地做 Map 的 key(哈希值不会变,见 《HashMap》);
  3. 没有隐藏的状态变化,排查问题时不用考虑"谁改了它"。

Java 的 String、Integer、LocalDate 都是不可变的——它们的行为可以借鉴。record(《Java 9-21 新特性与 Record》)是声明不可变数据的快捷方式。

5.6 "问而不是要"(Tell, Don't Ask) ​

java
// 要:把数据拿出来,在外面做判断
if (account.getBalanceInCents() >= amount) {
    account.setBalanceInCents(account.getBalanceInCents() - amount);
}

// 问:让对象自己做
account.withdraw(amount);      // 规则在类内部

第二种写法把规则留在了它该在的地方。判断标准很简单:如果一段逻辑反复出现"取字段 → 判断 → 写回字段",它就该被搬进类里。

6. 递进示例 ​

6.1 最小示例:private + 校验 ​

java
public class Wallet {
    private long balanceInCents;

    public void deposit(long amountInCents) {
        if (amountInCents <= 0) {
            throw new IllegalArgumentException("金额必须大于 0");
        }
        balanceInCents += amountInCents;
    }

    public long balanceInCents() {
        return balanceInCents;
    }
}

注意 balanceInCents() 只有读取,没有写入——余额只能通过业务动作改变。

6.2 业务示例:钱包 + 流水 ​

完整代码见第 7 节。关键设计有三处:

  • 构造方法校验持有者与初始余额;
  • 每个业务方法先校验再改状态;
  • records() 返回 List.copyOf(records),外部读得到、改不了。

6.3 反例:三种"假封装" ​

java
// 反例一:直接返回内部集合
public List<String> records() { return records; }        // 外部能 clear()

// 反例二:提供无校验的 setter
public void setBalanceInCents(long value) { balanceInCents = value; }

// 反例三:把可变对象当不可变返回(浅拷贝)
public String[] getTags() { return tags; }               // 数组内容仍可被改

一个快速自检:把你的类的所有 getter 摊开看——有多少个方法能让外部改变对象状态? 如果除业务方法之外还有,就说明封装有漏洞。

7. 完整可运行代码 ​

文件:labs/oop-basics/src/main/java/cn/kb/learn/oop/Wallet.java 与 EncapsulationLesson.java

java
package cn.kb.learn.oop;

import java.util.ArrayList;
import java.util.List;

public class Wallet {
    private final String owner;
    private long balanceInCents;
    private final List<String> records = new ArrayList<>();

    public Wallet(String owner, long initialInCents) {
        if (owner == null || owner.isBlank()) {
            throw new IllegalArgumentException("持有者不能为空");
        }
        if (initialInCents < 0) {
            throw new IllegalArgumentException("初始余额不能为负");
        }
        this.owner = owner;
        this.balanceInCents = initialInCents;
    }

    public void deposit(long amountInCents) {
        if (amountInCents <= 0) {
            throw new IllegalArgumentException("存款金额必须大于 0");
        }
        balanceInCents += amountInCents;
        records.add("存入 " + amountInCents + " 分");
    }

    public void withdraw(long amountInCents) {
        if (amountInCents <= 0) {
            throw new IllegalArgumentException("取款金额必须大于 0");
        }
        if (amountInCents > balanceInCents) {
            throw new IllegalStateException("余额不足");
        }
        balanceInCents -= amountInCents;
        records.add("取出 " + amountInCents + " 分");
    }

    public String owner() {
        return owner;
    }

    public long balanceInCents() {
        return balanceInCents;
    }

    /** 返回内部流水的只读副本:外部可以读,但不能改 */
    public List<String> records() {
        return List.copyOf(records);
    }
}

运行命令:

powershell
cd labs
./mvnw -pl oop-basics compile
java -cp oop-basics/target/classes cn.kb.learn.oop.EncapsulationLesson

预期输出:

text
余额(分): 12000
流水: [存入 5000 分, 取出 3000 分]
外部无法修改内部流水: UnsupportedOperationException
预期内的失败: 余额不足
预期内的失败: 存款金额必须大于 0
最终余额(分): 12000
最终流水: [存入 5000 分, 取出 3000 分]

注意最后两行:外部尝试篡改流水失败后,余额与流水都没变化——这就是封装在起作用的证据。

8. Debug 观察点 ​

  1. 在 wallet.records().add("伪造记录") 处断点,按 F7 进入 ArrayList.add,观察它先在 ArrayList 内部检查 modCount 与容量,最终抛出异常——理解"为什么 List.copyOf 是只读的";
  2. 在 wallet.withdraw(1_000_000) 处断点,F7 进入 withdraw,观察校验分支直接抛异常、没有执行到 balanceInCents -= ...;
  3. 展开 Variables 里的 wallet 对象,观察三个字段都是私有字段,但调试器依然能看到(调试器不受访问权限限制,这是排查问题的重要提示);
  4. 故意把 records() 改成 return records;,重跑程序,你会发现"外部无法修改内部流水"那行变成了别的输出——因为篡改成功了。

9. 常见错误 ​

错误 1:用 setter 暴露一切 ​

java
public void setOwner(String owner) { this.owner = owner; }   // 持有者应该能改吗?

修复:字段一旦应该是"创建后不变"的,就用 final 且不给 setter。

错误 2:构造方法里不校验 ​

java
public Wallet(String owner, long initialInCents) {
    this.owner = owner;              // owner 可能是 null
    this.balanceInCents = initialInCents;   // 可能是负数
}

修复:把校验放在构造方法里——对象一旦创建出来,就应该处于合法状态。这条原则叫"失败要早"。

错误 3:把可变对象交给外部 ​

java
private final List<String> records = new ArrayList<>();
public List<String> records() { return records; }        // 漏洞

修复:返回 List.copyOf(...) 或 Collections.unmodifiableList(...)。

错误 4:以为 final 让对象不可变 ​

java
private final List<String> records = new ArrayList<>();
// final 只保证"引用不变",不保证"内容不变"
records.add("新记录");      // 合法

修复:要真正不可变,需要"引用 final + 内容不可变",或返回副本。

10. 动手实验 ​

目标:把一个"漏风"的类改造成封装良好的类。

步骤:

  1. 运行第 7 节代码,核对输出;
  2. 写一个 ScoreBoard 类(字段 private Map<String, List<Integer>> scores),提供 addScore、average、top 三个方法;
  3. 让 scores() 一行代码都不返回——思考:外部真的需要看到原始结构吗?(如果确实需要,返回什么形式才安全?)
  4. 加一个方法 clearAll(),思考它是否该存在——如果班级到期结算,应该怎么设计(提示:可能是一个新的 ScoreBoard 对象,而不是清空旧的);
  5. 用第 6.3 节的"自检"审视你的类:除业务方法外,还有哪些方法能改变状态?

验收方式:能说明"为什么 records() 返回副本"与"为什么不能暴露可变集合"。

扩展任务:把 Wallet 改造成完全不可变的版本(所有字段 final,deposit 返回新的 Wallet),对比两种设计的优缺点(提示:不可变更安全,但频繁修改时会产生大量对象)。

11. 练习题 ​

基础题

  1. 判断:给每个字段都加上 getter 与 setter 就是封装。(对 / 错)
  2. 填空:返回内部集合时应返回 ______ 或 ______。
  3. 简答:为什么"余额"这种字段不应该有 setter?

代码阅读题

java
public class Cart {
    private final List<String> items = new ArrayList<>();
    public List<String> items() { return items; }
}

Cart cart = new Cart();
cart.items().add("商品A");
System.out.println(cart.items().size());
  1. 这段代码能运行吗?它暴露了什么设计问题?

编程题

  1. 写一个 Temperature 类:字段为摄氏度(double),要求:
    • 构造时校验不低于 -273.15;
    • 提供 celsius() 与 fahrenheit() 两个读取方法;
    • 不提供任何 setter;
    • 说明为什么把它设计成不可变的。

综合任务

  1. 设计一个 UserAccount 类,满足:
    • 用户名创建后不可改;
    • 密码只能通过 changePassword(oldPassword, newPassword) 修改,且要校验旧密码正确、新密码长度不少于 8 位;
    • 登录失败次数可以被记录,但外部只能读取,不能直接修改;
    • 提供 isLocked() 方法(失败 5 次锁定);
    • 写一段说明:这个类里哪些字段有 setter、哪些没有、为什么。
分级提示

提示 1(方向):第 4 题的 items() 返回的是内部列表本身,外部可以直接改。

提示 2(关键点):第 6 题的"失败次数只能读不能写"意味着失败次数只能通过 login() 方法改变,这就是业务动作驱动的状态变化。

提示 3(接近答案):第 5 题的 fahrenheit() 用 celsius * 9 / 5 + 32 计算,注意用 double 避免整数除法。

12. 参考答案 ​

基础题
  1. 错。getter/setter 只是"把字段换个方式暴露"。如果 setter 没有校验、没有业务含义,封装的价值就丢失了。
  2. 不可变副本(List.copyOf)、不可修改的包装(Collections.unmodifiableList)。
  3. 因为余额的变化一定伴随着业务动作(存款、取款、扣费)与规则(不能为负、不能超额)。允许直接 set,等于绕过了所有规则与记账逻辑。
代码阅读题(第 4 题)

能运行,输出 1。

但它暴露了三个问题:

  1. final 只保护了引用,items 内容可以被外部随意修改;
  2. 没有方法承载业务规则(加入购物车、校验库存、计算总价);
  3. 外部绕过 Cart 直接操作列表,将来想加"最多 20 件"这类限制时无处下手。

修复:

java
public List<String> items() { return List.copyOf(items); }
public void addItem(String item) { ... }   // 业务动作 + 校验
编程题(第 5 题)
java
package cn.kb.learn.oop;

public final class Temperature {
    private static final double ABSOLUTE_ZERO_CELSIUS = -273.15;
    private final double celsius;

    public Temperature(double celsius) {
        if (celsius < ABSOLUTE_ZERO_CELSIUS) {
            throw new IllegalArgumentException("温度不能低于绝对零度");
        }
        this.celsius = celsius;
    }

    public double celsius() {
        return celsius;
    }

    public double fahrenheit() {
        return celsius * 9 / 5 + 32;
    }

    public static void main(String[] args) {
        Temperature body = new Temperature(36.6);
        System.out.printf("%.1f℃ = %.1f℉%n", body.celsius(), body.fahrenheit());

        try {
            new Temperature(-300);
        } catch (IllegalArgumentException exception) {
            System.out.println("预期内的失败: " + exception.getMessage());
        }
    }
}

为什么设计为不可变:温度是一个值——"36.6 度"本身不会变化,需要新温度时应该创建一个新对象。不可变让它天然线程安全,也能安全地作为 Map 的 key。

预期输出:

text
36.6℃ = 97.9℉
预期内的失败: 温度不能低于绝对零度
综合任务(第 6 题)
java
package cn.kb.learn.oop;

public class UserAccount {
    private static final int MAX_FAILURES = 5;
    private static final int MIN_PASSWORD_LENGTH = 8;

    private final String username;
    private String password;
    private int failureCount;         // 外部只能读

    public UserAccount(String username, String password) {
        if (username == null || username.isBlank()) {
            throw new IllegalArgumentException("用户名不能为空");
        }
        validPassword(password);
        this.username = username;
        this.password = password;
    }

    public void changePassword(String oldPassword, String newPassword) {
        if (!password.equals(oldPassword)) {
            throw new IllegalArgumentException("旧密码不正确");
        }
        validPassword(newPassword);
        password = newPassword;
        failureCount = 0;
    }

    public boolean login(String inputPassword) {
        if (isLocked()) {
            throw new IllegalStateException("账户已锁定");
        }
        if (password.equals(inputPassword)) {
            failureCount = 0;
            return true;
        }
        failureCount++;
        return false;
    }

    public String username() {
        return username;
    }

    public int failureCount() {
        return failureCount;
    }

    public boolean isLocked() {
        return failureCount >= MAX_FAILURES;
    }

    private static void validPassword(String password) {
        if (password == null || password.length() < MIN_PASSWORD_LENGTH) {
            throw new IllegalArgumentException("密码长度不能少于 " + MIN_PASSWORD_LENGTH + " 位");
        }
    }

    public static void main(String[] args) {
        UserAccount account = new UserAccount("zhangsan", "password123");
        System.out.println("登录失败: " + account.login("wrong"));
        System.out.println("失败次数: " + account.failureCount());
        System.out.println("登录成功: " + account.login("password123"));
        System.out.println("成功后失败次数重置: " + account.failureCount());

        for (int i = 0; i < 5; i++) {
            account.login("wrong");
        }
        System.out.println("是否锁定: " + account.isLocked());
    }
}

哪些字段有 setter:一个都没有。

  • username:final,创建后不变;
  • password:只能通过 changePassword 修改,且必须校验旧密码 —— 直接把 setter 打开就等于允许任何人改密码;
  • failureCount:只能通过 login 累加或重置 —— 允许外部直接写,就等于允许攻击者把失败次数清零,锁定机制形同虚设。

这就是"业务动作驱动状态变化"的具体形态。

13. 掌握标准 ​

  • [ ] 能给字段选对访问修饰符,并说出理由;
  • [ ] 会区分"需要 setter"与"只能通过业务方法修改"的字段;
  • [ ] 会写防御性拷贝,不把内部集合直接交出去;
  • [ ] 能写出不可变类并说出好处;
  • [ ] 会用"问而不是要"重构"取值—判断—写回"的代码。

14. 扩展资源 ​

15. 下一步学习 ​

《继承与重写》:复用代码的另一种方式,也是理解多态的前提。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引