Скотт Влащин — безусловный гуру в мире F#, написавший введение в язык, которое рекомендуют новичкам вместо официального руководства.


Группа энтузиастов давно (и с переменным успехом) пытается перевести руководство Скотта на русский.


Я завершаю перевод цикла, посвящённого одной из самых сакральных тем языка — вычислительным выражениям. Это как монады, только в .NET.


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


Далее передают слово автору. Перед вами — первая статья цикла.




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


В этом цикле статей вы узнаете, что такое вычислительные выражения, как их создавать, а также освоите несколько общих паттернов, связанных с ними. В процессе мы также познакомимся с продолжениями, функцией связывания, типами-обёртками и прочим.


Введение


Кажется, что вычислительные выражениях имеют репутацию заумной штуки, трудной для понимания.


С одной стороны, их достаточно легко применять. Любой, кто написал достаточно кода на F# наверняка использовал стандартные конструкции, такие как seq{...} или async{...}.


Но как вы можете создать новую похожую конструкцию? Как они работают за кулисами?


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


Если мы обратимся за помощью к официальной документации MSDN, то обнаружим, что она точна, но достаточно бесполезна для начинающего.


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


{| let! pattern = expr in cexpr |}

то это на самом деле синтаксический сахар для такого вызова:


builder.Bind(expr, (fun pattern -> {| cexpr |}))

Но… что это такое и для чего нужно?


Я надеюсь, что к концу цикла, документация, пример которой приведён выше, станет очевидной. Не верите? Читайте дальше!


Вычислительные выражения на практике


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


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


let log p = printfn "expression is %A" p

let loggedWorkflow =
    let x = 42
    log x
    let y = 43
    log y
    let z = x + y
    log z
    //return
    z

Если вы запустите эту программу, вы увидите:


expression is 42
expression is 43
expression is 85

Совсем несложно.


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


Хорошо, что вы спросили… Вычислительные выражения помогут справиться с проблемой. Вот код, которые будет делать то же самое.


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


type LoggingBuilder() =
    let log p = printfn "expression is %A" p

    member this.Bind(x, f) =
        log x
        f x

    member this.Return(x) =
        x

Пока не беспокойтесь по поводу таинственных Bind и Return — мы обязательно вернёмся к ним позже.


Обратите внимание, что "построитель" или "строитель" (builder) в контексте вычислительных выражений — это не то же самое, что объектно-ориентированный паттерн Строитель, который применяется для создания сложных объектов.

Затем мы создадим экземпляр объявленного типа, в нашем случае logger.


let logger = new LoggingBuilder()

Теперь, имея logger, мы можем переписать оригинальный пример вот так:


let loggedWorkflow =
    logger
        {
        let! x = 42
        let! y = 43
        let! z = x + y
        return z
        }

Запустив код, вы увидите на экране то же самое, но, как вы могли заметить, использование конструкции logger{...} позволило нам избавиться от повторяющегося кода.


Безопасное деление


Теперь давайте разберёмся с одной бородатой историей.


Представим, что нам надо разделить друг на друга несколько чисел, но одно из них может быть равно нулю. Как нам обработать возможную ошибку? Можно выбросить исключения, но это будет выглядеть достаточно уродливо. Скорее, здесь подошёл бы тип option.


Вначале нам надо написать вспомогательную функцию, которая делит числа друг на друга и возвращает результат типа int option. Если всё прошло нормально, мы получаем какое то (Some) значение, а если нет — не получаем ничего (None).


Затем мы объединяем деления в цепочку и после каждого деления проверяем результат, продолжая только в случае успеха.


Сначала напишем вспомогательную функцию, а потом основной код.


let divideBy bottom top =
    if bottom = 0
    then None
    else Some(top/bottom)

Обратите внимание, что первым в списке параметров мы поставили делитель. Это позволит нам записывать выражения в виде 12 |> divideBy 3, благодаря чему мы сможем объединять их в цепочку.


Теперь используем нашу функцию. Вот код, где начальное значение последовательно делится на три числа.


let divideByWorkflow init x y z =
    let a = init |> divideBy x
    match a with
    | None -> None  // останавливаемся
    | Some a' ->    // продолжаем
        let b = a' |> divideBy y
        match b with
        | None -> None  // останавливаемся
        | Some b' ->    // продолжаем
            let c = b' |> divideBy z
            match c with
            | None -> None  // останавливаемся
            | Some c' ->    // продолжаем
                // возвращаем результат
                Some c'

А здесь мы его используем:


let good = divideByWorkflow 12 3 2 1
let bad = divideByWorkflow 12 3 0 1

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


Обратите внимание, что выражение целиком также должно иметь тип int option. Оно не может быть просто целым, потому что тогда непонятно, чему оно должно быть равно в случае ошибки. Как видите, тип, который мы используем внутри цепочки — option — это тот же тип, который мы получим в конце. Запомните этот момент — мы вернёмся к нему позже.


В любом случае, все эти бесконечные проверки и ветвления выглядят поистине ужасно! Смогут ли вычислительные выражения избавить от них?


Опять определим новый тип (MaybeBuilder) и создадим его экземпляр (maybe).


type MaybeBuilder() =

    member this.Bind(x, f) =
        match x with
        | None -> None
        | Some a -> f a

    member this.Return(x) =
        Some x

let maybe = new MaybeBuilder()

Я дал название MaybeBuilder вместо divideByBuilder, потому что проблема с необязательным результатом, которую мы решаем с помощью вычислительного выражения, встречается довольно часто, и слово maybe — устояшееся название
для этой штуки.


Теперь, когда мы определили шаги для maybe, давайте перепишем оригинальный код с их использованием.


let divideByWorkflow init x y z =
    maybe
        {
        let! a = init |> divideBy x
        let! b = a |> divideBy y
        let! c = b |> divideBy z
        return c
        }

Выглядит гораздо приятнее! Выражение maybe полностью скрыло ветвления!


И, если мы протестируем код, мы получим тот же результат, что и раньше:


let good = divideByWorkflow 12 3 2 1
let bad = divideByWorkflow 12 3 0 1

Цепочка проверок в "ветках else"


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


Но иногда нам бывает нужно что-то другое. Иногда поток управления зависит от последовательности проверок в "ветках else". Проверьте первое условие и, если оно истинно, вы закончили. В противном случае проверьте второе, а если и оно ложно, проверьте третье, и так далее.


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


let map1 = [ ("1","One"); ("2","Two") ] |> Map.ofList
let map2 = [ ("A","Alice"); ("B","Bob") ] |> Map.ofList
let map3 = [ ("CA","California"); ("NY","New York") ] |> Map.ofList

let multiLookup key =
    match map1.TryFind key with
    | Some result1 -> Some result1   // нашли
    | None ->   // не нашли
        match map2.TryFind key with
        | Some result2 -> Some result2 // нашли
        | None ->   // не нашли
            match map3.TryFind key with
            | Some result3 -> Some result3  // нашли
            | None -> None // не нашли

Поскольку в F# всё является выражением, мы не можем прервать вычисления в произвольном месте, нам придётся уложить все проверки в одно большое выражение.


Теперь посмотрим, как это можно использовать:


multiLookup "A" |> printfn "Result for A is %A"
multiLookup "CA" |> printfn "Result for CA is %A"
multiLookup "X" |> printfn "Result for X is %A"

Работает прекрасно, но можно ли упростить наш код?


Да, определённо. Вот строитель для "веток else", который позволяет упростить такого рода проверки:


type OrElseBuilder() =
    member this.ReturnFrom(x) = x
    member this.Combine (a,b) =
        match a with
        | Some _ -> a  // получилось — используем a
        | None -> b    // не получилось — используем b
    member this.Delay(f) = f()

let orElse = new OrElseBuilder()

А вот так можно переписать код проверок с использованием строителя.


let map1 = [ ("1","One"); ("2","Two") ] |> Map.ofList
let map2 = [ ("A","Alice"); ("B","Bob") ] |> Map.ofList
let map3 = [ ("CA","California"); ("NY","New York") ] |> Map.ofList

let multiLookup key = orElse {
    return! map1.TryFind key
    return! map2.TryFind key
    return! map3.TryFind key
    }

И снова убедимся, что код работает, как ожидается.


multiLookup "A" |> printfn "Result for A is %A"
multiLookup "CA" |> printfn "Result for CA is %A"
multiLookup "X" |> printfn "Result for X is %A"

Асинхронные вызовы с функциями обратного вызова


И в завершение взглянем на функции обратного вызова. Стандартным способом выполнения асинхронных вызовов в .NET является использование делегата AsyncCallback, который вызывается, когда завершается асинхронная операциия.


Вот пример, как можно скачать вебстраницу, используя эту технику:


open System.Net
let req1 = HttpWebRequest.Create("http://fsharp.org")
let req2 = HttpWebRequest.Create("http://google.com")
let req3 = HttpWebRequest.Create("http://bing.com")

req1.BeginGetResponse((fun r1 ->
    use resp1 = req1.EndGetResponse(r1)
    printfn "Downloaded %O" resp1.ResponseUri

    req2.BeginGetResponse((fun r2 ->
        use resp2 = req2.EndGetResponse(r2)
        printfn "Downloaded %O" resp2.ResponseUri

        req3.BeginGetResponse((fun r3 ->
            use resp3 = req3.EndGetResponse(r3)
            printfn "Downloaded %O" resp3.ResponseUri

            ),null) |> ignore
        ),null) |> ignore
    ),null) |> ignore

Множество вызовов BeginGetResponse и EndGetResponse, и вложенные лямбда-функции достаточно трудны для понимания. Важный код (в нашем случае это операторы печати) теряется на фоне логики с обратными вызовами.


На самом деле, большая вложенность асинхронного кода — известная проблема, у неё даже есть собственное название — "Пирамида Судьбы" (хотя, на мой взгляд, ни одно из предложенных решений не выглядит достаточно элегантным).


Естественно, нам никогда не придётся писать подобный код на F#, потому что в F# есть встронное вычислительное выражение async, которое и упрощает логику и избавляет код от вложенности (делает его плоским).


open System.Net
let req1 = HttpWebRequest.Create("http://fsharp.org")
let req2 = HttpWebRequest.Create("http://google.com")
let req3 = HttpWebRequest.Create("http://bing.com")

async {
    use! resp1 = req1.AsyncGetResponse()
    printfn "Downloaded %O" resp1.ResponseUri

    use! resp2 = req2.AsyncGetResponse()
    printfn "Downloaded %O" resp2.ResponseUri

    use! resp3 = req3.AsyncGetResponse()
    printfn "Downloaded %O" resp3.ResponseUri

    } |> Async.RunSynchronously

Позже в этом цикле статей мы разберёмся, как устроен процесс async.


Заключение


Итак, мы познакомились с несколькими простейшими примерами вычислительных выражений, как "до", так и "после". Примеры неплохо показывают, для решения каких проблем подходят вычислительные выражения.


  • В примере с логгированием, нам нужны были побочные эффекты, сопровождавшие каждый шаг вычислений.
  • В примере с делением, нам потребовалась элегантная обработка ошибка, чтобы мы сосредоточились на успешном сценарии.
  • В примере со словарями мы хотели прервать большое вычисление, как только получали результат.
  • И, наконец, в примере с асинхронным кодом, мы хотели спрятать функции обратного вызова и изабавиться от "пирамиды судьбы".

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


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


Почему они называются "вычислительные выражения"? Что же, очевидно, что речь идёт об особом виде выражений, так что по крайней мере с одним словом всё ясно. Я верю, что команда разработчиков F# первоначально хотела дать им название "выражения-которые-делают-что-то-на-заднем-плане-между-каждым-вычислением", но потом они почему-то подумали, что это слишком громоздко и решили вместо этого дать им имя "вычислительные выражения".


Немного о разнице между "вычислительным выражением" (computation expression) и "процессом" (workflow). Когда я говорю "вычислительное выражение", я подразумеваю синтаксис из {...} и let!. "Процесс" относится к конкретным реализациям, когда мы будем их обсуждать.
Не все реализации вычислительных выражений являются процессами. Например, можно говорить о "процессе async" или "процессе maybe", но "процесс seq" звучит неправильно.


В примере ниже я бы сказал, что maybe — это процесс, который мы используем, а код в фигурных скобках { let! a = .... return c } — вычислительное выражение.


maybe
    {
    let! a = x |> divideBy y
    let! b = a |> divideBy w
    let! c = b |> divideBy z
    return c
    }

Сейчас вы, возможно, хотите написать своё первое вычислительное выражение, но сначала нам нужно разобраться с продолжениями (continuations). Это тема следующей статьи.

Комментарии (1)


  1. kozlov_de
    02.04.2024 15:10

    Влашина завезли, ура!

    Только надо было начинать с чего-то попроще

    Или с мотивационной части