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

  • Tutorial

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


Я начал думать о написании данной статьи несколько недель назад, после того, когда я старался объяснить моему 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 с соответствующим индикатором ошибки, то последующие элементы цепи будут попросту игнорированы и рабочий процесс пропустит их все и будет попросту распространять выброшенное исключение от предыдущего звена к последующему. Вы можете самостоятельно попробовать поиграть со сценариями в которых присутствуют ошибки.


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


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


Ссылки


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

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

Применяете ли вы на практике монады или композицию функций

Поделиться публикацией

Комментарии 15

    +2
    Раз уж статья про ФП, то так и хочется убрать сайдэффекты:

    import scalaz._, Scalaz._
    
    object Main extends App {
      case class Wheat()
      case class Flour()
      case class Dough()
      case class Bread()
    
      type Result[T] = String \/ T
      type Log[T] = WriterT[Result, Vector[String], T]
    
      def write(s: String): Log[Unit] = WriterT.writerT(\/-(Vector(s) -> ()))
    
      def grind(w: Wheat): Log[Flour] = for {
        _ <- write("make the flour")
      } yield Flour()
    
      def kneadDough(f: Flour): Log[Dough] = for {
        _ <- write("make the dough")
      } yield Dough()
    
      def distributeDough(d: Dough): Log[Seq[Dough]] = for {
        _ <- write("distribute the dough")
      } yield Seq[Dough]()
    
      def bake(temperature: Int, duration: Int, sd: Seq[Dough]): Log[Seq[Bread]] = for {
        _ <- write(s"bake the bread, duration: $duration, temperature: $temperature")
      } yield Seq[Bread]()
    
      val bakeRecipe1 = (bake _).curried(350)(45)
    
      val result = grind(Wheat()) >>= kneadDough >>= distributeDough >>= bakeRecipe1
    
      result.run match {
        case -\/(e) => println(s"Error: $e")
        case \/-((log, res)) => log foreach println
      }
    }
    
    // результат тот же:
    // make the flour
    // make the dough
    // distribute the dough
    // bake the bread, duration: 45, temperature: 350
    


    Кстати, не понятно зачем писать def grind: A => B, когда достаточно val.
      0
      Ну статья то как раз о композиции функций на scala и f#, которые чистыми функциональными языками не являются.
      По def vs val, тут scala более permissive чем f#, только и всего.
        0

        Я кстати про permissive scala к тому что в object можно использовать и def и val/var, а вот в модулях f# только val, что и понятно видя разницу конструкций.

        0
        def — это функция, значение которой будет вычисляться при каждом вызове, а val — значение, которое вычисляется лишь однажды.
          +2
          def — метод. В статье метод возвращает функцию, причем каждый раз одинаковую.
          Подробнее: What is the difference between “def” and “val” to define a function.
            0
            Так все и задумывалось, вернуть функцию. Это с таким же успехом достижимо через val/var как и через def.
        0
        Замечу, что «если что-то идёт не так», то в Scala лучше использовать монаду Try. Either — это для случая, когда есть два равноправных варианта нормального развития событий. Хотя в качестве примера сгодится.
          +2
          Не совсем. Try — для случая, когда требуется работать с исключениями. Это не самый удобный подход. Для случая «что-то идет не так», если «не так» ограничено строго определенным набором случаев, то гораздо удобнее использовать Validation, \/ — прямые аналоги Either, для которых известно что является правильным, а что — неправильным вариантом. Аналогичный подход — Result в Rust.
          Try не позволяет указать как именно «не так» может это «что-то» пойти.
          0

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


          Полноты ради: у скалы тоже есть готовая композиция. Не сколь, правда, лаконичная как у F#:

          def mul2(x:Int) = 2*x
          def mul4 = mul2 _ compose mul2 _

            0
            Второе подчеркивание лишнее.
            К сожалению первое не опустить — автоматическое приведение метода к функции работает только для аргументов.
              0
              Аналог >> из статьи — это не compose, а andThen.
                0
                Да, вы правы. Не обратил внимания что в статье композиция не в математическом смысле композиция.
                0

                При всей моей любви к scala, композиция в ней сделана отвратно. Поэтому, в своей библиотечке я добавил следующий синтаксис для композиции:


                implicit class EnrichedFunction[-A,+B](f: A => B) {
                
                  /**
                    * Shortcut for function composition
                    * @return a composition of functions
                    */
                  def o [R] (g: R => A): R => B = x => f(g(x))
                  def ∘ [R] (g: R => A): R => B = x => f(g(x))
                }

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

                0
                Композиция функций с сайд эффектами весьма интересна так как позволяет понять применимость моноидов и комоноидов. hodzanassredin.github.io/2016/01/24/monoid_meets_comonoid.html
                  +1
                  Я думал ROP перевели сотню лет назад, но нет.
                  Здесь все радостно и понятно.
                  fsharpforfunandprofit.com/rop

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

                  Самое читаемое