Что такое монады в программировании
Перейти к содержимому

Что такое монады в программировании

  • автор:

Монады за 15 минут

На конференции YOW! 2013 один из разработчиков языка Haskell, проф. Филип Вадлер, показал, как монады позволяют чистым функциональным языкам осуществлять императивные по сути операции, такие, как ввод-вывод и обработку исключений. Неудивительно, что интерес аудитории к этой теме породил взрывной рост публикаций о монадах в Интернет. К сожалению, бо́льшая часть этих публикаций использует примеры, написанные на функциональных языках, подразумевая, что о монадах хотят узнать новички в функциональном программировании. Но монады не специфичны для Haskell или функциональных языков, и вполне могут быть проиллюстрированы примерами на императивных языках программирования. Это и является целью данного руководства.

Чем это руководство отличается от остальных? Мы попытаемся не более чем за 15 минут «открыть» монады, используя лишь интуицию и несколько элементарных примеров кода на Python. Мы поэтому не станем теоретизировать и углубляться в философию, рассуждая о буррито, космических скафандрах, письменных столах и эндофункторах.

Мотивационные примеры

Мы рассмотрим три проблемы, относящиеся к композиции функций. Мы решим их двумя способами: обычным императивным и при помощи монад. Затем мы сравним разные подходы.

1. Логгирование

Допустим, у нас есть три унарные функции: f1 , f2 и f3 , принимающие число и возвращающие его увеличенным на 1, 2 и 3 соответственно. Также каждая функция генерирует сообщение, представляющее собой отчёт о выполненной операции.

def f1(x): return (x + 1, str(x) + "+1") def f2(x): return (x + 2, str(x) + "+2") def f3(x): return (x + 3, str(x) + "+3")

Мы хотели бы объединить их в цепочку для обработки параметра x , иначе говоря, мы хотели бы вычислить x+1+2+3 . Кроме того, нам нужно получить человекочитаемое объяснение того, что было сделано каждой функцией.

Можно добиться нужного нам результата следующим способом:

log = "Ops:" res, log1 = f1(x) log += log1 + ";" res, log2 = f2(res) log += log2 + ";" res, log3 = f3(res) log += log3 + ";" print(res, log)

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

В идеале, программа должна выглядеть как простая цепочка функций, вроде f3(f2(f1(x))) . К сожалению, типы данных, возвращаемых f1 и f2 , не соответствуют типам параметров f2 и f3 . Но мы можем добавить в цепочку новые функции:

def unit(x): return (x, "Ops:") def bind(t, f): res = f(t[0]) return (res[0], t[1] + res[1] + ";")

Теперь мы можем решить проблему следующим образом:

print(bind(bind(bind(unit(x), f1), f2), f3))

Следующая диаграмма показывает вычислительный процесс, происходящий при x=0 . Здесь v1 , v2 и v3 − значения, полученные в результате вызовов unit и bind .

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

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

bind(f4, bind(f3, . ))

И никаких других изменений нам делать не понадобится.

2. Список промежуточных значений

Этот пример мы также начнём с простых унарных функций.

def f1(x): return x + 1 def f2(x): return x + 2 def f3(x): return x + 3

Как и в предыдущем примере, нам нужно скомпоновать эти функции, чтобы вычислить x+1+2+3 . Также нам нужно получить список всех значений, полученных в результате работы наших функций, то есть x , x+1 , x+1+2 и x+1+2+3 .

В отличие от предыдущего примера, наши функции компонуемы, то есть типы их входных параметров совпадают с типом результата. Поэтому простая цепочка f3(f2(f1(x))) вернёт нам конечный результат. Но в таком случае мы потеряем промежуточные значения.

Решим задачу «в лоб»:

lst = [x] res = f1(x) lst.append(res) res = f2(res) lst.append(res) res = f3(res) lst.append(res) print(res, lst)

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

Поэтому добавим две дополнительные функции, как и в предыдущем примере:

def unit(x): return (x, [x]) def bind(t, f): res = f(t[0]) return (res, t[1] + [res])

Теперь мы перепишем программу в виде цепочки вызовов:

print(bind(bind(bind(unit(x), f1), f2), f3))

Следующая диаграмма показывает вычислительный процесс, происходящий при x=0 . Снова v1 , v2 и v3 обозначают значения, полученные в результате вызовов unit и bind .

3. Пустые значения

Попробуем привести более интересный пример, на этот раз с классами и объектами. Допустим, у нас есть класс Employee с двумя методами:

class Employee: def get_boss(self): # Return the employee's boss def get_wage(self): # Compute the wage

Каждый объект класса Employee имеет руководителя (другой объект класса Employee ) и зарплату, доступ к которым осуществляется через соответствующие методы. Оба метода могут также возвращать None (работник не имеет руководителя, зарплата неизвестна).

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

В идеале нам нужно написать что-то вроде

print(john.get_boss().get_wage())

Но в таком случае, если какой-то из методов вернёт None , наша программа завершится с ошибкой.

Очевидный способ учесть эту ситуацию может выглядеть так:

result = None if john is not None and john.get_boss() is not None and john.get_boss().get_wage() is not None: result = john.get_boss().get_wage() print(result)

В этом случае мы допускаем лишние вызовы методов get_boss и get_wage . Если эти методы достаточно тяжелы (например, обращаются к базе данных), наше решение не годится. Поэтому изменим его:

result = None if john is not None: boss = john.get_boss() if boss is not None: wage = boss.get_wage() if wage is not None: result = wage print(result)

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

def unit(e): return e def bind(e, f): return None if e is None else f(e)

И теперь мы можем скомпоновать всё решение в одну строку.

print(bind(bind(unit(john), Employee.get_boss), Employee.get_wage))

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

Обратите внимание также на то, что в Python мы можем использовать методы как функции, что позволило нам написать Employee.get_boss(john) вместо john.get_boss() .

Следующая диаграмма показывает процесс вычислений в том случае, когда у Джона нет руководителя, то есть john.get_boss() возвращает None .

Выводы

Допустим, мы хотим объединить однотипные функции f1 , f2 , … , fn . Если их входные параметры совпадают по типу с результатами, мы можем использовать простую цепочку вида fn(… f2(f1(x)) …) . Следующая диаграмма показывает обобщённый процесс вычислений с промежуточными результатами, обозначенными как v1 , v2 , … , vn .

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

1. Императивное решение

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

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

2. Монады

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

bind(bind( . bind(bind(unit(x), f1), f2) . fn-1), fn)

Процесс вычисления можно представить диаграммой:

Вызов unit(x) генерирует начальное значение v1 . Затем bind(v1, f1) генерирует новое промежуточное значение v2 , которое используется в следующем вызове bind(v2, f2) . Этот процесс продолжается, пока не будет получен итоговый результат. Определяя различные unit и bind в рамках этого шаблона, мы можем объединять различные функции в одну цепочку вычислений. Библиотеки монад (например, PyMonad или OSlash, − прим. перев.) обычно содержат готовые к употреблению монады (пары функций unit и bind ) для реализации определённых композиций функций.

Чтобы собрать функции в цепочку, значения, возвращаемые unit и bind , должны быть того же типа, что и входные параметры bind . Этот тип называется монадическим. В терминах вышеприведённой диаграммы, тип переменных v1 , v2 , … , vn должен быть монадическим типом.

Наконец, рассмотрим, как можно улучшить код, написанный с использованием монад. Очевидно, повторяющиеся вызовы bind выглядят неэлегантно. Чтобы избежать этого, определим ещё одну внешнюю функцию:

def pipeline(e, *functions): for f in functions: e = bind(e, f) return e

Теперь вместо

bind(bind(bind(bind(unit(x), f1), f2), f3), f4)

мы можем использовать следующее сокращение:

pipeline(unit(x), f1, f2, f3, f4)

Заключение

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

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

  1. Википедия
  2. Monads in Python (with nice syntax!)
  3. Monad tutorials timeline
  • Python
  • Функциональное программирование

Монада (программирование)

Мона́да в программировании — это абстракция линейной цепочки связанных вычислений. Её основное назначение — инкапсуляция функций с побочным эффектом от чистых функций, а точнее их выполнений от вычислений [1] . Монады применяются в языке Haskell, так как он повсеместно использует ленивые вычисления, которые вместе с побочным эффектом, как правило, образуют плохо прогнозируемый результат. Она описывается [2] полиморфным контейнерным типом для выполнений с одним параметром, стратегией «поднятия» значения в монаду и стратегией связывания двух вычислений, второе из которых зависит от параметра, вычисляемого первым:

m :: * -> * class Monad m where (>>=) :: m a -> (a -> m b) -> m b (>>) :: m a -> m b -> m b return :: a -> m a fail :: String -> m a class Functor f where fmap :: (a -> b) -> f a -> f b

Функция return описывает «возвращение» (втягивание) типа a в монаду m , то есть обрамление его контейнером. Функция fail не имеет отношения к теоретической сущности монад, однако используется в случае ошибки сопоставления с образцом внутри монадического кода — останавливает процесс последовательных действий и выводит сообщение о причине ошибки (в будущих версиях библиотеки может быть переведён в отдельный класс [3] ). Оператор >>= описывает, что в монаде действия происходят последовательно, то есть после применения функции её результат передаётся далее (.. -> a -> b -> ..), примером которой может быть передача текста в буфер: типы данные облачаются в монаду (конструктором), а затем с ними функция производит действия, в данном случае добавление. Оператор >> — частный случай оператора >>= , когда предыдущие данные просто заменяются следующими, которые не формируются на основании предыдущих.

В частности, к монадам относятся:

  • IO (монада строго последовательных вычислений): стратегия связывания — «сначала первое вычисление, затем второе»;
  • Maybe (монада вычислений с отсутствующими значениями): стратегия связывания — «если первое вычисление дало результат, то второе; иначе — отсутствие результата»;
  • List (монада вычислений с несколькими результатами): стратегия связывания — «все возможные результаты второго вычисления, примененного к каждому из вычисленных первым значений параметра»;
  • State (монада вычислений с переменной состояния): стратегия связывания — «начать второе вычисление с состоянием, измененным в результате первого»;
  • и некоторые другие типы.

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

Концепция монад в программировании была унаследована из теории категорий: Монада (математика)

Примечания

  1. Контейнер не имеет задачи инкапсулирования данных.
  2. Описание класса Monad находится в модуле Monad пакета Control и в стандартном модуле Prelude , класса Functor и MonadPlus — только в модуле Monad пакета Control .
  3. Евгений Кирпичев.Монады в Haskell (рус.) . Монады — «обобщение некоторых привычных идиом, а также как еще один метод для их абстракции».

Ссылки

Учебные пособия

  • Monad Tutorials Timeline (англ.) Большая коллекция пособий по монадам, представлены в порядке появления.
  • What the hell are Monads?
  • You Could Have Invented Monads! (And Maybe You Already Have.), простое введение
  • All About Monads
  • Monads as Computation
  • Monads as Containers
  • Monads for the Working Haskell Programmer
  • The Haskell Programmer’s Guide to the IO Monad — Don’t Panic
  • Introduction to Haskell, Part 3: Monads
  • On Monads
  • Crash Monad Tutorial (англ.) — статья о монадах, объясняющая их с точки зрения теории категорий
  • Learn You a Haskell for Great Good! (англ.) — книга содержит доступное описание языка Haskell, в котором много внимания уделено понятию монады и аналогичным конструкциям

Другие статьи

  • A tour of the Haskell Monad functions (англ.)
  • Notions of Computation and Monads от Eugenio Moggi, первая статья, предлагающая использование монад в программировании
  • «Monads for Functional Programming» от Philip Wadler, описание монад в языке Хаскелл (написано еще до того, как они в нем появились)
  • 4. Монады — простое изложение основ языка

Литература

  • Душкин Р.В. Охрана // Приёмы программирования // Функции // Синтаксис и идиомы языка // Справочник по языку Haskell / Гл. ред. Д. А. Мовчан. — М .: ДМК Пресс, 2008. — С. 37-38. — 554 с. — 1500 экз. — ISBN 5-94074-410-9, ББК 32.973.26-018.2, УДК 004.4
  • П. Д. Симон. 8. Лекция: Стандартное начало (Prelude) // Язык и библиотеки Haskell 98.
  • Erkok Levent. Value Recursion in Monadic Computations. Oregon Graduate Institute. — 2002. — 162 p.
  • Теория категорий
  • Haskell
  • Концепции языков программирования

Wikimedia Foundation . 2010 .

Немного о монадах в JavaScript

Начну с того, что хорошо программировать на JS можно, вообще не зная слова “монада” и не представляя об их существовании. Ни в одном учебнике по JavaScript я этого слова не встречал, не слышал его из уст даже опытных JS-разработчиков и в принципе узнал о них только тогда, когда начал изучать Haskell, полностью функциональный язык программирования. Поэтому, если вы изучаете JS, не пугайтесь: знать такое вовсе не обязательно даже для человека с достаточно большим опытом в разработке. Мне просто захотелось попытаться написать о монадах так просто, как только это возможно.

Я специально возьму скучное определение монад из Википедии, чтобы сначала нам стало скучно 🙂 но не стоит огорчаться — дальше будет гораздо интереснее. Например, как вам такой факт: вы уже используете монады, программируя на JS?

Итак, то самое определение: монада — это особый тип данных, для которого можно задать императивную последовательность выполнения некоторых операций над хранимыми значениями. Вроде слова знакомые, а ничего не понятно. Зачем нужен такой тип данных? Что мы можем сделать с ним, чего не можем без него? Примерно об этом будет эта статья. И да, существует теория: как только ты понимаешь, что такое монады, ты теряешь возможность об этом рассказывать. Эту теорию я надеюсь опровергнуть, ждать осталось совсем немного 🙂

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

Сразу к сути

Мы знаем, что в JS существуют промисы, и мы возьмем один пример из моей статьи об асинхронности:

function getBeef(name: string): Promiseboolean>  return new Promise((resolve, reject) =>  setTimeout(() =>  if (beefExists)  const beef = true; resolve(beef); > else  reject('No beef available') > >, 300) >); > getBeef('John') .then(sliceBeef) .then(cookBeef) .then(serveBeef) .then((servedBeef) => servedBeef ? console.log('Beef is served!'): console.log('Beef is not served')) .catch((err) => console.log(err)); 

Что здесь происходит? Некоторая функция getBeef() выполняется и по цепочке передает полученное значение следующей функции, которая, в свою очередь, делает буквально то же самое — и так далее. Еще мы можем связывать в такие же цепочки методы массивов вроде Array.prototype.map или Array.prototype.flatMap (о том, что здесь значит таинственное слово prototype , можно прочитать здесь).

Так вот: если вы хоть раз так делали, вы уже использовали монады.

Чуть подробнее

Давайте, используя TypeScript, опишем Promise как интерфейс:

interface PromiseA>  thenB>(callback: (a: A) => B | PromiseB>): PromiseB> > 

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

Это немного упрощенный интерфейс Promise , но суть понятна: у промиса, работающего с любым типом, который мы обозначили как A есть метод then() , который работает с другим любым типом, обозначенным как B . Этот метод принимает функцию-коллбек, которая принимает аргумент типа A и возвращает либо значение типа B , либо промис типа B . Метод then() , в свою очередь, возвращает промис типа B.

Становится понятно, что эти методы then() можно вызывать по цепочке: раз then() вернет промис, то у него тоже есть метод then() , который мы можем вызвать — и делать это неограниченное количество раз.

В ES2017 были добавлены асинхронные функции и ключевые слова async и await . Теперь мы можем делать так:

async (): Promiseboolean> =>  try  const beef = await getBeef('John'); const slicedBeef = await sliceBeef(beef); const cookedBeef = await cookBeef(slicedBeef); const servedBeef = await serveBeef(cookedBeef); servedBeef ? console.log('Beef is served!') : console.log('Beef is not served!'); > catch (err)  console.log(err); > > 

Асинхронные функции могут помочь нам тогда, когда даже промисы превращаются в нечитаемый callback hell. Вот пример:

declare function getA(): Promisenumber>; declare function getB(a: number): Promisenumber>; declare function getC(a: number, b: number): Promisenumber>; declare function getD(a: number, b: number, c: number): Promisenumber>; function sumAwithBwithCwithD(): Promisenumber>  return getA() .then(a => getB(a) .then(b => getB(a, b) .then(c => getD(a, b, c) .then(d =>  return a + b + c + d; >) ) ) ); > 

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

Вот как мы можем переписать такое с использованием async и await :

declare function getA(): Promisenumber>; declare function getB(a: number): Promisenumber>; declare function getC(a: number, b: number): Promisenumber>; declare function getD(a: number, b: number, c: number): Promisenumber>; async function sumAwithBwithCwithD(): Promisenumber>  const a = await getA(); const b = await getB(a); const c = await getC(a, b); const d = await getD(a, b, c); return a + b + c + d; > 

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

И при чем здесь какие-то монады?

К сожалению, мы немного ограничены синтаксисом TypeScript — TS не умеет в полиморфные типы данных. Вы можете думать о полиморфных типах данных как о типе данных, в который можно вложить другой тип данных. Стало немножко сложновато, да? Обратимся к определению монад из Википедии снова (да-да, фу): там упоминались некие хранимые значения. Если говорить языком типов, то для реализации типа “монада” мы должны уметь хранить тип данных внутри типа данных “монада”.

Так, вероятно, стало чуть понятнее — но, поскольку мы вынужденно немного упростим всю эту функциональщину, станет еще понятнее. Давайте определим тип Monad как монаду для типа A , если мы можем реализовать вот такие функции:

function ofA>(value: A): MonadA> function flatMapA, B>(monad: MonadA>, callback: (a: A) => MonadB>): MonadB> 

Что тут происходит? С помощью функции of() , работающей с любым типом, который мы обозначим как A , и принимающей аргумент типа A , мы можем вложить в монаду это самое значение типа A . И мы можем это значение достать, передав функцию-коллбек в функцию flatMap, работающую с типами A и B (помним, это не типы, это дженерики).

Не очень-то понятно, о чем я вообще говорю, при чем здесь JS, да? Давайте рассмотрим то же самое на промисах:

type MonadA> = PromiseA>; function ofA>(value: A): MonadA>  return Promise.resolve(value); > function flatMapA, B>(monad: MonadA>, callback: Function)  return monad.then(callback); // мы же помним, что Monad = Promise? > 

А можно и еще менее запутанно:

function ofA>(value: A): PromiseA>  return Promise.resolve(value); > function flatMapA, B>(promise: PromiseA>, callback: Function)  return promise.then(callback); > 

То есть, для Promise функция of() реализована как функция Promise.resolve() , а функция flatMap реализована как метод then() . Таким образом, Promise в JS можно считать монадой. И вправду, можем ли мы для хранимых в Promise данных задать императивную последовательность действий, несмотря на асинхронную природу промисов? Да — у нас для этого есть метод then() , который мы можем вызывать по цепочке. Можем ли мы хранить что-то в промисе? Да, у нас есть Promise.resolve(value) — притом, замечу, промис может хранить и промис, и что-то другое внутри себя — и это крайне напоминает нам о полиморфных типах данных 🙂

То есть, Promise — это вполне себе что-то похожее на монаду. И используется он для того же, для чего монады используются в функциональных языках типа Haskell: для работы с побочными эффектами (I/O).

Законы монад в Haskell и применимость этих законов к подобию монад в JS

В Haskell монады не только должны реализовать функции of() и flatMap() , но и подчиняться трем законам:

Первый закон идентичности (left identity law)

Первый акон идентичности гласит, что функции of() и flatMap() для монады должны быть обратны друг другу. То есть:

declare const a: A; declare function of(a: A): MonadA>; flatMap(of(a), a => of(a)) === of(a) //true 

То есть, если мы кладем в монаду значение с помощью of() и забираем это значение с помощью коллбека, оно равно результату выполнения функции of() над тем же значением. Для промисов это выглядит как-то так:

const likeFlatMap = Promise.resolve(5).then(x => console.log(x)) // 5 const likeOf = await Promise.resolve(5) // 5 

Как мы видим, первый закон выполняется 🙂

Второй закон идентичности

Согласно второму закону идентичности, если мы достанем значение из монады с помощью flatMap() , а потом положим это значение обратно в монаду с помощью of() , то мы получим идентичную монаду. Смотрите, как этот закон выполняется на промисах:

const promiseOf = promiseFlatMap.then(value => Promise.resolve(value)); const promiseFlatMap = Promise.resolve(await promiseOf); // promiseOf и PromiseFlatMap идентичны 
Закон композиции

Закон композиции проявляется как-то так:

declare const monad: MonadA>; declare function f1(a: A): MonadB>; declare function f2(b: B): MonadC>; flatMap(flatMap(monad, f1), f2) === flatMap(monad, (a) => flatMap(f1(a), f2)); // true 

То есть, композиция сбора значений из разных монад эквивалентна вызовам flatMap() в коллбеке предыдущей монады.

Для промисов закон композиции тоже выполняется:

getA() .then(a => getB(a) .then(b => getC(b) .then(c => getD(c)))); // можно отрефакторить так: getA().then(getB).then(getC).then(getD); 

То есть, нам неважно, объединим ли мы в цепочку вызовы then() внутри коллбека другого метода then() , или же будем вызывать then() как метод предыдущего промиса — результат будет эквивалентен.

И зачем все это?

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

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

  • Promise — монада, поскольку, если мы попытаемся просто достать значение из промиса напрямую, у нас не выйдет — нам нужно использовать then() , другими словами, выполнить некую императивную последовательность действий и получить значение, используя коллбек.
  • Array может быть монадой, когда мы трансформируем значения не напрямую, а с помощью коллбека — используя, к примеру, flatMap() (он есть в JS как Array.prototype.flatMap() ), map() и прочие методы из прототипа Array .
  • монада Optional не существует по умолчанию ни в ванильном JS, ни в TS, но подобием может быть что-то вроде Nullable = T | null, T !== null . Если мы используем коллбеки вместо ручной проверки на null, то эта конструкция ведет себя как монада
  • Iterable очень даже может использоваться как монада, если мы, итерируя эту последовательность, применяем к каждому объекту `

Вот, к примеру, реализация методов монады для массива:

type MonadA> = ArrayA>; function ofA>(a: A): MonadA>  return [a]; > function flatMapA, B>(monad: MonadA>, cb: (a: A) => MonadB>): MonadB>  return monad.flatMap(cb); > 

Вообще, промисам повезло, им подвезли async / await , и избегать callback hell стало очень легко. А что, если callback hell произойдет при работе с массивом? К примеру, как-нибудь вот так:

function rightTriangles(maxLengthC: number)  return range(1, maxLengthC + 1) .flatMap((c) => range(1, c) .flatMap((a) => range(1, a) .flatMap((b) => [[a, b, c]]) .filter(([a, b, c]) => a**2 + b**2 === c**2) ) ); > console.log(rightTriangles(10)); // [[4,3,5], [8,6,10]] 

Есть планы добавить в JS подобие синтаксиса do для монад в Haskell, который выглядит как-то так:

rightTriangles :: [(Integer, Integer, Integer)] rightTriangles = do c  [1..10] a  [1..c] b  [1..a] guard (a^2 + b^2 == c^2) return (a,b,c) 

Вот что предлагается добавить в JS (но это не точно):

multi function rightTrianglesG(maxLengthC: number)  const c = pick range(1, maxLengthC + 1); const a = pick range(1, c); const b = pick range(1, a); pick where(a**2 + b**2 === c**2); return [a, b, c] as const; > multi function where(cond: boolean)  if (cond) return pick []; > 

К счастью, в JS уже есть удобный метод работы с любыми монадами — генераторы. Что это такое и как они могут помочь в работе с монадами, вы можете почитать в моей статье о генераторах.

Финалочка

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

Не ограничивайтесь тем, что спрашивают на собесах, пожалуйста 🙂

Интересный пост?

Вот еще похожие:

  • Событийно-ориентированная архитектура и Node.js Events
  • Реактивное программирование: теория и практика
  • Как и зачем писать тесты?
  • Функциональное программирование. Что это и зачем?
  • Профилирование Node.js-приложений

Форма для записи на менторинг — тут. Ну, и по любым вопросам можете написать в телегу, там я отвечаю быстрее всего.

Записки программиста

Когда я впервые увидел код в стиле f1 >>= \x -> f2 >>= \y -> Right ( x , y ) моя реакция была «Ааа! Что тут происходит? Как вообще кто-то может писать на таком языке?». Но, как выяснилось, если сесть и спокойно во всем разобраться, в монадах нет абсолютно ничего сложного. Кроме того, оказывается, монады имеют множество важных практических применений и способны существенно облегчить выполнение нашей с вами повседневной работы.

Что такое монада?

В Haskell монада — это совершенно обычный класс типов:

class Monad m where
( >>= ) :: m a -> ( a -> m b ) -> m b
( >> ) :: m a -> m b -> m b
return :: a -> m a
fail :: String -> m a

С тем же успехом мы можем объявить интерфейс в Java или абстрактный класс в C++. В большинстве случаев для превращения некого типа в монаду достаточно определить только функции ( >>= ) (произносится «bind») и return , потому что остальные функции имеют разумную реализацию по умолчанию.

Функции (>>=), (>>), return и fail

Давайте временно забудем о монадах вообще и подумаем об одной конкретной, всем хорошо знакомой, монаде IO. Если заменить в перечисленных ранее функциях «m» на «IO», получим:

( >>= ) :: IO a -> ( a -> IO b ) -> IO b
( >> ) :: IO a -> IO b -> IO b
return :: a -> IO a
fail :: String -> IO a

Вспомним тип функции putStrLn:

ghci> :t putStrLn
putStrLn :: String -> IO ()
ghci> :t putStrLn «hello»
putStrLn «hello» :: IO ()

Смотрите-ка! Функция putStrLn , примененная к строке, имеет тип IO ( ) , прямо как аргументы функции ( >> ) , если заменить a или b на конкретный тип ( ) . Давайте попробуем вызвать ( >> ) и посмотрим, что будет:

ghci> putStrLn «hello» >> putStrLn «world»
hello
world
ghci> :t putStrLn «hello» >> putStrLn «world»
putStrLn «hello» >> putStrLn «world» :: IO ()

Как интересно! Сначала была выведена первая строка, а потом вторая. При этом тип всего выражения оказался IO ( ) , что позволяет снова передать его в качестве аргумента функции ( >> ) :

ghci> putStrLn «hello» >> putStrLn «world» >> putStrLn «of monads»
hello
world
of monads

Получается, ( >> ) позволяет создавать что-то вроде цепочек последовательно выполняемых команд. Есть, правда, одна проблема. Что, если я хочу передать второй функции в цепочке значение, возвращенное первой функцией? Например, сначала получить от пользователя строку с помощью getLine , а затем вывести эту строку с помощью putStrLn . Как раз для этого и нужна функция ( >>= ) . Давайте посмотрим на типы:

getLine :: IO String
putStrLn :: String -> IO ( )
( >>= ) :: IO a -> ( a -> IO b ) -> IO b

Получается, функцию getLine можно связать с putStrLn при помощи функции ( >>= ) , при этом a будет типом String , а b будет ( ) :

ghci> :t getLine >>= putStrLn
getLine >>= putStrLn :: IO ()
ghci> getLine >>= putStrLn
Hello
Hello
ghci> getLine >>= (\s -> putStrLn $ «Hello, » ++ s)
Alex
Hello, Alex

Очень, очень хорошо! А что, если у нас есть чистая функция, возвращающая строку, и мы хотим вывести ее при помощи putStrLn? Мы не можем просто поместить функцию в цепочку, потому что после применения к своим аргументам она будет иметь тип String, а нам нужен IO String. Вот для этого и нужна функция return, которая «оборачивает» произвольный тип в монаду:

ghci> let f x y = x ++ «, » ++ y ++ «!»
ghci> f «Hello» «World»
«Hello, World!»
ghci> return (f «Hello» «World») >>= putStrLn
Hello, World!

Наконец, функция fail нужна для обработки исключительных ситуаций. В контексте монады IO она бросает исключение IOError. Хотя, в зависимости от конкретной монады, она может делать и что-то более осмысленное.

do-нотация

Все это замечательно, но при работе с монадой IO мы же просто пишем:

main :: IO ( )
main = do
putStrLn «What’s your name?»
name putStrLn $ «Hello, » ++ name ++ «!»

Никаких стрелочек! Как же так? В действительности, приведенный выше код полностью аналогичен следующему:

main :: IO ( )
main =
putStrLn «What’s your name?» >>
getLine >>=
\name -> putStrLn $ «Hello, » ++ name ++ «!»

Другими словами, do-нотация — это просто синтаксический сахар над стрелочками. Можно спокойно писать на Haskell без нее, просто код будет чуть менее понятен и придется вводить чуть больше символов.

Кстати, при работе в ghci мы на самом деле находимся внутри монады и используем do-нотацию.

Монадные законы

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

return a >>= k = k a
m >>= return = m
m >>= ( \x -> k x >>= h ) = ( m >>= k ) >>= h

Компилятор не может проверить выполнение этих законов на этапе компиляции, поэтому ответственность за их соблюдение при объявлении новых монад ложиться на программиста. Если внимательно присмотреться, первые два правила напоминают свойство существования нейтрального элемента, а последнее — это что-то вроде ассоциативности. Давайте проверим на примерах, что эти правила выполняются для монады IO.

ghci> let k = (\x -> return $ x * x + 1) :: Int -> IO Int
ghci> let a = 2 :: Int
ghci> return a >>= k
5
ghci> k a
5

ghci> let m = return 2 :: IO Int
ghci> m >>= return
2
ghci> m
2
ghci> let h = k
ghci> m >>= (\x -> k x >>= h)
26
ghci> (m >>= k) >>= h
26

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

Монада Maybe

В заметке Пишем простой RESTful сервис с использованием Scotty мы видели такую вот странную функцию:

extractNamePhone :: M . Map String String -> Maybe ( String , String )
extractNamePhone m =
M . lookup «name» m >>=
\name -> M . lookup «phone» m >>=
\phone -> Just ( name , phone )

Давайте разберемся, как это работает. Экземпляр класса Monad для типа Maybe определен следующим образом:

instance Monad Maybe where
( Just x ) >>= k = k x
Nothing >>= _ = Nothing

( Just _ ) >> k = k
Nothing >> _ = Nothing

return = Just
fail _ = Nothing

Обратите внимание на функцию ( >>= ) . Благодаря ей мы можем определить extractNamePhone всего лишь в три строчки кода вместо того, чтобы писать:

extractNamePhone m =
case M . lookup «name» m of
Nothing -> Nothing
Just name ->
case M . lookup «phone» m of
Nothing -> Nothing
Just phone -> Just ( name , phone )

Если бы Maybe не был монадой, а нам нужно было бы извлечь из Map’а десяток значений, пришлось бы написать десяток вложенных case of. Кроме того, что монады в данном случае позволяют писать меньше кипятильно-тарелочного кода, также мы фактически получаем что-то вроде механизма исключений для чистых функций.

Благодаря do-нотации мы можем записать это выражение еще проще. А при помощи аппликативных функторов можно записать его вообще в одну строку. Однако об аппликативных функторах речь пойдет как-нибудь в другой раз.

Монада Either

В модуле Control.Monad.Error объявлен экземпляр класса Monad для типа Either:

ghci> :m + Control.Monad.Error
ghci> let f x = Right x
ghci> Right 1 >>= f :: Either String Int
Right 1
ghci> Left «Error 123» >> Right 1 >>= f
Left «Error 123»
ghci> Left 1.0 >> Right 1 >>= f
Left 1.0

Здесь все работает аналогично Maybe, только вместо того, чтобы в случае неудачи просто возвращать Nothing, мы можем как-то уточнить причину ошибки.

Монада State

Монада State бывает полезна, когда имеется некоторое состояние, которое мы постоянно изменяем. Например, если у нас есть Map, в который мы хотим поместить десять значений, возвращаемых разными функциями, нам придется ввести множество переменных m0, m1, m2, … m9. Благодаря монаде State мы можем создать изменяемое состояние, не потеряв при этом чистоты функций, и избежать тем самым описанной проблемы.

Работает это как-то так:

ghci> :m + Control.Monad.State
ghci> let f = (\x y -> modify (+1) >>= \_ -> get >>= \t -> return $ x ++ y ++ show t) :: String -> String -> State Int String
ghci> evalState (f «aaa» «bbb») 0
«aaabbb1»
ghci> execState (f «aaa» «bbb») 0
1
ghci> runState (f «aaa» «bbb») 0
(«aaabbb1»,1)

Здесь Int — это наше состояние, а String — как бы значение, возвращаемое функцией. Получить текущее состояние можно с помощью функции get, сохранить — при помощи put, а изменить — при помощи modify. Для выполнения функции и получения возвращаемого ею значения используйте evalState. Если же вы хотите получить конечное состояние, воспользуйтесь execState. Если вам нужно как возвращаемое значение, так и конечное состояние, скажите runState.

В do-нотации монада State, конечно, выглядит намного красивее.

Монада Reader

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

ghci> :m + Control.Monad.Reader
ghci> let f = (\x y -> local (+1) (asks (+1) >>= \t1 -> ask >>= \t2 -> return $ x ++ y ++ show t1 ++ show t2) ) :: String -> String -> Reader Int String
ghci> runReader (f «aaa» «bbb») 0
«aaabbb21»

С помощью функции local мы можем создать скоп с измененным окружением. Это намного удобнее, чем читать окружение и создавать на его основе новое. Функция ask возвращает текущее окружение. Функция asks применяет заданную функцию к текущему окружению и возвращает результат. Ее удобно использовать в сочетании с селекторами.

По понятным причинам функций execReader и evalReader не предусмотрено.

Монада Writer

Если Reader — это State, из которого можно только читать, то Writer — это State, в который можно только писать. Монада Writer часто применяется для логирования и трассировки без потери чистоты функций.

ghci> let f = (\x y -> tell [«111»] >>= \_ -> tell [«222»] >>= \_ -> return $ x ++ y ) :: String -> String -> Writer [String] String
ghci> runWriter (f «aaa» «bbb»)
(«aaabbb»,[«111″,»222»])
ghci> execWriter (f «aaa» «bbb»)
[«111″,»222»]
ghci> runWriter (censor (map (++ «!»)) $ f «aaa» «bbb»)
(«aaabbb»,[«111!»,»222!»])
ghci> runWriter (listen $ f «aaa» «bbb»)
((«aaabbb»,[«111″,»222»]),[«111″,»222»])
ghci> runWriter (listens (filter (/= «111»)) $ f «aaa» «bbb»)
((«aaabbb»,[«222»]),[«111″,»222»])

Функция tell производит запись в лог. Интересно, что лог при этом должен быть моноидом. В рамках данного поста будет достаточно сказать, что списки являются моноидами. Функция censor применяется для модификации записей в логе. Функция listen превращает Writer, который возвращает a и пишет w, во Writer, который возвращает (a, w) и пишет w. Функция listens позволяет делать с логом что угодно. Мы можем отфильтровать записи в нем, добавить новые, привести их к другому типу или сделать все это одновременно.

Монада [] (список)

Список тоже являются монадой. Вот классический пример:

— почему при игре в кости выгодно ставить на семь

combinations :: Int -> [ ( Int , Int ) ]
combinations n = do
a b if a + b == n then return ( a , b ) else [ ]

solve :: [ ( Int , Int ) ]
solve = do
n return ( n , length $ combinations n )

main = do
putStrLn $ show solve

Вызываем функцию main:

ghci> main
[(2,1),(3,2),(4,3),(5,4),(6,5),(7,6),(8,5),(9,4),(10,3),(11,2),(12,1)]

Можно сказать, что это другой способ записи генераторов списков.

Некоторые функции для работы с монадами

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

(= <<) :: Monad m =>(a -> m b) -> m a -> m b
(>=>) :: Monad m => (a -> m b) -> (b -> m c) -> a -> m c
( <=<) :: Monad m =>(b -> m c) -> (a -> m b) -> a -> m c
mapM :: Monad m => (a -> m b) -> [a] -> m [b]
mapM_ :: Monad m => (a -> m b) -> [a] -> m ()
forM :: Monad m => [a] -> (a -> m b) -> m [b]
forM_ :: Monad m => [a] -> (a -> m b) -> m ()
foldM :: Monad m => (a -> b -> m a) -> a -> [b] -> m a
foldM_ :: Monad m => (a -> b -> m a) -> a -> [b] -> m ()
filterM :: Monad m => (a -> m Bool) -> [a] -> m [a]
zipWithM :: Monad m => (a -> b -> m c) -> [a] -> [b] -> m [c]
zipWithM_ :: Monad m => (a -> b -> m c) -> [a] -> [b] -> m ()
liftM :: Monad m => (a1 -> r) -> m a1 -> m r
liftM2 :: Monad m => (a1 -> a2 -> r) -> m a1 -> m a2 -> m r
replicateM :: Monad m => Int -> m a -> m [a]
replicateM_ :: Monad m => Int -> m a -> m ()
sequence :: Monad m => [m a] -> m [a]
sequence_ :: Monad m => [m a] -> m ()
when :: Monad m => Bool -> m () -> m ()
unless :: Monad m => Bool -> m () -> m ()
forever :: Monad m => m a -> m b
join :: Monad m => m (m a) -> m a
ap :: Monad m => m (a -> b) -> m a -> m b

Поэкспериментируйте с ними на досуге в ghci с конкретными монадами, например, IO, Maybe и [], чтобы разобраться, как эти функции работают. Если хотите, можете считать это своим домашним заданием.

Заключение

Помимо перечисленных в данной заметке монад есть и другие. Например, уже знакомая нам монада STM, а также Indentity, монада цитирования Q, Error, ST, Cont, Eval, Par, ActionM и многие другие. В языке Scala много, так сказать, завуалированных монад, среди которых особый интерес представляет Future. Еще в Haskell есть моноиды, MonadPlus, функторы, аппликативные функторы, monad trasformer’ы и другие интересные вещи. Но эти вопросы выходят за рамки поста.

В качестве источников дополнительной информации я бы советовал:

  • Две главы в «Learn You a Haskell for Great Good» — раз и два;
  • Еще две главы из «Real World Haskell» — эту и эту;
  • «Еще одно руководство по монадам», оригинал на английском и перевод на русский первых четырех частей из цикла;
  • Хороший туториал по монадам в Haskell Wiki;

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

Дополнение: Вот отличный пример для медитации:

ghci> liftM3 (,,) (+1) (> 0) show 23
(24,True,»23″)

Попробуйте посмотреть на типы в REPL и понять, как это работает.

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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *