封装最佳实践
封装是个技术活,但知道原理不代表做得好。
这一节不聊「应该怎么做」,而是聊聊容易踩的坑和怎么避坑。
防御性复制:你以为的不可变,其实还是可变的
看这段代码:
java
public class Company {
private List<String> employees = new ArrayList<>();
public List<String> getEmployees() {
return employees; // ❌ 直接返回内部引用
}
}调用方拿到引用后,可以直接往里加、删、Clear:
java
Company company = new Company();
company.getEmployees().add("赵六"); // 改动了内部状态!
company.getEmployees().clear(); // 清空了!这不是开玩笑,这是真实的 bug 来源。如果不想被外界改,就把引用锁死。
两种做法:
java
public class DefensiveCopyDemo {
static class Company {
private String name;
private List<String> employees;
public Company(String name, List<String> employees) {
this.name = name;
// 构造时复制:外部的改动进不来
this.employees = new ArrayList<>(employees);
}
// getter 返回副本:外部拿到也改不动
public List<String> getEmployees() {
return new ArrayList<>(employees);
}
public void addEmployee(String employee) {
employees.add(employee);
}
}
public static void main(String[] args) {
List<String> original = new ArrayList<>();
original.add("张三");
Company company = new Company("ABC", original);
// 修改原列表不影响内部
original.add("李四");
System.out.println(company.getEmployees()); // [张三]
// 通过 getter 获取后修改也不影响
List<String> emps = company.getEmployees();
emps.add("王五");
System.out.println(company.getEmployees()); // [张三]
}
}关键就两个原则:
- 构造时复制:把参数复制一份再存
- getter 返回副本:永远不把内部集合的引用暴露出去
代价是每次返回都创建一个新对象。对于普通集合,这不值一提;对于超大集合,可以考虑只读包装:
java
public List<String> getEmployees() {
return Collections.unmodifiableList(employees);
}但这个只防外层修改,如果集合里装的是可变对象,元素本身还是能被改。更严格的做法是深拷贝,但实际项目中这种情况很少。
不可变对象:最干净的封装
如果你想让一个类完全不可变,最理想的状态是这样:
java
public class ImmutablePoint {
private final int x;
private final int y;
public ImmutablePoint(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
// 没有 setter,没有能修改字段的方法
}不可变对象的优势是全局的:不可变的东西不需要同步,不需要防御性复制,不需要担心线程安全。
实际项目里,优先把类设计成不可变的。如果必须可变,就把可变范围控制到最小。
Builder 模式:复杂对象的好帮手
当一个类有很多字段,尤其是可选字段多的时候,重载构造函数很快就失控:
java
// 这是构造函数地狱的开始
User user = new User("张三", 25, null, null, true, null);Builder 模式把构造过程变成链式调用:
java
public class BuilderPatternDemo {
static class User {
private final String name; // 必填
private final int age; // 选填,有默认值
private final String email; // 选填
private final boolean active; // 选填
private User(Builder builder) {
this.name = builder.name;
this.age = builder.age;
this.email = builder.email;
this.active = builder.active;
}
public static class Builder {
private String name;
private int age = 0;
private String email;
private boolean active = true;
public Builder name(String name) {
this.name = name;
return this;
}
public Builder age(int age) {
this.age = age;
return this;
}
public Builder email(String email) {
this.email = email;
return this;
}
public Builder active(boolean active) {
this.active = active;
return this;
}
public User build() {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name 不能为空");
}
return new User(this);
}
}
}
public static void main(String[] args) {
User user = new User.Builder()
.name("张三")
.age(25)
.email("zhangsan@example.com")
.build();
System.out.println(user.name + ", " + user.age);
}
}Builder 的好处不只是「链式调用好看」,更关键的是在 build() 方法里做统一的参数校验。构造函数里的校验容易被忽略,Builder 的 build() 是最后一道关卡,校验逻辑集中、可读。
字段私有后,别用 public 的 getter 暴露一切
很多人在把字段设成 private 后,觉得反正有 getter 了,就随便暴露:
java
public class Account {
private double balance;
public double getBalance() {
return balance; // ❌ 余额直接暴露
}
}调用方拿到余额后,你管不住他拿去干什么。加减乘除、业务校验,全在外部分散着做了。
更好的做法是提供行为方法,而不是暴露数据:
java
public class Account {
private double balance;
public double getBalance() {
return balance;
}
public void deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("存款金额必须大于 0");
}
balance += amount;
}
public boolean withdraw(double amount) {
if (amount <= 0) {
return false;
}
if (amount > balance) {
return false;
}
balance -= amount;
return true;
}
}这样,余额只能通过特定的操作来改变,而且操作里可以内嵌业务规则。封装的本质是把数据和操作绑在一起,不是把数据藏起来然后到处传。
总结
封装的常见误区:
- ❌ 字段 private + getter 就完事了——外部拿到的引用可能还是能改
- ❌ 所有字段都提供 setter——这是在公开内部状态
- ❌ 构造函数不校验——垃圾进,垃圾出
封装的正确姿势:
- ✅ 返回副本或只读包装,防止外部修改内部状态
- ✅ 优先设计不可变类,减少同步和防御成本
- ✅ 复杂对象用 Builder,校验逻辑集中在 build() 里
- ✅ 提供行为方法而非裸 getter,让操作和业务规则绑在一起
