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

Функторы

Если вы читали вышеупомянутую статью о монадах, вы, возможно, помните, как я описывал их как оболочки вокруг значений. Каждое значение, заключенное в монаду, становится объектом, оснащенным двумя методами: unit и flatMap (с использованием соглашения Scala для именования).

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

Все, что вы получите, это:

  • map (fmap на Haskel; как flatMap монады, но без плоской части)

Функция map, по крайней мере, звучит знакомо, хотя я совершенно уверен, что большинство из вас использовали ее на практике. Новички обычно знакомятся с этим с помощью примера list / array / someOtherCollection, но дело в том, что все функторы имеют эту функцию. Вы можете сопоставить каждый элемент внутри списка или набора, но вы также можете сопоставить значение внутри Future или Option с другим значением (того же или другого типа).

Идем дальше, но сначала мы должны кое-что разобраться. Разве я не объявил Future, Option, List и т. Д. Как монады в статье о монаде? Да. Все, что является монадой, также является функтором; вы можете смотреть на это с точки зрения ООП, как на подкласс (или подтип) функтора. Хорошо, на самом деле не говорите это всем, потому что, если вы наткнетесь на математиков теории категорий, они могут начать говорить о функторах, являющихся просто отображениями элементов и морфизмов между категориями, в то время как монады на самом деле являются моноидами в категории эндофункторов с естественными преобразованиями. определяется как композиция функтора и функтор идентичности. Я не шучу; они действительно так говорят. Но да, с точки зрения программиста, вполне справедливо сказать, что монада на самом деле является функтором с дополнительными элементами на стороне. Более конкретно, поверх монады функтора map есть flatten (также известный как join), который позволяет ей определять обновленную версию map - наша любимая flatMap (также известная как bind) . Помните, что у монады также есть менее интересный, но не менее важный элемент метод.

Помните законы монад? У нас тоже есть законы. Учитывая, что m - это экземпляр нашего функтора (например, List или Future), содержащий некоторое значение (например, Int), а функции f и g являются однопараметрическими функции, которые преобразуют это значение (в нашем примере они должны иметь сигнатуру Int → Something), то мы можем определить два закона функтора следующим образом:

  • закон идентичности:
    map id = id
    или Scala-way: m.map (identity) == m
  • закон распределения:
    F map f map g = F map (g ◦ f)
    или Scala-way: m.map ( f) .map (g) == m.map (x = ›g (f (x))

Здесь identity - это функция идентификации, определенная в пакете Pref в Scala (при заданном значении она просто возвращает то же значение), а - это стандартная математическая запись для композиции функций (что означает «G после f» или «применить f, а затем g»). Обратите внимание, что вы можете использовать compose или andThen, если хотите скомпоновать две функции в Scala; однако суть данной статьи не в этом, поэтому я выбрал самую простую форму и просто применил их одну за другой: g (f (x)).

Тяжелая атлетика

Вместо того, чтобы map () принимать два параметра, мы теперь его карри. Это означает, что функция, например (Int, Int) → Int теперь станет Int → Int → Int. Помните, что это правоассоциативно, поэтому это то же самое, что и Int → (Int → Int). Если вы не знакомы с каррированием, мы скоро к нему вернемся.

Возьмем List в качестве функтора; нашей отправной точкой (то есть нашим стартовым экземпляром функтора) может быть, например, List (1, 2, 3). Давайте также возьмем некоторую функцию, с которой мы можем сопоставить наш список, например:

val f = (x: Int) => x.toString

В Scala мы могли бы сопоставить этот список так же просто, как List (1, 2, 3) .map (f). Теперь, если мы сместим нашу точку обзора, как мы делали раньше, чтобы карта стала бинарной функцией, мы могли бы вызвать ее как map (List (1, 2, 3), f). Первый аргумент - это экземпляр функтора, а второй аргумент - это функция. (Обратите внимание, что здесь я придумываю синтаксис; я просто иллюстрирую концепцию. Во всех стандартных конструкциях Scala map () всегда будет методом класса, который вызывается для экземпляра этого класса, и для этого потребуется только один аргумент - функция сопоставления. Это соглашение.)

Мы сказали, что сделаем еще один шаг и карри, поэтому вызов нашей функции карты теперь становится следующим (обратите внимание, что я поменял места параметров, потому что так легче проиллюстрировать мою точку зрения; функции не нужно изменять ее реализацию если порядок его аргументов в подписи перемешан):

map(f)(List(1, 2, 3))

Каррирование берет функцию от n параметров и превращает ее в однопараметрические функции n. Любая функция с параметром n может быть каррирована следующим образом; фактически, в Haskell это единственный способ получить функцию с более чем одним параметром. Так например. вместо функции, которая принимает два числа и умножает их, вы можете иметь только функцию, которая принимает число и возвращает функцию, которая принимает число и возвращает число. Таким образом, как мы говорили ранее, (Int, Int) → Int становится Int → Int → Int. Или, если вы предпочитаете явно указывать приоритет, он становится Int → (Int → Int).

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

map(f) // returns a function List[Int] => List[String]

Если мы продолжим и передадим наш List (1, 2, 3) результату map (f), мы вернем List («1», «2», «3»). Но если мы остановимся здесь, то вернемся к функции , которая принимает список целых чисел и возвращает список строк. Подход Карри дает нам функцию более высокого порядка, которая отображает typeAtypeB на Functor [typeA]Functor [typeB]. Эта функция высшего порядка широко известна как подъем. Он берет функцию и «поднимает ее», так что ее операнды помещаются в некоторый контекст (например, функтор или монада).

Это означает, что мы можем написать подпись для map () следующим образом:

(A → B) → F[A] → F[B]

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

  • map принимает функцию A → B и экземпляр функтора F [A] и создает другой экземпляр функтора, F [B] (наш классический , карта без карри)
  • map принимает функцию A → B и возвращает функцию F [A] → F [B]

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

Проблема с функторами

Мы видели три разных представления функции карты:

  • способ Scala: m.map (f)
  • как двухпараметрическая функция: map (m, f)
  • как каррированная функция: map (m) (f) или map (f) (m)

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

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

Однако мы также знаем, что с помощью каррирования можно немного схитрить. Если у нас есть функция из n параметров, мы можем каррировать ее и преобразовать в функцию только одного параметра, которая возвращает функцию из n-1 параметров. Это позволит нам передать map () функцию любого количества параметров (конечно, первый должен быть соответствующего типа, чтобы соответствовать тому, который заключен в функтор, например Future [Int] может быть отображено с помощью функции Int → Whatever → Whatever → Whatever →…).

Хорошая идея, но map () она не очень нравится.

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

val f = (x: Int) => (y: Int) => x + y

Если мы скармливаем этой функции значение, мы вернем функцию с одним параметром меньше, чем исходный, что оставляет нам только один параметр (в нашем случае с именем «y»). Итак, если мы скармливаем функции f значение 42, мы вернем функцию, которая принимает число и добавляет к нему 42.

Прохладный. Итак, давайте скормим нашу каррированную функцию map ():

val f = (x: Int) => (y: Int) => x + y
val result = Future(42).map(f)
// result is Future(x => x + 42)

Мы возвращаемся в будущее, в котором целое число 42 преобразуется в функцию, которая принимает некоторое целое число x и возвращает x +42 . Теперь это небольшая проблема. Как мы уже говорили ранее, обычно, когда вы каррируете функцию из n параметров и предоставляете ей первый параметр, вы получаете обратно функцию из n-1 параметров. В случае нашей функции f, которая складывает два числа, мы вернем функцию Int → Int. Но когда мы передаем эту функцию в map (), мы получаем не Int → Int, а Future [Int → Int]. Мы не можем больше применять какие-то данные к остальной части нашей каррированной функции, потому что map () не знает, как работать с функцией, заключенной в future.

Позвольте мне повторить это еще раз, это ключевая часть. Итак, если бы мы хотели сопоставить наше будущее с некоторой функцией, скажем, четырех параметров на этот раз, например f (a: Int, b: Int, c: Int, d: Int), мы должны преобразовать функцию в Int → Int → Int → Int и передать ее в map (), но на этом все будет кончено. Карта будет использовать первый параметр, но не оставит нас с Int → Int → Int. Если бы это было так, мы могли бы продолжить то же самое. В результате мы получим Future [Int → Int → Int].

Теперь нам нужен аппликативный функтор.

Аппликативные функторы

Аппликативный функтор (иногда называемый просто аппликативным) - это обновленная версия общего функтора, которую мы видели ранее. В то время как функторы используют только однопараметрические функции, аппликативные функторы могут использовать функции с любым количеством аргументов. Они также вводят метод unit / return, который переводит данный тип A в F [A]. (Примечание: аппликативные функторы также имеют несколько иной набор законов, чем обычные функторы; я не буду вдаваться в них здесь, поскольку я просто намерен объяснить идею аппликативных функторов, оставляя при этом механические части, такие как законы к вашему личному исследованию, когда вы освоите концепцию)

Давайте снова рассмотрим некоторую четырехпараметрическую функцию f в каррированной форме. Его подпись: Int → Int → Int → Int. Теперь давайте скормим его map () некоторого Future [Int]. Результат: Future [Int → Int → Int]. Мы прошли через это минуту назад. Хорошо, но теперь происходит кое-что классное: в игру вступает наш новый друг, аппликативный функтор и говорит: «Эй, так у вас есть функция, заключенная в некоторый контекст, а? Не волнуйтесь, я знаю, как применять такие обернутые функции. Да, вы меня хорошо слышали; Я могу применять функции в контексте функтора (например, Future, Option или List) ». Чтобы избежать путаницы, оставим термин «отображение» стандартным функторам и будем использовать термин «применять» для этой новой классной функции в аппликативных функторах. Итак, если карта определена как (с использованием каррированной записи):

  • карта [A, B] (f: F [A]) (f: A → B): F [B]

тогда наш новый метод apply () определяется как:

  • применить [A, B] (f: F [A]) (f: F [A → B]): F [B]

Есть еще два способа определить аппликативный функтор: заменить apply столь же мощным map2 или заменить его комбинацией product и карта.

Вот все три определения:

  1. unit [A] (a: A): F [A]
    применить [A, B] (f: F [A]) (f: F [A → B]): F [B]
  2. unit [A] (a: A): F [A]
    map2 [A, B, C] (fa: F [A], fb: F [B]) (f: (A, B) = ›C): F [C]
  3. unit [A] (a: A): F [A]
    карта [A] (fa: F [A]]) (f: A = ›B): F [B]
    product [A, B] (fa: F [A], fb: F [B]): F [(A, B)]

Когда я сказал, что методы apply и map2 одинаково мощны, я имел в виду, что вы можете выразить одно, используя другое. Продукт немного слабее и должен сопровождаться картой. Я не буду показывать здесь переводы между этими определениями, но вы можете найти их в Интернете (например, здесь) или попытаться найти их самостоятельно.

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

Решение проблемы с функтором

Хорошо. Так что же произойдет, если наполнить карту будущего () функцией Int → Int → Int → Int? Мы возвращаем Future [Int → Int → Int]. Затем мы можем скормить его apply () с помощью Future [Int → Int → Int] и получить обратно F uture [Int → Int]. Наконец, мы применяем Future [Int → Int] и остаемся с Future [Int].

Давайте закончим наш предыдущий пример с добавлением на 42. Обратите внимание, что я собираюсь написать еще несколько псевдо-Scala, потому что в Future нет метода apply (), определенного таким образом. А пока представьте себе, что такой метод существует, и мы объясним это затруднительное положение позже.

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

val f = (x: Int) => (y: Int) => x + y
val r1: Future[Int => Int] = Future(42).map(f)
Future(10).apply(r1) // results in Future(52)

Вы видите, что здесь произошло? Мы взяли два значения типа Future [Int] (Future (42) и Future (10)) и функцию f с сигнатурой Int → Int, и нам удалось создать еще одно Future, базовое значение которого является результатом применения значений наших двух начальных Futures к функции f. Это действительно здорово. Допустим, вы извлекаете из базы данных три точки треугольника и получаете три значения Future [Int]. Как рассчитать периметр треугольника, образованного этими точками, с помощью функции calculate (a: Int, b: Int, c: Int)? Вы не можете просто передать точки в функцию, потому что точки имеют не тип Int, а тип Future [Int].

Использование аппликативного функтора становится возможным:

val f1 = Future(3)
val f2 = Future(4)
val f3 = Future(5)
val calculate = (a: Int) => (b: Int) => (c: Int) => a + b + c
// btw you can also do:
// val calculate = ( (a:Int, b:Int, c:Int) => a + b + c ).curried
f1.apply(f2.apply(f3.apply(unit(calculate)))) // Future(12)

Круто, правда? Обратите внимание, что нам пришлось обернуть функцию в прикладной контекст с помощью unit, чтобы apply мог ее использовать.

Хватит этой псевдо-скалы. Напишем реальный код. Воспользуемся библиотекой scalaz:

import scalaz._, Scalaz._
val f1 = Future(3)
val f2 = Future(4)
val f3 = Future(5)
val calculate = (a: Int) => (b: Int) => (c: Int) => a + b + c
val area = f1 <*> (f2 <*> (f3 <*> Future(calculate))) // Future(12)

Оператор ‹*› - это операция применить. Для unit я просто использовал конструктор Future.

Помните, как мы говорили о трех разных определениях аппликативов? Давайте посмотрим, как все работает с product + map:

import scalaz._, Scalaz._
val f1 = Future(3)
val f2 = Future(4)
val f3 = Future(5)
val calculate = (a: Int) => (b: Int) => (c: Int) => a + b + c
val area = (f1 |@| f2 |@| f3)(calculate)// Future(12)

Оператор | @ | - это операция product. Здесь мы собрали три аппликатора и «объединили» их в один (мы вычислили их product), а затем сопоставили этот продукт с помощью нашей функции calculate. Обратите внимание, что мы не вызывали map () явно, потому что так работает синтаксис scalaz для аппликативного продукта; объединение ваших приложений в продукт с помощью | @ | приводит к ApplicativeBuilder, который выполняет функцию для продукта (поскольку продукт + карта является очень распространенным вариантом использования). Обратите внимание на одну деталь: calculate здесь не может быть каррирован, поскольку аппликативный продукт принимает неторопливую многопараметрическую функцию (в данном случае арность 3). я

Если вам легче рассуждать обо всем процессе, используя определение product вместо использования определения apply, это на самом деле вполне нормально. Я показал вам оба пути, вы можете выбрать то, что вам больше нравится. Все, что вы можете сделать с помощью unit + apply, вы можете сделать с помощью unit + product + map и наоборот. То же самое и с третьим определением, unit + map2 (я позволю вам изучить это самостоятельно; в любом случае оно было удалено из scalaz7, поэтому, если вы решите пропустить его, ничего страшного).

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

val area = (f1 zip f2 zip f3) map { 
  case ((a, b), c) => calculate(a, b, c) 
} // Future(12)

Немного коряво, но выполнимо.

Заключение

«Обычные» функторы - это, по сути, оболочки для значений некоторого типа. List [Int], Future [String] и Option [Whatever] являются примерами функторов.

Аппликативные функторы немного сложнее - они умеют применять функцию, заключенную в контекст функтора. Например, для функтора Future [Int] мы можем применить к нему функцию Future [Int → Int], тогда как обычный map () в обычных функторах знает только, как применить Int → Int. Или вы можете посмотреть на это так: поверх функциональности обычного функтора map аппликативный функтор добавляет функцию unit, которая поднимает обычный A в контекст функтора F [A] и функцию product, которые можно использовать для объединения нескольких функторов в один.

Итак, есть несколько способов описания аппликативного функтора, которые приводят к одному и тому же основному принципу. Аппликативный функтор похож на функтор, но:

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

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

Возможно, вы уже знаете что-то о монадах, а можете и не знать. Что ж, монады идут еще дальше и предоставляют вам еще более мощную операцию. Вот они для сравнения, все три (я опускаю unit, потому что он не нужен для того, чтобы подчеркнуть эту точку зрения):

  • функтор:
    map (f: F [A]) (f: A → B): F [B]
  • аппликативный функтор:
    apply (f: F [A]) (f: F [A → B]): F [B]
  • монада:
    flatMap (f: F [A]) (f: A → F [B]): F [B]

Точно так же, как аппликативы могут использовать другие определения (например, product + map вместо apply), то же самое касается монад (например, flatten + map вместо flatMap). Монады - самые могущественные; они могут быть «обусловлены», что означает, что одна монада может действовать в зависимости от результата предыдущей монадической операции. Или, если хотите - они могут работать последовательно, тогда как аппликативы работают параллельно. Видите, что f: A → F [B] в определении монады? Это та часть, которая делает возможной эту зависимость; он говорит: «возьмите A из монады F [A] и вычислите монаду F [B] на ее основе».

Итак, если у вас, например, n асинхронных запросов (например, запросов к базе данных), которые приводят к n Futures, вы должны выбрать правильную абстракцию в зависимости от ваших потребностей:

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

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

Ладно, пока все. Как обычно, если вы чувствуете, что что-то неясно, сбивает с толку, вводит в заблуждение или неверно, напишите мне здесь комментарий или напишите мне письмо на [email protected]. Также не стесняйтесь найти меня в Twitter.

Ваше здоровье!

Хакерский полдень - это то, с чего хакеры начинают свои дни. Мы часть семьи @AMI. Сейчас мы принимаем заявки и рады обсуждать рекламные и спонсорские возможности.

Чтобы узнать больше, прочтите нашу страницу о нас, поставьте лайк / напишите нам в Facebook или просто tweet / DM @HackerNoon.

Если вам понравился этот рассказ, мы рекомендуем прочитать наши Последние технические истории и Современные технические истории. До следующего раза не воспринимайте реалии мира как должное!