Как известно из функциональных интерфейсов в Stream API нельзя выбрасывать контролируемые исключения. Если по каким-то причинам это необходимо (например, работа с файлами, базами данных или по сети), приходится оборачивать их в RuntimeException. Это неплохо работает если ошибки игнорируются, но если их необходимо обрабатывать, то код получается громоздкий и трудночитаемый. Я заинтересовался можно ли объявлять интерфейсы и методы с generic исключениями и неожиданно для себя узнал, что можно.

Зададим такой функциональный интерфейс, от стандартного интерфейса Function<A, B> он отличается только наличием третьего generic-типа для бросаемого исключения.

public interface FunctionWithExceptions<A, B, T extends Throwable>{
	B apply(A a) throws T;
}

И объявим простенький метод, который преобразует коллекцию используя этот интерфейс, у этого метода также объявлен generic-тип для бросаемого исключения (совпадающий с типом исключения которое может выбросить функциональный интерфейс).

public static <A, B, T extends Throwable> Collection<B> map(Collection<A> source, FunctionWithExceptions<A, B, T> function) throws T {
	Collection<B> result = new ArrayList<>();
	for (A a : source) {
		result.add(function.apply(a));
	}
	return result;
}

Посмотрим, как будет выглядит обработка исключений с ними в разных случаях.

Одно исключение


Попробуем преобразовать коллекцию используя лямбда функцию выбрасывающую обрабатываемое исключение, за счет generic-типа оно будет корректно передано в место вызова метода map. При этом тип исключения будет сохранён.

public Collection<byte[]> singleException(Collection<String> filenames) throws IOException
{
	return map(filenames, f -> Files.readAllBytes(new File(f).toPath());
}

Два исключения в одной лямбда-функции


Если мы используем функцию, выбрасывающую несколько обрабатываемых исключений, то они будет приведены к наиболее общему типу, что не очень хорошо (но, по-моему, не хуже завертывания исключений в RuntimeException).

public static byte[] waitAndRead(String filename, long time) throws InterruptedException, IOException {
	Thread.sleep(time);
	return Files.readAllBytes(new File(filename).toPath());
}
public Collection<byte[]> joinedExceptions(Collection<String> filenames) throws Exception
{
	return map(filenames, f -> waitAndRead(f, 1000L));
}

Исключения в разных лямбда-функциях


Однако если мы запишем цепочку лямбда функций каждая из которых выбрасывает не более одного исключения, то разумеется они не будут объединены и могут быть корректно обработаны по отдельности.

private <T> T wait(T t, long time) throws InterruptedException {
	Thread.sleep(time);
	return t;
}
private byte[] read(String filename) throws IOException {
	return Files.readAllBytes(new File(filename).toPath());
}
public Collection<byte[]> separatedExceptions(Collection<String> filenames) throws InterruptedException {
	try {
		return map(map(filenames, f -> wait(f, 1000L)), f -> read(f));
	} catch (IOException e) {
		return Collections.emptyList();
	}
}

Как видно в этом примере в IOException мы перехватываем и возвращаем пустую коллекцию, а InterruptedException передаём выше.

Лямбда-функция без исключений


И наконец посмотрим, как поведет себя функция которая не выбрасывает контролируемых исключений, не потребует ли она обрабатывать исключение которого нет?

public Collection<Boolean> noExceptions(Collection<String> filenames) 
{
	return Mapper.map(filenames, f -> new File(f).exists());
}

Всё работает замечательно и нет необходимости обрабатывать исключения. Интересно что при этом generic-тип исключения раскрылся в RuntimeException автоматически, что в принципе логично, но немного неожиданно.

Недостатки


Главным минусом описанного выше подхода является несовместимость с Stream API из-за невозможности использовать интерфейсы с generic исключениями вместо стандартных. Потенциально можно написать ThrowableStream API по аналогии StreamEx или расширить StreamEx, но это потребует написания большого объёма тривиального кода. Вторым минусом является, то что объявить больше одного generic исключения нельзя.

Кстати использовать исключения в классах с generic типами можно и на более ранних версиях Java (проверил на 1.7), но там это неудобно и поэтому довольно бессмысленно.

Ссылка на git с исходным кодом и junit тестом в котором проверяются ситуации аналогичные описанным в статье.

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


  1. Djaler
    07.11.2017 11:37

    По-моему, такой вариант более удобен и универсален gist.github.com/Djaler/d3e7e873326a3b96424231af6ab36f72.
    Примеры использования:

    List<Document> documents = filesModel.getFiles().values().stream()
                    .map(rethrowFunction(DocumentFactory::newFileDocument))
                    .collect(toList());

    return uncheck(() -> MessageDigest.getInstance("MD5"));

    Rethrow-методы удобны, когда необходимо явно ловить исключение за пределами Stream-цепочки. Uncheck же необходимы для случаев, когда мы точно знаем, что исключение в данном месте невозможно и нет смысл его перехватывать


    1. lany
      08.11.2017 06:01

      Это бред. Никогда не кидайте checked-исключение там, где оно не объявлено. Иначе оно улетит туда, где его никто не будет ждать. Это может быть удобно в трёхстрочных программах, но в серьёзном проекте вы потом не обрадуетесь, когда логи будете читать и баг-репорты разгребать.


      1. Djaler
        08.11.2017 09:17

        Вы не так поняли. rethrow ЗАСТАВЛЯЕТ ловить исключение (или явно объявлять его в сигнатуре) за пределами использования лямбды (в данном примере — стрим-цепочки).


        1. lany
          08.11.2017 09:38

          Ну-ну.


          Function<File, Document> fn = rethrowFunction(DocumentFactory::newFileDocument);
          
          List<Document> documents = filesModel.getFiles().values().stream()
                          .map(fn)
                          .collect(toList());

          Компилятор заставит бесполезно обернуть в try-catch присваивание, но не заставит обернуть стрим-цепочку. И не говорите мне не делать так. Выносить функции в переменные — нормальный стиль программирования на джаве. Прятать проверяемые исключения — это злоупотребление костылём в системе типов.


          1. Djaler
            08.11.2017 09:43

            Такой кейс даже не проверял, каюсь. Просто не вижу смысла выносить в отдельную переменную метод референс (Если уж сильно хочется, то можно вынести, но rethrow явно оставить в теле лямбды). Да, это костыль. Но что поделать, если лямбды в джаве кривые и не позволяют адекватно работать с исключениями.


  1. lany
    08.11.2017 06:03

    У вашего подхода одна существенная проблема: таким образом нельзя сделать ленивые стримы. А Stream API лениво по спецификации. Фактически все действия выполняет терминальная операция (например, collect), а вовсе не промежуточная типа map. Поэтому именно терминальная и будет кидать ваше исключение. Причём если стрим параллельный, оно может быть транслировано из другого потока.