Expresiones de cálculo

Las expresiones de cálculo en F# proporcionan una sintaxis adecuada para escribir cálculos que se pueden secuenciar y combinar mediante enlaces y construcciones de flujo de control. Dependiendo del tipo de expresión de cálculo, se pueden considerar como una manera de expresar monads, monoids, transformadores monad y funtores aplicativos. Sin embargo, a diferencia de otros lenguajes (como do-notation en Haskell), no están vinculados a una sola abstracción y no se basan en macros u otras formas de metaprogramación para lograr una sintaxis práctica y contextual.

Información general

Los cálculos pueden adoptar numerosas formas. La forma de cálculo más común es la ejecución de un solo subproceso, que es fácil de entender y modificar. Sin embargo, no todas las formas de cálculo son tan sencillas como la ejecución de un solo subproceso. Estos son algunos ejemplos:

  • Cálculos no deterministas
  • Cálculos asincrónicos
  • Cálculos con efecto
  • Cálculos generativos

Por lo general, hay cálculos contextuales que debe realizar en determinadas partes de una aplicación. Escribir código contextual puede ser complicado, ya que es fácil «filtrar» cálculos fuera de un contexto determinado sin abstracciones para evitar que lo haga. Estas abstracciones a menudo son difíciles de escribir por sí mismo, por lo que F# tiene una manera generalizada de hacerlo denominadas expresiones de cálculo.

Las expresiones de cálculo ofrecen una sintaxis uniforme y un modelo de abstracción para codificar cálculos contextuales.

Cada expresión de cálculo está respaldada por un tipo de generador. El tipo de generador define las operaciones que están disponibles para la expresión de cálculo. Vea Crear un nuevo tipo de expresión de cálculo, que muestra cómo crear una expresión de cálculo personalizada.

Información general sobre la sintaxis

Todas las expresiones de cálculo tienen la siguiente forma:

builder-expr { cexper }

En este formato, builder-expr es el nombre de un tipo de generador que define la expresión de cálculo y cexper es el cuerpo de expresión de la expresión de cálculo. Por ejemplo, el código de expresión de cálculo async puede tener este aspecto:

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

        let processedData = processData data

        return processedData
    }

Hay una sintaxis especial y adicional disponible en una expresión de cálculo, como se muestra en el ejemplo anterior. Los siguientes formularios de expresión son posibles con expresiones de cálculo:

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

Cada una de estas palabras clave y otras palabras clave de F# estándar solo están disponibles en una expresión de cálculo si se han definido en el tipo de generador de respaldo. La única excepción a esto es match!, que es en sí mismo azúcar sintáctica para el uso de let! seguido de una coincidencia de patrón en el resultado.

El tipo de generador es un objeto que define métodos especiales que rigen la forma en que se combinan los fragmentos de la expresión de cálculo; es decir, sus métodos controlan cómo se comporta la expresión de cálculo. Otra manera de describir una clase de generador es decir, que permite personalizar el funcionamiento de muchas construcciones de F#, como bucles y enlaces.

let!

La palabra clave let! enlaza el resultado de una llamada a otra expresión de cálculo con un nombre:

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

Si enlaza la llamada a una expresión de cálculo con let, no obtendrá el resultado de la expresión de cálculo. En su lugar, tendrá enlazado el valor de la llamada no realizada a esa expresión de cálculo. Use let! para enlazar al resultado.

let! se define mediante el miembro Bind(x, f) en el tipo de generador.

and!

La palabra claveand! permite enlazar los resultados de varias llamadas de expresión de cálculo de forma eficaz.

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

El uso de una serie de let! ... let! ... fuerzas vuelven a ejecutar enlaces costosos, por lo que se debe usar let! ... and! ... al enlazar los resultados de numerosas expresiones de cálculo.

and! se define mediante el miembro MergeSources(x1, x2) en el tipo de generador.

Opcionalmente, se puede definir MergeSourcesN(x1, x2 ..., xN) para reducir el número de nodos de acoplamiento y BindN(x1, x2 ..., xN, f), o BindNReturn(x1, x2, ..., xN, f) se pueden definir para enlazar resultados de expresiones de cálculo de forma eficaz sin nodos de tupling.

do!

La palabra clave do! es para llamar a una expresión de cálculo que devuelve un tipo unit similar (definido por el miembro Zero en el manipulador):

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

Para el flujo de trabajo asincrónico, este tipo es Async<unit>. Para otras expresiones de cálculo, es probable que el tipo sea CExpType<unit>.

do! se define mediante el miembro Bind(x, f) en el tipo de generador donde f produce un unit.

yield

La palabra clave yield es para devolver un valor de la expresión de cálculo para que se pueda consumir como un IEnumerable<T>:

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

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

En la mayoría de los casos, los autores de la llamada pueden omitirlo. La forma más común de omitir a yield es con el operador ->:

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

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

En el caso de expresiones más complejas que podrían producir muchos valores diferentes y, quizás condicionalmente, simplemente omitir la palabra clave puede hacer lo siguiente:

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

Al igual que con la palabra clave yield en C#, cada elemento de la expresión de cálculo se devuelve a medida que se itera.

yield se define mediante el miembro Yield(x) en el tipo de generador, donde x es el elemento que se va a devolver.

yield!

La palabra clave yield! es para acoplar una colección de valores de una expresión de cálculo:

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

Cuando se evalúa, la expresión de cálculo llamada por yield! tendrá sus elementos devueltos de uno a uno, acoplando el resultado.

yield! se define mediante el miembro YieldFrom(x) en el tipo de generador, donde x es una colección de valores.

A diferencia de yield, yield! debe especificarse explícitamente. Su comportamiento no es implícito en las expresiones de cálculo.

return

La palabra clave return ajusta un valor en el tipo correspondiente a la expresión de cálculo. Aparte de las expresiones de cálculo que usan yield, se usa para «completar» una expresión de cálculo:

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 se define mediante el miembro Return(x) en el tipo de generador, donde x es el elemento que se va a devolver. Para el uso de let! ... return, puede usarse BindReturn(x, f) para mejorar el rendimiento.

return!

La palabra clave return! se da cuenta del valor de una expresión de cálculo y ajusta ese resultado en el tipo correspondiente a la expresión de cálculo:

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

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

return! se define mediante el miembro ReturnFrom(x) en el tipo de generador, donde x es otra expresión de cálculo.

match!

La palabra clave match! permite insertar una llamada a otra expresión de cálculo y coincidencia de patrones en su resultado:

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

Al llamar a una expresión de cálculo con match!, se dará cuenta del resultado de la llamada como let!. A menudo se usa al llamar a una expresión de cálculo en la que el resultado es opcional.

Expresiones de cálculo integradas

La biblioteca principal de F# define cuatro expresiones de cálculo integradas: expresiones de secuencia, expresiones asincrónicas, expresiones de tarea y expresiones de consulta.

Crear un nuevo tipo de expresión de cálculo

Puede definir las características de sus propias expresiones de cálculo mediante la creación de una clase de generador y la definición de determinados métodos especiales en la clase. La clase de generador puede definir opcionalmente los métodos como se muestra en la tabla siguiente.

En la tabla siguiente se describen los métodos que se pueden usar en una clase del generador de flujos de trabajo.

Método Firmas típicas Descripción
Bind M<'T> * ('T -> M<'U>) -> M<'U> Se llama a let! y do! en expresiones de cálculo.
BindN (M<'T1> * M<'T2> * ... * M<'TN> * ('T1 * 'T2 ... * 'TN -> M<'U>)) -> M<'U> Se llama para let! y and! eficaces en expresiones de cálculo sin combinar entradas.

Por ejemplo, Bind3, Bind4.
Delay (unit -> M<'T>) -> Delayed<'T> Ajusta una expresión de cálculo como una función. Delayed<'T> puede ser cualquier tipo, se usan normalmente M<'T> o unit -> M<'T>. La implementación predeterminada devuelve un M<'T>.
Return 'T -> M<'T> Se llama a return en expresiones de cálculo.
ReturnFrom M<'T> -> M<'T> Se llama a return! en expresiones de cálculo.
BindReturn (M<'T1> * ('T1 -> 'T2)) -> M<'T2> Se llama para una let! ... return eficaz en expresiones de cálculo.
BindNReturn (M<'T1> * M<'T2> * ... * M<'TN> * ('T1 * 'T2 ... * 'TN -> M<'U>)) -> M<'U> Se llama para una let! ... and! ... return eficaz en expresiones de cálculo sin combinar entradas.

Por ejemplo, Bind3Return, Bind4Return.
MergeSources (M<'T1> * M<'T2>) -> M<'T1 * 'T2> Se llama a and! en expresiones de cálculo.
MergeSourcesN (M<'T1> * M<'T2> * ... * M<'TN>) -> M<'T1 * 'T2 * ... * 'TN> Se llama para and! en expresiones de cálculo, pero mejora la eficacia reduciendo el número de nodos de tupling.

Por ejemplo, MergeSources3, MergeSources4.
Run Delayed<'T> -> M<'T> o

M<'T> -> 'T
Ejecuta una expresión de cálculo.
Combine M<'T> * Delayed<'T> -> M<'T> o

M<unit> * M<'T> -> M<'T>
Se llama para la secuenciación en expresiones de cálculo.
For seq<'T> * ('T -> M<'U>) -> M<'U> o

seq<'T> * ('T -> M<'U>) -> seq<M<'U>>
Se llama para expresiones for...do en expresiones de cálculo.
TryFinally Delayed<'T> * (unit -> unit) -> M<'T> Se llama para expresiones try...finally en expresiones de cálculo.
TryWith Delayed<'T> * (exn -> M<'T>) -> M<'T> Se llama para expresiones try...with en expresiones de cálculo.
Using 'T * ('T -> M<'U>) -> M<'U> when 'T :> IDisposable Se llama para enlaces use en expresiones de cálculo.
While (unit -> bool) * Delayed<'T> -> M<'T>or

(unit -> bool) * Delayed<unit> -> M<unit>
Se llama para expresiones while...do en expresiones de cálculo.
Yield 'T -> M<'T> Se llama para expresiones yield en expresiones de cálculo.
YieldFrom M<'T> -> M<'T> Se llama para expresiones yield! en expresiones de cálculo.
Zero unit -> M<'T> Se llama para ramas else vacías de expresiones if...then en expresiones de cálculo.
Quote Quotations.Expr<'T> -> Quotations.Expr<'T> Indica que la expresión de cálculo se pasa al miembro Run como una cita. Convierte todas las instancias de un cálculo en una cita.

Muchos de los métodos de una clase de generador usan y devuelven una construcción M<'T>, que suele ser un tipo definido por separado que caracteriza el tipo de cálculos que se combinan, por ejemplo, Async<'T> para expresiones asincrónicas y Seq<'T> para flujos de trabajo de secuencia. Las firmas de estos métodos permiten combinarlas y anidarse entre sí, de modo que el objeto de flujo de trabajo devuelto de una construcción se pueda pasar al siguiente.

Muchas funciones usan el resultado de Delay como argumento: Run, While, TryWith, TryFinallyy Combine. El tipo Delayed<'T> es el tipo devuelto de Delay y, por consiguiente, el parámetro para estas funciones. Delayed<'T> puede ser un tipo arbitrario que no necesita estar relacionado con M<'T>; normalmente se usan M<'T> o (unit -> M<'T>). La implementación predeterminada es M<'T>. Consulte aquí para obtener un análisis más detallado.

El compilador, cuando analiza una expresión de cálculo, traduce la expresión en una serie de llamadas de función anidadas mediante los métodos de la tabla anterior y el código de la expresión de cálculo. La expresión anidada tiene el siguiente formato:

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

En el código anterior, las llamadas a Run y Delay se omiten si no están definidas en la clase generador de expresiones de cálculo. El cuerpo de la expresión de cálculo, que se indica aquí como {{ cexpr }}, se traduce en llamadas adicionales a los métodos de la clase builder. Este proceso se define de forma recursiva según las traducciones de la tabla siguiente. El código entre corchetes {{ ... }} sigue siendo traducido, expr representa una expresión de F# y cexpr representa una expresión de cálculo.

Expresión Traducción
{{ 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()

En la tabla anterior, other-expr describe una expresión que no aparece en la tabla de otro modo. No es necesario implementar todos los métodos y admitir todas las traducciones enumeradas en la tabla anterior. Esas construcciones que no se implementan no están disponibles en expresiones de cálculo de ese tipo. Por ejemplo, si no desea admitir la palabra clave use en las expresiones de cálculo, puede omitir la definición de Use en la clase de generador.

En el ejemplo de código siguiente se muestra una expresión de cálculo que encapsula un cálculo como una serie de pasos que se pueden evaluar un paso a la vez. Un tipo de unión discriminado, OkOrException, codifica el estado de error de la expresión como se evaluó hasta ahora. Este código muestra varios patrones típicos que se pueden usar en las expresiones de cálculo, como implementaciones reutilizables de algunos de los métodos de generador.

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

Una expresión de cálculo tiene un tipo subyacente, que devuelve la expresión. El tipo subyacente puede representar un resultado calculado o un cálculo retrasado que se puede realizar o puede proporcionar una manera de recorrer en iteración algún tipo de colección. En el ejemplo anterior, el tipo subyacente era Eventually<_>. Para una expresión de secuencia, el tipo subyacente es System.Collections.Generic.IEnumerable<T>. Para una expresión de consulta, el tipo subyacente es System.Linq.IQueryable. Para una expresión asincrónica, el tipo subyacente es Async. El objeto Async representa el trabajo que se va a realizar para calcular el resultado. Por ejemplo, llama a Async.RunSynchronously para ejecutar un cálculo y devolver el resultado.

Operaciones personalizadas

Puede definir una operación personalizada en una expresión de cálculo y usar una operación personalizada como operador en una expresión de cálculo. Por ejemplo, puede incluir un operador de consulta en una expresión de consulta. Al definir una operación personalizada, debe definir los métodos Yield y For en la expresión de cálculo. Para definir una operación personalizada, colóquela en una clase de generador para la expresión de cálculo y, a continuación, aplique CustomOperationAttribute. Este atributo toma una cadena como argumento, que es el nombre que se va a usar en una operación personalizada. Este nombre entra en el ámbito al principio de la llave de apertura de la expresión de cálculo. Por lo tanto, no debe usar identificadores que tengan el mismo nombre que una operación personalizada en este bloque. Por ejemplo, evite el uso de identificadores como all o last en expresiones de consulta.

Extensión de los generadores existentes con nuevas operaciones personalizadas

Si ya tiene una clase de generador, sus operaciones personalizadas se pueden extender desde fuera de esta clase de generador. Las extensiones deben declararse en módulos. Los espacios de nombres no pueden contener miembros de extensión excepto en el mismo archivo y el mismo grupo de declaraciones de espacio de nombres donde se define el tipo.

En el ejemplo siguiente se muestra la extensión de la clase FSharp.Linq.QueryBuilder existente.

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

Las operaciones personalizadas se pueden sobrecargar. Para obtener más información, vea F# RFC FS-1056: permitir sobrecargas de palabras clave personalizadas en expresiones de cálculo.

Compilar expresiones de cálculo de forma eficaz

Las expresiones de cálculo de F# que suspenden la ejecución se pueden compilar en máquinas de estado altamente eficientes mediante un uso cuidadoso de una característica de bajo nivel denominada código reanudable. El código reanudable se documenta en F# RFC FS-1087 y se usa para expresiones de tarea.

Las expresiones de cálculo de F# que son sincrónicas (es decir, no suspenden la ejecución) se pueden compilar de forma alternativa en máquinas de estado eficientes mediante funciones insertadas, incluido el atributo InlineIfLambda. Se proporcionan ejemplos en F# RFC FS-1098.

El compilador de F# da un tratamiento especial a las expresiones de lista, las expresiones de matriz y las expresiones de secuencia para garantizar la generación de código de alto rendimiento.

Vea también