60、重构与函数式编程:用不可变数据重构可变状态
说实话,可变状态是很多bug的温床。
我早期做Java项目时,经常被多线程下的共享状态搞到头秃。明明逻辑看起来没问题,一上生产就随机崩溃。后来接触了函数式编程,才意识到——很多问题不是逻辑错了,是状态变了。
这一章,我们就聊聊怎么用不可变数据来重构那些“到处改来改去”的代码。
什么是可变状态?它为什么危险?
先看一个典型的例子。假设我们有一个订单处理系统:
class OrderProcessor {
private double total = 0.0;
private List<String> logs = new ArrayList<>();
public void process(Order order) {
total += order.getAmount();
logs.add("Processed: " + order.getId());
// 其他逻辑...
}
public double getTotal() { return total; }
public List<String> getLogs() { return logs; }
}
这段代码有什么问题?
- total 和 logs 是可变字段。任何方法都能改它们。
- 多线程环境下,
total += order.getAmount()不是原子操作,会丢数据。 - 外部调用者拿到
logs后,也能随意修改列表内容。
我在项目中遇到过类似的情况。一个同事写的缓存模块,内部用HashMap存数据,结果外部代码直接调用了 cache.getMap().clear()。嗯,排查了整整两天。
不可变数据:让状态“只读”
不可变数据的核心思想很简单:对象一旦创建,就不能再改变。想改?那就创建一个新的副本。
重构后的版本:
class OrderProcessorImmutable {
private final double total;
private final List<String> logs;
// 构造时初始化
public OrderProcessorImmutable() {
this.total = 0.0;
this.logs = List.of(); // 不可变列表
}
private OrderProcessorImmutable(double total, List<String> logs) {
this.total = total;
this.logs = logs;
}
// 每次返回新对象
public OrderProcessorImmutable process(Order order) {
double newTotal = this.total + order.getAmount();
List<String> newLogs = new ArrayList<>(this.logs);
newLogs.add("Processed: " + order.getId());
return new OrderProcessorImmutable(newTotal, Collections.unmodifiableList(newLogs));
}
public double getTotal() { return total; }
public List<String> getLogs() { return logs; }
}
你看,total 和 logs 都加了 final。想更新状态?必须通过 process() 方法返回一个新对象。外部拿到的 logs 也是不可修改的。
核心原则:不要修改现有对象,而是创建新对象来表达状态变化。
用纯函数替代副作用
函数式编程里有个概念叫“纯函数”——同样的输入,永远返回同样的输出,不修改任何外部状态。
举个例子。传统写法:
class ShoppingCart {
private Map<String, Integer> items = new HashMap<>();
public void addItem(String item, int quantity) {
items.merge(item, quantity, Integer::sum);
}
public void removeItem(String item) {
items.remove(item);
}
}
改成纯函数风格:
class ShoppingCartImmutable {
private final Map<String, Integer> items;
public ShoppingCartImmutable() {
this.items = Map.of();
}
private ShoppingCartImmutable(Map<String, Integer> items) {
this.items = Collections.unmodifiableMap(new HashMap<>(items));
}
public ShoppingCartImmutable addItem(String item, int quantity) {
Map<String, Integer> newItems = new HashMap<>(this.items);
newItems.merge(item, quantity, Integer::sum);
return new ShoppingCartImmutable(newItems);
}
public ShoppingCartImmutable removeItem(String item) {
Map<String, Integer> newItems = new HashMap<>(this.items);
newItems.remove(item);
return new ShoppingCartImmutable(newItems);
}
public Map<String, Integer> getItems() {
return items;
}
}
你想想看,这样写有什么好处?
- 线程安全:没有共享可变状态,不需要加锁。
- 可测试:每个方法都是纯函数,输入输出明确。
- 可追溯:每次修改都产生新对象,可以保留历史版本。
我的习惯:在核心业务逻辑中,优先使用不可变对象。只在性能敏感的边界(比如高频循环)才考虑可变对象。
重构策略:从可变到不可变
实际项目中,不可能一夜之间把所有代码都改成不可变的。我建议分步走:
- 识别“热点”:找出那些被多处修改、容易出问题的状态变量。
- 封装可变性:先把可变字段设为 private,只通过方法修改。
- 引入不可变副本:在方法内部创建不可变副本,返回新对象。
- 逐步推广:从核心模块开始,慢慢扩展到整个系统。
举个例子。假设你有一个 User 类:
class User {
private String name;
private int age;
public void setName(String name) { this.name = name; }
public void setAge(int age) { this.age = age; }
}
第一步,去掉 setter,改用构造器或 Builder:
class User {
private final String name;
private final int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
// 只有 getter,没有 setter
public String getName() { return name; }
public int getAge() { return age; }
}
第二步,如果需要“修改”用户信息,返回新对象:
public User withName(String newName) {
return new User(newName, this.age);
}
public User withAge(int newAge) {
return new User(this.name, newAge);
}
我曾经踩过的坑:在不可变对象里用了 Date 类型。Date 本身是可变的,外部拿到引用后还能改。后来我全部换成了 LocalDate 或 Instant,才彻底解决问题。
不可变数据与集合操作
Java 8 引入的 Stream API 是函数式编程的好帮手。它天然支持不可变操作:
// 传统方式:修改列表
List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
// 后来想过滤掉长度小于3的名字
names.removeIf(name -> name.length() < 3);
// 函数式方式:不修改原列表
List<String> names = List.of("Alice", "Bob");
List<String> filtered = names.stream()
.filter(name -> name.length() >= 3)
.collect(Collectors.toUnmodifiableList());
你看,原列表 names 没有被修改,而是生成了一个新的 filtered 列表。这样既安全又清晰。
用 SVG 梳理核心逻辑
下面这张图展示了从可变状态到不可变数据的重构路径:
避坑指南
用不可变数据重构时,有几个常见的坑:
- 性能陷阱:频繁创建新对象确实有开销。但大多数业务场景下,这点开销远小于调试bug的时间。如果真遇到性能瓶颈,可以用
@Value注解或record来减少样板代码。 - 循环引用:不可变对象不能有循环引用,因为构造时无法互相依赖。解决办法是引入ID引用,或者用事件驱动的方式解耦。
- 遗留系统:老代码里到处都是 setter,不可能一次性改完。我的做法是“新代码用不可变,旧代码逐步迁移”。
一个小技巧:如果你用 Lombok,可以用 @With 注解自动生成 withXxx 方法。省去手写样板代码的麻烦。
总结
不可变数据不是银弹,但它能显著降低代码的复杂度。说白了,就是让状态变化变得可预测、可追踪。
我个人习惯在核心业务逻辑中优先使用不可变对象。虽然刚开始写起来有点别扭,但一旦习惯了,你会发现自己写的bug少了很多。
嗯,这一章就聊到这里。记住:能不变,就别变。