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

Обработка исключений в функциональном стиле и с поддержкой типизации объектов

Java™-разработчики привыкли обрабатывать ошибки путём выдачи и перехвата исключительных ситуаций, но этот подход не соответствует функциональной парадигме. В данной статье из серии "Функциональное мышление" показывается, как можно «функционально» выражать ошибки в Java, при этом обеспечив защиту типов объектов, и как обертывать проверенные исключения с помощью функциональных возвращаемых значений. Также статья знакомит с полезной абстракцией Either.

Нил Форд, Архитектор приложений, ThoughtWorks

Нил Форд (Neal Ford) работает архитектором приложений в ThoughtWorks, революционной компании, предоставляющей профессиональные IT-услуги и помогающей талантливым людям по всему миру эффективнее использовать программное обеспечение. Он также является проектировщиком и разработчиком приложений, учебных материалов, журнальных статей, учебных курсов, видео/DVD-презентаций и автором книг "Разработка с Delphi: Объектно-ориентированный подход", "JBuilder 3 Unleashed" и "Искусство разработки Web-приложений на Java". Он специализируется на консультациях по построению широкомасштабных корпоративных приложений. Он также является общепризнанным докладчиком и выступал на многочисленных конференциях разработчиков по всему миру. С ним можно связаться по адресу: nford@thoughtworks.com.



09.01.2013

Об этой серии статей

Эта серия статей призвана переориентировать читателя в "функциональном" направлении и помочь ему взглянуть на проблемы под новым углом, чтобы улучшить повседневное написание кода. В ней изучаются принципы функционального программирования и инфраструктуры, позволяющие использовать функциональное программирование в языке Java™. Так, в статьях рассматриваются функциональные языки программирования, работающие на виртуальной Java-машине, и некоторые направления будущего развития языков программирования. Эта серия статей предназначена для программистов, знающих язык Java и работу его абстракций, но не имеющих опыта в использовании функциональных языков.

Когда вы изучаете столь глубокую технологию, как функциональное программирование, вы иногда обнаруживаете различные интересные аспекты, которые находятся как будто в стороне от самой технологии. В предыдущей статье я продолжил миницикл, посвященный переосмыслению в функциональном стиле классических шаблонов проектирования из книги Э. Гамма, Р. Хелма, Р. Джонсона и Дж. Влиссидеса. Я вернусь к этой теме в следующей статье, где будут обсуждаться вопросы применения шаблонов в контексте языка Scala, но сначала следует сделать необходимое отступление, чтобы рассказать о концепции Either. Одним из способов применения Either является обработка ошибок в функциональном стиле, которая будет рассматриваться в данной статье. После того как мы изучим возможности Either для работы с ошибками, в следующей статье я вернусь к применению шаблонов и древовидных структур.

В языке Java ошибки обычно обрабатываются с помощью исключительных ситуаций (exceptions), и поэтому в язык встроены возможности для создания и обработки подобных ситуаций. Но что если у вас отсутствует формальный механизм для обработки ошибок? Во многих функциональных языках отсутствует само понятие исключительной ситуации, так что для них требуется найти альтернативные способы выражения ошибочных ситуаций. В этой статье я продемонстрирую механизмы с защитой типов для обработки ошибок в 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, в котором в качестве ключей используются объекты типа String, а в качестве значений – объекты типа Object. В методе divide() я помещаю в этот объект либо исключительную ситуацию (ключ – "exception"), чтобы продемонстрировать ошибку, или полученное число в случае успеха (ключ – "answer"). Оба режима работы проверяются в листинге 2.

Листинг 2. Проверка отслеживания успешных и неудачных ситуаций с помощью объекта Map
@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, не предусматривается жесткой типизации, что не позволяет компилятору "отлавливать" определенные ошибки. Использование перечислений (enums) в качестве ключей могло бы улучшить ситуацию, хотя и не сильно. Во-вторых, вызывающий метод не знает, успешно завершился метод divide() или нет, перекладывая задачу определения полученного результата на программиста. В-третьих, ничто не мешает связать с обоими ключами "answer" и "exception" такие значения, которые не позволят однозначно определить результат.

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


Класс Either

В функциональных языках часто возникает задача возвращения двух различных значений, и стандартной структурой для моделирования такого поведения является класс 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 спроектирован так, чтобы содержать либо значение переменной left, либо right (но только одной из них). Подобная структура данных называется непересекающимся множеством. В некоторых языках на основе С присутствует тип данных union, который может содержать по одному экземпляру объектов различных типов. В непересекающемся множестве имеются "ячейки" для объектов двух типов, но храниться может только объект одного типа. В классе Either имеется закрытый private-конструктор, который для непосредственного создания объекта использует один из двух статических методов – left(A a) или right(B b). Другие методы этого класса являются вспомогательными и используются для получения и изучения членов класса.

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

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

Я подготовил класс RomanNumeral (подробности его реализации я предлагаю разработать читателю) и класс 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 в методе parseNumber() выполняется очень простая проверка для демонстрации ошибки; сообщение об ошибке помещается в поле left класса Either, а в случае корректного значения результат преобразования записывается в поле right. Оба варианта показываются в unit-тестах — методах parsing_success() и parsing_failure().

Подобный подход значительно лучше решения, основанного на передаче объекта Map. Я обеспечил типизацию (так как я могу создать исключение любого специализированного типа), и возможность возникновения ошибки явно показана в объявлении метода через параметризованный тип <Exception, Integer>. Кроме того, результаты выполнения операции доступны не напрямую, а должны извлекаться из объекта-посредника Either. Этот промежуточный уровень позволяет реализовать возможность "отложенного срабатывания функциональности" (англ. laziness).

"Отложенный" разбор и Functional Java

Класс Either присутствует во множестве функциональных алгоритмов и настолько часто встречается в функциональном мире, что в инфраструктуре Functional Java (см. раздел "Ресурсы") содержится реализация класса Either, которую можно использовать в примерах из листингов 3 и 4. Но эта реализация также может использоваться с другими конструкциями Functional Java. Таким образом, я могу объединить классы Either и P1 из Functional Java, чтобы реализовать отложенную обработку ошибок. "Отложенным" называется выражение, которое выполняется по явному запросу пользователя (см. раздел "Ресурсы").

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

В 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() у объекта result, который вернет поле right класса Either, из которого я уже смогу извлечь значение. В методе parse_lazy_exception я могу проверить наличие поля left и извлечь из него исключительную ситуацию, чтобы просмотреть сообщение об ошибке.

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

Использование значений по умолчанию

Отложенная обработка — это не единственное преимущество, которое дает применение класса Either для обработки ошибок. Таким же способом вы можете обеспечить возврат значений по умолчанию. Рассмотрим код в листинге 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, по умолчанию будут заменяться на 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 я предпринимаю попытку деления, которая потенциально может привести к возникновению ArithmeticException. Если возникает исключительная ситуация, то я помещаю её в поле left класса Either; в противном случае я возвращаю результат в поле right. Использование класса Either позволяет преобразовывать обычные исключения (включая обязательные, т.е. checked exceptions) в более функциональные конструкции.

Конечно, вы также можете "отложить" обработку упакованных исключительных ситуаций, полученных из вызванных методов, как показано в листинге 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 метод для деления двух римских чисел, который бы возвращал две возможные исключительные ситуации — ошибку при разборе числа и ошибку при делении. С помощью стандартных параметризованных типов Java я могу вкладывать исключительные ситуации друг в друга, как показано в листинге 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 с исключительной ситуацией в поле left. Далее я должен извлечь переданные числовые значения и выполнить дополнительную проверку. Так как в римской записи чисел отсутствует понятие нуля, то я придумал правило "запрет деления на 1". Таким образом, если делитель равен 1, я создаю собственную исключительную ситуацию и помещаю её в поле left объекта Either, а сам этот объект в поле right другого объекта Either.

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


Класс Option

Класс Either, как будет показано в следующей статье, также оказывается полезным при построении древовидных структур данных. Аналогичный класс Option в языке Scala, перенесенный в Functional Java, предлагает более простой сценарий обработки ошибок: либо ничего, если приемлемое значение отсутствует, или что-то, если было успешно возвращено какое-либо значение. Использование класса 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, но при этом Option может применяться к методам, которые могут не иметь подходящего значения, чтобы вернуть его пользователю.

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


Заключение

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

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

Ресурсы

  • Functional thinking: Functional error handling with Either and Option: оригинал статьи (EN).
  • The Productive Programmer (Neal Ford, O'Reilly Media, 2008): новая книга Нила Форда, в которой более подробно рассматриваются некоторые вопросы из данной серии статей.
  • Инфраструктура Functional Java добавляет в язык Java множество возможностей из арсенала функциональных языков программирования.
  • Lazy evaluation: узнайте больше о стратегии «отложенного» вычисления выражений.
  • Монады: возможно самая сложная концепция, имеющаяся в функциональных языках, которая будет рассмотрена следующих статьях данной серии.
  • Scala: современный функциональный язык на основе JVM.
  • Посетите магазин книг, посвященных ИТ-технологиям и различным аспектам программирования.
  • Throwing Away Throws: эта публикация и другие записи из этого блога послужили отправной точкой при написании данной статьи.

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=854349
ArticleTitle=Функциональное мышление: Функциональная обработка ошибок с использованием Either и Option
publish-date=01092013