Фунциональное мышление: Функциональная обработка ошибок с помощью Either и Option

в 19:00, , рубрики: functional java, functional programming, java, scala, функциональное программирование

Нил Форд, Архитектор ПО, ThoughWorks Inc.
12 Июня 2012
перевод статьи Functional thinking: Functional error handling with Either and Option

Когда вы занимаетесь исследованием глубоких тем как функциональное программирование, появляются увлекательные ответвления. Я продолжаю свою мини серию переосмысления традиционных шаблонов проектирования банды четырех (GoF, Gang of Four) в функциональном стиле. Я продолжу исследование в следующей части, раскрыв поиск по образцу в Scala (pattern-matching), но для начала мне необходимо построить фундамент с помощью концепции Either. Одно из применений Either обработка ошибок в функциональном стиле, которое я разберу в этой части. После того как вы поймете магию, которую Either применяет к ошибками, я вернусь к поиску по образцу и деревьям в следующей части

В Java, ошибки традиционно обрабатываются исключениями, которые поддерживаются на уровне языка, и обеспечивающие всю необходимую работу. Однако, что делать если структурная обработка исключений не существует? Многие функциональные языки не поддерживают парадигму исключений, поэтому они должны искать способ описать состояние ошибки. В этой части, я покажу типобезопасные механизмы обработки ошибок в Java, которые обходят привычный механизм исключение-обработка (с помощью некоторых классов из фреймворка Functional Java)

Функциональная обработка ошибок

Если вы хотите обрабатывать ошибка в Java без использования исключений, то столкнетесь с фундаментальным препятствием ограничения языка в виде возвращения единственного значения из методов. Однако, методы могут, конечно, вернуть единственную ссылку Object(или подкласс), которая будет содержать множество значений. Таким образом я мог бы организовать возврат множество значений с помощью Map. Рассмотрим метод divide() в Листинге 1:

Листинг 1. Использование Map для возврата множества значений

public static Map<String, Object> divide(int x, int y) {
    Map<String, Object> result = new HashMap<String, Object>();
    if (y == 0)
        result.put("exception", new Exception("div by zero"));
    else
        result.put("answer", (double) x / y);
    return result;
}  

В Листинге 1, я создал Map c ключом типа String и значением типа Object. В методе divide() я записываю exception для обозначения неудачи или answer для обозначения удачи выполнения условия. Оба режима протестированы в Листинге 2:

Листинг 2. Тестирование удачи или

@Test
public void maps_success() {
    Map<String, Object> result = RomanNumeralParser.divide(4, 2);
    assertEquals(2.0, (Double) result.get("answer"), 0.1);
}

@Test
public void maps_failure() {
    Map<String, Object> result = RomanNumeralParser.divide(4, 0);
    assertEquals("div by zero", ((Exception) result.get("exception")).getMessage());
}  

В Листинге 2, тест maps_success проверяет, что корректная запись существует в возвращаемом Map. Тест maps_failure тестирует случай исключения

Этот подход имеет несколько очевидных проблем. Во-первых, все что будет записано в Map не является типобезопасным(type-safe), что устраняет возможность компилятора проверить наличие некоторых ошибок. Перечисления для ключей могли бы исправить ситуацию, но не сильно. Во-вторых, вызывающий метод не знает был ли вызов успешным, тем самый нагружая вызывающий метод проверкой результата. В-третьих, ничто не препятствует каждому из ключей иметь значения, которые делают результат неясным.

Вот почему нам необходим типобезопасный механизм, который бы мог возвращать 2 (или больше) значения.


Класс Either

Необходимость вернуть 2 разных значения часто встречается в функциональных языках и общая структура данных используемая для реализации такого поведения класс Either. Используя Java дженерики, я могу создать простой Either класс, как показано в Листинге 3:

Листинг 3. Возврат двух(типобезопасных) значений с помощью класс Either

public class Either<A,B> {
    private A left = null;
    private B right = null;

    private Either(A a,B b) {
        left = a;
        right = b;
    }

    public static <A,B> Either<A,B> left(A a) {
        return new Either<A,B>(a,null);
    }

    public A left() {
        return left;
    }

    public boolean isLeft() {
        return left != null;
    }

    public boolean isRight() {
        return right != null;
    }

    public B right() {
        return right;
    }

    public static <A,B> Either<A,B> right(B b) {
        return new Either<A,B>(null,b);
    }

   public void fold(F<A> leftOption, F<B> rightOption) {
        if(right == null)
            leftOption.f(left);
        else
            rightOption.f(right);
    }
}

В Листинге 3, Either спроектирован таким образом, чтобы хранить правое значение или левое (но никогда оба), Эта структура данных похожа на дизъюнктивное объединение (disjoint union). Некоторые С-подобные языка содержат типы данных union, который может содержать один экземпляр нескольких разных типов. Дизъюнктивное объединение имеет слоты для 2х типов, но содержит экземпляр только одного из них. Класс Either имеет private конструктор, делая ответственным за создание каждый из статических методов left(A a) или right(B b). Остальные методы класса являются вспомогательными, они получают и исследуют члены класса.

Вооружившись Either, я могу написать код, который вернет одно из двух исключение или правильный результат(но никогда оба) сохраняя типобезопасность. Общепринятое соглашение, что часть left класса Either содержит исключение(если есть) и right содержит результат.

Разбор римских чисел

У меня есть класс, который называется RomanNumerals (реализацию класса оставлю на откуп воображению читателя) и класс RomanNumeralParser, который вызывает класс RomanNumeral. Метод parseNumber() и тесты приведены в Листинге 4:

Листинг 4. Разбор Римских цифр

public static Either<Exception, Integer> parseNumber(String s) {
    if (! s.matches("[IVXLXCDM]+"))
        return Either.left(new Exception("Invalid Roman numeral"));
    else
        return Either.right(new RomanNumeral(s).toInt());
}

@Test
public void parsing_success() {
    Either<Exception, Integer> result = RomanNumeralParser.parseNumber("XLII");
    assertEquals(Integer.valueOf(42), result.right());
}

@Test
public void parsing_failure() {
    Either<Exception, Integer> result = RomanNumeralParser.parseNumber("FOO");
    assertEquals(INVALID_ROMAN_NUMERAL, result.left().getMessage());
}  

В листинге 4 метод parseNumbers() выполняет удивительно наивную валидацию(с целью показать ошибку), помещая состояние ошибки в часть left, а результат в right класса Either. Оба случая показаны в юнит-тестах.

Это серьезное улучшение в сравнении с передачей туда-обратно MapS. Я сохраняю типобезопасность (обратите внимание, что я могу сделать исключение настолько специфичным насколько пожелаю); ошибки очевидны в объявлениях методов, благодаря generics; и мои результаты возвращаются с одним дополнительным уровнем — распаковка результата (будь то исключение или результат) из Either. Этот дополнительный уровень делает возможным использование ленивости(laziness)

Ленивый парсинг и Functional Java

Класс Either встречается во многих функциональных алгоритмах и он так распространен в функциональном мире, что фреймворк Functional Java тоже содержит реализацию класса Either, которая будет работать в примерах Листинга 3 и Листинга 4. Однако, реализованный для работы с конструкциями Functional Java. Соответственно, я могу использовать комбинацию класса Either и класса P1 из Functional Java, для того чтобы создать ленивую обработку ошибок. Ленивое выражение — это выражение, которое выполняется по требованию.

В Functional Java, класс P1 это простая обертка вокруг единственного метода _1() который не принимает параметров. (Другие варианты P2, P3 и т.д. — содержат больше методов). P1 используется для того, чтобы передать блок кода без его выполнения, позволяя вам выполнить его в контексте, который определите самостоятельно.

В Java, исключения срабатывают как только происходит throw. Возвращая ленивый метод, я могу отложить срабатывание исключения. Рассмотрим пример и тесты в Листинге 5:

Листинг 5. Использование Functional Java для создания ленивого парсера

public static P1<Either<Exception, Integer>> parseNumberLazy(final String s) {
    if (! s.matches("[IVXLXCDM]+"))
        return new P1<Either<Exception, Integer>>() {
            public Either<Exception, Integer> _1() {
                return Either.left(new Exception("Invalid Roman numeral"));
            }
        };
    else
        return new P1<Either<Exception, Integer>>() {
            public Either<Exception, Integer> _1() {
                return Either.right(new RomanNumeral(s).toInt());
            }
        };
}

@Test
public void parse_lazy() {
    P1<Either<Exception, Integer>> result = FjRomanNumeralParser.parseNumberLazy("XLII");
    assertEquals((long) 42, (long) result._1().right().value());
}

@Test
public void parse_lazy_exception() {
    P1<Either<Exception, Integer>> result = FjRomanNumeralParser.parseNumberLazy("FOO");
    assertTrue(result._1().isLeft());
    assertEquals(INVALID_ROMAN_NUMERAL, result._1().left().value().getMessage());
}  

Код в Листинге 5 аналогичен коду в Листинге 4, с дополнительной P1 оберткой. В parse_lazy тесте, я должен распаковать результат с помощью вызова _1(), который вернет right класса Either, из которого я могу получить значение. В parse_lazy_exceptions тесте, я могу проверить наличие left, а затем распаковать исключение для того чтоб распознать его сообщение.

Исключение (вместе с его stack trace, генерация которого дорого обходится) не будет создана до тех пор пока вы не распакуете left класса Either с помощью вызова _1(). Таким образом, исключение ленивое, позволяющее вам отсрочить выполнение конструктора исключения.

Обеспечение значений по умолчанию

Ленивость не единственное преимущество использования Either для обработки ошибок. Еще одно преимущество, в возможности предоставить значения по умолчанию (default values). Рассмотрим код в Листинге 6:

Листинг6 Обеспечение разумных возвращаемых значений по умолчанию

public static Either<Exception, Integer> parseNumberDefaults(final String s) {
    if (! s.matches("[IVXLXCDM]+"))
        return Either.left(new Exception("Invalid Roman numeral"));
    else {
        int number = new RomanNumeral(s).toInt();
        return Either.right(new RomanNumeral(number >= MAX ? MAX : number).toInt());
    }
}

@Test
public void parse_defaults_normal() {
    Either<Exception, Integer> result = FjRomanNumeralParser.parseNumberDefaults("XLII");
    assertEquals((long) 42, (long) result.right().value());
}

@Test
public void parse_defaults_triggered() {
    Either<Exception, Integer> result = FjRomanNumeralParser.parseNumberDefaults("MM");
    assertEquals((long) 1000, (long) result.right().value());
}  

В Листинге 6, предположим, что я никогда не позволю использовать римские цифры больше чем MAX и любая попытка установить значение больше будет приводит к установке значения MAX по умолчанию. Метода parseNumberDefaults() убеждается в том, ч то значение по умолчанию помещено в right класса Either.

Обертка исключений

Также я могу использовать Either для обертки исключений, преобразуя структурную обработку исключения к функциональной, как показано в Листинге7:

Листинг 7. Перехват исключений других людей

public static Either<Exception, Integer> divide(int x, int y) {
    try {
        return Either.right(x / y);
    } catch (Exception e) {
        return Either.left(e);
    }
}

@Test
public void catching_other_people_exceptions() {
    Either<Exception, Integer> result = FjRomanNumeralParser.divide(4, 2);
    assertEquals((long) 2, (long) result.right().value());
    Either<Exception, Integer> failure = FjRomanNumeralParser.divide(4, 0);
    assertEquals("/ by zero", failure.left().value().getMessage());
}  

В Листинге 7, я делаю попытку деления, которое по идее должно выдать ArithmeticExeption. Если исключение появляется, я оборачиваю его в left класса Either, в противном случае возвращаю значение в right. Использование Either дает возможность преобразовывать традиционные исключения (включая checked) в исключения функционально стиля.

Конечно, вы также можете лениво обернуть исключения бросаемые из вызываемых методов, как показано в Листинге 8:

Листинг 8. Ленивый перехват исключений

public static P1<Either<Exception, Integer>> divideLazily(final int x, final int y) {
    return new P1<Either<Exception, Integer>>() {
        public Either<Exception, Integer> _1() {
            try {
                return Either.right(x / y);
            } catch (Exception e) {
                return Either.left(e);
            }
        }
    };
}

@Test
public void lazily_catching_other_people_exceptions() {
    P1<Either<Exception, Integer>> result = FjRomanNumeralParser.divideLazily(4, 2);
    assertEquals((long) 2, (long) result._1().right().value());
    P1<Either<Exception, Integer>> failure = FjRomanNumeralParser.divideLazily(4, 0);
    assertEquals("/ by zero", failure._1().left().value().getMessage());
}  
Вложенные исключения

Одно из главных преимуществ исключений Java это возможность объявить несколько потенциальных исключений как часть сигнатуры метода. Класс Either также обладает этим преимуществом, хотя с довольно извилистым синтаксисом. Например, что если мне нужен метод в RomanNumeralParser, который бы позволил мне делить 2 римские цифры, но я должен возвращать 2 разных возможных исключения, или ошибку парсинга или ошибку деления? Используя стандартные Java generics, я могу сделать вложение исключений, как показано в Листинге 9:

Листинг 9. Вложенные исключения

public static Either<NumberFormatException, Either<ArithmeticException, Double>> 
        divideRoman(final String x, final String y) {
    Either<Exception, Integer> possibleX = parseNumber(x);
    Either<Exception, Integer> possibleY = parseNumber(y);
    if (possibleX.isLeft() || possibleY.isLeft())
        return Either.left(new NumberFormatException("invalid parameter"));
    int intY = possibleY.right().value().intValue();
    Either<ArithmeticException, Double> errorForY = 
            Either.left(new ArithmeticException("div by 1"));
    if (intY == 1)
        return Either.right((fj.data.Either<ArithmeticException, Double>) errorForY);
    int intX = possibleX.right().value().intValue();
    Either<ArithmeticException, Double> result = 
            Either.right(new Double((double) intX) / intY);
    return Either.right(result);
}

@Test
public void test_divide_romans_success() {
    fj.data.Either<NumberFormatException, Either<ArithmeticException, Double>> result = 
        FjRomanNumeralParser.divideRoman("IV", "II");
    assertEquals(2.0,result.right().value().right().value().doubleValue(), 0.1);
}

@Test

public void test_divide_romans_number_format_error() {
    Either<NumberFormatException, Either<ArithmeticException, Double>> result = 
        FjRomanNumeralParser.divideRoman("IVooo", "II");
    assertEquals("invalid parameter", result.left().value().getMessage());
}

@Test
public void test_divide_romans_arthmetic_exception() {
    Either<NumberFormatException, Either<ArithmeticException, Double>> result = 
        FjRomanNumeralParser.divideRoman("IV", "I");
    assertEquals("div by 1", result.right().value().left().value().getMessage());
}  

В Листинге 9, метод divideRoman() в первую очередь распаковывает Either, возвращенный из оригинального метода parseNumber() из Листинга 4. Если исключение появится в любом из двух, я верну левую часть Either с исключением. Следующим шагом, я должен распаковать непосредственные целочисленное значения, потом выполнить критерий проверки. Римские цифры не имеют концепции ноля, поэтому я сделал правило, которое отключает деление на 1:

Другими словами, у меня есть 3 слота, описанные типами: NumberFormatExeption, ArithmeticException и Double. Первое левое значение Either содержит потенциальный NumberFormatException, а правое значение содержит другой Either. Левое значение второго Either содержит потенциальноеArithmeticException, а правое значение содержит результат. Таким образом, для того, чтоб получить ответ, я должен выполнить result.right().value.right().value().doubleValue()! Очевидно, практичность такого подхода рушится моментально, но он предоставляет типобезопасный способ включать исключения как часть сигнатуры класса.


Класс Option

Either довольно удобная концепция, которую я буду использовать для того чтобы построить древовидный тип данных в следующей части серии. Похожий класс в Scala называется Option, продублированный в библиотеке Functional Java, предоставляет более простой способ обработки исключения: или none, обозначающее отсутствие разрешенного значения, или some, обозначающее успешный возврат результата. Option демонстрируется в Листинге 10:

Листинг 10. Использование класса Option

public static Option<Double> divide(double x, double y) {
    if (y == 0)
        return Option.none();
    return Option.some(x / y);
}

@Test
public void option_test_success() {
    Option result = FjRomanNumeralParser.divide(4.0, 2);
    assertEquals(2.0, (Double) result.some(), 0.1);
}

@Test
public void option_test_failure() {
    Option result = FjRomanNumeralParser.divide(4.0, 0);
    assertEquals(Option.none(), result);

}  

Как показано в Листинге 10, класс Option содержит или none() или some(), аналогично left и right в Either но специфичные для методов, которые могут не содержать разрешенных возвращаемых значений.

И Either и Option в библиотеке Functional Java являются монадами — специальный тип данных, который представляет вычисление и активно используемые в функциональных языках программирования. В следующей части, Я буду исследовать монадические концепции относительно Either и покажу как это дает возможность подключить паттернг матчинг характерный для Scala, в изолированных классах.


Заключение

Когда вы изучаете новую парадигму, вы должны переосмыслить все возможные пути решения проблемы. Функциональное программирование использует различные идиомы для обработки ошибок, большинство которых может быть повторено в Java, c добавлением витиеватого синтаксиса.

В следующей части, я буду показывать как использовать Either для построения деревьев.

Автор: Sigrlami

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js