Обработка исключений в Java с использованием сопоставления с образцом (pattern matching)

от автора

Обработка исключений в Java с использованием сопоставления с образцом (pattern matching).

Данная статья является логическим продолжением статей

В данной статье рассмотрим способы обработки исключений Java при помощи pattern-matching, как это делается в других FP языках.

Пример на других языках

В функциональных языках программирования существуют удобные средства для работы с исключениями. В Kotlin и Rust это класс Result, в Scala и Haskell — Try. Обработка успешного результата или ошибки может производится при помощи pattern-matching как на примерах ниже.

Scala

val result = divideWithTry(10, 0) match {   case Success(i) => i   case Failure(DivideByZero()) => None }

Rust

    let greeting_file_result = File::open("hello.txt");     let greeting_file = match greeting_file_result {         Ok(file) => file,         Err(error) => panic!("Problem opening the file: {:?}", error),     };

Использование pattern-matching является естественным (idiomatic) в функциональных языках. А как дело обстоит в Java?

Немного предыстории

Существует мнение, что сейчас в Java происходит 3-я революция. Первая революция произошла с появлением генериков в Java 5, вторая революция — с появлением лямбд и потоков а Java 8. Новые возможности кардинально изменили язык Java сделав его более современным и выразительным. Заметим, что эти изменения происходили одномоментно с выходом соответствующей версии Java. Однако после 8-й версии Java release-train изменился, новые версии стали выпускаться чаще, но новые фичи стали не такими крупными. Вот некоторые из них за последние годы, многие из них имеют по несколько pre-view, некоторые еще не финализированы до сих пор.

  • JEP 305: Pattern Matching for instanceof — приятный сахар, не более того;
  • JEP 359: Records — действительно полезная вещь, в том числе как замена кортежей (tuples);
  • JEP 360: Sealed Classes — ценность не вполне понятна в изолированном контексте;
  • JEP 405: Record Patterns — попытка де-конструировать записи, зачем?
  • JEP 406: Pattern Matching for switch — уже четыре preview, еще не финализировано, но картина начинает складываться.

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

Моделирование результата с исключением

Представим себе некоторое вычисление которое может завершиться успешно с результатом типа T или выбросить исключение. Знакомая ситуация?

Смоделируем результат с помощью запечатанного (sealed) интерфейса Result<T>.

public sealed interface Result<T> permits Success, Failure { ... }

Этот запечатанный интерфейс позволяет иметь только двух наследников — Success и Failure. Особенностью запечатанных классов (интерфейсов) является то, что конструкция switch может теперь знать весь набор возможных значений этого типа.

Определим наследников при помощи записей (records)

public record Success<T>(T value) implements Result<T> { }  public record Failure<T>(Exception exception) implements Result<T> { }

Подобный стиль моделирования данных широко используется в функциональном программировании и носит название алгебраических типов данных (ADT).

Получение результата

Для получения результата можно использовать производящую (factory) функцию

    static <T> Result<T> runCatching(CheckedSupplier<T> suppl) {         try {             return new Success<>(suppl.get());         } catch (Exception e) {             return new Failure<>(e);         }     }

Вот так например мы можем создать URL результат из строки, здесь конструктор URL может выбросить MalformedURLException.

    @Test public void testUrl() {         var urlResult = Result.runCatching(() -> new URL("foo/bar"));         assertTrue(urlResult instanceof Failure);         urlResult.onFailure(e -> assertTrue(e instanceof MalformedURLException));     }

Обработка результата

Рассмотрим различные варианты обработки результата на примере функции которая извлекает номер порта из строкового представления URL. Здесь может возникнуть ошибка при преобразовании строки в URL, или URL может не иметь явно указанного порта и тогда getPort() вернет -1.

1. Традиционный код

    Optional<Integer> getURLPortTraditional(String urlStr) {         try {             URL url = new URL(urlStr);             int port = url.getPort();             return port == -1 ? Optional.empty() : Optional.of(port);         } catch (MalformedURLException e) {             return Optional.empty();         }     }    

Без комментариев.

2. Сопоставление с образцом класса

    Optional<Integer> getURLPortWithSimplePatternMatching(String url) {         var portResult = runCatching(() -> new URL(url)).map(URL::getPort);         return switch (portResult) {         case Success<Integer> s -> s.value() == -1 ? Optional.empty() : Optional.of(s.value());         case Failure f -> Optional.empty();         };     }

Сопоставляем c образцом класса Success<Integer> s, порт достаем явно с помощью метода s.value()

3. Сопоставление с образцом записи с деконструкцией записи на компоненты

    Optional<Integer> getURLPortWithRecordMatching(String url) {         var portResult = runCatching(() -> new URL(url)).map(URL::getPort);         return switch (portResult) {         case Success<Integer>(Integer port) -> port == -1 ? Optional.empty() : Optional.of(port);         case Failure f -> Optional.empty();         };     }

Сопоставляем с записью Success<Integer>(Integer port), компилятор определяет для нас переменную port и неявно инициализирует ее значением из записи. Происходит так называемая де-конструкция записи (record deconstruction) на компоненты.

4. Сопоставление с образцом записи с выводом типа

    Optional<Integer> getURLPortWithRecordMatchingInfere(String url) {         var portResult = runCatching(() -> new URL(url)).map(URL::getPort);         return switch (portResult) {         case Success<Integer>(var port) -> port == -1 ? Optional.empty() : Optional.of(port);         case Failure f -> Optional.empty();         };     }

Как и в случае 3, только не нужно указывать тип компоненты, можно написать var port, компилятор сам выведет нужный тип.

5. Как будет в окончательной версии Java 20+

    Optional<Integer> getURLPortWithRecordMatchingInfere(String url) {         var portResult = runCatching(() -> new URL(url)).map(URL::getPort);         return switch (portResult) {         case Success(var port) -> port == -1 ? Optional.empty() : Optional.of(port);         case Failure f -> Optional.empty();         };     }

Самая компактный код, сопоставляем с записью Success(var port), типы указывать не надо вообще, компилятор это выведет сам. Красота.

6. Без сопоставления с образцом

    Optional<Integer> getURLPortWithMonad(String url) {         return runCatching(() -> new URL(url)).map(URL::getPort)             .filter(port -> port != -1)             .fold(port -> Optional.of(port), exception -> Optional.empty());     }

Задача не сложная, можно обойтись и без pattern-matching-а.

Комбинирование результатов.

Иногда нужно из нескольких результатов получить итоговый результат. Для примера возьмем два результата Result<Integer> и найдем сумму если оба результата успешные.

7. Наивное комбинирование с распаковкой Result

    Result<Integer> sumResultsNaive(Result<Integer> i1, Result<Integer> i2) {         if (i1.isSuccess() && i2.isSuccess()) {             Integer x1 = i1.getOrNull();             Integer x2 = i2.getOrNull();             return Success.of(sum(x1, x2));         }          return i1.isFailure() ? i1 : i2;     }

Так напишет программист не знакомый с приемами FP, но это типичный вполне рабочий код.

8. Комбинирование в стиле FP при помощи вложенных (nested) flatMap

    Result<Integer> sumResultsFlatMap(Result<Integer> i1, Result<Integer> i2) {         return i1.flatMap(x1 -> i2.map(x2 -> sum(x1, x2)));     }

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

9. Комбинирование с применением pattern matching.

Если нужно скомбинировать монады различных типов, например Result<Integer> и Option<Integer>, то у нас есть либо наивный вариант #7, либо привести типы к одному виду и использовать #8, либо использование pattern matching.

    Result<Integer> sumResultsFromDifferentMonads(Option<Integer> i1, Result<Integer> i2) {         record TwoInts(Option<Integer> i1, Result<Integer> i2) {};         return switch (new TwoInts(i1,i2)) {         case TwoInts(Some<Integer>(var x1), Success<Integer>(var x2)) -> Success.of(sum(x1, x2));         default -> i2.isFailure() ? i2 : Failure.of(new NoSuchElementException());          };     }

Объявляем локальную (!) запись TwoInts, создаем экземпляр той записи и затем сравниваем её с различными вариантами этой записи с декомпозицией на компоненты.

На Scala этот код будет выглядеть так.

    def sumScala(i1: Option[Int], i2: Try[Int]) : Option[Int] = {       (i1, i2) match {         case (Some(x1), Success(x2)) => Some(x1 + x2)         case (_, Failure(f)) => i2         case (None, _) => None()         }     }

На Scala покрасивей будет конечно чем в Java, но идея та же. Вместо записи TwoInts используем безымянный кортеж (i1, i2), используем символ "_" когда конкретное значение компоненты нас не интересует, то есть любое значение. Java пока не умеет игнорировать компоненты, возможно появится в будущем.

Мне не удалось написать последнюю функцию на Kotlin. Уважаемые читатели, кто знает другие FP языки. Напишите пожалуйста в комментариях как будет выглядеть последняя функция на вашем языке (Kotlin, Haskell, Rust, др.) Будет интересно сравнить с Java.

Проблемы

JEP 406 еще не финализирована, находится в 4-ой стадии preview в Java 20, которая на момент написания статьи (февраль 2023) еще официально не вышла. Автор экспериментировал с последней доступной версией Java 19 и последней версией Eclipse Version: 2023-03 M2 (4.27.0 M2).

  • Eclipse не может скомпилировать пример 4, не может вывести тип переменной port
  • JDK 19 не может скомпилировать пример 3 и 4, говорит что switch не покрывает все возможные варианты.

Последняя проблема похожа на ошибку компилятора, аналогичный случай описан на https://stackoverflow.com/questions/73787918/java-19-compiler-issues-when-trying-record-patterns-in-switch-expressions, лечится добавлением default кейса.

Фичи еще находятся в preview, надеюсь в финальной версии Java 20 это будет исправлено.

Имплементация Result

Я решил взять Kotlin Result как исходный образец API и портировал его в Java. С результатом можно ознакомиться в репозитории на GitHub. Почему Kotlin? Во первых, у Kotlin очень хорошая стандартная библиотека, во вторых — не хотелось изобретать новые API. Дополнительно реализовано

  • возможность использования интерсептора для унифицированной обработки исключений
  • интеграция с Java Optional и Stream
  • AutoCloseable result

Выводы

В последние годы в Java идет тихая функциональная революция

  • С использованием запечатанных (sealed) классов и записей (records) стало возможным эффективное моделирование данных в функциональном стиле как алгебраических типов данных (ADT).
  • Сопоставление записей с образцом (records pattern matching) с одновременной деконструкцией записи на компоненты является сильной стороной функциональных языков и теперь это возможно в Java.

Конечно Java не станет чистым функциональным языком, однако эти новые возможности значительно обогатят экосистему Java.

Хотя несмотря на большой крен в сторону FP, в стандартной библиотеке Java до сих пор нет таких функциональных примитивов как Result, Either, Option и других (кстатит java.util.Optional не является запечатанным классом и не поддается декомпозиции при сопоставлении с образцом). С нетерпением жду такой стандартной библиотеки.

Код с образцами pattern-matching можно посмотреть и поиграться в репозитории на GitHub

С уважением
Сергей Копылов


ссылка на оригинал статьи https://habr.com/ru/post/721326/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *