封装是 Java 面向对象编程中保护数据的第一道防线,但它并不是把字段隐藏起来就结束了。假设一个订单类的商品列表字段被声明为 public,任何代码都能在任意位置往里面塞入空字符串、重复元素,甚至把整个列表替换掉,订单状态很快会变得不可控。即便改成 private,如果 getter 直接返回内部集合,调用方仍然可以绕过业务规则修改数据。真正的封装需要从字段可见性、方法边界、防御性拷贝、不可变设计等几个层面一起配合,把非法状态挡在对象之外。

一、从 public 字段到 private 字段:先关掉直接访问通道
很多对象数据被破坏,最初原因就是字段被直接暴露。比如账户类里写一个 public double balance,任何拿到账户引用的代码都可以执行 account.balance = -100,而且编译器不会给出任何提示。这样的代码在小型项目里也许还能靠人工检查,一旦系统变大,调用位置分散在多个模块中,余额怎么会变成负数就极难排查。
用 private 修饰字段后,对象状态只能由类自身的方法改变。这样当 balance 出现负数时,排查范围就从整个项目缩小到 Account 类内部,定位问题会快很多。封装的核心价值不是多写几个方法,而是让不变量可维护。例如账户余额不允许为负,这个规则写在 deposit 和 withdraw 方法里,调用方就无法绕过。下面是一个简单示例:
public class Account {
private double balance;
public Account(double initialBalance) {
if (initialBalance < 0) {
throw new IllegalArgumentException("初始余额不能为负");
}
this.balance = initialBalance;
}
public double getBalance() {
return balance;
}
public void deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("存入金额必须大于0");
}
this.balance += amount;
}
public void withdraw(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("取出金额必须大于0");
}
if (amount > balance) {
throw new IllegalArgumentException("余额不足");
}
this.balance -= amount;
}
}
如果仅把字段改 private 却提供不加校验的 setBalance,和 public 字段没有本质区别。外部依然可以传入 -100 或 0,对象照样进入非法状态。因此 private 只是基础,下一个关键点是访问器必须承担校验职责。
二、getter 和 setter 要把修改入口变成业务规则入口
setter 中应当先检查参数是否合法,再决定是否赋值。常见的校验包括非空判断、数值范围、字符串格式、集合元素限制。访问器也不应该原样返回内部对象,特别是集合、数组、日期这些可变类型。返回一个可修改引用,等于把内部状态交给了调用方,调用方可以不经业务方法直接改数据。
例如订单类内部维护一个商品列表,构造器直接接收外部列表,getter 直接返回内部列表,都会造成隐患。调用方拿到列表后执行 clear(),订单里所有商品就消失了。更合适的做法是进入时复制,返回时包装成只读视图:
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class Order {
private final List<String> items;
public Order(List<String> items) {
if (items == null) {
throw new IllegalArgumentException("商品列表不能为空");
}
this.items = new ArrayList<>(items);
}
public List<String> getItems() {
return Collections.unmodifiableList(items);
}
public void addItem(String item) {
if (item == null || item.isBlank()) {
throw new IllegalArgumentException("商品名称不能为空");
}
items.add(item);
}
}
另外,getter 读取次数可能远高于写入次数,每次返回新副本会带来一点分配成本。如果类内部不会修改集合,可以返回 Collections.unmodifiableList 包装后的视图;如果必须完全隔离,就返回 new ArrayList<>(items)。选择哪种方式取决于数据敏感程度和调用频率。
三、防御性拷贝:防止可变对象从构造器和 getter 泄漏
Java 中的 Date、ArrayList、HashMap 和数组都是可变对象。把它们直接赋给字段或直接返回,会让外部拿到内部引用。比如构造器接收一个 List 后直接赋值,调用方在原列表中添加元素,对象内部状态就会跟着变化,甚至可能破坏已经校验过的内容。数组和 Date 的问题尤其隐蔽,因为很多开发者会习惯性地直接赋值或返回引用。
防御性拷贝的原则是:进入类时复制一份,交给外部时再复制或包装成只读视图。构造函数执行拷贝后,外部对原始列表的任何修改都不会影响内部数据;getter 返回只读视图,调用方尝试 add 时会直接抛异常,从而强制走对象提供的业务方法。下面的构造器如果没有执行拷贝,外部持有同一个 List 引用就能绕过 addItem 校验直接污染订单数据。
四、用不可变对象和最小访问范围降低维护成本
如果一个对象创建后状态不需要改变,可以设计成不可变类。把所有字段设为 final,构造函数完成全部校验,之后不提供任何 setter。不可变对象天然线程安全,可以安全地在多个线程之间共享,也不会出现外部篡改内部状态的问题。金额、坐标、配置项这类值对象特别适合做成不可变类。
public final class Money {
private final double amount;
private final String currency;
public Money(double amount, String currency) {
if (amount < 0) {
throw new IllegalArgumentException("金额不能为负");
}
if (currency == null || currency.isBlank()) {
throw new IllegalArgumentException("币种不能为空");
}
this.amount = amount;
this.currency = currency;
}
public double getAmount() {
return amount;
}
public String getCurrency() {
return currency;
}
}
对于无法完全不可变的类,可以用包级私有构造器、静态工厂方法或私有内部类来限制谁能创建和修改对象。比如将 setter 改为包级私有,只允许同一包内的服务类调用,避免 API 使用者直接修改核心状态。这种细粒度控制能让封装从语法层面走向真正的设计约束。
实际项目中,封装失效通常不是因为没有 private,而是因为访问器设计过于随意、可变引用直接泄漏、构造器没有复制参数。把这三处补上后,对象数据才能长期保持一致性。与其在出现问题后到处找是谁改了订单状态,不如一开始就让非法修改无法编译通过或在运行时立即失败。

