主题
界面
主题
《类与对象》 末尾留了一个问题:字段为什么应该是 private。本节回答它,并把"封装"从一个语法要求升级成一种设计能力。
很多人对封装的理解停在"字段加 private、加 getter/setter"。但机械地加 getter/setter 等于没封装——真正的问题是:哪些东西该暴露、暴露成什么形式。
完成本节后,你应该能够:
List.copyOf(防御性拷贝);看一段没有封装的代码会发生什么:
public class Wallet {
public long balanceInCents; // 谁都能改
public List<String> records = new ArrayList<>();
}
// 别处代码
wallet.balanceInCents = -99999; // 余额变负数,没人拦
wallet.records.clear(); // 流水被清空,无法追溯三个后果:
balanceInCents 从 long 改成 BigDecimal,所有直接访问它的代码都要改;封装的目的就是把这三件事收回来:规则集中在类内部、实现可以自由替换、外部只能通过明确的方法交互。
| 层次 | 做什么 | 例子 |
|---|---|---|
| 数据保护 | 私有字段 + 校验 | deposit 拒绝负数金额 |
| 隐藏实现 | 暴露行为而非数据结构 | 外部调 withdraw,不知道内部怎么记流水 |
| 稳定接口 | 内部实现可变,方法签名不变 | 流水从 ArrayList 换成数据库表,调用方无感 |
| 修饰符 | 可见范围 | 什么时候用 |
|---|---|---|
private | 仅本类 | 默认选择,字段一律用它 |
| 默认(不写) | 同一个包 | 包内协作的工具类 |
protected | 同包 + 子类 | 只给子类用的钩子方法(《继承与重写》 会讨论它的风险) |
public | 所有 | API 方法 |
经验法则:能小就小,需要时再放大。 从 private 开始,别人真的需要访问时再考虑放开——反过来(从 public 收紧)会破坏已存在的调用。
// 机械式封装:加了 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;
}判断标准:这个字段的修改有业务规则吗?
record。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(...)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);
}
}不可变的三个好处:
Java 的 String、Integer、LocalDate 都是不可变的——它们的行为可以借鉴。record(《Java 9-21 新特性与 Record》)是声明不可变数据的快捷方式。
// 要:把数据拿出来,在外面做判断
if (account.getBalanceInCents() >= amount) {
account.setBalanceInCents(account.getBalanceInCents() - amount);
}
// 问:让对象自己做
account.withdraw(amount); // 规则在类内部第二种写法把规则留在了它该在的地方。判断标准很简单:如果一段逻辑反复出现"取字段 → 判断 → 写回字段",它就该被搬进类里。
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() 只有读取,没有写入——余额只能通过业务动作改变。
完整代码见第 7 节。关键设计有三处:
records() 返回 List.copyOf(records),外部读得到、改不了。// 反例一:直接返回内部集合
public List<String> records() { return records; } // 外部能 clear()
// 反例二:提供无校验的 setter
public void setBalanceInCents(long value) { balanceInCents = value; }
// 反例三:把可变对象当不可变返回(浅拷贝)
public String[] getTags() { return tags; } // 数组内容仍可被改一个快速自检:把你的类的所有 getter 摊开看——有多少个方法能让外部改变对象状态? 如果除业务方法之外还有,就说明封装有漏洞。
文件:labs/oop-basics/src/main/java/cn/kb/learn/oop/Wallet.java 与 EncapsulationLesson.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);
}
}运行命令:
cd labs
./mvnw -pl oop-basics compile
java -cp oop-basics/target/classes cn.kb.learn.oop.EncapsulationLesson预期输出:
余额(分): 12000
流水: [存入 5000 分, 取出 3000 分]
外部无法修改内部流水: UnsupportedOperationException
预期内的失败: 余额不足
预期内的失败: 存款金额必须大于 0
最终余额(分): 12000
最终流水: [存入 5000 分, 取出 3000 分]注意最后两行:外部尝试篡改流水失败后,余额与流水都没变化——这就是封装在起作用的证据。
wallet.records().add("伪造记录") 处断点,按 F7 进入 ArrayList.add,观察它先在 ArrayList 内部检查 modCount 与容量,最终抛出异常——理解"为什么 List.copyOf 是只读的";wallet.withdraw(1_000_000) 处断点,F7 进入 withdraw,观察校验分支直接抛异常、没有执行到 balanceInCents -= ...;wallet 对象,观察三个字段都是私有字段,但调试器依然能看到(调试器不受访问权限限制,这是排查问题的重要提示);records() 改成 return records;,重跑程序,你会发现"外部无法修改内部流水"那行变成了别的输出——因为篡改成功了。public void setOwner(String owner) { this.owner = owner; } // 持有者应该能改吗?修复:字段一旦应该是"创建后不变"的,就用 final 且不给 setter。
public Wallet(String owner, long initialInCents) {
this.owner = owner; // owner 可能是 null
this.balanceInCents = initialInCents; // 可能是负数
}修复:把校验放在构造方法里——对象一旦创建出来,就应该处于合法状态。这条原则叫"失败要早"。
private final List<String> records = new ArrayList<>();
public List<String> records() { return records; } // 漏洞修复:返回 List.copyOf(...) 或 Collections.unmodifiableList(...)。
final 让对象不可变 private final List<String> records = new ArrayList<>();
// final 只保证"引用不变",不保证"内容不变"
records.add("新记录"); // 合法修复:要真正不可变,需要"引用 final + 内容不可变",或返回副本。
目标:把一个"漏风"的类改造成封装良好的类。
步骤:
ScoreBoard 类(字段 private Map<String, List<Integer>> scores),提供 addScore、average、top 三个方法;scores() 一行代码都不返回——思考:外部真的需要看到原始结构吗?(如果确实需要,返回什么形式才安全?)clearAll(),思考它是否该存在——如果班级到期结算,应该怎么设计(提示:可能是一个新的 ScoreBoard 对象,而不是清空旧的);验收方式:能说明"为什么 records() 返回副本"与"为什么不能暴露可变集合"。
扩展任务:把 Wallet 改造成完全不可变的版本(所有字段 final,deposit 返回新的 Wallet),对比两种设计的优缺点(提示:不可变更安全,但频繁修改时会产生大量对象)。
基础题
代码阅读题
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());编程题
Temperature 类:字段为摄氏度(double),要求: celsius() 与 fahrenheit() 两个读取方法;综合任务
UserAccount 类,满足: changePassword(oldPassword, newPassword) 修改,且要校验旧密码正确、新密码长度不少于 8 位;isLocked() 方法(失败 5 次锁定);提示 1(方向):第 4 题的 items() 返回的是内部列表本身,外部可以直接改。
提示 2(关键点):第 6 题的"失败次数只能读不能写"意味着失败次数只能通过 login() 方法改变,这就是业务动作驱动的状态变化。
提示 3(接近答案):第 5 题的 fahrenheit() 用 celsius * 9 / 5 + 32 计算,注意用 double 避免整数除法。
List.copyOf)、不可修改的包装(Collections.unmodifiableList)。能运行,输出 1。
但它暴露了三个问题:
final 只保护了引用,items 内容可以被外部随意修改;Cart 直接操作列表,将来想加"最多 20 件"这类限制时无处下手。修复:
public List<String> items() { return List.copyOf(items); }
public void addItem(String item) { ... } // 业务动作 + 校验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。
预期输出:
36.6℃ = 97.9℉
预期内的失败: 温度不能低于绝对零度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 累加或重置 —— 允许外部直接写,就等于允许攻击者把失败次数清零,锁定机制形同虚设。这就是"业务动作驱动状态变化"的具体形态。
《继承与重写》:复用代码的另一种方式,也是理解多态的前提。