Статьи

Функциональная Java на примере |

Это часть 8, последняя часть серии под названием «Функциональная Java на примере».

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

В этом последнем посте серии я довожу функции до крайности : все становится функцией.

Если вы пришли впервые, лучше начать читать с самого начала. Это помогает понять, с чего мы начали и как продвигались вперед на протяжении всей серии.

Это все части:

Я буду обновлять ссылки по мере публикации каждой статьи. Если вы читаете эту статью через синдикацию контента, пожалуйста, проверьте оригинальные статьи в моем блоге .

Каждый раз также код добавляется в этот проект GitHub .

Максимизация движущихся частей

Возможно, вы слышали следующую фразу Micheal Feathers :

ОО делает код понятным за счет инкапсуляции движущихся частей. FP делает код понятным, сводя к минимуму движущиеся части.

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class FeedHandler {
 
  List<Doc> handle(List<Doc> changes,
    Function<Doc, Try<Resource>> creator) {
 
    changes
      .findAll { doc -> isImportant(doc) }
      .collect { doc ->
        creator.apply(doc)
        }.map { resource ->
          setToProcessed(doc, resource)
        }.getOrElseGet { e ->
          setToFailed(doc, e)
        }
      }
  }
 
  private static boolean isImportant(doc) {
    doc.type == 'important'
  }
 
  private static Doc setToProcessed(doc, resource) {
    doc.copyWith(
      status: 'processed',
      apiId: resource.id
    )
  }
 
  private static Doc setToFailed(doc, e) {
    doc.copyWith(
      status: 'failed',
      error: e.message
    )
  }
 
}

Заменить на функциональные типы

Мы можем заменить каждый метод ссылкой на переменную типа функционального интерфейса , например, Predicate или BiFunction .

А) Мы можем заменить метод, который принимает 1 аргумент, который возвращает логическое значение .

1
2
3
private static boolean isImportant(doc) {
  doc.type == 'important'
}

предикатом

1
2
3
private static Predicate<Doc> isImportant = { doc ->
  doc.type == 'important'
}

Б) и мы можем заменить метод, который принимает 2 аргумента и возвращает результат

1
2
3
4
5
6
7
private static Doc setToProcessed(doc, resource) {
  ...
}
 
private static Doc setToFailed(doc, e) {
  ...
}

с бифункцией

1
2
3
4
5
6
7
private static BiFunction<Doc, Resource, Doc> setToProcessed = { doc, resource ->
  ...
}
 
private static BiFunction<Doc, Throwable, Doc> setToFailed = { doc, e ->
  ...
}

Чтобы фактически вызвать логику, инкапсулированную в (Bi) функцию, мы должны вызвать apply для нее. Результат следующий:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class FeedHandler {
 
  List<Doc> handle(List<Doc> changes,
    Function<Doc, Try<Resource>> creator) {
 
    changes
      .findAll { isImportant }
      .collect { doc ->
        creator.apply(doc)
        .map { resource ->
          setToProcessed.apply(doc, resource)
        }.getOrElseGet { e ->
          setToFailed.apply(doc, e)
        }
      }
  }
 
  private static Predicate<Doc> isImportant = { doc ->
    doc.type == 'important'
  }
 
  private static BiFunction<Doc, Resource, Doc> setToProcessed = { doc, resource ->
    doc.copyWith(
      status: 'processed',
      apiId: resource.id
    )
  }
 
  private static BiFunction<Doc, Throwable, Doc> setToFailed = { doc, e ->
    doc.copyWith(
      status: 'failed',
      error: e.message
    )
  }
 
}

Перемещение всего ввода в саму функцию

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

Подпись метода изменится с:

1
2
List<Doc> handle(List<Doc> changes,
  Function<Doc, Try<Resource>> creator)

в

1
2
3
4
5
List<Doc> handle(List<Doc> changes,
  Function<Doc, Try<Resource>> creator,
  Predicate<Doc> filter,
  BiFunction<Doc, Resource, Doc> successMapper,
  BiFunction<Doc, Throwable, Doc> failureMapper)

Во-вторых, мы переименовываем наши оригинальные (статические) переменные Predicate и BiFunction.

  • isImportant
  • setToProcessed
  • setToFailed

новым постоянным в верхней части класса, отражая их новую роль, соотв.

  • DEFAULT_FILTER
  • DEFAULT_SUCCESS_MAPPER
  • DEFAULT_FAILURE_MAPPER

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

Например, когда нужно настроить только обработку ошибок, метод handle может быть вызван так:

01
02
03
04
05
06
07
08
09
10
11
12
BiFunction<Doc, Throwable, Doc> customFailureMapper = { doc, e ->
  doc.copyWith(
    status: 'my-custom-fail-status',
    error: e.message
  )
}
 
new FeedHandler().handle(...,
  FeedHandler.DEFAULT_FILTER,
  FeedHandler.DEFAULT_SUCCESS_MAPPER,
  customFailureMapper
  )

Если ваш язык поддерживает это, вы можете убедиться, что ваш клиент на самом деле не должен предоставлять каждый параметр, назначая значения по умолчанию. Я использую Apache Groovy, который поддерживает присвоение значений по умолчанию параметрам в методе:

1
2
3
4
5
List<Doc> handle(List<Doc> changes,
  Function<Doc, Try<Resource>> creator,
  Predicate<Doc> filter = DEFAULT_FILTER,
  BiFunction<Doc, Resource, Doc> successMapper = DEFAULT_SUCCESS_MAPPER,
  BiFunction<Doc, Throwable, Doc> failureMapper = DEFAULT_FAILURE_MAPPER)

Посмотрите на код, прежде чем мы собираемся применить еще одно изменение:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class FeedHandler {
 
  private static final Predicate<Doc> DEFAULT_FILTER = { doc ->
    doc.type == 'important'
  }
 
  private static final BiFunction<Doc, Resource, Doc> DEFAULT_SUCCESS_MAPPER = { doc, resource ->
    doc.copyWith(
      status: 'processed',
      apiId: resource.id
    )
  }
 
  private static final BiFunction<Doc, Throwable, Doc> DEFAULT_FAILURE_MAPPER = { doc, e ->
    doc.copyWith(
      status: 'failed',
      error: e.message
    )
  }
 
  List<Doc> handle(List<Doc> changes,
                   Function<Doc, Try<Resource>> creator,
                   Predicate<Doc> filter = DEFAULT_FILTER,
                   BiFunction<Doc, Resource, Doc> successMapper = DEFAULT_SUCCESS_MAPPER,
                   BiFunction<Doc, Throwable, Doc> failureMapper = DEFAULT_FAILURE_MAPPER) {
 
    changes
      .findAll { filter }
      .collect { doc ->
        creator.apply(doc)
        .map { resource ->
          successMapper.apply(doc, resource)
        }.getOrElseGet { e ->
          failureMapper.apply(doc, e)
        }
      }
  }
 
}

Введите либо

Вы заметили следующую часть?

1
2
3
4
5
6
7
8
.collect { doc ->
  creator.apply(doc)
  .map { resource ->
    successMapper.apply(doc, resource)
  }.getOrElseGet { e ->
    failureMapper.apply(doc, e)
  }
}

Помните, что тип creator

1
Function<Doc, Try<Resource>>

Это означает, что он возвращает Try . Мы представили Try в части 7 , заимствуя его из таких языков, как Scala.

К счастью, переменная «doc» из collect { doc все еще находится в области действия, чтобы передать ее нашим successMapper и failureMapper которые в ней нуждаются , но есть несоответствие между сигнатурой метода Try#map , которая принимает функцию , и нашим successMapper , который БиФункция . То же самое касается Try#getOrElseGet — ему также нужна только функция .

Из Try Javadocs:

  • map (функция <? super T, extends U> mapper)
  • getOrElseGet (Функция <? super Throwable, расширяет T> прочее)

Проще говоря, нам нужно идти от

  1. BiFunction <Doc, Resource, Doc> successMapper
  2. BiFunction <Doc, Throwable, Doc> faultMapper

в

  1. Функция <Resource, Doc> successMapper
  2. Функция <Throwable, Doc> faultMapper

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

Давайте введем два простых типа, инкапсулирующих 2 аргумента 2 BiFunctions:

1
2
3
4
5
6
7
8
9
class CreationSuccess {
  Doc doc
  Resource resource
}
 
class CreationFailed {
  Doc doc
  Exception e
}

Мы меняем аргументы от

  1. BiFunction <Doc, Resource, Doc> successMapper
  2. BiFunction <Doc, Throwable, Doc> faultMapper

вместо функции :

  1. Функция <CreationSuccess, Doc> successMapper
  2. Функция <CreationFailed, Doc> faultMapper

Метод handle теперь выглядит так:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
List<Doc> handle(List<Doc> changes,
                 Function<Doc, Try<Resource>> creator,
                 Predicate<Doc> filter,
                 Function<CreationSuccess, Doc> successMapper,
                 Function<CreationFailed, Doc> failureMapper) {
 
  changes
    .findAll { filter }
    .collect { doc ->
      creator.apply(doc)
      .map(successMapper)
      .getOrElseGet(failureMapper)
    }
}

но это еще не работает .

При getOrElseGet сделать map и getOrElseGet требуют соотв.

  • Функция <Resource, Doc> successMapper
  • Функция <Throwable, Doc> faultMapper

Вот почему мы должны изменить его на другую известную конструкцию FP, называемую Either .

К счастью, у Вавра тоже есть Либо . Его Javadoc говорит:

Любой представляет значение двух возможных типов.

Тип Either обычно используется для различения между значением, которое является правильным («правильным») или ошибочным.

Абстрагируется довольно быстро:

Либо это либо Либо. Левый, либо Либо. Правый. Если данный Either является Right и спроецирован на Left, операции Left не влияют на значение Right. Если данный Either является Left и проецируется вправо, операции Right не влияют на значение Left. Если слева проецируется слева или справа проецируется справа, операции имеют эффект.

Позвольте мне объяснить выше загадочную документацию. Если мы заменим

1
Function<Doc, Try<Resource>> creator

по

1
Function<Doc, Either<CreationFailed, CreationSuccess>> creator

мы присваиваем CreationFailed аргументу «left», который по соглашению обычно содержит ошибку (см. документацию по Haskell на любом из них ), а CreationSuccess — это «правильное» (и «правильное») значение.

Во время выполнения реализация возвращала Try , но теперь она может вернуть Either.Right в случае успеха, например

1
2
3
4
5
6
return Either.right(
  new CreationSuccess(
    doc: document,
    resource: [id: '7']
  )
)

или Either.Left с исключением в случае сбоя — и оба, включая исходный документ тоже . Да.

Потому что теперь в конечном итоге типы совпадают, мы наконец сквош

1
2
3
4
5
6
7
8
.collect { doc ->
  creator.apply(doc)
  .map { resource ->
    successMapper.apply(doc, resource)
  }.getOrElseGet { e ->
    failureMapper.apply(doc, e)
  }
}

в

1
2
3
4
5
.collect { doc ->
  creator.apply(doc)
  .map(successMapper)
  .getOrElseGet(failureMapper)
}

Метод handle теперь выглядит так:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
List<Doc> handle(List<Doc> changes,
                 Function<Doc, Either<CreationFailed, CreationSuccess>> creator,
                 Predicate<Doc> filter,
                 Function<CreationSuccess, Doc> successMapper,
                 Function<CreationFailed, Doc> failureMapper) {
 
  changes
    .findAll { filter }
    .collect { doc ->
      creator.apply(doc)
      .map(successMapper)
      .getOrElseGet(failureMapper)
    }
}

Вывод

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

  • Да, мне удалось избежать переназначения переменных
  • Да, мне удалось избежать изменчивых структур данных
  • Да, мне удалось избежать состояния (ну, по крайней мере, в FeedHandler)
  • Да, мне удалось отдать предпочтение функциям (используя некоторые из встроенных функциональных типов Java и некоторые сторонние библиотеки Vavr)

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

  • фильтрация списка документов по некоторым критериям
  • создание ресурса для каждого документа
  • сделать что-то, когда ресурс был успешно создан
  • делать что-то еще, когда ресурс не может быть создан

Тем не менее, в первой части эти обязанности были выписаны в обязательном порядке , утверждение для утверждения, все объединены в один метод большой handle . Теперь, в конце, каждое решение или действие представляется функцией с абстрактными именами, такими как «фильтр», «создатель», «successMapper» и «failMapper». По сути, она стала функцией более высокого порядка, взяв одну из нескольких функций в качестве аргумента. Ответственность за предоставление всех аргументов была перенесена на уровень вверх по стеку к клиенту. Если вы посмотрите на проект GitHub, вы заметите, что для этих примеров мне приходилось постоянно обновлять модульные тесты.

Спорные части

На практике я, вероятно, не стал бы писать свой (Java) бизнес-код, например, как класс FeedHandler отношении использования передачи в универсальных функциональных типах Java (т. BiFunction Function , BiFunction , Predicate , Consumer , Supplier ), если мне не нужно все это чрезвычайная гибкость. Все это происходит за счет читабельности. Да, Java является языком со статической типизацией, поэтому, используя дженерики, нужно быть явным во всех параметрах типа , что приводит к сложной сигнатуре функции:

1
2
3
4
5
handle(List<Doc> changes,
Function<Doc, Either<CreationFailed, CreationSuccess>> creator,
Predicate<Doc> filter,
Function<CreationSuccess, Doc> successMapper,
Function<CreationFailed, Doc> failureMapper)

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

1
handle = function(changes, creator, filter, successMapper, failureMapper)

Но эй, это компромисс. Groovy, также язык JVM, позволил бы мне опустить информацию о типах во всех примерах этой серии, и даже позволил мне использовать Closures (как лямбда-выражения в Java), которые являются ядром парадигмы функционального программирования в Groovy.

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

1
2
3
4
5
handle(List<T> changes,
Function<T, Either<R, S>> creator,
Predicate<T> filter,
Function<S, T> successMapper,
Function<R, T> failureMapper)

Когда это уместно?

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

В конечном счете, выше немного затрагивается дизайн API , да, и разделение , но «превращение всего в функцию» в типичном Java-проекте Enterprise ™, вероятно, требует некоторого обсуждения с вами и вашими товарищами по команде. Некоторые коллеги с годами привыкли к более традиционному идиоматическому способу написания кода.

Хорошие части

  • Я бы определенно предпочел неизменяемые структуры данных (и «ссылочную прозрачность»), чтобы помочь рассуждать о состоянии, в котором находятся мои данные. Подумайте о Collections.unmodifiableCollection для коллекций. В моих примерах я использовал Groovy @Immutable для POJO, но в простых библиотеках Java, таких как Immutables , AutoValue или Project Lombok, можно использовать.
  • Самым большим улучшением на самом деле стало создание более функционального стиля: создание кода, рассказывающего историю , которая состояла в основном из разделения задач и надлежащего присвоения имен. Это хорошая практика в любом стиле программирования (даже OO: D), но это действительно очистило хаос и позволило вообще ввести (чистые) функции.
  • В Java мы настолько привыкли к обработке исключений особым образом, что разработчикам, таким как я, сложно придумывать другие решения. Функциональный язык, такой как Haskell, просто возвращает коды ошибок, потому что «Никлаус Вирт рассматривал исключения как реинкарнацию GOTO и таким образом опускал их» . В Java можно использовать CompletableFuture или…
  • Определенные типы, такие как Try and Either , которые можно использовать в своей собственной кодовой базе, представляя стороннюю библиотеку, такую ​​как Vavr, могут помочь в создании большего количества опций, написанных в стиле FP! Я был очень очарован элегантностью написания путей «успеха» или «неудачи» бегло и быть очень читабельным.

Java — это не Scala, не Haskell или Clojure для F #, и изначально она следовала парадигме объектно-ориентированного программирования (ООП), точно так же, как C ++, C #, Ruby и т. Д., Но после введения лямбда-выражений в Java 8 и в сочетании с некоторыми удивительными библиотеки с открытым исходным кодом, разработчики в настоящее время определенно могут выбирать и смешивать лучшие элементы, которые могут предложить OOP и FP .

Уроки, извлеченные из выполнения серии

Я начал эту серию очень давно . Еще в 2017 году я провел несколько рефакторингов в стиле FP для фрагмента кода, что вдохновило меня на поиск примера для серии статей под названием «Функциональная Java на примере» . Это стало кодом FeedHandler который я использовал в каждой статье.

Я уже тогда делал все индивидуальные изменения кода, но в то время, когда я планировал писать реальные посты в блоге, я часто думал: «Я просто не могу показать только рефакторинг, я должен что-то объяснить!» Вот где я как бы заложил ловушку для себя, потому что со временем у меня становилось все меньше и меньше времени, чтобы сидеть и писать . (Любой, кто когда-либо писал блог, знает разницу во времени, когда он просто делится смыслом и пишет понятные абзацы понятного английского языка ?)

В следующий раз, когда я подумаю о создании серии, я вернусь в Google для некоторых из этих уроков:

  1. Не включайте оглавление (TOC) вверху каждой статьи, если вы не готовы обновлять все ссылки каждый раз в каждой ранее опубликованной партии при публикации новой статьи. И если вы перепишете их в корпоративном блоге компании, это в 2 раза больше работы ?
  2. Со временем вы можете прийти к выводу, что вы предпочитаете отклоняться от основного варианта использования, вашего Большого примера кодирования, с которого вы начинали. Я предпочел бы продемонстрировать гораздо больше концепций FP — таких как каррирование, запоминание, лень, а также другое мышление при использовании техник FP — но я не мог вписаться в это в рамках ранее выполненных рефакторингов и TOC, которые я установил в начале , Если вы пишете о конкретной концепции, обычно можно найти соответствующий пример, помогающий объяснить конкретную концепцию под рукой, и при этом относящийся к читателю. Со временем, как я понял, приходит понимание того, что лучше написать о следующем и какие более подходящие примеры использовать. В следующий раз я должен найти способ дать (лучше: позволить) себе творческую свободу на этом пути ?

Прочитайте больше

Если у вас есть какие-либо комментарии или предложения, я хотел бы услышать о них!

Удачного программирования! ?

Опубликовано на Java Code Geeks с разрешения Теда Винке, партнера нашей программы JCG . Смотрите оригинальную статью здесь: Функциональная Java на примере | Часть 8 — Больше чистых функций

Мнения, высказанные участниками Java Code Geeks, являются их собственными.