Обработка исключений в 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


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

Комментарии (3)


  1. amishaa
    00.00.0000 00:00
    +1

    Кажется, скаловский sumScala и джавовский sumResultsFromDifferentMonads делают разное: первый приводит к Option, а второй - к Try.
    Если приводить к Option, то, вроде можно ещё проще:

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


  1. Nagh42
    00.00.0000 00:00
    +4

    Не очень удачные примеры, лучше было бы рассмотреть какой-нить реальный кейс и ооп/функциональные варианты решений. У вас же получился пример конвертирования ооп в функциональные рамки, причем без особой на это надобности, при этом вы замаскировали исключение, а не обработали его. Конечно, иногда это может быть требованием задачи, но чаще является ошибкой и примером того, как не нужно писать код.

    Опять же, исключения выбрасываются не для маскировки, а для обработки. И обрабатывать их нужно на том уровне, где это имеет смысл. Если вы не знаете, что делать с исключением, то вы просто отдаете его дальше, там разберутся. Но что делать в ваших функциональных примерах? Мы ожидали значение, а получили empty. Что произошло? Как на это реагировать? Зачем писать код таким образом? Какую конструкцию для корректной обработки придумать? Есть ли хороший паттерн для таких случаев?

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


  1. odisseylm
    00.00.0000 00:00

    Как на побаловаться, то пойдет.

    Странно видеть код port == -1 ? Optional.empty() : Optional.of(port) от фаната ФП... Уж извольте делать код нечитабельным до конца Optional.of(port).filter(p -> p != -1)

    >> i1.flatMap(x1 -> i2.map(x2 -> sum(x1, x2)));

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

    Я сам не против иногда написать что-то такое же нечитабельное в ФП стиле, но публично рекомендовать это как-то перебор ))