Expressions de calcul

Les expressions de calcul en F# fournissent une syntaxe pratique pour l’écriture de calculs qui peuvent être séquencés et combinés à l’aide de liaisons et de constructions de flux de contrôle. Selon le type d’expression de calcul, elles peuvent être considérées comme un moyen d’exprimer des monades, des monoïdes, des transformateurs de monade et des foncteurs applicatifs. Toutefois, contrairement à d’autres langages (comme la notation do dans Haskell), elles ne sont pas liées à une seule abstraction et ne s’appuient pas sur des macros ou d’autres formes de métaprogrammation pour obtenir une syntaxe pratique et contextuelle.

Vue d’ensemble

Les calculs peuvent prendre plusieurs formes. La forme de calcul la plus courante est l’exécution à thread unique, qui est facile à comprendre et à modifier. Cependant, 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 avec effet
  • Calculs génératifs

Plus généralement, il existe des calculs contextuels que vous devez effectuer dans certaines parties d’une application. L’écriture de code contextuel peut être difficile, car il est facile de « divulguer » des calculs en dehors d’un contexte donné sans abstractions pour vous empêcher de le faire. Ces abstractions sont souvent difficiles à écrire par vous-même, c’est pourquoi F# dispose d’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 contextuels.

Chaque expression de calcul est soutenue par un type de générateur. 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 cette forme, 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, le code d’expression de calcul async peut ressembler à ceci :

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

        let processedData = processData data

        return processedData
    }

Une syntaxe supplémentaire spéciale est disponible dans une expression de calcul, comme illustré dans l’exemple précédent. Les formes d’expression suivantes sont possibles avec des 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 et 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 de stockage. La seule exception à ceci est match!, qui est lui-même un liant syntaxique pour l’utilisation de let! suivi d’une correspondance de modèle 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 le fonctionnement de nombreuses constructions F#, telles que les boucles et les 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. À la place, vous aurez lié la valeur de l’appel non réalisé à cette expression de calcul. Utilisez let! pour lier au 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’expressions 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, let! ... and! ... doit donc être utilisé 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 tupling, et BindN(x1, x2 ..., xN, f), ou BindNReturn(x1, x2, ..., xN, f) peut être défini pour lier de manière efficace les résultats d’expression de calcul sans nœuds tupling.

do!

Le mot clé do! est destiné à appeler une expression de calcul qui renvoie un type semblable à unit (défini par le membre Zero sur le générateur) :

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

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

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

yield

Le mot clé yield permet de renvoyer une valeur à partir 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 peut-être de manière conditionnelle, omettre simplement le mot clé peut faire :

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

Comme pour le mot clé yield dans C#, chaque élément de l’expression de calcul est renvoyé au fur et à mesure qu’il est itéré.

yield est défini par le membre Yield(x) sur le type de générateur, où x est l’élément à renvoyer.

yield!

Le mot clé yield! est destiné à 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

Lors de l’évaluation, l’expression de calcul appelée par yield! aura ses éléments renvoyés un par un, ce qui aplatit 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, il est utilisé 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) sur le type de générateur, où x est l’élément à encapsuler. Pour utiliser let! ... return, BindReturn(x, f) peut être utilisé pour améliorer les performances.

return!

Le mot clé return! réalise 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 de générateur, où x est une autre expression de calcul.

match!

Le mot clé match! vous permet d’inclure un appel à une autre expression de calcul et à une autre 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!, il réalise le résultat de l’appel comme let!. Cela est souvent utilisé lors de l’appel d’une expression de calcul où le résultat est 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âche et 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 de générateur peut éventuellement définir les méthodes répertoriées 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) standard(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 les expressions de calcul sans fusionner les entrées.

p. ex., 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, M<'T> ou unit -> M<'T> sont généralement utilisés. L’implémentation par défaut renvoie 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 un let! ... and! ... return efficace dans les expressions de calcul sans fusionner les entrées.

p. ex., 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 tupling.

p. ex., 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 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 sous la forme d’un guillemet. Il traduit toutes les instances d’un calcul en guillemets.

La plupart des méthodes d’une classe de générateur utilisent et renvoient 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 de séquence. Les signatures de ces méthodes permettent de les combiner et de les imbriquer les unes avec les autres, afin que l’objet de flux de travail renvoyé d’une construction puisse être passé à la suivante.

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

Le compilateur, lorsqu’il analyse une expression de calcul, traduit l’expression en une série d’appels de fonction imbriqués à l’aide des méthodes décrites de la table précédente et du code contenu dans l’expression de calcul. L’expression imbriquée se présente sous 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 du générateur d’expressions de calcul. Le corps de l’expression de calcul, désigné ici par {{ cexpr }}, est traduit en appels supplémentaires aux méthodes de la classe de générateur. Ce processus est défini de manière récursive en fonction des traductions décrites dans le tableau suivant. Le code entre les double accolades {{ ... }} 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 le tableau. Une classe de générateur 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. Les 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 pouvant être évaluées étape par étape. Un type d’union discriminée, OkOrException, encode l’état d’erreur de l’expression comme évalué jusqu’à présent. Ce code illustre plusieurs modèles typiques que vous pouvez utiliser dans vos expressions de calcul, tels que des implémentations réutilisables de certaines méthodes de 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 renvoie. Le type sous-jacent peut représenter un résultat calculé ou un calcul différé qui peut être effectué, ou il 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 renvoyer 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 comme 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 CustomOperationAttribute. Cet attribut prend une chaîne comme argument, qui est le nom à utiliser dans une opération personnalisée. Ce nom entre dans l’étendue au 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 des expressions de requête.

Extension des générateurs existants avec 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 le même groupe de déclarations d’espace de noms où le type est défini.

L’exemple suivant montre l’extension de la classe existante 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

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 des expressions de calcul

Les expressions de calcul F# qui interrompent l’exécution peuvent être compilées sur des machines à états très efficaces grâce à une utilisation minutieuse d’une fonctionnalité de bas niveau appelée code pouvant être repris. Le code pouvant être repris est documenté dans F# RFC FS-1087 et utilisé pour les expressions de tâche.

Les expressions de calcul F# qui sont synchrones (qui n’interrompent pas l’exécution) peuvent également être compilées sur des machines à états efficaces à l’aide des fonctions incluses comprenant 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