Expressões de computação

Expressões de computação no F# fornecem uma sintaxe conveniente para criar cálculos que podem ser sequenciados e combinados usando construções de fluxo de controle e associações. Dependendo do tipo de expressão de computação, elas podem ser consideradas como uma forma de expressar transformadores monads, monoids, monad e functors aplicativos. No entanto, ao contrário de outras linguagens (como do-notation em Haskell), elas não estão vinculadas a uma só abstração e não dependem de macros ou outras formas de metaprogramação para realizar uma sintaxe conveniente e sensível ao contexto.

Visão geral

As computações podem ter várias formas. A forma mais comum de computação é a execução de thread único, que é fácil de entender e modificar. No entanto, nem todas as formas de computação são tão simples quanto a execução de thread único. Alguns exemplos incluem:

  • Computações não determinísticas
  • Computações assíncronas
  • Computações com efeito
  • Computações generativas

Em geral, há computações sensíveis ao contexto que você deve executar em determinadas partes de um aplicativo. Escrever código sensível ao contexto pode ser desafiador, pois é fácil "vazar" computações fora de um dado contexto sem abstrações para impedir que você faça isso. Essas abstrações geralmente são desafiadoras de escrever sozinho, e é por isso que o F# tem uma forma generalizada de fazer isso, chamada expressões de computação.

As expressões de computação oferecem uma sintaxe uniforme e um modelo de abstração para codificar cálculos sensíveis ao contexto.

Cada expressão de computação é apoiada por um tipo de construtor. O tipo de construtor define as operações disponíveis para a expressão de computação. Confira Como criar um tipo de expressão de computação, que mostra como criar uma expressão de computação personalizada.

Visão geral da sintaxe

Todas as expressões de computação têm a seguinte forma:

builder-expr { cexper }

Nessa forma, builder-expr é o nome de um tipo de construtor que define a expressão de computação e cexper é o corpo da expressão de computação. Por exemplo, o código da expressão de computação async pode ter esta aparência:

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

        let processedData = processData data

        return processedData
    }

Há uma sintaxe especial e adicional disponível em uma expressão de computação, conforme mostrado no exemplo anterior. As seguintes formas de expressão são possíveis com expressões de computação:

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

Cada uma dessas palavras-chave e outras palavras-chave padrão do F# só estarão disponíveis em uma expressão de computação se tiverem sido definidas no tipo de construtor de apoio. A única exceção para isso é match!, que é, em si, açúcar sintático para uso de let! seguido uma correspondência de padrão no resultado.

O tipo de construtor é um objeto que define métodos especiais que regem à forma como os fragmentos da expressão de computação são combinados; ou seja, seus métodos controlam como a expressão de computação se comporta. Outra maneira de descrever uma classe de construtor é dizer que ela permite personalizar a operação de muitas construções F#, como loops e associações.

let!

A palavra-chave let! associa o resultado de uma chamada a outra expressão de computação a um nome:

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

Se você associar a chamada a uma expressão de computação a let, não obterá o resultado da expressão de computação. Em vez disso, você terá associado o valor da chamada não realizada a essa expressão de computação. Use let! para associar ao resultado.

let! é definido pelo membro Bind(x, f) no tipo de construtor.

and!

A palavra-chave and! permite associar os resultados de várias chamadas de expressão de computação de maneira performativa.

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

O uso de uma série de forças re-execução let! ... let! ... de associações caras, portanto, o uso let! ... and! ... deve ser usado ao associar os resultados de várias expressões de computação.

and! é definido principalmente pelo membro MergeSources(x1, x2) no tipo de construtor.

Opcionalmente, MergeSourcesN(x1, x2 ..., xN) pode ser definido para reduzir o número de nós de tupling, e BindN(x1, x2 ..., xN, f) ou BindNReturn(x1, x2, ..., xN, f) podem ser definidos para associar resultados de expressão de computação com eficiência sem vincular nós.

do!

A palavra-chave do! é para chamar uma expressão de computação que retorna um tipo semelhante a unit (definido pelo membro Zero no construtor):

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

Para o fluxo de trabalho assíncrono, esse tipo é Async<unit>. Para outras expressões de computação, é provável que o tipo seja CExpType<unit>.

do! é definido pelo membro Bind(x, f) no tipo de construtor, em que f produz um unit.

yield

A palavra-chave yield é para retornar um valor da expressão de computação para que possa ser consumida como um IEnumerable<T>:

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

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

Na maioria dos casos, pode ser omitido por chamadores. A maneira mais comum de omitir yield é com o operador ->:

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

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

Para expressões mais complexas que podem produzir muitos valores distintos, e talvez condicionalmente, apenas omitir a palavra-chave pode fazer:

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

Assim como acontece com a palavra-chave de geração em C#, cada elemento na expressão de computação é gerado de volta à medida que é iterado.

yield é definido pelo membro Yield(x) no tipo de construtor, em que x está o item a ser gerado.

yield!

A palavra-chave yield! é para nivelar uma coleção de valores de uma expressão de computação:

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

Quando avaliada, a expressão de computação chamada por yield! terá seus itens gerados um por um, nivelando o resultado.

yield! é definido pelo membro YieldFrom(x) no tipo de construtor, em que x é uma coleção de valores.

Ao contrário de yield, yield! deve ser explicitamente especificado. Seu comportamento não está implícito em expressões de computação.

return

A palavra-chave return encapsula um valor no tipo correspondente à expressão de computação. Além das expressões de computação que usam yield, ela é usada para "concluir" uma expressão de computação:

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 é definido pelo membro Return(x) no tipo de construtor, em que x está o item a ser encapsulado. Para uso de let! ... return, BindReturn(x, f) pode ser usado para melhorar o desempenho.

return!

A palavra-chave return! percebe o valor de uma expressão de computação e encapsulamentos que resultam no tipo correspondente à expressão de computação:

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

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

return! é definido pelo membro ReturnFrom(x) no tipo de construtor, em que x é outra expressão de computação.

match!

A palavra-chave match! permite embutir uma chamada em outra expressão de computação e faça a correspondência de padrão em seu resultado:

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

Ao chamar uma expressão de computação com match!, ela perceberá o resultado da chamada, como let!. Isso geralmente é usado ao chamar uma expressão de computação em que o resultado é opcional.

Expressões de computação inseridas

A biblioteca principal F# define quatro expressões de computação inseridas: expressões de sequência, expressões assíncronas, expressões de tarefa e expressões de consulta.

Criando um tipo de expressão de computação

Você pode definir as características de suas expressões de computação criando uma classe de construtor e definindo determinados métodos especiais na classe. Opcionalmente, a classe de construtor pode definir os métodos conforme listado na tabela a seguir.

A tabela a seguir descreve os métodos que podem ser usados em uma classe de construtor de fluxo de trabalho.

Método Assinaturas típicas Descrição
Bind M<'T> * ('T -> M<'U>) -> M<'U> Chamado para let! e do! em expressões de computação.
BindN (M<'T1> * M<'T2> * ... * M<'TN> * ('T1 * 'T2 ... * 'TN -> M<'U>)) -> M<'U> Chamado para let! e and! eficientes em expressões de computação sem mesclar entradas.

Por exemplo, Bind3, Bind4.
Delay (unit -> M<'T>) -> Delayed<'T> Encapsula uma expressão de computação como uma função. Delayed<'T> pode ser qualquer tipo, geralmente M<'T> ou unit -> M<'T> é usado. A implementação padrão retorna M<'T>.
Return 'T -> M<'T> Chamado para return em expressões de computação.
ReturnFrom M<'T> -> M<'T> Chamado para return! em expressões de computação.
BindReturn (M<'T1> * ('T1 -> 'T2)) -> M<'T2> Chamado para um let! ... return eficiente em expressões de computação.
BindNReturn (M<'T1> * M<'T2> * ... * M<'TN> * ('T1 * 'T2 ... * 'TN -> M<'U>)) -> M<'U> Chamado para let! ... and! ... return eficiente em expressões de computação sem mesclar entradas.

Por exemplo, Bind3Return, Bind4Return.
MergeSources (M<'T1> * M<'T2>) -> M<'T1 * 'T2> Chamado para and! em expressões de computação.
MergeSourcesN (M<'T1> * M<'T2> * ... * M<'TN>) -> M<'T1 * 'T2 * ... * 'TN> Chamado para and! em expressões de computação, mas melhora a eficiência reduzindo o número de nós de tupling.

Por exemplo, MergeSources3, MergeSources4.
Run Delayed<'T> -> M<'T> ou

M<'T> -> 'T
Executa uma expressão de computação.
Combine M<'T> * Delayed<'T> -> M<'T> ou

M<unit> * M<'T> -> M<'T>
Chamado para sequenciamento em expressões de computação.
For seq<'T> * ('T -> M<'U>) -> M<'U> ou

seq<'T> * ('T -> M<'U>) -> seq<M<'U>>
Chamado para expressões for...do em expressões de computação.
TryFinally Delayed<'T> * (unit -> unit) -> M<'T> Chamado para expressões try...finally em expressões de computação.
TryWith Delayed<'T> * (exn -> M<'T>) -> M<'T> Chamado para expressões try...with em expressões de computação.
Using 'T * ('T -> M<'U>) -> M<'U> when 'T :> IDisposable Chamado para associações use em expressões de computação.
While (unit -> bool) * Delayed<'T> -> M<'T>ou

(unit -> bool) * Delayed<unit> -> M<unit>
Chamado para expressões while...do em expressões de computação.
Yield 'T -> M<'T> Chamado para expressões yield em expressões de computação.
YieldFrom M<'T> -> M<'T> Chamado para expressões yield! em expressões de computação.
Zero unit -> M<'T> Chamado para ramificações else vazias de expressões if...then em expressões de computação.
Quote Quotations.Expr<'T> -> Quotations.Expr<'T> Indica que a expressão de computação é passada para o membro Run como uma citação. Ele converte todas as instâncias de uma computação em uma citação.

Muitos dos métodos em uma classe de construtor usam e retornam um constructo M<'T>, que normalmente é um tipo definido separadamente que caracteriza o tipo de cálculos que estão sendo combinados, por exemplo, Async<'T> para expressões assíncronas e Seq<'T> para fluxos de trabalho de sequência. As assinaturas desses métodos permitem que eles sejam combinados e aninhados entre si, de modo que o objeto de fluxo de trabalho retornado de uma construção possa ser passado para o próximo.

Muitas funções usam o resultado de Delay como um argumento: Run, While, TryWith, TryFinally e Combine. O tipo Delayed<'T> é o tipo de retorno e Delay, consequentemente, o parâmetro para essas funções. Delayed<'T> pode ser um tipo arbitrário ao qual não precisa estar relacionado M<'T>; normalmente M<'T> ou (unit -> M<'T>) são usados. A implementação padrão é M<'T>. Veja aqui uma aparência mais detalhada.

O compilador, ao analisar uma expressão de computação, a converte em uma série de chamadas de função aninhadas usando os métodos na tabela anterior e o código na expressão de computação. A expressão aninhada é da seguinte forma:

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

No código acima, as chamadas a Run e Delay são omitidas se elas não são definidas na classe construtor de expressões de computação. O corpo da expressão de computação, aqui indicado como {{ cexpr }}, é convertido em outras chamadas para os métodos da classe de construtor. Esse processo é definido recursivamente de acordo com as traduções na tabela a seguir. O código dentro de colchetes duplos {{ ... }} continua com a tradução pendente, expr representa uma expressão F# e cexpr representa uma expressão de computação.

Expression Tradução
{{ 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()

Na tabela anterior, other-expr descreve uma expressão que não está listada de outra forma na tabela. Uma classe de construtor não precisa implementar todos os métodos e dar suporte a todas as traduções listadas na tabela anterior. Esses constructos que não são implementados não estão disponíveis em expressões de computação desse tipo. Por exemplo, se você não quiser dar suporte à palavra-chave use em suas expressões de computação, poderá omitir a definição de Use em sua classe de construtor.

O exemplo de código a seguir mostra uma expressão de computação que encapsula uma computação como uma série de etapas que podem ser avaliadas uma etapa por vez. Um tipo de união discriminado, OkOrException, codifica o estado de erro da expressão conforme avaliado até agora. Esse código demonstra vários padrões típicos que você pode usar em suas expressões de computação, como implementações de padronizadas de alguns dos métodos do construtor.

/// 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

Uma expressão de computação tem um tipo subjacente, que a expressão retorna. O tipo subjacente pode representar um resultado computado ou uma computação atrasada que pode ser executada ou permitir iterar por algum tipo de coleção. No exemplo anterior, o tipo subjacente era Eventually<_>. Para uma expressão de sequência, o tipo subjacente é System.Collections.Generic.IEnumerable<T>. Para uma expressão de consulta, o tipo subjacente é System.Linq.IQueryable. Para uma expressão assíncrona, o tipo subjacente é Async. O objeto Async representa o trabalho a ser executado para computar o resultado. Por exemplo, você chama Async.RunSynchronously para executar uma computação e retornar o resultado.

Operações personalizadas

Você pode definir uma operação personalizada em uma expressão de computação e usar uma operação personalizada como um operador em uma expressão de computação. Por exemplo, você pode incluir um operador de consulta em uma expressão de consulta. Quando você define uma operação personalizada, deve definir os métodos Yield e For na expressão de computação. Para definir uma operação personalizada, coloque-a em uma classe de construtor para a expressão de computação e aplique o CustomOperationAttribute. Esse atributo usa uma cadeia de caracteres como um argumento, que é o nome a ser usado em uma operação personalizada. Esse nome entra no escopo no início da chave de abertura da expressão de computação. Portanto, você não deve usar identificadores que tenham o mesmo nome que uma operação personalizada neste bloco. Por exemplo, evite o uso de identificadores como all ou last em expressões de consulta.

Como estender construtores com novas operações personalizadas

Se você já tiver uma classe de construtor, suas operações personalizadas poderão ser estendidas de fora dessa classe de construtor. As extensões devem ser declaradas em módulos. Namespaces não podem conter membros de extensão, exceto no mesmo arquivo e no mesmo grupo de declarações de namespace em que o tipo é definido.

O exemplo a seguir mostra a extensão da classe existente FSharp.Linq.QueryBuilder.

open System
open FSharp.Linq

type QueryBuilder with

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

As operações personalizadas podem ser sobrecarregadas. Para mais informações, confira F# RFC FS-1056 – Permitir sobrecargas de palavras-chave personalizadas em expressões de computação.

Como compilar expressões de computação com eficiência

Expressões de computação F# que suspendem a execução podem ser compiladas para computadores de estado altamente eficientes usando de modo cuidadoso um recurso de baixo nível chamado código retomável. O código retomável é documentado no F# RFC FS-1087 e usado para expressões de tarefa.

Expressões de computação F# síncronas (ou seja, não suspendem a execução) podem, como alternativa, ser compiladas para computadores de estado eficientes usando funções embutidas, incluindo o atributo InlineIfLambda. Exemplos são dados em F# RFC FS-1098.

Expressões de lista, expressões de matriz e expressões de sequência recebem tratamento especial pelo compilador F# para garantir a geração de código de alto desempenho.

Confira também