Проще говоря о чем все это
Я начал думать о написании данной статьи несколько недель назад, после того, когда я старался объяснить моему 7 летнему чаду что такое математические функции. Мы начали с рассмотрения очень простых вещей. Это прозвучит безумно и наверное несуразно, но я закончил мое вводное объяснение повествованием о композиции функций. Это казалось настолько логичным разъясняя что такое функции, приводя примеры их использования из окружающего мира, говорить о композиции. Цель данной статьи — показать насколько простой и мощной является композиция функций. Начну я с рассмотрения понятия чистой композиции и приземленного разъяснения, после чего мы попробуем немного карри и позабавимся с монадами. Надеюсь вам понравится.
Функция как небольшой ящик
Давайте представим математические функции в виде небольших ящиков(коробок), где каждый ящик способен принимать любое положительное число аргументов, выполнять какую либо задачу и возвращать результат. Короче говоря, мы могли бы представить функцию сложения как показано ниже:
Изображение 1, буквенно-числовое представление функции сложения
Изображение 2, символьное представление функции сложения
Давайте рассмотрим ситуацию когда нам нужно собрать и запустить хлебную фабрику а ля все в одном. Эта фабрика будет построена на принципе запросов, где каждый такой запрос будет активизировать цепь специфических операций и на конечном этапе будет выдавать нам результат в виде готового хлеба. В начале нам необходимо определить эти самые специфические операции, мы будем представлять каждую операцию в виде функции/ящика. Вот список операций высшего порядка, которые могли бы нам понадобиться:
- Grind, принимает пшеницу, перемалывает ее и возвращает муку
- KneadDough, берет муку на входе, мешает ее с внутренними ингредиентами и производит тесто
- DistributeDough, принимает все количество теста, и распределяет его по формам на выходе получается формочки с тестом
- Bake, берет формочки с тестом, запекает их и выдает порции хлеба
Пришло время организовать хлебную фабрику, собрав во едино производственную цепочку как показано ниже:
w -> [Grind] -> [KneadDough] -> [DistributeDough] -> [Bake] -> b
Изображение 3, представление собранной цепи
На этом пока все, наша цепь готова к работе, она собрана из маленьких кусочков, где каждый кусочек может быть разобран на отдельные под-кусочки, итд. Вы можете моделировать огромное количество вещей из окружающего нас мира, просто используя понятие композиции функций. Это на самом деле очень просто. Вы можете ознакомится с более теоретическими аспектами здесь.
Выражение композиции
Давайте рассмотрим как представить производственную цепочку, описанную выше, используя javascript:
var b = bake(distribureDough(kneadDough(grind(w))));
Попробуйте представить как будет выглядеть цепь из 10 — 15 функций, и это только одна из возможных проблем с которой вы можете столкнуться. Так же это не совсем композиция, т.к. в математике, композиция функций это по-точечное применение одной функции к результату другой для получения третьей функции. Мы можем достичь этого следующим способом:
function myChain1(w) {
return bake(distribureDough(kneadDough(grind(w))));
}
var b = myChain1(w);
Это выглядит как-то нелепо, не так ли? Давайте призовем мощь функционального программирования и реализуем это в более удобоваримой форме. Мы будем оперировать более понятными примерами. Для начала нам нужно определить что есть композиция в функциональном понятии.
Версия Scala
implicit class Forward[TIn, TIntermediate](f: TIn => TIntermediate) {
def >> [TOut](g: TIntermediate => TOut): TIn => TOut = source => g(f(source))
}
Версия F
Вообще-то, F# уже имеет по умолчанию оператор композиции, вам не нужно ничего объявлять. Но если вам все таки понадобится переопределить его, вы сможете это сделать, так:
let (>>) f g x = g ( f(x) )
Компилятор F# достаточно умен, что бы предположить что вы имеете дело с функциями, так что, тип вышеописанной функции (>>)
будет выглядеть как:
f:('a -> 'b) -> g:('b -> 'c) -> x:'a -> 'c
Сцепляем все вместе
Решение для предыдущей задачи будет выглядеть на Scala как:
object BreadFactory {
case class Wheat()
case class Flour()
case class Dough()
case class Bread()
def grind: (Wheat => Flour) = w => {println("make the flour"); Flour()}
def kneadDough: (Flour => Dough) = f => {println("make the dough"); Dough()}
def distributeDough: (Dough => Seq[Dough]) = d => {println("distribute the dough"); Seq[Dough]()}
def bake: (Seq[Dough] => Seq[Bread]) = sd => {println("bake the bread"); Seq[Bread]()}
def main(args: Array[String]): Unit = {
(grind >> kneadDough >> distributeDough >> bake) (Wheat())
}
implicit class Forward[TIn, TIntermediate](f: TIn => TIntermediate) {
def >> [TOut](g: TIntermediate => TOut): TIn => TOut = source => g(f(source))
}
}
Версия на F# будет более лаконичной:
type Wheat = {wheat:string}
type Flour = {flour:string}
type Dough = {dough:string}
type Bread = {bread:string}
let grind (w:Wheat) = printfn "make the flour"; {flour = ""}
let kneadDough (f:Flour) = printfn "make the dough"; {dough = ""}
let distributeDough (d:Dough) = printfn "distribute the dough"; seq { yield d}
let bake (sd:seq<Dough>) = printfn "bake the bread"; seq { yield {bread = ""}}
(grind >> kneadDough >> distributeDough >> bake) ({wheat = ""})
Вывод на консоль будет:
make the flour
make the dough
distribute the dough
bake the bread
Карринг
Если вы не знакомы с понятием карринга, вы можете найти больше информации здесь. В этой части мы совместим два мощных механизма родом из мира функционального программирования — карринг и композицию. Давайте рассмотрим ситуацию когда вам нужно работать с функциями которые имеют более одного параметра и большая часть этих параметров известна до выполнения самой функции. Например функция bake
из предыдущей части может иметь такие параметры как температура и длительность запекания, которые в свою очередь хорошо известны заранее.
Scala:
def bake: (Int => Int => Seq[Dough] => Seq[Bread]) =
temperature => duration => sd => {
println(s"bake the bread, duration: $duration, temperature: $temperature")
Seq[Bread]()
}
F#:
let bake temperature duration (sd:seq<Dough>) =
printfn "bake the bread, duration: %d, temperature: %d" temperature duration
seq { yield {bread = ""}}
Карринг это наш друг, давайте определим один рецепт для выпекания хлеба.
Scala:
def bakeRecipe1 = bake(350)(45)
def main(args: Array[String]): Unit = {
(grind >> kneadDough >> distributeDough >> bakeRecipe1) (Wheat())
}
F#:
let bakeRecipe1: seq<Dough> -> seq<Bread> = bake 350 45
(grind >> kneadDough >> distributeDough >> bakeRecipe1) ({wheat = ""})
Вывод в обоих случаях будет следующим:
make the flour
make the dough
distribute the dough
bake the bread, duration: 45, temperature: 350
Монадические цепочки
Можете ли вы представить себе ситуацию когда в середине вашей цепочки что-то идет не так? Ну например, ситуацию когда провод подачи дрожжей или воды засоряется и производство теста нарушается, или ситуацию при которой печь ломается и мы получаем полузапеченную массу теста. Чистая композиция функций может быть интересна для задач толерантных к сбоям или к их без-сбойным аналогам. Но что же нам делать в вышеописанных случаях? Ответ очевиден — использовать монады, хм. Вы можете найти кучу фундаментальных вещей по теме монад на странице в википедии. Давайте посмотрим как монады могут быть полезны в нашей ситуации, для начала нам нужно определить (на F#) или использовать (на Scala) специальный тип, называемый Either
. Определение на F# может выглядеть как размеченное объединение представленное ниже:
type Either<'a, 'b> =
| Left of 'a
| Right of 'b
Теперь мы готовы к сцеплению всех элементов, для этого нам понадобится создать эквивалент монадической операции bind, которая принимает монадическое значение (M) и функцию (f) способную трансформировать это значение (f: (x -> M y)
).
F#:
let chainFunOrFail twoTrackInput switchFunction =
match twoTrackInput with
| Left s -> switchFunction s
| Right f -> Right f
let (>>=) = chainFunOrFail
Scala:
implicit class MonadicForward[TLeft, TRight](twoTrackInput: Either[TLeft,TRight]) {
def >>= [TIntermediate](switchFunction: TLeft => Either[TIntermediate, TRight]) =
twoTrackInput match {
case Left (s) => switchFunction(s)
case Right (f) => Right(f)
}
}
Последнее что мы должны сделать это небольшая адаптация представленной выше цепи в новом, более Either
-дружественном формате.
F#:
let grind (w:Wheat): Either<Flour, string> =
printfn "make the flour"; Left {flour = ""}
let kneadDough (f:Flour) =
printfn "make the dough"; Left {dough = ""}
let distributeDough (d:Dough) =
printfn "distribute the dough"; Left(seq { yield d})
let bake temperature duration (sd:seq<Dough>) =
printfn "bake the bread, duration: %d, temperature: %d" duration temperature
Left (seq { yield {bread = ""}})
let bakeRecipe1: seq<Dough> -> Either<seq<Bread>, string> = bake 350 45
({wheat = ""} |> grind) >>= kneadDough >>= distributeDough >>= bakeRecipe1
Scala:
def grind: (Wheat => Either[Flour, String]) = w => {
println("make the flour"); Left(Flour())
}
def kneadDough: (Flour => Either[Dough, String]) = f => {
println("make the dough"); Left(Dough())
}
def distributeDough: (Dough => Either[Seq[Dough], String]) = d => {
println("distribute the dough"); Left(Seq[Dough]())
}
def bake: (Int => Int => Seq[Dough] => Either[Seq[Bread], String]) =
temperature => duration => sd => {
println(s"bake the bread, duration: $duration, temperature: $temperature")
Left(Seq[Bread]())
}
def bakeRecipe1 = bake(350)(45)
def main(args: Array[String]): Unit = {
grind(Wheat()) >>= kneadDough >>= distributeDough >>= bakeRecipe1
}
Вывод будет выглядеть так:
make the flour
make the dough
distribute the dough
bake the bread, duration: 45, temperature: 350
Если один из элементов вашей цепи вернет Right
с соответствующим индикатором ошибки, то последующие элементы цепи будут попросту игнорированы и рабочий процесс пропустит их все и будет попросту распространять выброшенное исключение от предыдущего звена к последующему. Вы можете самостоятельно попробовать поиграть со сценариями в которых присутствуют ошибки.
Заключительная часть
Как вы могли заметить, есть некоторая волшебная связь между теорией категорий(истоки монад) и композицией функций. Задача данной статьи сводится к тому что бы показать как управляться на практике с представленными механизмами и как организовать ваш код в более функциональном виде. Вы можете погрузится в более фундаментальные аспекты по представленному материалу самостоятельно. Надеюсь эта статья будет полезной для тех из вас кто ищет как отказаться от императивного программирования и понять манеру функционального мышления, или же кто просто хочет открыть для себя практические аспекты монад и функциональной композиции.
Ссылки
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Комментарии (14)
orthanner
10.05.2016 06:51Замечу, что «если что-то идёт не так», то в Scala лучше использовать монаду Try. Either — это для случая, когда есть два равноправных варианта нормального развития событий. Хотя в качестве примера сгодится.
senia
10.05.2016 07:19+2Не совсем. Try — для случая, когда требуется работать с исключениями. Это не самый удобный подход. Для случая «что-то идет не так», если «не так» ограничено строго определенным набором случаев, то гораздо удобнее использовать Validation, \/ — прямые аналоги Either, для которых известно что является правильным, а что — неправильным вариантом. Аналогичный подход — Result в Rust.
Try не позволяет указать как именно «не так» может это «что-то» пойти.
vba
10.05.2016 10:33Я несознательно удалил комментарий уважаемого adelier, а комментарий был следующего содержания:
Полноты ради: у скалы тоже есть готовая композиция. Не сколь, правда, лаконичная как у F#:
def mul2(x:Int) = 2*x def mul4 = mul2 _ compose mul2 _
senia
10.05.2016 10:42Второе подчеркивание лишнее.
К сожалению первое не опустить — автоматическое приведение метода к функции работает только для аргументов.
hodzanassredin
10.05.2016 18:23Композиция функций с сайд эффектами весьма интересна так как позволяет понять применимость моноидов и комоноидов. hodzanassredin.github.io/2016/01/24/monoid_meets_comonoid.html
Neftedollar
11.05.2016 21:22+1Я думал ROP перевели сотню лет назад, но нет.
Здесь все радостно и понятно.
fsharpforfunandprofit.com/rop
senia
Раз уж статья про ФП, то так и хочется убрать сайдэффекты:
Кстати, не понятно зачем писать def grind: A => B, когда достаточно val.
vba
Ну статья то как раз о композиции функций на scala и f#, которые чистыми функциональными языками не являются.
По def vs val, тут scala более permissive чем f#, только и всего.
vba
Я кстати про permissive scala к тому что в
object
можно использовать и def и val/var, а вот в модулях f# только val, что и понятно видя разницу конструкций.aleksandy
def — это функция, значение которой будет вычисляться при каждом вызове, а val — значение, которое вычисляется лишь однажды.
senia
def — метод. В статье метод возвращает функцию, причем каждый раз одинаковую.
Подробнее: What is the difference between “def” and “val” to define a function.
vba
Так все и задумывалось, вернуть функцию. Это с таким же успехом достижимо через val/var как и через def.