Композиция функций на F# и Scala

Проще говоря о чем все это

Я начал думать о написании данной статьи несколько недель назад, после того, когда я старался объяснить моему 7 летнему чаду что такое математические функции. Мы начали с рассмотрения очень простых вещей. Это прозвучит безумно и наверное несуразно, но я закончил мое вводное объяснение повествованием о композиции функций. Это казалось настолько логичным разъясняя что такое функции, приводя примеры их использования из окружающего мира, говорить о композиции. Цель данной статьи — показать насколько простой и мощной является композиция функций. Начну я с рассмотрения понятия чистой композиции и приземленного разъяснения, после чего мы попробуем немного карри и позабавимся с монадами. Надеюсь вам понравится.


Функция как небольшой ящик

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

b30d9972c5a34b74bcbeaadf82bb1d0f.png

Изображение 1, буквенно-числовое представление функции сложения

35528d99027a4be19dd4d7d831ab7907.png

Изображение 2, символьное представление функции сложения

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


  • Grind, принимает пшеницу, перемалывает ее и возвращает муку
  • KneadDough, берет муку на входе, мешает ее с внутренними ингредиентами и производит тесто
  • DistributeDough, принимает все количество теста, и распределяет его по формам на выходе получается формочки с тестом
  • Bake, берет формочки с тестом, запекает их и выдает порции хлеба

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

 w -> [Grind] -> [KneadDough] -> [DistributeDough] -> [Bake] -> b

d541602d613140b99f935162349ee1fa.png

Изображение 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) = 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) =
    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 -> seq = 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 =
    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) =
    printfn "bake the bread, duration: %d, temperature: %d" duration temperature
    Left (seq { yield {bread = ""}})
let bakeRecipe1: seq -> Either, 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 с соответствующим индикатором ошибки, то последующие элементы цепи будут попросту игнорированы и рабочий процесс пропустит их все и будет попросту распространять выброшенное исключение от предыдущего звена к последующему. Вы можете самостоятельно попробовать поиграть со сценариями в которых присутствуют ошибки.


Заключительная часть

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


Ссылки


  • Английская версия данной статьи.
  • Все в одном, модуль на F# доступен здесь
  • Scala версия может быть скачана здесь

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

© Habrahabr.ru