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; }
}

这段代码有什么问题?

  • totallogs 是可变字段。任何方法都能改它们。
  • 多线程环境下,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; }
}

你看,totallogs 都加了 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;
    }
}

你想想看,这样写有什么好处?

  • 线程安全:没有共享可变状态,不需要加锁。
  • 可测试:每个方法都是纯函数,输入输出明确。
  • 可追溯:每次修改都产生新对象,可以保留历史版本。

我的习惯:在核心业务逻辑中,优先使用不可变对象。只在性能敏感的边界(比如高频循环)才考虑可变对象。

重构策略:从可变到不可变

实际项目中,不可能一夜之间把所有代码都改成不可变的。我建议分步走:

  1. 识别“热点”:找出那些被多处修改、容易出问题的状态变量。
  2. 封装可变性:先把可变字段设为 private,只通过方法修改。
  3. 引入不可变副本:在方法内部创建不可变副本,返回新对象。
  4. 逐步推广:从核心模块开始,慢慢扩展到整个系统。

举个例子。假设你有一个 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 本身是可变的,外部拿到引用后还能改。后来我全部换成了 LocalDateInstant,才彻底解决问题。

不可变数据与集合操作

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 梳理核心逻辑

下面这张图展示了从可变状态到不可变数据的重构路径:

不可变数据重构路径 可变状态(问题) • 多线程不安全 • 副作用难追踪 • 测试困难 重构 重构策略 1. 识别热点状态 2. 封装可变性 3. 引入不可变副本 产出 不可变数据 • 线程安全 • 纯函数 • 可追溯 关键技巧 • 使用 final 关键字声明字段 • 集合操作使用 Stream + 不可变收集器 • 用 withXxx 方法替代 setter • 避免使用可变类型(如 Date) • 返回不可变视图(unmodifiableList) • 优先使用 record / 值对象

避坑指南

用不可变数据重构时,有几个常见的坑:

  • 性能陷阱:频繁创建新对象确实有开销。但大多数业务场景下,这点开销远小于调试bug的时间。如果真遇到性能瓶颈,可以用 @Value 注解或 record 来减少样板代码。
  • 循环引用:不可变对象不能有循环引用,因为构造时无法互相依赖。解决办法是引入ID引用,或者用事件驱动的方式解耦。
  • 遗留系统:老代码里到处都是 setter,不可能一次性改完。我的做法是“新代码用不可变,旧代码逐步迁移”。

一个小技巧:如果你用 Lombok,可以用 @With 注解自动生成 withXxx 方法。省去手写样板代码的麻烦。

总结

不可变数据不是银弹,但它能显著降低代码的复杂度。说白了,就是让状态变化变得可预测、可追踪。

我个人习惯在核心业务逻辑中优先使用不可变对象。虽然刚开始写起来有点别扭,但一旦习惯了,你会发现自己写的bug少了很多。

嗯,这一章就聊到这里。记住:能不变,就别变


公众号:蓝海资料掘金营,微信deep3321