Records, pattern matching и sealed-типы сделали Java куда более «функциональной» — читать вложенные данные стало легко и красиво. Но как только вам нужно изменить что-то глубоко внутри неизменяемой структуры, начинается боль: каскад пересоздания records, копирование полей, десятки строк ради одного изменения.

В новом переводе от команды Spring АйО рассмотрим, почему у современной Java всё ещё есть пробел в истории неизменяемости — и как оптики закрывают его. Если pattern matching — это про элегантное чтение, то оптики дают то, чего так не хватает, — композиционную запись: определили путь один раз и дальше меняете вложенные поля одной строкой, без ручной реконструкции и без циклов.

Комментарий от Михаила Поливаха

Друзья, хочу заметить пару моментов. Во-первых, проблема в статье актуальная, она есть, с этим спорить тяжело. Тем не менее, имейте в виду, что когда в Haskell и в других функциональных ЯП рождались линзы, призмы и т.д. - там это имело веские под собой основания.

В ФП мы чаще всего оперируем по-умолчанию неизменяемыми конструкциями, которые могут и часто имеют вложенную структуру. Там линзы, призмы и т.д. - это всё является естественным порождением экосистемы вокруг языка (см. F#, Haskell, Scala и т.д.).

В мире же ООП immutable структуры (условно records в Java) это больше исключение, чем правило. Мы не создаем record by default, record это больше исключение, чем правило. То есть со одной стороны это проблема далеко не такая острая, и с другой - в Java обвязки вокруг кода для создания самих оптик будет больше, чем кажется на первый взгляд.

На деле, за историю Java было довольно много попыток написать библиотеки для оптик, но, как правило, все эти решения долго не приживались. Опять же, тут выводы делайте сами.

В общем, в ЯП работающих в парадигме ООП с линзами не всё так просто и очевидно.

Часть 1 серии «Функциональные оптики для современной Java»

Современная Java сделала многое, чтобы поддержать неизменяемость. Records дают нам лаконичные, неизменяемые контейнеры данных. Pattern matching позволяет изящно деструктурировать вложенные структуры. Sealed-интерфейсы обеспечивают исчерпывающие иерархии типов. И всё же, несмотря на эти улучшения, одна базовая операция по-прежнему оказывается неожиданно болезненной: обновление значения глубоко внутри неизменяемой структуры.

Эта статья знакомит с оптиками — семейством композиционных абстракций, которые дополняют историю неизменяемости. Если pattern matching — это то, как мы читаем вложенные данные, то оптики — это то, как мы их записываем.

Знакомьтесь: Higher-Kinded-J

На протяжении всей серии мы используем Higher-Kinded-J — библиотеку, объединяющую две мощные парадигмы:

  • Оптики для навигации и модификации неизменяемых структур данных

  • Эффекты для вычислений, которые могут завершиться неудачей, накапливать ошибки или требовать отложенного выполнения

Первая половина серии сосредоточена на оптиках: линзах, призмах и обходах (traversals). Во второй половине мы представим API Effect Path и покажем, как навигация и вычисления работают вместе. К завершению серии у вас будет полный набор инструментов для программирования, ориентированного на данные, в Java.

Философское замечание: многие функциональные библиотеки для Java — это порты библиотек Haskell или Scala, которые приносят чуждые идиомы и в Java-коде выглядят неуклюже. Higher-Kinded-J выбирает другой подход: прежде всего Java. Мы заимствуем удачные идеи из других языков, но это функциональная библиотека для Java, спроектированная так, чтобы использовать преимущества современной Java: records, sealed-интерфейсы, pattern matching и обработку аннотаций. Higher-Kinded-J — не имитация; это функциональное программирование, которое ощущается естественным для Java.

Обещание современной Java

Эволюция Java за последние несколько лет впечатляет. Благодаря records мы можем определить неизменяемые типы данных в одну строку:

public record Address(String street, String city, String postcode) {}

Больше никакого бойлерплейта. Больше никаких изменяемых полей, о которых нужно беспокоиться. Компилятор сам генерирует для нас equals(), hashCode() и toString(). Records — final, их поля — final, и они поощряют стиль программирования, ориентированный на данные, который функциональные программисты давно продвигают.

Pattern mathcing, которое поэтапно вводилось начиная с Java 16, позволяет элегантно деструктурировать эти records:

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

Мы можем «заглядывать» во вложенные структуры, извлекать всё необходимое и привязывать значения к переменным в рамках одного выражения. В сочетании с sealed-интерфейсами мы получаем исчерпывающие switch-expressions, которые компилятор способен проверить:

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 стала языком, подходящим для программирования, ориентированного на данные, где неизменяемость находится в центре.

Но есть проблема.

Проблема вложенного обновления

Читать вложенные неизменяемые данные — элегантно. Записывать в них — нет.

Рассмотрим простую доменную модель компании:

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) {}

Четыре прямолинейных record. Ничего сложного. Теперь допустим, нам нужно обновить название улицы в адресе менеджера департамента Engineering. В изменяемом мире это было бы тривиально:

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

Одна строка — и готово. Но наши records неизменяемы: сеттеров нет. Вместо этого мы должны заново собрать каждый record по пути от корня к листу:

public static Company updateManagerStreet(Company company, String deptName, String newStreet) {
    List<Department> updatedDepts = new ArrayList<>();

    for (Department dept : company.departments()) {
        if (dept.name().equals(deptName)) {
            Employee manager = dept.manager();
            Address oldAddress = manager.address();

            // Rebuild address with new street
            Address newAddress = new Address(
                newStreet,
                oldAddress.city(),
                oldAddress.postcode()
            );

            // Rebuild employee with new address
            Employee newManager = new Employee(
                manager.id(),
                manager.name(),
                newAddress
            );

            // Rebuild department with new manager
            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)
    );
}

Двадцать пять строк кода, чтобы изменить одну строку. Каждый record на пути приходится вручную пересоздавать, копируя все неизменившиеся поля. Это каскад копирующих конструкторов — анти-паттерн, который отравляет неизменяемые кодовые базы.

Не отчаивайтесь. К концу этой статьи вы увидите, как эту же операцию можно сократить до одной строки. Но сначала разберёмся, почему более простые подходы не срабатывают.

Можно подумать: «Просто добавим методы withX() в каждый record». Действительно, можно:

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

Это немного помогает, но не даёт кумулятивного эффекта. Всё равно нужно протаскивать обновлённое значение обратно через каждый слой:

var newAddress = manager.address().withStreet("100 New Street");
var newManager = manager.withAddress(newAddress);
var newDept = dept.withManager(newManager);
// ... and so on

Церемонии никуда не исчезают. Бойлерплейт как был так и остался. А вероятность ошибки (случайно скопировать не то поле, забыть обновить промежуточный слой) растёт с каждым уровнем вложенности.

Pattern matching: только половина решения

Вот ключевая мысль, которая подтолкнула к этой статье: pattern matching решает задачу чтения вложенных данных, но никак не помогает с записью.

Посмотрим на асимметрию. Чтобы прочитать улицу в адресе сотрудника, мы можем написать:

if (employee instanceof Employee(_, , Address(var street, , _))) {
  return street;
}

Pattern matching позволяет «пробуриться» через уровни, игнорируя поля, которые нас не интересуют, и извлечь ровно то, что нужно. Это декларативно, композиционно и элегантно.

А вот чтобы записать новый street? Мы снова возвращаемся к императивному каскаду копирующих конструкторов. В Java нет «установки полей по образцу». Мы не можем написать так:

employee with { address.street = "100 New Street" } // Вложенные обновления: не поддерживается

Примечание о JEP 468: производное создание records

Java и здесь движется вперёд. JEP 468 вводит производное создание records — выражение with для records. Сейчас это preview (JDK 25) и оно позволяет писать так:

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

Для начала очень неплохо. Вместо того чтобы вручную копировать каждое поле, вы указываете только те, что меняются. Остальное берёт на себя компилятор.

Однако JEP 468 решает обновления на одном уровне, а не вложенные. Нельзя написать:

employee with { address.street = "100 New Street" } // Не поддерживается JEP 468

Чтобы обновить вложенное поле, нужно цепочкой применять with-выражения на каждом уровне:

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

Это, безусловно, лучше, чем полный каскад копирующих конструкторов. Но обновления всё равно приходится вручную «протягивать» через каждый слой. Церемоний становится поменьше, но они не пропадают до конца. По мере роста вложенности (компания, содержащая департаменты, содержащие сотрудников, содержащих адреса) даже цепочки with-выражений становятся громоздкими.

JEP 468 — желанное дополнение, но оно решает вопрос синтаксиса, а не композиции. Оптики дают нечто принципиально иное: переиспользуемые, комбинируемые пути доступа, которые можно определить один раз и применять где угодно.

Более широкий контекст


Другие языки уже распознали этот разрыв. В Haskell есть линзы (lenses). В Scala — Monocle. В F# — выражения доступа к свойствам. В C# — with-выражения для records (похожие на JEP 468). Отличительная черта оптик — композиция: способность объединять небольшие, узко сфокусированные аксессоры в более крупные, которые работают с произвольной глубиной вложенности.

Эта асимметрия не просто неудобна — она активно отбивает желание придерживаться неизменяемости. Разработчики, сталкиваясь с каскадом копирующих конструкторов, нередко выбирают mutability. «Просто сделаем поля не final, — говорят они. — Так проще». И в краткосрочной перспективе это действительно проще. Но mutability приносит собственные проблемы: трудности с потокобезопасностью, необходимость защитного копирования, и жуткое «действие на расстоянии», когда объект, который, как вам казалось, вы контролируете, внезапно изменяется кодом, над которым у вас нет власти.

Обещание современной Java (чистый, неизменяемый, ориентированный на данные код) остаётся выполненным лишь наполовину. Pattern matching дал нам элегантное чтение. Теперь нужна элегантная запись.

Pattern matching — половина головоломки; оптики завершают её.

Оптики: новая ментальная модель

Оптика – это представление пути доступа внутрь структуры данных как объекта первого класса. Думайте о ней как о материализованной паре getter-и-setter, которую можно компоновать, хранить и передавать дальше.

Ключевой инсайт в том, что эти пути доступа комбинируются. Если у вас есть способ сфокусироваться на адресе сотрудника и способ сфокусироваться на улице в адресе, вы можете объединить их и сфокусироваться на улице сотрудника. Эта композиция — основная фича оптик.

Представьте аналогию: XPath для объектов. В XPath вы могли бы написать /company/departments/manager/address/street, чтобы дойти до конкретного элемента. Оптики дают похожую навигацию, но при этом:

  • они типобезопасны: компилятор гарантирует, что путь корректен;

  • они поддерживают и чтение, и запись;

  • они компонуются с помощью стандартной композиции функций.

Самая простая оптика — линза (lens). Линза фокусируется ровно на одном значении внутри более крупной структуры. Имея линзу от Employee к Address, вы можете:

  • получить адрес из любого сотрудника;

  • установить новый адрес, вернув нового сотрудника, где всё остальное неизменно;

  • модифицировать адрес функцией, вернув нового сотрудника.

Концептуально линза выглядит так:

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

Две функции: одна извлекает, другая заменяет. Метод modify объединяет их: извлекает значение, преобразует его и возвращает обратно.

Магия начинается, когда вы компонуете линзы:

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 и линзу от Address к String (улица), andThen порождает линзу от Employee к String. Скомпонованная линза автоматически выполняет промежуточную реконструкцию, устраняя создание каскада копирующих конструкторов вручную.

У оптик богатая история. Они вышли из сообщества Haskell в начале 2010-х, а библиотека lens Эдварда Кметта стала фактической эталонной реализацией. Идеи распространились в Scala (Monocle), PureScript и другие функциональные языки. Теоретические основы связаны с теорией категорий, хотя для практического использования оптик теория не обязательна.

Для Java-разработчиков практический вывод прост: оптики позволяют работать с глубоко вложенными обновлениями в неизменяемых структурах как с простыми операциями, которые можно комбинировать. Тот самый метод на двадцать пять строк превращается в одно выражение.

Комментарий от Михаила Поливаха

Не обольщайтесь, на java вот прямо одной строчкой не получится. Вам всё равно придется писать код для создания самой Lens, писать логику пропогирования нового значения (функция set в конструкторе) и т.д.

Здесь важно другое - важно то, что если Вам это приходится делать много, то тогда линзы они действительно имеют место быть, т.к. вы просто эту логику обособляете и покрываете тестами, а потом переиспользуете линзу. В случае однократного использования - подумайте 200 раз.

Семейство оптик

Линзы — лишь один представитель семейства оптик. Каждый тип отвечает за свой вид фокуса — и этот термин стоит разобрать.

Когда мы говорим, что оптика «фокусируется» на значении, мы имеем в виду, что она задаёт способ «приблизить» это значение внутри б��лее крупной структуры. При этом фокус может быть:

  • Гарантированным (всегда ровно одна цель)

  • Условным (ноль или одна цель, в зависимости от данных)

  • Множественным (от нуля до многих целей)

Разные типы оптик кодируют эти гарантии по-разному. Понять, какую оптику выбрать, — значит ответить на вопрос: «Сколько значений может затрагивать этот путь и гарантировано ли, что фокусировка всегда пройдёт удачно?»

Lens: фокус на ровно одном (Has-A)


Линза фокусируется ровно на одном значении, которое гарантированно существует. Это оптика для отношений «Has-A»:

Employee имеет Address
Address имеет street
Department имеет manager

Линзы всегда делают успешную фокусировку: вы всегда можете получить сфокусированное значение и всегда можете установить новое.

Prism: фокус на одном варианте (Is-A)

Призма фокусируется на одном варианте суммарного типа. Это оптика для отношений «является»:

sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
Комментарий от Михаила Поливаха

Речь про "sum type", он же discriminated union, он же "Жорик" (шутка).

https://ru.wikipedia.org/wiki/%D0%A2%D0%B8%D0%BF-%D1%81%D1%83%D0%BC%D0%BC%D0%B0

Призма для Circle предоставляет две операции:

  • Match: извлечь Circle из Shape, если это он (возвращая Optional)

  • Build: сконструировать Shape из Circle (всегда успешно)

Ключевая мысль в том, что призмы — это частичные изоморфизмы. В одном направлении они всегда работают (build), а в другом могут не сработать (match). Не всякий Shape — это Circle, поэтому сопоставление может провалиться. Но любой Circle — это Shape, значит построение всегда возможно.

Эта асимметрия и отличает призмы. Они идеально подходят для sealed-интерфейсов и enum, когда нужно сфокусироваться на конкретном варианте.

Traversal: фокус на многих (Has-Many)

Обход (traversal) фокусируется на нуле или большем числе значений одновременно. Это оптика для коллекций:

Department содержит много Employee в списке staff
Company содержит много Department
Обходы позволяют модифицировать все сфокусированные значения разом:

// Дать прибавку всем сотрудникам отдела
Traversal<Department, BigDecimal> allSalaries = ...;
Department updated = allSalaries.modify(s -> s.multiply(RAISE_FACTOR), dept);

Зарплата каждого сотрудника обновляется. Обход выполняет итерацию внутри себя.

Иерархия оптик

Оптики образуют иерархию в зависимости от «силы» фокусировки. Диаграмма ниже показывает, как они связаны. Читать её нужно снизу вверх: более специфичные оптики (внизу) всегда можно использовать там, где ожидаются более общие (вверху).

Диаграмма
Диаграмма

Как читать диаграмму:

  • Iso (внизу): самая специфичная оптика. Она фокусируется ровно на одном значении и умеет преобразовывать туда и обратно без потерь. Можно думать о ней как об обратимом преобразовании — например, конвертации между градусами Цельсия и Фаренгейта или между record и его представлением в виде кортежа.

  • Lens и Prism (следующий уровень): обе фокусируются максимум на одном значении, но по-разному. Lens всегда работает (поле существует); Prism может не сработать (вариант может не совпасть). Они сходятся на Iso, потому что Iso умеет и то и другое: она всегда срабатывает успешно и всегда имеет обратное преобразование.

  • Affine (середина): объединяет «может не существовать» из Prism с «нет построения» из Lens. Affine фокусируется на нуле или одном значении, не гарантируя ни наличие, ни успешность. Используйте её для Optional-полей, поиска в java.util.Map или любого пути, который может не разрешиться.

  • Fold (ветка только чтения): как Traversal, но только для чтения. Полезно, когда нужно извлечь или агрегировать значения без модификации.

  • Traversal (вверху): самая общая оптика. Она может фокусироваться на любом количестве значений (ноль, одно или много). Любую другую оптику можно использовать как Traversal.

Как работает композиция

Когда вы компонуете две оптики, результат — это «наименее мощная» оптика, которая способна выразить обе:

  • Lens + Lens = Lens: обе всегда фокусируются ровно на одном значении

  • Lens + Prism = Affine: призма может не совпасть, поэтому результат может фокусироваться на нуле или одном значении

  • Anything + Traversal = Traversal: как только появляются множественные потенциальные цели, вы остаётесь на этом уровне

Интуиция проста: композиция оптик, которые могут не сфокусироваться, даёт оптику, отражающую эту неопределённость. Линза "пропущенная" через призму становится Affine, потому что призма может дать совпадения.

Affine vs Prism: тонкое различие

И Affine, и Prism фокусируются на нуле или одном значении — так в чём же разница?

Prism: умеет конструировать целое из части. Призма Circle может построить Shape из Circle. Призмы предназначены для типов-сумм, где часть сама по себе является валидным целым.

Affine: не умеет конструировать, только предоставляет доступ. Поиск ключа в map может не дать результата, но вы не можете «построить» map из одного значения. Affine подходит для опционального доступа без возможности построения.

Практическое различие:

  • Используйте Prism для вариантов sealed interface, случаев с enum или любых отношений «is-a», где может понадобиться сконструировать родительский тип.

  • Используйте Affine для optional-полей, поиска в map, индексации списка или путей, проходящих через призму, за которой следует линза.

Когда вы компонуете Lens с Prism, результатом становится Affine. Вы теряете способность Prism конструировать (Lens не знает как), но сохраняете семантику «может не существовать».

Higher-Kinded-J предоставляет полноценную поддержку Affine, завершая иерархию оптик.

Когда что использовать

Оптика

Цели

Можно сконструировать?

Сценарий использования

Iso

Ровно 1, обратимо

Да (в обе стороны)

Преобразования без потерь, обёртки newtype

Lens

Ровно 1

Нет

Поля записей, гарантированная связь «has-a»

Prism

0 или 1

Да (в одну сторону)

Варианты sealed-интерфейсов, случаи enum

Affine

0 или 1

Нет

Необязательные поля, поиск по map

Traversal

от 0 до многих

Нет

Коллекции, массовые операции

На практике вы будете свободно их компоновать. Навигация к «зарплате каждого сотрудника на полной ставке в компании» требует traversal (для списка департаментов), ещё одного traversal (для списка сотрудников), prism (для сотрудников на полной ставке) и lens (для зарплаты). Доступ к опциональному значению конфигурации использует Affine. Преобразование между record и кортежем его полей — Iso.

Выигрыш: оптики за 60 секунд

Прежде чем углубляться в теорию, посмотрим на результат. Вот метод на двадцать пять строк из примера выше:

// Ручной подход: ~25 строк
public static Company updateManagerStreet(Company company, String deptName, String newStreet) {
    List<Department> 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));
}

А вот та же операция с оптиками:

// Подход с оптикой: 1 строка
private static final Lens<Employee, String> employeeStreet =
    Employee.Lenses.address().andThen(Address.Lenses.street());

private static final Lens<Department, String> managerStreet =
    Department.Lenses.manager().andThen(employeeStreet);

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

Определите путь один раз. Используйте где угодно. Композиция линз автоматически выполняет всю промежуточную реконструкцию.

Нужно поднять всем сотрудникам отдела зарплату на 10%? Вручную пришлось бы писать вложенные циклы и тщательно пересобирать структуры. С оптиками — так:

// Определите путь ко всем зарплатам один раз
private static final Traversal<Department, BigDecimal> 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 берёт на себя коллекцию, линзы — путь. Каждый сотрудник получает прибавку, а каждый промежуточный record корректно пересобирается.

Если это вас зацепило — читайте дальше.

Первое знакомство: простая линза

Давайте соберём рабочую линзу с нуля. Начнём с базовой абстракции:

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

Теперь мож��о определить линзы для наших records:

public record Address(String street, String city, String postcode) {

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

Шаблон здесь механический: getter — это аксессор record, setter создаёт новый record с изменённым одним полем. В продакшен-коде с Higher-Kinded-J аннотация @GenerateLenses генерирует это автоматически.

Композиция — вот где начинается магия:

Lens<Employee, String> employeeStreet =
    Employee.Lenses.address().andThen(Address.Lenses.street());

// Получить street
String street = employeeStreet.get(employee);

// Установить новый street (вернуть новый Employee)
Employee updated = employeeStreet.set("100 New Street", employee);

// Изменить street (вернуть новый Employee)
Employee uppercased = employeeStreet.modify(String::toUpperCase, employee);

Одна составная линза заменяет то, что иначе потребовало бы ручной реконструкции на нескольких уровнях. Глубокие обновления превращаются в «плоские» выражения.

Комментарий от Ильи Сазонова

Я просто хотел, чтобы можно было легко поменять значение какого-то поля в неизменяемой структуре! Может быть нескольких.

У нас есть лучше - сказал мне автор. Мы можем помочь тебе пройти траверсом по массиву структур массивов данных и написать код для их изменения!
Только сделай пару новых классов и потом комбинируй объекты с помощью нашего API. 

А я ведь просто хотел удобный способ создать из существующей структуры другую, в которой изменилось значение одного поля!

Я чего-то начал хорошо понимать авторов JEP-468. Мало, зато просто, понятно и можно взять и сразу писать код.

Что дальше


В этой статье мы обозначили проблему (разрыв в неизменяемости) и наметили решение (оптики). Мы увидели:

  • почему вложенные обновления неизменяемых данных в Java болезненны;

  • как pattern matching решает чтение, но не запись;

  • семейство оптик: iso, lens, prism, affine, traversal;

  • быстрый выигрыш, показывающий драматическое сокращение кода.

Что даёт Higher-Kinded-J


Как было сказано в начале, Higher-Kinded-J объединяет оптики и эффекты. В части оптик библиотека предоставляет:

  • оптики продакшен-уровня: Lens, Prism, Affine, Traversal, Iso и другие — с корректной композицией и законами;

  • генерацию на основе аннотаций: @GenerateLenses, @GeneratePrisms и @GenerateFocus устраняют бойлерплейт;

  • Focus DSL: fluent API для навигации без явной композиции;

  • нулевые накладные расходы в рантайме: вся абстракция «происходит» на этапе компиляции.

Для эффектов (начиная с части 5):

  • API Effect Path: MaybePath, EitherPath, ValidationPath, TryPath, IOPath;

  • «железнодорожную» обработку ошибок: явные ветки успеха/провала с композицией;

  • bridge-методы: бесшовное соединение путей Focus с путями Effect.

Библиотека закрывает заметную дыру в экосистеме Java. Если в Scala есть Monocle, а в Haskell — библиотека lens, то в Java долгое время не хватало зрелой, идиоматичной реализации. Higher-Kinded-J переносит эти паттерны в Java, не жертвуя типобезопасностью.

Чтобы эффективно пользоваться библиотекой, не нужно понимать типы высших порядков. API интуитивны: линзы компонуются через andThen, навигация делается через Focus-пути, ошибки обрабатываются через Effect-пути. Вся «типовая магия» остаётся за кадром и не мешает.

Дорога впереди

В следующий раз, во второй части, мы глубже разберём основы оптик:

  • законы линз и почему они важны для корректности;

  • призмы для типов-сумм и sealed-интерфейсов;

  • affine для опциональных значений;

  • traversal для коллекций и массовых операций;

  • настройку Higher-Kinded-J для генерации линз через аннотации.

Начиная с части 3 мы будем строить интерпретатор языка выражений — каноническую «витрину» для оптик, показывающую, как эти абстракции раскрываются при работе с AST, преобразованиями деревьев и эффектными операциями.

К концу серии вам уже не захочется вручную обновлять вложенные данные.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.