Поделиться через


Выражения вычислений

Выражения вычислений в F# предоставляют удобный синтаксис для написания вычислений, которые можно выполнить последовательно и объединить с помощью конструкций потока управления и привязок. В зависимости от типа вычислительного выражения они могут рассматриваться как способ выражения монадов, моноид, преобразователей monad и применимых функторов. Однако в отличие от других языков (таких как do-notation в Haskell), они не привязаны к одной абстракции и не полагаются на макросы или другие формы метапрограммирования для реализации удобного и контекстно-зависимого синтаксиса.

Обзор

Вычисления могут принимать множество форм. Наиболее распространенная форма вычисления — однопоточное выполнение, которое легко понять и изменить. Однако не все формы вычислений столь же просты, как однопоточное выполнение. Ниже приведены некоторые примеры:

  • Недетерминированные вычисления
  • Асинхронные вычисления
  • Эффектные вычисления
  • Генеративные вычисления

В целом существуют контекстно-чувствительные вычисления, которые необходимо выполнить в определенных частях приложения. Написание контекстно-зависимого кода может быть сложной задачей, так как без абстракций легко утекать вычислениям за пределы заданного контекста, чтобы этого избежать. Эти абстракции часто сложно писать самостоятельно, поэтому F# имеет обобщенный способ сделать так называемые выражения вычислений.

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

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

Общие сведения о синтаксисе

Все выражения вычислений имеют следующую форму:

builder-expr { cexper }

В этой форме builder-expr — это имя типа построителя, который определяет выражение вычисления и cexper является телом выражения вычисления. Например, async код выражения вычислений может выглядеть следующим образом:

let fetchAndDownload url =
    async {
        let! data = downloadData url

        let processedData = processData data

        return processedData
    }

Существует специальный дополнительный синтаксис, доступный в выражении вычислений, как показано в предыдущем примере. Следующие формы выражений возможны с помощью выражений вычислений:

expr { let! ... }
expr { and! ... }
expr { do! ... }
expr { yield ... }
expr { yield! ... }
expr { return ... }
expr { return! ... }
expr { match! ... }

Каждое из этих ключевых слов и другие стандартные ключевые слова F# доступны только в выражении вычислений, если они определены в типе построителя резервного копирования. Единственное исключение из этого — match!, который сам является синтаксическим сахаром для использования let! с последующим сопоставлением с образцом результата.

Тип построителя — это объект, определяющий специальные методы, определяющие способ объединения фрагментов выражения вычисления; то есть его методы управляют поведением выражения вычислений. Другой способ описать класс построителя заключается в том, чтобы сказать, что он позволяет настраивать операцию многих конструкций F#, таких как циклы и привязки.

let!

Ключевое let! слово привязывает результат вызова другого вычислительного выражения к имени:

let doThingsAsync url =
    async {
        let! data = getDataAsync url
        ...
    }

Если привязать вызов к вычислительному выражению let, вы не получите результат вычисления. Вместо этого вы привязали значение нереализованного вызова к такому вычислительному выражению. Используется let! для привязки к результату.

let! определяется элементом Bind(x, f) типа конструктора.

and!

Ключевое and! слово позволяет привязать результаты нескольких вызовов вычислительных выражений эффективно.

let doThingsAsync url =
    async {
        let! data = getDataAsync url
        and! moreData = getMoreDataAsync anotherUrl
        and! evenMoreData = getEvenMoreDataAsync someUrl
        ...
    }

Использование последовательности let! ... let! ... приводит к повторному выполнению дорогостоящих привязок, поэтому let! ... and! ... следует использовать при привязке результатов множества вычислительных выражений.

and! определяется главным образом членом MergeSources(x1, x2) в типе строителя.

При необходимости MergeSourcesN(x1, x2 ..., xN) можно определить, чтобы уменьшить количество узлов, а BindN(x1, x2 ..., xN, f) и/или BindNReturn(x1, x2, ..., xN, f) можно определить для эффективной привязки результатов вычисления без создания узлов.

do!

Ключевое слово do! предназначено для вызова вычислительного выражения, которое возвращает тип, подобный unit (определяется членом Zero в построителе).

let doThingsAsync data url =
    async {
        do! submitData data url
        ...
    }

Для асинхронного рабочего процесса этот тип имеет тип Async<unit>. Для других выражений вычислений тип, скорее всего, будет CExpType<unit>.

do! определяется элементом Bind(x, f) в типе сборщика, где f порождает unit объект.

yield

Ключевое yield слово предназначено для возврата значения из вычислительного выражения, чтобы его можно было использовать в качестве IEnumerable<T>:

let squares =
    seq {
        for i in 1..10 do
            yield i * i
    }

for sq in squares do
    printfn $"%d{sq}"

В большинстве случаев это может быть опущено вызывающими абонентами. Самый распространенный способ опустить yield — это при помощи оператора ->.

let squares =
    seq {
        for i in 1..10 -> i * i
    }

for sq in squares do
    printfn $"%d{sq}"

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

let weekdays includeWeekend =
    seq {
        "Monday"
        "Tuesday"
        "Wednesday"
        "Thursday"
        "Friday"
        if includeWeekend then
            "Saturday"
            "Sunday"
    }

Как и в случае с ключевым словом yield в C#, каждый элемент в вычислительном выражении возвращается при итерации.

yield определяется членом Yield(x) в типе построителя, где x является элементом, который нужно вернуть.

yield!

Ключевое yield! слово предназначено для выравнивания коллекции значений из вычислительного выражения:

let squares =
    seq {
        for i in 1..3 -> i * i
    }

let cubes =
    seq {
        for i in 1..3 -> i * i * i
    }

let squaresAndCubes =
    seq {
        yield! squares
        yield! cubes
    }

printfn $"{squaresAndCubes}"  // Prints - 1; 4; 9; 1; 8; 27

При вычислении вычислительное выражение, вызываемое yield!, будет возвращать свои элементы по одному, сглаживая результат.

yield! определяется членом YieldFrom(x) в типе построителя, где x является коллекцией значений.

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

return

Ключевое return слово упаковывает значение в тип, соответствующий выражению вычисления. Помимо вычислительных выражений, использующих yield, он применяется для "завершения" вычислительного выражения:

let req = // 'req' is of type 'Async<data>'
    async {
        let! data = fetch url
        return data
    }

// 'result' is of type 'data'
let result = Async.RunSynchronously req

return определяется членом Return(x) в типе построителя, где x является элементом для упаковки. Для let! ... return использования BindReturn(x, f) можно использовать для повышения производительности.

return!

Ключевое return! слово реализует значение выражения вычисления и оборачивает этот результат в тип, соответствующий этому выражению вычисления.

let req = // 'req' is of type 'Async<data>'
    async {
        return! fetch url
    }

// 'result' is of type 'data'
let result = Async.RunSynchronously req

return! определяется членом ReturnFrom(x) в типе построителя, в котором x является другим вычислительным выражением.

match!

Ключевое match! слово позволяет ввести вызов к другому вычислительному выражению и сопоставлению шаблонов в результатах:

let doThingsAsync url =
    async {
        match! callService url with
        | Some data -> ...
        | None -> ...
    }

При вызове вычислительного выражения match!, будет вычислен результат вызова, как и let!. Это часто используется при вызове вычислительного выражения, в котором результат является необязательным.

Встроенные выражения вычислений

Базовая библиотека F# определяет четыре встроенных вычислительных выражения: выражения последовательности, асинхронные выражения, выражения задач и выражения запросов.

Создание нового типа выражения вычислений

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

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

Метод Типичные подписи Описание
Bind M<'T> * ('T -> M<'U>) -> M<'U> Вызывается для let! и do! в выражениях вычисления.
BindN (M<'T1> * M<'T2> * ... * M<'TN> * ('T1 * 'T2 ... * 'TN -> M<'U>)) -> M<'U> Требуется для обеспечения эффективности let! и and! в вычислительных выражениях без объединения входных данных.

например, Bind3, Bind4.
Delay (unit -> M<'T>) -> Delayed<'T> Упаковывает вычислительное выражение как функцию. Delayed<'T> может быть любого типа, обычно используются M<'T> или unit -> M<'T>. Реализация по умолчанию возвращает M<'T>.
Return 'T -> M<'T> Вызов return используется в выражениях вычислений.
ReturnFrom M<'T> -> M<'T> Вызов return! используется в выражениях вычислений.
BindReturn (M<'T1> * ('T1 -> 'T2)) -> M<'T2> Вызывается для эффективного вычисления вычислительных выражений let! ... return.
BindNReturn (M<'T1> * M<'T2> * ... * M<'TN> * ('T1 * 'T2 ... * 'TN -> M<'U>)) -> M<'U> Вызывается для эффективного let! ... and! ... return вычисления выражений без объединения входных данных.

например, Bind3Return, Bind4Return.
MergeSources (M<'T1> * M<'T2>) -> M<'T1 * 'T2> Вызов and! используется в выражениях вычислений.
MergeSourcesN (M<'T1> * M<'T2> * ... * M<'TN>) -> M<'T1 * 'T2 * ... * 'TN> and! Вызывается в выражениях вычислений, но повышает эффективность за счет уменьшения числа узлов tupling.

например, MergeSources3, MergeSources4.
Run Delayed<'T> -> M<'T> или

M<'T> -> 'T
Выполняет вычислительное выражение.
Combine M<'T> * Delayed<'T> -> M<'T> или

M<unit> * M<'T> -> M<'T>
Вызывается для упорядочивания в вычислительных выражениях.
For seq<'T> * ('T -> M<'U>) -> M<'U> или

seq<'T> * ('T -> M<'U>) -> seq<M<'U>>
Вызывается для for...do выражений в выражениях вычислений.
TryFinally Delayed<'T> * (unit -> unit) -> M<'T> Вызывается для try...finally выражений в выражениях вычислений.
TryWith Delayed<'T> * (exn -> M<'T>) -> M<'T> Вызывается для try...with выражений в выражениях вычислений.
Using 'T * ('T -> M<'U>) -> M<'U> when 'T :> IDisposable Вызывается для use привязок в выражениях вычислений.
While (unit -> bool) * Delayed<'T> -> M<'T>или

(unit -> bool) * Delayed<unit> -> M<unit>
Вызывается для while...do выражений в выражениях вычислений.
Yield 'T -> M<'T> Вызывается для yield выражений в выражениях вычислений.
YieldFrom M<'T> -> M<'T> Вызывается для yield! выражений в выражениях вычислений.
Zero unit -> M<'T> Вызывается для пустых else ветвей выражений if...then в вычислительных выражениях.
Quote Quotations.Expr<'T> -> Quotations.Expr<'T> Указывает, что выражение вычисления передается члену Run в виде цитаты. Он преобразует все экземпляры вычисления в цитату.

Многие методы в классе построителя используют и возвращают конструкцию M<'T> , которая обычно представляет собой отдельный определенный тип, характеризующий тип объединенных вычислений, например Async<'T> для асинхронных выражений и Seq<'T> рабочих процессов последовательности. Сигнатуры этих методов позволяют им объединяться и вкладываться друг в друга, так что объект рабочего процесса, возвращаемый из одной конструкции, может быть передан следующему.

Многие функции используют результат в качестве аргумента Delay : Run, While, TryWith, TryFinallyи Combine. Тип Delayed<'T> — это возвращаемый тип Delay и, следовательно, параметр для этих функций. Delayed<'T> может быть произвольным типом, который не должен быть связан с M<'T>; обычно M<'T> или (unit -> M<'T>) используется. Реализация по умолчанию M<'T>. Дополнительные сведения см. в разделе "Общие сведения о ограничениях типов".

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

builder.Run(builder.Delay(fun () -> {{ cexpr }}))

В приведенном выше коде вызовы Run и Delay опущены, если они не определены в классе построителя выражений вычислений. Текст выражения вычисления, который здесь обозначается как {{ cexpr }}, преобразуется в дальнейшие вызовы методов класса построителя. Этот процесс определяется рекурсивно в соответствии с переводами в следующей таблице. Код в двойных скобках {{ ... }} остается переведенным, expr представляет выражение F# и cexpr представляет выражение вычисления.

Выражение Перевод текста
{{ let binding in cexpr }} let binding in {{ cexpr }}
{{ let! pattern = expr in cexpr }} builder.Bind(expr, (fun pattern -> {{ cexpr }}))
{{ do! expr in cexpr }} builder.Bind(expr, (fun () -> {{ cexpr }}))
{{ yield expr }} builder.Yield(expr)
{{ yield! expr }} builder.YieldFrom(expr)
{{ return expr }} builder.Return(expr)
{{ return! expr }} builder.ReturnFrom(expr)
{{ use pattern = expr in cexpr }} builder.Using(expr, (fun pattern -> {{ cexpr }}))
{{ use! value = expr in cexpr }} builder.Bind(expr, (fun value -> builder.Using(value, (fun value -> {{ cexpr }}))))
{{ if expr then cexpr0 }} if expr then {{ cexpr0 }} else builder.Zero()
{{ if expr then cexpr0 else cexpr1 }} if expr then {{ cexpr0 }} else {{ cexpr1 }}
{{ match expr with | pattern_i -> cexpr_i }} match expr with | pattern_i -> {{ cexpr_i }}
{{ for pattern in enumerable-expr do cexpr }} builder.For(enumerable-expr, (fun pattern -> {{ cexpr }}))
{{ for identifier = expr1 to expr2 do cexpr }} builder.For([expr1..expr2], (fun identifier -> {{ cexpr }}))
{{ while expr do cexpr }} builder.While(fun () -> expr, builder.Delay({{ cexpr }}))
{{ try cexpr with | pattern_i -> expr_i }} builder.TryWith(builder.Delay({{ cexpr }}), (fun value -> match value with | pattern_i -> expr_i | exn -> System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(exn).Throw()))
{{ try cexpr finally expr }} builder.TryFinally(builder.Delay({{ cexpr }}), (fun () -> expr))
{{ cexpr1; cexpr2 }} builder.Combine({{ cexpr1 }}, {{ cexpr2 }})
{{ other-expr; cexpr }} expr; {{ cexpr }}
{{ other-expr }} expr; builder.Zero()

В предыдущей таблице other-expr описывается выражение, которое не указано в таблице. Класс построителя не должен реализовывать все методы и поддерживать все переводы, перечисленные в предыдущей таблице. Эти конструкции, которые не реализованы, недоступны в выражениях вычислений этого типа. Например, если вы не хотите поддерживать use ключевое слово в выражениях вычислений, можно опустить определение Use в классе построителя.

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

/// Represents computations that can be run step by step
type Eventually<'T> =
    | Done of 'T
    | NotYetDone of (unit -> Eventually<'T>)

module Eventually =

    /// Bind a computation using 'func'.
    let rec bind func expr =
        match expr with
        | Done value -> func value
        | NotYetDone work -> NotYetDone (fun () -> bind func (work()))

    /// Return the final value
    let result value = Done value

    /// The catch for the computations. Stitch try/with throughout
    /// the computation, and return the overall result as an OkOrException.
    let rec catch expr =
        match expr with
        | Done value -> result (Ok value)
        | NotYetDone work ->
            NotYetDone (fun () ->
                let res = try Ok(work()) with | exn -> Error exn
                match res with
                | Ok cont -> catch cont // note, a tailcall
                | Error exn -> result (Error exn))

    /// The delay operator.
    let delay func = NotYetDone (fun () -> func())

    /// The stepping action for the computations.
    let step expr =
        match expr with
        | Done _ -> expr
        | NotYetDone func -> func ()

    /// The tryFinally operator.
    /// This is boilerplate in terms of "result", "catch", and "bind".
    let tryFinally expr compensation =
        catch (expr)
        |> bind (fun res ->
            compensation();
            match res with
            | Ok value -> result value
            | Error exn -> raise exn)

    /// The tryWith operator.
    /// This is boilerplate in terms of "result", "catch", and "bind".
    let tryWith exn handler =
        catch exn
        |> bind (function Ok value -> result value | Error exn -> handler exn)

    /// The whileLoop operator.
    /// This is boilerplate in terms of "result" and "bind".
    let rec whileLoop pred body =
        if pred() then body |> bind (fun _ -> whileLoop pred body)
        else result ()

    /// The sequential composition operator.
    /// This is boilerplate in terms of "result" and "bind".
    let combine expr1 expr2 =
        expr1 |> bind (fun () -> expr2)

    /// The using operator.
    /// This is boilerplate in terms of "tryFinally" and "Dispose".
    let using (resource: #System.IDisposable) func =
        tryFinally (func resource) (fun () -> resource.Dispose())

    /// The forLoop operator.
    /// This is boilerplate in terms of "catch", "result", and "bind".
    let forLoop (collection:seq<_>) func =
        let ie = collection.GetEnumerator()
        tryFinally
            (whileLoop
                (fun () -> ie.MoveNext())
                (delay (fun () -> let value = ie.Current in func value)))
            (fun () -> ie.Dispose())

/// The builder class.
type EventuallyBuilder() =
    member x.Bind(comp, func) = Eventually.bind func comp
    member x.Return(value) = Eventually.result value
    member x.ReturnFrom(value) = value
    member x.Combine(expr1, expr2) = Eventually.combine expr1 expr2
    member x.Delay(func) = Eventually.delay func
    member x.Zero() = Eventually.result ()
    member x.TryWith(expr, handler) = Eventually.tryWith expr handler
    member x.TryFinally(expr, compensation) = Eventually.tryFinally expr compensation
    member x.For(coll:seq<_>, func) = Eventually.forLoop coll func
    member x.Using(resource, expr) = Eventually.using resource expr

let eventually = new EventuallyBuilder()

let comp =
    eventually {
        for x in 1..2 do
            printfn $" x = %d{x}"
        return 3 + 4
    }

/// Try the remaining lines in F# interactive to see how this
/// computation expression works in practice.
let step x = Eventually.step x

// returns "NotYetDone <closure>"
comp |> step

// prints "x = 1"
// returns "NotYetDone <closure>"
comp |> step |> step

// prints "x = 1"
// prints "x = 2"
// returns "Done 7"
comp |> step |> step |> step |> step

Вычислительное выражение имеет базовый тип, который оно возвращает. Базовый тип может представлять вычисляемый результат или отложенное вычисление, которое может быть выполнено, или он может предоставить способ итерации через некоторый тип коллекции. В предыдущем примере базовый тип был Eventually<_>. Для выражения с последовательностью базовый тип — System.Collections.Generic.IEnumerable<T>. Для выражения запроса базовый тип — System.Linq.IQueryable. Для асинхронного выражения базовый тип — это Async. Объект Async представляет работу, которую необходимо выполнить для вычисления результата. Например, вы вызываете Async.RunSynchronously выполнение вычисления и возвращаете результат.

Пользовательские операции

Вы можете определить пользовательскую операцию в выражении вычисления и использовать пользовательскую операцию в качестве оператора в выражении вычисления. Например, можно включить оператор запроса в выражение запроса. При определении пользовательской операции необходимо определить методы Yield и For в выражении вычислений. Чтобы определить пользовательскую операцию, поместите её в класс построителя для выражения вычисления, а затем примените CustomOperationAttribute. Этот атрибут принимает строку в качестве аргумента, который является именем, используемым в пользовательской операции. Это имя становится доступным в области видимости в начале открывающей фигурной скобки выражения вычисления. Поэтому не следует использовать идентификаторы, которые имеют то же имя, что и пользовательская операция в этом блоке. Например, избегайте использования идентификаторов, таких как all или last в выражениях запросов.

Расширение существующих билдеров с помощью новых кастомных операций

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

В следующем примере показаны расширения существующего FSharp.Linq.QueryBuilder класса.

open System
open FSharp.Linq

type QueryBuilder with

    [<CustomOperation>]
    member _.any (source: QuerySource<'T, 'Q>, predicate) =
        System.Linq.Enumerable.Any (source.Source, Func<_,_>(predicate))

    [<CustomOperation("singleSafe")>] // you can specify your own operation name in the constructor
    member _.singleOrDefault (source: QuerySource<'T, 'Q>, predicate) =
        System.Linq.Enumerable.SingleOrDefault (source.Source, Func<_,_>(predicate))

Пользовательские операции могут быть перегружены. Для получения дополнительной информации см. документ F# RFC FS-1056 "Разрешение перегрузки пользовательских ключевых слов в вычислительных выражениях".

Эффективная компиляция вычислительных выражений

Выражения вычислений F#, которые приостанавливают выполнение, можно скомпилировать на высокопроизводительных компьютерах состояний с помощью низкоуровневой функции, называемой возобновлением кода. Возобновляемый код задокументирован в F# RFC FS-1087 и используется для Task-выражений.

Выражения вычислений F#, которые являются синхронными (т. е. не приостанавливают выполнение), могут быть скомпилированы в эффективные автоматы состояний с помощью встроенных функций, используя атрибут InlineIfLambda. Примеры приведены в F# RFC FS-1098.

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

См. также