Pull to refresh

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

Level of difficultyMedium
Reading time7 min
Views7.1K

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


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

Tags:
Hubs:
Total votes 7: ↑5 and ↓2+4
Comments4

Articles