Java不可变数据的致命短板,被Optics一行代码彻底修复!


现代 Java 虽支持不可变数据,但嵌套更新仍极繁琐。光学(Optics)提供可组合访问路径,一行代码取代数十行重建,Higher-Kinded-J 为此量身打造原生 Java 实现。

Java 的“不可变鸿沟”:为什么 Records 需要光学(Optics)?

现代 Java 在拥抱不可变性方面已经走了很远。Records 让我们能用一行代码定义不可变的数据载体,模式匹配让我们优雅地解构嵌套结构,密封接口则确保类型层次的穷尽性。但即便如此,一个基本操作依然令人痛苦:如何高效更新嵌套在不可变结构深处的值?Scott Logic 博客最新文章《Java 的不可变鸿沟》直指这一痛点,并提出“光学”(Optics)作为解决方案——如果说模式匹配是读取嵌套数据的方式,那么光学就是写入它的方式。

作者 Magnus Smith 是英国知名金融科技咨询公司 Scott Logic 的资深工程师,长期专注于函数式编程、数据导向设计和现代 Java 架构演进。他在本文中系统剖析了 Java 当前在不可变数据更新上的结构性缺陷,并引入其团队开发的开源库 Higher-Kinded-J,旨在为 Java 带来真正原生、可组合、零运行时开销的光学支持,而非照搬 Haskell 或 Scala 的外来范式。



不可变性的承诺与现实的落差

Java 近年来的进化堪称惊艳。Records 彻底告别了样板代码,字段自动 final,编译器自动生成 equals、hashCode 和 toString,鼓励开发者采用函数式推崇的数据导向风格。配合从 Java 16 开始逐步落地的模式匹配,我们可以这样优雅地提取嵌套数据:

java
if (employee instanceof Employee(var id, var name, Address(var street, _, _))) {
    System.out.println(name + " lives on " + street);
}

再加上密封接口(sealed interface),还能写出编译器可验证的穷尽 switch 表达式:

java
sealed interface Shape permits Circle, Rectangle, Triangle {}

String describe(Shape shape) {
    return switch (shape) {
        case Circle(var r) -> "A circle with radius " + r;
        case Rectangle(var w, var h) -> "A " + w + " by " + h + " rectangle";
        case Triangle(var a, var b, var c) -> "A triangle";
    };
}

这确实很棒。Java 已成为一门可信的数据导向编程语言,核心正是不可变性。但问题来了:读取嵌套数据很优雅,写入却极其笨拙。

假设有一个公司模型:

java
public record Address(String street, String city, String postcode) {}
public record Employee(String id, String name, Address address) {}
public record Department(String name, Employee manager, List staff) {}
public record Company(String name, Address headquarters, List departments) {}

现在要更新“工程部经理的街道地址”。在可变世界里,一行代码搞定:

java
company.getDepartment("Engineering").getManager().getAddress().setStreet("100 New Street");

但在不可变世界,必须手动重建从根到叶子的每一层记录:

java
public static Company updateManagerStreet(Company company, String deptName, String newStreet) {
    List updatedDepts = new ArrayList<>();
    for (Department dept : company.departments()) {
        if (dept.name().equals(deptName)) {
            Employee manager = dept.manager();
            Address oldAddress = manager.address();
            Address newAddress = new Address(newStreet, oldAddress.city(), oldAddress.postcode());
            Employee newManager = new Employee(manager.id(), manager.name(), newAddress);
            Department newDept = new Department(dept.name(), newManager, dept.staff());
            updatedDepts.add(newDept);
        } else {
            updatedDepts.add(dept);
        }
    }
    return new Company(company.name(), company.headquarters(), List.copyOf(updatedDepts));
}

整整25行代码,只为改一个字符串!这就是“复制构造器级联”反模式——每层都得手动复制所有未变字段,极易出错,且随着嵌套加深,代码迅速膨胀。开发者面对这种痛苦,往往退而求其次,放弃不可变性:“干脆把字段设成非 final 吧,简单!”但可变性会带来线程安全、防御性拷贝、对象被意外修改等新问题。于是,现代 Java 的不可变承诺,只兑现了一半。



模式匹配只解决了一半问题

关键洞察在于:模式匹配解决了“读”,但对“写”毫无帮助。读取时,我们可以用模式匹配钻进多层结构,忽略无关字段,精准提取所需值;但写入时,却只能回到原始的命令式重建流程。Java 甚至没有“模式设置”这样的语法。

有人可能说:“给每个 record 加个 withX 方法不就行了?”比如:

java
public record Address(String street, String city, String postcode) {
    public Address withStreet(String street) {
        return new Address(street, this.city, this.postcode);
    }
}

这确实有点用,但无法组合。你仍需逐层传递更新:

java
var newAddress = manager.address().withStreet("100 New Street");
var newManager = manager.withAddress(newAddress);
var newDept = dept.withManager(newManager);
// ...继续向上

仪式感仍在,样板代码未减,出错风险随嵌套增加而上升。

值得肯定的是,Java 正在改进。JEP 468(JDK 25 预览特性)引入了“派生记录创建”(derived record creation),允许这样写:

java
Address updated = oldAddress with { street = "100 New Street"; };

这比手写构造器简洁多了。但它只解决单层更新,不支持嵌套路径:

java
employee with { address.street = "100 New Street" } // 不支持!

你仍需链式调用:

java
Employee updated = employee with {
    address = address with { street = "100 New Street"; };
};

虽然比25行好,但嵌套一深(比如公司→部门→员工→地址→邮编),链式 with 依然繁琐。JEP 468 解决的是语法糖问题,而非可组合性问题。而光学提供的,是可复用、可组合的访问路径——定义一次,处处可用。



光学:一种全新的心智模型

光学(Optics)是一种将数据结构中的访问路径“具体化”(reified)的抽象。你可以把它理解为“对象版的 XPath”——但它是类型安全的、支持读写、且可通过标准函数组合。

最简单的光学是 Lens(透镜)。Lens 聚焦于一个必定存在且唯一的值。例如,Employee 一定有 Address,Address 一定有 street。Lens 提供两个操作:获取(get)和设置(set),并可衍生出修改(modify)。

概念上,Lens 可表示为:

java
public record Lens(
    Function get,
    BiFunction set
) {
    public S modify(Function f, S whole) {
        return set.apply(f.apply(get.apply(whole)), whole);
    }
}

魔法在于组合。

通过 andThen 方法,可以将两个 Lens 合并:


public <B> Lens<S, B> andThen(Lens<A, B> other) {
    return Lens.of(
        s -> other.get(this.get(s)),
        (b, s) -> this.set(other.set(b, this.get(s)), s)
    );
}

于是,Employee → Address 的 Lens 与 Address → street 的 Lens 组合后,就得到 Employee → street 的 Lens。这个组合后的 Lens 自动处理中间层的重建,彻底消除手动级联。

光学家族不止 Lens。根据聚焦目标的数量和确定性,可分为:

- Iso:完全可逆的一对一转换,如摄氏与华氏。
- Lens:必定存在的一对一关系(has-a),如 record 字段。
- Prism:针对代数数据类型的某一分支(is-a),如 sealed interface 的某个子类。它可从父类型“匹配”出子类型(可能失败),也可从子类型“构建”出父类型(必定成功)。
- Affine:零或一个目标,但不能反向构建。适用于 Optional 字段、Map 查找等。
- Traversal:零到多个目标,用于集合批量操作。

这些光学构成一个层级:Iso 最具体,Traversal 最通用。组合时,结果取两者中“能力较弱”的那个。例如 Lens + Prism = Affine,因为 Prism 可能匹配失败;任何光学 + Traversal = Traversal,因为一旦涉及多个目标,就无法退回到单一目标。

Affine 与 Prism 的区别很微妙:Prism 能从部分构建整体(如 Circle → Shape),而 Affine 不能(如 Map.get(key) 不能反推 Map)。实践中,当你用 Lens 穿过 Prism(如从 Shape 获取 Circle 的 radius),结果就是 Affine。



实战:一行代码取代25行

回到最初的问题。用光学,25行代码可压缩为:

java
private static final Lens employeeStreet =
    Employee.Lenses.address().andThen(Address.Lenses.street());

private static final Lens managerStreet =
    Department.Lenses.manager().andThen(employeeStreet);

public static Department updateManagerStreet(Department dept, String newStreet) {
    return managerStreet.set(newStreet, dept);
}

路径定义一次,随处复用。中间重建全自动。

再比如,给部门所有员工加薪10%。手动写需嵌套循环和小心重建;用光学:

java
private static final Traversal allSalaries =
    Department.Lenses.staff().andThen(Traversals.list())
        .andThen(Employee.Lenses.salary());

public static Department giveEveryoneARaise(Department dept) {
    return allSalaries.modify(salary -> salary.multiply(new BigDecimal("1.10")), dept);
}

一行表达式,无循环,无手动重建。Traversal 处理集合,Lens 处理路径,一切自动完成。



手把手:构建一个简单 Lens

从零开始实现 Lens 很直观:


public record Lens<S, A>(
    Function<S, A> get,
    BiFunction<A, S, S> set
) {
    public static <S, A> Lens<S, A> of(Function<S, A> getter, BiFunction<A, S, S> setter) {
        return new Lens<>(getter, setter);
    }

    public A get(S whole) { return get.apply(whole); }
    public S set(A newValue, S whole) { return set.apply(newValue, whole); }
    public S modify(Function<A, A> f, S whole) { return set(f.apply(get(whole)), whole); }

    public <B> Lens<S, B> andThen(Lens<A, B> other) {
        return Lens.of(
            s -> other.get(this.get(s)),
            (b, s) -> this.set(other.set(b, this.get(s)), s)
        );
    }
}


然后为 Address 定义 Lens:

java
public record Address(String street, String city, String postcode) {
    public static final class Lenses {
        public static Lens street() {
            return Lens.of(
                Address::street,
                (newStreet, addr) -> new Address(newStreet, addr.city(), addr.postcode())
            );
        }
    }
}

模式机械:getter 是 record 访问器,setter 创建新 record 并替换一个字段。实际使用 Higher-Kinded-J 时,@GenerateLenses 注解可自动生成这些,零样板。

组合后使用:

java
Lens employeeStreet =
    Employee.Lenses.address().andThen(Address.Lenses.street());

String street = employeeStreet.get(employee);
Employee updated = employeeStreet.set("100 New Street", employee);
Employee uppercased = employeeStreet.modify(String::toUpperCase, employee);

深度更新,从此变浅。



Higher Kinded J:为 Java 量身打造的光学库

Higher-Kinded-J 不是 Haskell lens 库的 Java 移植,而是“Java 优先”的函数式库。它充分利用现代 Java 特性(records、sealed interfaces、模式匹配、注解处理器),让光学在 Java 中感觉原生。

其核心优势包括:

- 生产级光学实现:完整支持 Lens、Prism、Affine、Traversal、Iso,符合光学定律,保证正确性。
- 注解驱动生成:@GenerateLenses、@GeneratePrisms 等注解自动生成光学实例,彻底消除样板。
- Focus DSL:提供流畅 API(如 FocusPath)进行导航,无需显式组合。
- 零运行时开销:所有抽象在编译期完成,无反射、无动态代理。

更重要的是,Higher-Kinded-J 后续还将统一“光学”与“效应”(Effects),提供 MaybePath、EitherPath 等铁路式错误处理,让数据导航与计算逻辑无缝衔接。

开发者无需理解高阶类型(higher-kinded types)即可高效使用。API 直观:用 andThen 组合 Lens,用 Focus 路径导航,用 Effect 路径处理错误。底层类型机制完全透明。



未来展望:从理论到实践

本文只是系列第一篇。后续文章将深入:

- Lens 定律及其对正确性的保障
- Prism 如何处理密封接口与代数数据类型
- Affine 处理可选值
- Traversal 批量操作集合
- 如何用注解自动生成光学
- 从第三部分起,将构建一个表达式语言解释器,展示光学在 AST 操作、树变换中的强大能力

当这套工具掌握后,你将再也不想手动更新嵌套数据。



总结:不可变性的最后一块拼图

现代 Java 用 Records、模式匹配、密封接口搭建了不可变数据的殿堂,却在“写入”环节留下巨大缺口。开发者要么忍受冗长的重建代码,要么放弃不可变性。光学正是填补这一缺口的关键技术——它提供可组合、可复用、类型安全的访问路径,让深度更新如浅层操作般简单。

Higher-Kinded-J 的出现,标志着 Java 函数式生态的成熟。它不照搬其他语言,而是扎根于 Java 本身的演进,让光学真正“属于 Java”。对于追求代码简洁、安全、可维护的开发者而言,这无疑是不可错过的新范式。



作者背景:Magnus Smith 是英国金融科技咨询公司 Scott Logic 的高级软件工程师,专注于现代 Java 架构、函数式编程与数据导向设计,长期推动 Java 社区对不可变性与组合式抽象的实践。