Compartir a través de


Expresiones de cálculo

Las expresiones de cálculo de F# proporcionan una sintaxis cómoda para escribir cálculos que se pueden secuenciar y combinar mediante construcciones y enlaces de flujo de control. Dependiendo del tipo de expresión de cálculo, se pueden considerar como una manera de expresar mónadas, monoides, transformadores de mónada y functores aplicativos. Sin embargo, a diferencia de otros lenguajes (como la notación do en Haskell), no están vinculados a una sola abstracción y no dependen de macros ni de otras formas de metaprogramación para lograr una sintaxis cómoda y contextual.

Visión general

Los cálculos pueden adoptar muchas formas. La forma más común de cálculo 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. Algunos ejemplos son:

  • 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 dependiente del contexto puede ser difícil, ya que es fácil que se escapen cálculos fuera de un contexto determinado sin abstracciones que lo impidan. Estas abstracciones suelen ser difíciles de escribir por su cuenta, 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 disponibles para la expresión de cálculo. Consulte Crear un nuevo tipo de expresión de cálculo, que muestra cómo crear una expresión de cálculo personalizada.

Introducción a 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 cálculo de expresión 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 dentro de 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 estándar de F# solo están disponibles en una expresión computacional si se han definido en el tipo de constructor 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 a 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 clave and! 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! ... fuerza la nueva ejecución de enlaces costosos, por lo que el uso de let! ... and! ... debe usarse 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 expresión de cálculo de forma eficaz sin nodos de acoplamiento.

do!

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

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 llamadas pueden omitirlo. La manera más común de omitir yield es con el operador ->:

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

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

Para expresiones más complejas que podrían producir muchos valores diferentes y, quizás condicionalmente, simplemente omitir la palabra clave puede hacer:

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 aplanar 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! deben especificarse explícitamente. Su comportamiento no es implícito en expresiones de cálculo.

return

La palabra clave return envuelve un valor en el tipo correspondiente a la expresión computacional. 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, se puede usar 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 realizar una 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!. Esto se suele usar al llamar a una expresión de cálculo donde el resultado es un opcional.

Expresiones de cálculo integradas

La biblioteca principal de F# define cuatro expresiones de cálculo integradas: expresiones de secuencia de , 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 builder 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 de 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> Envuelve una expresión de cálculo como una función. Delayed<'T> puede ser cualquier tipo, normalmente se usan 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>o

(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ómputo en una cita.

Muchos de los métodos de una clase de generador usan y devuelven una construcción de 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 de valor devuelto de Delay y, en consecuencia, 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 un análisis más en profundidad.

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 formato siguiente:

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

En el código anterior, las llamadas a Run y Delay se omiten si no se definen en la clase del 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 de otra manera en la tabla. No es necesario que una clase de constructor implemente todos los métodos y admita todas las traducciones enumeradas en la tabla anterior. Esas construcciones que no están implementadas 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 builder.

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 tal como se ha evaluado hasta el momento. Este código muestra varios patrones típicos que puede 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 el 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 muestran las extensiones de la clase FSharp.Linq.QueryBuilder existente.

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

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 para 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# proporciona 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.

Consulte también