Статьи

Как необязательный нарушает законы монады и почему это важно

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

Представляем java.util.Optional

Optional<T> описывается как «контейнерный объект, который может содержать или не содержать ненулевое значение». Это довольно хорошо обобщает то, что вы должны ожидать от него.

У него есть несколько полезных методов, таких как:

  • of(x) , что позволяет создать Optional контейнерный контейнер для значения x .
  • isPresent() , который возвращает true тогда и только тогда, когда объект containerPer содержит ненулевое значение.

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

Существуют другие методы, которые ведут себя по-разному в зависимости от наличия значения:

  • orElse(v) возвращает содержащееся значение, если оно есть, или v по умолчанию, если контейнер пуст.
  • ifPresent выполняет блок кода тогда и только тогда, когда есть значение.

Как ни странно, вы можете видеть, что в описании его класса нет упоминания о таких методах, как map , flatMap или даже filter . Все они могут быть использованы для дальнейшей обработки значения, заключенного в Optional . (Если это не пусто: тогда функции не вызываются, а Optional остается пустым). Их упущение может быть связано с тем, что в намерениях создателей библиотеки, Optional не должен был быть монадой .

Шаг назад: монады

Хлоп! Я могу представить, как некоторые из вас насмехаются, когда читаете это имя: монада .

Для тех, кто еще не имел удовольствия, я постараюсь обобщить введение в эту неуловимую концепцию. Имейте в виду и принимайте следующие строки с зерном соли! В соответствии с определением Дугласа Крокфорда, монады — это «то, что, как только разработчикам действительно удается понять, мгновенно теряют способность объяснять кому-либо еще».

Мы можем определить монаду как:

  • Параметризованный тип M<T> : в терминах Java public class M<T> .

  • Единичная функция, которая является фабричной функцией для создания монады из элемента: public <T> M<T> unit(T element) .

  • Операция связывания , метод, который принимает монаду, а также функцию, отображающую элемент в монаду , и возвращает результат применения этой функции к значению, заключенному в монаду :

     public static <T, U> M<U> bind(M<T> monad, Function<T, M<U>> f) { return f.apply(monad.wrappedValue()); } 

Это все, что нужно знать о монадах ? Не совсем, но этого пока достаточно. Не стесняйтесь проверить предлагаемые чтения в конце статьи, если вы хотите прочитать больше на эту тему.

Является ли Optional монадой?

Да и нет. Почти. Безусловно, может быть.

Optional per se квалифицируется как монада , несмотря на некоторое сопротивление в команде библиотеки Java 8 . Давайте посмотрим, как это соответствует 3 свойствам выше:

  • M<T> является Optional<T> .
  • Единичная функция является Optional.ofNullable .
  • Операция связывания — Optional.flatMap .

Так что может показаться, что Optional действительно является монадой , верно? Не так быстро.

Законы Монады

Любой класс, чтобы по-настоящему быть монадой , должен подчиняться 3 законам:

  1. Оставленная идентичность , применение функции модуля к значению и затем привязка полученной монады к функции f — это то же самое, что вызов f для того же значения: пусть f — функция, возвращающая монаду , затем bind(unit(value), f) === f(value) .
  2. Правильная идентичность , привязка функции модуля к монаде не меняет монаду : пусть m будет монадическим значением (экземпляр M<T> ), затем bind(m, unit) === m .
  3. Ассоциативность , если у нас есть цепочка приложений монадических функций, не имеет значения, как они вложены: bind(bind(m, f), g) === bind(m, x -> g(f(x)))

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

Optional и монадские законы

Теперь, как вы можете себе представить, возникает вопрос: есть ли у Optional<T> эти свойства?

Давайте выясним это, проверив свойство 1, Left Identity :

 Function<Integer, Optional<Integer>> f = x -> { if (x == null) { x = -1; } else if (x == 2) { x = null; } else { x = x + 1; } return Optional.ofNullable(x); }; // true, Optional[2] === Optional[2] Optional.of(1).flatMap(f).equals(f.apply(1)); // true, Optional.empty === Optional.empty Optional.of(2).flatMap(f).equals(f.apply(2)); 

Это работает как для пустых, так и для непустых результатов. Как насчет того, чтобы кормить обе стороны null ?

 // false Optional.ofNullable((Integer) null).flatMap(f).equals(f.apply(null)); 

Это как-то неожиданно. Давай посмотрим что происходит:

 // prints "Optional.empty" System.out.println(Optional.ofNullable((Integer) null).flatMap(f)); // prints "Optional[-1]" System.out.println(f.apply(null)); 

Итак, в общем, является ли Optional монадой или нет? Строго говоря, это не очень хорошая монада , так как она не подчиняется законам монады . Однако, поскольку он удовлетворяет определению монады , его можно считать одним, хотя и с некоторыми ошибочными методами.

Optional::map и Закон об ассоциациях

Если вы думаете, что нам не повезло с flatMap , подождите, чтобы увидеть, что происходит с map .

Когда мы используем Optional.map , null также отображается в Optional.empty . Предположим, мы снова отображаем результат первого отображения в другую функцию. Тогда эта вторая функция вообще не будет вызываться, когда первая возвращает null . Если вместо этого мы отобразим начальный Optional в состав двух функций, результат будет совсем другим. Проверьте этот пример, чтобы уточнить:

 Function<Integer, Integer> f = x -> (x % 2 == 0) ? null : x; Function<Integer, String > g = y -> y == null ? "no value" : y.toString(); Optional<Integer> opt = Optional.of(2); // A value that f maps to null - this breaks .map opt.map(f).map(g); // Optional.empty opt.map(f.andThen(g)); // "no value" 

Составляя функции f и g (используя удобный Function::andThen ), мы получаем результат, отличный от того, который мы получили, применяя их одну за другой. Еще более очевидный пример — первая функция возвращает null а вторая выдает NullPointerException если аргумент равен null . Затем повторная map работает нормально, потому что второй метод никогда не вызывается, но композиция выдает исключение.

Таким образом, Optional::map нарушает закон ассоциативности. Это даже хуже, чем flatMap нарушающий левый закон идентичности (мы вернемся к нему в следующем разделе).

orElse на помощь?

Вы можете подумать, что может стать лучше, если мы используем orElse . За исключением того, что это не так .

Легко создать цепочку с более чем двумя функциями, где null на разных этапах может привести к разным результатам. К сожалению, в конце цепочки у нас нет способа сказать, где был обработан ноль. И поэтому нет способа обеспечить правильный результат при применении orElse . Более абстрактно и, что еще хуже, используя orElse мы будем полагаться на тот факт, что каждый разработчик, обслуживающий наш код, или каждый клиент, использующий нашу библиотеку, будут придерживаться нашего выбора и продолжать использовать orElse .

Что Поймать с Optional ?

Проблема в том, что по своей природе непустые Optional s не могут содержать null . В конце концов, вы можете на законных основаниях возразить, что он предназначен для того, чтобы избавиться от null : в действительности Optional.of(null) сгенерирует NullPointerException . Конечно, null значения все еще распространены, поэтому был введен ofNullable чтобы мы не повторяли одну и ту же проверку if-null-then- empty -else- of всем нашем коде. Однако — и в этом суть всего зла — Optional.ofNullable(null) переводится в Optional.empty .

Конечный результат состоит в том, что, как показано выше, следующие две ситуации могут привести к различным результатам:

  • Применение функции перед переносом значения в Optional ;
  • Оборачивая значение в Optional сначала, а затем сопоставляя его с той же функцией.

Это так плохо, как кажется: это означает, что порядок, в котором мы применяем функции, имеет значение. Когда мы используем map , как мы видели, она становится еще хуже, потому что мы также теряем инвариантность ассоциативности и даже то, как составляются функции, имеет значение.

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

Пример из реального мира

Теперь это может выглядеть как пример, созданный специально для создания проблемы. Это не. Просто замените f на Map::get (которая возвращает null если указанный ключ не содержится в карте или если он сопоставлен со значением null ) и g с любой функцией, которая должна обрабатывать и преобразовывать null .

Вот пример ближе к реальным приложениям. Сначала давайте определим несколько служебных классов:

  • Account , моделирование банковского счета с идентификатором и балансом;
  • Balance , моделирующий пару (сумма, валюта);
  • Currency , перечисление, собирающее несколько констант для наиболее распространенных валют.

Вы можете найти полный код этого примера на GitHub . Чтобы быть ясным, это ни в коем случае не должно быть задумано как правильное проектирование для этих классов: мы значительно упрощаем, просто чтобы сделать пример чище и легко представить.

Теперь предположим, что банк представлен в виде набора счетов, хранящихся на Map , связывающих идентификаторы счетов с экземплярами. Давайте также определим несколько вспомогательных функций для получения баланса счета в долларах США, начиная с идентификатора счета.

 Map<Long, Account> bank = new HashMap<>(); Function<Long, Account> findAccount = id -> bank.get(id); Function<Account, Balance> extractBalance = account -> account != null ? account.getBalance() : new Balance(0., Currency.DOLLAR); Function<Balance, Double> toDollars = balance -> { if (balance == null) { return 0.; } switch (balance.getCurrency()) { case DOLLAR: return balance.getAmount(); case POUND: return balance.getAmount() * 1.3; case EURO: return balance.getAmount() * 1.1; default: return 0.; } }; 

Теперь мы готовы увидеть, где индивидуальное сопоставление наших трех функций отличается от их состава. Давайте рассмотрим несколько разных случаев, когда мы начинаем с идентификатора аккаунта, заключенного в Optional , и сопоставляем его с суммой в долларах для этого аккаунта.

 Optional<Long> accountId3 = Optional.of(3L); Optional<Long> accountId4 = Optional.of(4L); Optional<Long> accountId5 = Optional.of(5L); bank.put(4L, null); bank.put(5L, new Account(5L, null)); 

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

Вместо этого давайте попробуем случай, когда идентификатор учетной записи отсутствует на карте:

 accountId3.map(findAccount) .map(extractBalance) .map(toDollars) .ifPresent(System.out::println); // Optional.empty accountId3.map(findAccount .andThen(extractBalance) .andThen(toDollars)) .ifPresent(System.out::println); // 0.0 

В этом случае findAccount возвращает значение null , которое сопоставляется с Optional.empty . Это означает, что когда мы отображаем наши функции индивидуально, extractBalance никогда даже не будет вызываться, поэтому конечным результатом будет Optional.empty .

Если, с другой стороны, мы составляем findAccount и extractBalance , последний вызывается с null качестве аргумента. Поскольку функция «знает», как обрабатывать null значения, она создает ненулевой вывод, который будет корректно обработан toDollars по цепочке.

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

То же самое происходит, если мы храним null на карте для идентификатора или если баланс аккаунта равен null , поскольку toDollars аналогично toDollars для обработки null . Проверьте репо для получения дополнительной информации.

Практические последствия

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

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

наручники

Возможные исправления

У нас есть две основные альтернативы, чтобы попытаться сделать это правильно:

  • Не используйте Optional .
  • Живи с этим.

Давайте посмотрим каждый из них в деталях.

Не используйте Optional

Ну, это похоже на алгоритм страуса, упомянутый Таненбаумом для решения тупика. Или игнорирование JavaScript из-за его недостатков.

Некоторые разработчики Java явно выступают против Optional , чтобы не отставать от использования null . Конечно, вы можете сделать это, если вам не нужен переход к более чистому, менее подверженному ошибкам стилю программирования . TL; DR : Optional позволяет вам обрабатывать рабочие процессы, в которых некоторый ввод может или не может присутствовать через монады , что означает более модульный и более чистый способ.

Живи с этим

Необязательно нарушает законы монады при нескольких обстоятельствах, и с этим ничего не поделаешь. Но мы можем узнать несколько вещей из того, как мы пришли к такому выводу:

  • При запуске цепочки с возможно null значением и функцией, которая возвращает Optional , имейте в виду, что применение функции к значению может привести к отличному результату — сначала создайте Optional а затем отобразите функцию на плоскости. Это, в свою очередь, может привести к тому, что функция не будет вызываться, что является проблемой, если вы зависите от эффектов сайта.
  • При составлении цепочек map следует помнить, что, хотя отдельные функции никогда не вызывались с null , объединенная функция может выдавать null в качестве промежуточного результата и передавать его в ненулевые безопасные части функции, что приводит к исключениям.
  • Разлагая одну map на несколько, имейте в виду, что, хотя исходная функция была выполнена целиком, ее части теперь могут оказаться в функциях, которые никогда не вызываются, если предыдущая часть выдает null . Это проблема, если вы зависите от эффектов сайта этих частей.

Как правило, предпочитайте flatMap , а не map , так как первая подчиняется закону ассоциативности, а map — нет, и нарушение ассоциативности гораздо более подвержено ошибкам, чем разрыв левой идентичности. В общем, лучше не рассматривать Optional как монаду, которая обещает легкую компоновку, но как средство избежать появления null в качестве аргументов (плоских) отображаемых функций.

Я разработал несколько возможных подходов, чтобы сделать код более надежным, не стесняйтесь проверять их на GitHub

Выводы и дальнейшее чтение

Мы видели, что определенные рефакторинги в flatMap и map могут привести к изменению поведения кода. Основная причина заключается в том, что Optional был разработан, чтобы избежать преткновения в NPE и для достижения того, что он преобразует null в empty . Это, в свою очередь, означает, что такие цепочки выполняются не полностью, а заканчиваются, когда посредническая операция приводит к empty .

Важно помнить об этом, полагаясь на эффекты сайта, которые могут вызывать потенциально невыполненные функции.

Если вы хотите узнать больше о некоторых темах в этой статье: