Здравствуйте, AlexRK, Вы писали:
ARK>Может кто-нибудь описать простым языком — что такое монады и зачем они нужны?
Я могу! И отвечу еще на ряд вопросов, заданных в ветке.
Монады в классическом виде такие страшные, потому что помимо основной идеи еще идет борьба с системой типов языка. Концепция на самом деле более универсальная и удобная.
Теперь по порядку.
Введение в весь стек с монадами в картинках. Полезно, если любите графическое представление. Плюс до функторов (включительно) я буду говорить примерно о том же.
С примитивными (и не только) значениями все вроде бы понятно. Это обычные значения. Функции — тоже вполне классические функции, включая функции от многих аргументов:
f1 : T => R
f2 : (T1, T2, T3, ..., TN) => R
Для введения дальнейших понятий нам нужен еще какой-нибудь контейнер, в который можно "положить" значение любого типа. Пусть это будет Box<T>. Как у него семантика — зависит от реализации. Это может быть Option, может быть Either, может быть асинхронное выполнение, observable, collection, etc... Важно только то, что это некая "обертка" поверх типа.
Здесь же можно отметить, что у Box должен быть метод "упаковки" простого значения в контейнер. Например, так:
Box.box : T => Box<T>
//применение
val boxed : Box<Int> = Box.box(3)
Это является аналогом монадного return.
Просто складывать значения в Box не интересно (можно складывать Box в Box, но это тоже быстро надоест). Хочется сделать что-нибудь с Box<T>. Например, у нас Box — асинхронная задача. И хочется что-нибудь запустить после завершения результата. Другими словами,
применить функцию к результату операции. Очевидно, что применение "чистой функции" к асинхронной операции может применить эту функцию не сразу, а по завершению операции. Т.е "природа" Box сохраняется.
Можно сделать применение такой функции, например, так:
class Box<T> {
// какая-то реализация семантики Box
//Применение функции "внутри" box
def map<R>(fn : T => R) : Box<R>
}
// Применение к асинхронной операции
val operation : Async<Image> = loadImage(...);
operation.map(image => alert("Она загрузилась!"));
Это может быть применение функции к результату асинхронной операции, применение функции к элементу коллекции/option/either и т.д. в зависимости от семантики Box. Вот эта вот функция map называется функтором.
Если вы внимательно посмотрите, это очень напоминает Promise. И это очень неудачный вариант предоставления функтора! Например, почти эквивалентно будет
class Box<T> {
static def<T, R> map(fn : T => R, box : Box<T>) : Box<R>
}
//Применение к асинхронной операции:
val operation : Async<Image> = loadImage(...);
Async.map(image => alert("Оно загрузилось"), operation);
//Или более реалистичный пример, с дополнительными функциями
val operation : Async<Image> = loadImage(...);
Async.apply(showImage, operation);
def showImage(image : Image) : void {
//....
}
Казалось бы, не такая и большая разница между двумя API. На самом деле очень большая, потому что мы будем обобщать применение функций дальше. Итак, следующая секция:
Усложним задачу. Теперь нужно грузить не один ресурс, а несколько. Например, картинку с нашего сервера и какие-то данные с сервера партнеров. И после этого отображать окно интерфейса. Т.е. у нас уже есть
val imageOp : Async<Image> = loadImage(...);
val partnerData : Async<SpamData> = loadPartnerData(...);
def showUI(image : Image, data : SpamData) : void {
//...
}
И теперь нам нужно бы связать операции с целевой функцией. Но ведь в предыдущей секции мы что-то подобное уже делали! Можно попробовать сделать точно так же:
Box.map(showUI, imageOp, partnerData);
Т.е. нам нужно обобщить применение функции с одного Box на несколько. Если система типов не сильно мешает, можно сделать и прямолинейно:
class Box<T> {
static def map<T1, T2, T3, ..., TN, R>(
fn : (T1, T2, T3, ..., TN) => R,
a1 : Box<T1>, a2 : Box<T2>, a3 : Box<T3>, ..., an : Box<TN>) : Box<R>
}
// Вполне реальный для JS API:
// Такими могли бы быть Promise!
// И Promise достаточно легко допиливаются до этого состояния.
var imageOp = loadImage(...);
var spamData = loadPartnerData(...);
Aysnc.apply(showUI, [imageOp, spamData]);
// Сравните с обычным (синхронным) применением:
var image = getSomeImage();
var spamData = getSomeData();
showUI(image, spamData);
Вот так получается, если система типов не мешает. Мы обобщили "функтор" на несколько аргументов и получили нечто. Вот именно для получения этого нечто и сделан Applicative в haskell и аналогичных языках! В первую очередь для обобщения применения функции к нескольким Box'ам в рамках системы типов. И на практике именно так аппликативы и применяются:
fn <$> arg1 <*> arg2 <*> arg3
В то же время это очень похоже на обычное применение функции к аргументам. Только теперь функция применяется не к самим аргументам, а к Box<arg>.
Пойдем еще дальше. Нам нужно грузить некоторые данные в зависимости от уже полученных данных. Например, получив данные от партнера мы хотим еще загрузить картинку. Т.е. у нас есть функция, которая по входным данным запускает некоторый процесс и мы хотим применить ее к асинхронно загружаемым данным.
var partnerData : Async<Int> = loadPartnerData(...);
var otherPartnerData : Async<String> = loadPartnerData(...);
var evenMoreData : Async<String> = loadEvenMoreData(...);
def loadDependentData(d1 : Int, d2 : String, d3 : String) : Async<Image> {
final String imageUrl = d2 + d1 + d3;
return loadImage(imageUrl);
}
Использовать Async.apply не пройдет по типам, у нас будет Async<Async<Image>>. Но мы пока изобратаем API. Так что давайте добавим еще один метод:
class Box<T> {
static def mmap<T1, T2, T3, ..., TN, R>(
fn : (T1, T2, T3, ..., TN) => Box<R>,
a1 : Box<T1>, a2 : Box<T2>, a3 : Box<T3>, ..., an : Box<TN>) : Box<R>
}
// Применение!
// И это тоже стоило бы сделать в Promise, но...
var partnerImage : Box<Image> = Async.mapply(loadDependentData, partnerData, otherPartnerData, evenMoreData);
Async.apply(showUI, partnerImage);
Вот этот вот mmap + Box.box из первого пункта и дают монаду
. Да, с формальной точки зрения это не монада (у монады другой формализм). А вот решаемая задача — именно эта. И на самом деле классичесаая монада эквивалентна вот этой "обобщенной функции" mmap. Они достаточно просто выражаются друг через друга. Зато с практической точки зрения мой вариант понятнее. И заодно показывает, как примерно монады применяются. На самом деле в таком "аппликативном стиле" они удобны даже в императивных языках (в примерах показан Async, именно с таким API я себе делал его и успешно использовал).
Сводная табличка идей (cheat sheet):
// function application
var a : T1 = ..., b : T2 = ..., c : T3 = ...;
def f(x1 : T1, x2 : T2, x3 : T3) : R
var r : R = f(a, b, c);
// Functor
// Только для одноаргументных функций
var a : Box<T1> = ...;
def f(x1 : T1) : R
var r : Box<R> = Box.apply(f, a);
// "Applicative"
var a : Box<T1> = ..., b : Box<T2> = ..., c : Box<T3> = ...;
def f(x1 : T1, x2 : T2, x3 : T3) : R
var r : Box<R> = Box.apply(f, a, b, c);
// "Monad"
var a : Box<T1> = ..., b : Box<T2> = ..., c : Box<T3> = ...;
def f(x1 : T1, x2 : T2, x3 : T3) : Box<R>
var r : Box<R> = Box.mapply(f, a, b, c);
Теперь о практической части. Да, я использовал и использую монады в императивщине. Например, в виде API из cheat sheet оно использовалось в AS3 (язык позволяет такие фокусы) для реактивного программирования и асинхронных операций. Вполне успешно и удобно. Сейчас есть "посмореть"
библиотечка аппликативного реактивного программирования для scala. Вот там в силу языка аппликативы и монады гораздо больше похожи на классичесчкий вариант. Есть даже небольшой
пример использования библиотечки в действии (не дописан, но смотреть можно, там уже база есть). Искать использование по ":<" и ":>" (без кавычек).
Плюсы этих "монад" для практики. Большую часть приложения все еще можно изобразить в чистых функциях. Их удобно писать и тестировать. Какая-то небольшая часть приложения работает с "грязными" данными (в виде асинхронных операций, текущего изменяемого состояния представленного в виде Behavior, etc...). Применение функций в этих классах грязных данных тоже оттестировано (functor, applicative, monad это 4 операции на каждый класс). В результате чего места для ошибок практически нет (не надо вручную все компоненты синхронизировать, например). Ну и вид программы более декларативный. Я объявляю те же компоненты интерфейса (и текущий вид) как функцию от входных данных и состояний, а не как набор методов по копированию всего подряд из модели в вид и обратно.