Partager via


Expressions de calcul

Les expressions de calcul en F# fournissent une syntaxe pratique pour écrire des calculs qui peuvent être séquencés et combinés à l’aide de constructions et de liaisons de flux de contrôle. Selon le type d’expression de calcul, ils peuvent être considérés comme un moyen d’exprimer des monads, des monoids, des transformateurs monad et des functors applicatifs. Cependant, à la différence d'autres langages tels que le do-notation en Haskell, ils ne se rattachent pas à une abstraction unique et ne reposent ni sur des macros ni sur d'autres formes de métaprogrammation pour obtenir une syntaxe à la fois pratique et adaptée au contexte.

Aperçu

Les calculs peuvent prendre de nombreuses formes. La forme de calcul la plus courante est l’exécution à thread unique, ce qui est facile à comprendre et à modifier. Toutefois, toutes les formes de calcul ne sont pas aussi simples que l’exécution à thread unique. Voici quelques exemples :

  • Calculs non déterministes
  • Calculs asynchrones
  • Calculs effectifs
  • Calculs génératifs

De manière plus générale, il existe des calculs sensibles au contexte qu'il est nécessaire de réaliser dans certaines parties spécifiques d'une application. Écrire du code contextuel peut être difficile, car il est facile de laisser échapper des calculs en dehors d’un contexte donné sans les abstractions nécessaires pour l'éviter. Ces abstractions sont souvent difficiles à écrire par vous-même, c’est pourquoi F# a un moyen généralisé de le faire appelé expressions de calcul.

Les expressions de calcul offrent une syntaxe et un modèle d’abstraction uniformes pour l’encodage de calculs sensibles au contexte.

Chaque expression de calcul est soutenue par un builder type. Le type de générateur définit les opérations disponibles pour l’expression de calcul. Consultez Création d’un nouveau type d’expression de calcul, qui montre comment créer une expression de calcul personnalisée.

Vue d’ensemble de la syntaxe

Toutes les expressions de calcul ont la forme suivante :

builder-expr { cexper }

Dans ce formulaire, builder-expr est le nom d’un type de générateur qui définit l’expression de calcul et cexper est le corps de l’expression de calcul. Par exemple, async code d’expression de calcul peut ressembler à ceci :

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

        let processedData = processData data

        return processedData
    }

Il existe une syntaxe spéciale et supplémentaire disponible dans une expression de calcul, comme illustré dans l’exemple précédent. Les formulaires d’expression suivants sont possibles avec les expressions de calcul :

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

Chacun de ces mots clés, ainsi que d'autres mots clés F# standard, ne sont disponibles dans une expression de calcul que s'ils ont été définis dans le type de générateur sous-jacent. La seule exception à cette règle est match!, qui est lui-même un sucre syntaxique pour l'utilisation de let! suivi d'une correspondance de motifs sur le résultat.

Le type de générateur est un objet qui définit des méthodes spéciales qui régissent la façon dont les fragments de l’expression de calcul sont combinés ; autrement dit, ses méthodes contrôlent le comportement de l’expression de calcul. Une autre façon de décrire une classe de générateur consiste à dire qu’elle vous permet de personnaliser l’opération de nombreuses constructions F#, telles que des boucles et des liaisons.

let!

Le mot clé let! lie le résultat d’un appel à une autre expression de calcul à un nom :

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

Si vous liez l’appel à une expression de calcul avec let, vous n’obtiendrez pas le résultat de l’expression de calcul. Au lieu de cela, vous aurez lié la valeur de l'appel non réalisé à cette expression de calcul. Utilisez let! pour lier le résultat.

let! est défini par le membre Bind(x, f) sur le type de générateur.

and!

Le mot clé and! vous permet de lier les résultats de plusieurs appels d’expression de calcul de manière performante.

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

L’utilisation d’une série de let! ... let! ... force la réexécution de liaisons coûteuses. Par conséquent, l’utilisation de let! ... and! ... doit être utilisée lors de la liaison des résultats de nombreuses expressions de calcul.

and! est défini principalement par le membre MergeSources(x1, x2) sur le type de générateur.

Si vous le souhaitez, MergeSourcesN(x1, x2 ..., xN) peut être défini pour réduire le nombre de nœuds de tupling, et BindN(x1, x2 ..., xN, f), ou BindNReturn(x1, x2, ..., xN, f) peut être défini pour lier efficacement les résultats d'expression de calcul sans nœuds de tupling.

do!

Le mot clé do! sert à appeler une expression de computation qui retourne un type similaire à unit(défini par le membre Zero sur le constructeur) :

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

Pour le flux de travail asynchrone , ce type est Async<unit>. Pour d’autres expressions de calcul, le type est susceptible d’être CExpType<unit>.

do! est défini par le membre Bind(x, f) du type de générateur, où f produit un unit.

yield

Le mot clé yield est destiné à renvoyer une valeur de l’expression de calcul afin qu’elle puisse être consommée en tant que IEnumerable<T>:

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

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

Dans la plupart des cas, il peut être omis par les appelants. La façon la plus courante d’omettre yield est avec l’opérateur -> :

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

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

Pour les expressions plus complexes qui peuvent produire de nombreuses valeurs différentes, et éventuellement conditionnellement, omettre simplement le mot clé peut effectuer :

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

Comme avec le mot clé de rendement en C#, chaque élément de l’expression de calcul est renvoyé à mesure qu’il est itéré.

yield est défini par le membre Yield(x) du type de constructeur, où x est l'élément à restituer.

yield!

Le mot clé yield! consiste à aplatir une collection de valeurs à partir d’une expression de calcul :

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

Lorsqu'elle est évaluée, l'expression de calcul appelée par yield! voit ses éléments restitués un par un, ce qui a pour effet d'aplatir le résultat.

yield! est défini par le membre YieldFrom(x) sur le type de générateur, où x est une collection de valeurs.

Contrairement à yield, yield! doit être spécifié explicitement. Son comportement n’est pas implicite dans les expressions de calcul.

return

Le mot clé return encapsule une valeur dans le type correspondant à l’expression de calcul. Outre les expressions de calcul utilisant yield, elle est utilisée pour « terminer » une expression de calcul :

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 est défini par le membre Return(x) du type de constructeur, où x est l'élément à envelopper. Pour une utilisation de let! ... return, BindReturn(x, f) peut être utilisé pour améliorer les performances.

return!

Le mot clé return! évalue la valeur d’une expression de calcul et encapsule ce résultat dans le type correspondant à l’expression de calcul.

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

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

return! est défini par le membre ReturnFrom(x) sur le type du générateur, où x est une autre expression de calcul.

match!

Le mot clé match! vous permet d'intégrer en ligne un appel à une autre expression de calcul et de faire une correspondance de modèle sur son résultat :

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

Lors de l'appel d'une expression de calcul avec match!, cela génère le résultat de l'appel sous la forme de let!. Cela est souvent utilisé lors de l’appel d’une expression de calcul où le résultat est un facultatif.

Expressions de calcul intégrées

La bibliothèque principale F# définit quatre expressions de calcul intégrées : expressions de séquence, expressions asynchrones, expressions de tâcheet expressions de requête.

Création d’un nouveau type d’expression de calcul

Vous pouvez définir les caractéristiques de vos propres expressions de calcul en créant une classe de générateur et en définissant certaines méthodes spéciales sur la classe. La classe builder peut éventuellement définir les méthodes comme indiqué dans le tableau suivant.

Le tableau suivant décrit les méthodes qui peuvent être utilisées dans une classe de générateur de flux de travail.

Méthode signature(s) typique(s) Description
Bind M<'T> * ('T -> M<'U>) -> M<'U> Appelé pour let! et do! dans les expressions de calcul.
BindN (M<'T1> * M<'T2> * ... * M<'TN> * ('T1 * 'T2 ... * 'TN -> M<'U>)) -> M<'U> Appelé pour des let! et and! efficaces dans des expressions de calcul sans fusionner les entrées.

par exemple, Bind3, Bind4.
Delay (unit -> M<'T>) -> Delayed<'T> Encapsule une expression de calcul en tant que fonction. Delayed<'T> peut être n’importe quel type, généralement M<'T> ou unit -> M<'T> sont utilisés. L’implémentation par défaut retourne un M<'T>.
Return 'T -> M<'T> Appelé pour return dans les expressions de calcul.
ReturnFrom M<'T> -> M<'T> Appelé pour return! dans les expressions de calcul.
BindReturn (M<'T1> * ('T1 -> 'T2)) -> M<'T2> Appelé pour un let! ... return efficace dans les expressions de calcul.
BindNReturn (M<'T1> * M<'T2> * ... * M<'TN> * ('T1 * 'T2 ... * 'TN -> M<'U>)) -> M<'U> Appelé pour une let! ... and! ... return efficace dans les expressions de calcul sans fusionner les entrées.

par exemple, Bind3Return, Bind4Return.
MergeSources (M<'T1> * M<'T2>) -> M<'T1 * 'T2> Appelé pour and! dans les expressions de calcul.
MergeSourcesN (M<'T1> * M<'T2> * ... * M<'TN>) -> M<'T1 * 'T2 * ... * 'TN> Appelé pour and! dans les expressions de calcul, mais améliore l'efficacité en réduisant le nombre de nœuds d'assemblage.

par exemple, MergeSources3, MergeSources4.
Run Delayed<'T> -> M<'T> ou

M<'T> -> 'T
Exécute une expression de calcul.
Combine M<'T> * Delayed<'T> -> M<'T> ou

M<unit> * M<'T> -> M<'T>
Appelé pour effectuer le séquencement dans les expressions de calcul.
For seq<'T> * ('T -> M<'U>) -> M<'U> ou

seq<'T> * ('T -> M<'U>) -> seq<M<'U>>
Appelé pour les expressions for...do dans les expressions de calcul.
TryFinally Delayed<'T> * (unit -> unit) -> M<'T> Appelé pour les expressions try...finally dans les expressions de calcul.
TryWith Delayed<'T> * (exn -> M<'T>) -> M<'T> Appelé pour les expressions try...with dans les expressions de calcul.
Using 'T * ('T -> M<'U>) -> M<'U> when 'T :> IDisposable Appelé pour les liaisons use dans les expressions de calcul.
While (unit -> bool) * Delayed<'T> -> M<'T>ou

(unit -> bool) * Delayed<unit> -> M<unit>
Appelé pour les expressions while...do dans les expressions de calcul.
Yield 'T -> M<'T> Appelé pour les expressions yield dans les expressions de calcul.
YieldFrom M<'T> -> M<'T> Appelé pour les expressions yield! dans les expressions de calcul.
Zero unit -> M<'T> Appelé pour les branches else vides des expressions if...then dans les expressions de calcul.
Quote Quotations.Expr<'T> -> Quotations.Expr<'T> Indique que l’expression de calcul est passée au membre Run en tant que citation. Elle traduit toutes les instances d’un calcul en guillemets.

La plupart des méthodes d’une classe de générateur utilisent et retournent une construction M<'T>, qui est généralement un type défini séparément qui caractérise le type de calculs combinés, par exemple, Async<'T> pour les expressions asynchrones et Seq<'T> pour les flux de travail séquentiels. Les signatures de ces méthodes permettent de les combiner et de les imbriquer les unes avec les autres, afin que l’objet de workflow retourné d’une construction puisse être passé à la suivante.

De nombreuses fonctions utilisent le résultat de Delay comme argument : Run, While, TryWith, TryFinallyet Combine. Le type Delayed<'T> est le type de retour de Delay et, par conséquent, le paramètre à ces fonctions. Delayed<'T> peut être un type arbitraire qui n’a pas besoin d’être lié à M<'T>; couramment M<'T> ou (unit -> M<'T>) sont utilisés. L’implémentation par défaut est M<'T>. Pour un examen plus approfondi, consultez ici.

Le compilateur, lorsqu’il analyse une expression de calcul, convertit l’expression en une série d’appels de fonction imbriqués à l’aide des méthodes du tableau précédent et du code de l’expression de calcul. L’expression imbriquée est de la forme suivante :

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

Dans le code ci-dessus, les appels à Run et Delay sont omis s’ils ne sont pas définis dans la classe générateur d’expressions de calcul. Le corps de l’expression de calcul, ici indiqué comme {{ cexpr }}, est traduit en appels supplémentaires aux méthodes de la classe builder. Ce processus est défini de manière récursive en fonction des traductions dans le tableau suivant. Le code entre deux crochets {{ ... }} reste à traduire, expr représente une expression F# et cexpr représente une expression de calcul.

Expression Traduction
{{ 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()

Dans le tableau précédent, other-expr décrit une expression qui n’est pas répertoriée dans la table. Une classe builder n’a pas besoin d’implémenter toutes les méthodes et de prendre en charge toutes les traductions répertoriées dans le tableau précédent. Ces constructions qui ne sont pas implémentées ne sont pas disponibles dans les expressions de calcul de ce type. Par exemple, si vous ne souhaitez pas prendre en charge le mot clé use dans vos expressions de calcul, vous pouvez omettre la définition de Use dans votre classe de générateur.

L’exemple de code suivant montre une expression de calcul qui encapsule un calcul sous la forme d’une série d’étapes qui peuvent être évaluées une étape à la fois. Un type d’union discriminé, OkOrException, encode l’état d’erreur de l’expression tel qu'il a été évalué jusqu’à présent. Ce code illustre plusieurs modèles classiques que vous pouvez utiliser dans vos expressions de calcul, telles que des implémentations réutilisables de certaines des méthodes du générateur.

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

Une expression de calcul a un type sous-jacent, que l’expression retourne. Le type sous-jacent peut représenter un résultat calculé ou un calcul retardé qui peut être effectué, ou peut fournir un moyen d’itérer au sein d’un type de collection. Dans l’exemple précédent, le type sous-jacent était Eventually<_>. Pour une expression de séquence, le type sous-jacent est System.Collections.Generic.IEnumerable<T>. Pour une expression de requête, le type sous-jacent est System.Linq.IQueryable. Pour une expression asynchrone, le type sous-jacent est Async. L’objet Async représente le travail à effectuer pour calculer le résultat. Par exemple, vous appelez Async.RunSynchronously pour exécuter un calcul et retourner le résultat.

Opérations personnalisées

Vous pouvez définir une opération personnalisée sur une expression de calcul et utiliser une opération personnalisée en tant qu’opérateur dans une expression de calcul. Par exemple, vous pouvez inclure un opérateur de requête dans une expression de requête. Lorsque vous définissez une opération personnalisée, vous devez définir les méthodes Yield et For dans l’expression de calcul. Pour définir une opération personnalisée, placez-la dans une classe de générateur pour l’expression de calcul, puis appliquez la CustomOperationAttribute. Cet attribut prend une chaîne en tant qu’argument, qui est le nom à utiliser dans une opération personnalisée. Ce nom prend effet dès le début de l'accolade ouvrante de l'expression de calcul. Par conséquent, vous ne devez pas utiliser d’identificateurs portant le même nom qu’une opération personnalisée dans ce bloc. Par exemple, évitez d’utiliser des identificateurs tels que all ou last dans les expressions de requête.

Extension des constructeurs existants par l'ajout de nouvelles opérations personnalisées.

Si vous disposez déjà d’une classe de générateur, ses opérations personnalisées peuvent être étendues à partir de l’extérieur de cette classe de générateur. Les extensions doivent être déclarées dans les modules. Les espaces de noms ne peuvent pas contenir de membres d’extension, sauf dans le même fichier et dans le même groupe de déclaration d’espace de noms où le type est défini.

L’exemple suivant montre les extensions de la classe FSharp.Linq.QueryBuilder existante.

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

Les opérations personnalisées peuvent être surchargées. Pour plus d’informations, consultez F# RFC FS-1056 - Autoriser les surcharges de mots clés personnalisés dans les expressions de calcul.

Compilation efficace d’expressions de calcul

Les expressions de calcul F# qui suspendent l’exécution peuvent être compilées sur des machines à état hautement efficaces grâce à une utilisation minutieuse d’une fonctionnalité de bas niveau appelée code pouvant être repris. Le code résumable est documenté dans la F# RFC FS-1087 et appliqué dans le cadre des expressions de tâches.

Les expressions de calcul F# synchrones (autrement dit, elles ne suspendent pas l’exécution) peuvent également être compilées pour des ordinateurs d’état efficaces à l’aide de fonctions inline y compris l’attribut InlineIfLambda. Des exemples sont fournis dans F# RFC FS-1098.

Les expressions de liste, les expressions de tableau et les expressions de séquence reçoivent un traitement spécial par le compilateur F# pour garantir la génération de code hautes performances.

Voir aussi