计算表达式

F# 中的计算表达式提供一种用于编写计算的便捷语法,可以通过使用控制流构造和绑定对这些计算进行排序和组合。 根据计算表达式的类型,可以将其视为表示 monad、monoids、monad 转换器和 applicative functor 的一种方式。 但是,与其他语言(例如 Haskell 中的 do-notation)不同,它们不依赖于单一抽象,也不依赖宏或其他形式的元编程来实现上下文相关的便捷语法。

概述

计算可以采用多种形式。 最常见的计算形式是单线程执行,这种形式易于理解和修改。 但是,并非所有形式的计算都像单线程执行一样简单。 示例包括:

  • 非确定性计算
  • 异步计算
  • 有效计算
  • 生成计算

一般来说,上下文相关计算必须在应用程序的特定部分执行。 编写上下文相关代码可能会很难,因为很容易将计算“泄漏”到给定上下文之外,而没有抽象来阻止你这样做。 自行编写这些抽象通常很难,这就是为什么 F# 会提供一种通用的方法(称为计算表达式)来执行此操作。

计算表达式提供统一的语法和抽象模型来编码上下文相关计算。

每个计算表达式都由一种生成器类型提供支持。 生成器类型定义了可用于计算表达式的操作。 请参阅创建新型计算表达式,其中介绍了如何创建自定义计算表达式。

语法概述

所有计算表达式都采用以下形式:

builder-expr { cexper }

在此形式中,builder-expr 是定义计算表达式的生成器类型的名称,cexper 是计算表达式的表达式主体。 例如,async 计算表达式代码可能如下所示:

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

        let processedData = processData data

        return processedData
    }

如前面的示例所示,计算表达式中有一种特殊的附加语法。 计算表达式可能采用以下表达式形式:

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

只有在后备生成器类型中定义后,这些关键字中的每一个和其他标准 F# 关键字才可用于计算表达式。 唯一的例外是 match!,它本身就是使用 let! 后跟结果模式匹配的语法糖。

生成器类型是一个定义特殊方法的对象,这些方法控制计算表达式片段的组合方式;也就是说,其方法控制计算表达式的行为方式。 对生成器类的另一种描述是,它使你能够自定义许多 F# 构造的操作,例如循环和绑定。

let!

let! 关键字可将调用另一个计算表达式的结果绑定到名称:

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

如果使用 let 将调用绑定到计算表达式,则不会获得计算表达式的结果。 相反,你会将未实现的调用的值绑定到该计算表达式。 使用 let! 可绑定到结果。

let! 由生成器类型上的 Bind(x, f) 成员定义。

and!

通过 and! 关键字,可以高性能方式绑定多个计算表达式调用的结果。

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

使用一系列 let! ... let! ... 会强制重新执行成本较高的绑定,因此在绑定大量计算表达式的结果时应该使用 let! ... and! ...

and! 主要由生成器类型上的 MergeSources(x1, x2) 成员定义。

(可选)可以定义 MergeSourcesN(x1, x2 ..., xN) 以减少元组节点的数量,并且可以定义 BindN(x1, x2 ..., xN, f)BindNReturn(x1, x2, ..., xN, f) 以有效地绑定计算表达式结果而不需要元组节点。

do!

do! 关键字用于调用计算表达式,该表达式返回类似 unit 的类型(由生成器上的 Zero 成员定义):

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

对于异步工作流,此类型为 Async<unit>。 对于其他计算表达式,此类型可能为 CExpType<unit>

do! 由生成器类型上的 Bind(x, f) 成员定义,其中 f 生成 unit

yield

yield 关键字用于从计算表达式返回一个值,以便可以将其用作 IEnumerable<T>

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

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

在大多数情况下,调用方可以省略它。 省略 yield 的最常见方法是使用 -> 运算符:

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

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

对于可能包含许多不同值的更复杂的表达式(可能是条件表达式),只需省略该关键字即可:

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

C# 中的 yield 关键字一样,循环访问计算表达式中的每个元素时会重新包含该元素。

yield 由生成器类型上的 Yield(x) 成员定义,其中 x 是要重新包含的项。

yield!

yield! 关键字用于平展计算表达式中的值集合:

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

计算时,由 yield! 调用的计算表达式将逐一重新包含其所有项,并平展结果。

yield! 由生成器类型上的 YieldFrom(x) 成员定义,其中 x 是值的集合。

yield 不同,yield! 必须显式指定。 它的行为并不隐含在计算表达式中。

return

return 关键字可将值包装在与计算表达式对应的类型中。 除了使用 yield 的计算表达式之外,它还用于“完成”计算表达式:

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 由生成器类型上的 Return(x) 成员定义,其中 x 是要包装的项。 对于 let! ... return 的使用,BindReturn(x, f) 可用于提高性能。

return!

return! 关键字实现计算表达式的值,并将该结果包装在与计算表达式对应的类型中:

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

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

return! 由生成器类型上的 ReturnFrom(x) 成员定义,其中 x 是另一个计算表达式。

match!

match! 关键字用于内联对另一个计算表达式的调用,并对其结果进行模式匹配:

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

使用 match! 调用计算表达式时,它会像 let! 那样实现调用的结果。 这通常在调用结果为 optional 的计算表达式时使用。

内置计算表达式

F# 核心库定义了四个内置计算表达式:序列表达式异步表达式任务表达式查询表达式

创建新型计算表达式

你可以通过创建生成器类并在该类上定义某些特殊方法,来定义自己的计算表达式的特征。 生成器类可以选择定义下表中列出的方法。

下表描述了可在工作流生成器类中使用的方法。

方法 典型签名 说明
Bind M<'T> * ('T -> M<'U>) -> M<'U> 在计算表达式中为 let!do! 调用。
BindN (M<'T1> * M<'T2> * ... * M<'TN> * ('T1 * 'T2 ... * 'TN -> M<'U>)) -> M<'U> 在不合并输入的情况下在计算表达式中为高效的 let!and! 调用。

例如 Bind4Bind3
Delay (unit -> M<'T>) -> Delayed<'T> 将计算表达式包装为函数。 Delayed<'T> 可以是任意类型,通常使用 M<'T>unit -> M<'T>。 默认实现返回 M<'T>
Return 'T -> M<'T> 在计算表达式中为 return 调用。
ReturnFrom M<'T> -> M<'T> 在计算表达式中为 return! 调用。
BindReturn (M<'T1> * ('T1 -> 'T2)) -> M<'T2> 在计算表达式中为高效的 let! ... return 调用。
BindNReturn (M<'T1> * M<'T2> * ... * M<'TN> * ('T1 * 'T2 ... * 'TN -> M<'U>)) -> M<'U> 在不合并输入的情况下在计算表达式中为高效的 let! ... and! ... return 调用。

例如 Bind4ReturnBind3Return
MergeSources (M<'T1> * M<'T2>) -> M<'T1 * 'T2> 在计算表达式中为 and! 调用。
MergeSourcesN (M<'T1> * M<'T2> * ... * M<'TN>) -> M<'T1 * 'T2 * ... * 'TN> 在计算表达式中为 and! 调用,但通过减少元组节点的数量来提高效率。

例如 MergeSources4MergeSources3
Run Delayed<'T> -> M<'T>

M<'T> -> 'T
执行计算表达式。
Combine M<'T> * Delayed<'T> -> M<'T>

M<unit> * M<'T> -> M<'T>
在计算表达式中调用以进行排序。
For seq<'T> * ('T -> M<'U>) -> M<'U>

seq<'T> * ('T -> M<'U>) -> seq<M<'U>>
在计算表达式中为 for...do 表达式调用。
TryFinally Delayed<'T> * (unit -> unit) -> M<'T> 在计算表达式中为 try...finally 表达式调用。
TryWith Delayed<'T> * (exn -> M<'T>) -> M<'T> 在计算表达式中为 try...with 表达式调用。
Using 'T * ('T -> M<'U>) -> M<'U> when 'T :> IDisposable 在计算表达式中为 use 绑定调用。
While (unit -> bool) * Delayed<'T> -> M<'T>

(unit -> bool) * Delayed<unit> -> M<unit>
在计算表达式中为 while...do 表达式调用。
Yield 'T -> M<'T> 在计算表达式中为 yield 表达式调用。
YieldFrom M<'T> -> M<'T> 在计算表达式中为 yield! 表达式调用。
Zero unit -> M<'T> 在计算表达式中为 if...then 表达式的空 else 分支调用。
Quote Quotations.Expr<'T> -> Quotations.Expr<'T> 指示计算表达式作为引用传递给 Run 成员。 它会将计算的所有实例都转换为引用。

生成器类中的许多方法使用并返回 M<'T> 构造,该构造通常是一个单独定义的类型,用于表征要组合的计算类型,例如,Async<'T> 用于异步表达式,Seq<'T> 用于序列工作流。 这些方法的签名使它们能够相互组合和嵌套,以便将从一个构造返回的工作流对象传递给下一个构造。

许多函数使用 Delay 的结果作为参数:RunWhileTryWithTryFinallyCombineDelayed<'T> 类型是 Delay 的返回类型,因此是这些函数的参数。 Delayed<'T> 可以是不需要与 M<'T> 相关的任意类型;通常使用 M<'T>(unit -> M<'T>)。 默认实现是 M<'T>。 请参阅此处详加了解。

编译器在分析计算表达式时,通过使用上表中的方法和计算表达式中的代码,将表达式转换为一系列嵌套的函数调用。 嵌套表达式采用以下形式:

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

在上面的代码中,如果 RunDelay 未在计算表达式生成器类中定义,则会省略对它们的调用。 计算表达式的主体(此处表示为 {{ cexpr }})转换为对生成器类方法的进一步调用。 此过程根据下表中的转换以递归方式定义。 双括号中的代码 {{ ... }} 仍有待转换,expr 表示 F# 表达式,cexpr 表示计算表达式。

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

在上表中,other-expr 描述了表中未列出的表达式。 生成器类不需要实现所有方法,也不需要支持上表中列出的所有转换。 那些未实现的构造在该类型的计算表达式中不可用。 例如,如果不想在计算表达式中支持 use 关键字,则可以在生成器类中省略 Use 的定义。

以下代码示例展示了一个计算表达式,该表达式将计算封装为一系列步骤,一次只能计算一个步骤。 可区分的联合类型 OkOrException 可对目前所计算的表达式的错误状态进行编码。 此代码演示了可用于计算表达式的几种典型模式,例如一些生成器方法的样本实现。

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

计算表达式具有表达式返回的基础类型。 基础类型可能表示计算结果或可以执行的延迟计算,或者可能提供一种方法来循环访问某种类型的集合。 在前面的示例中,基础类型是 Eventually<_>。 对于序列表达式,基础类型是 System.Collections.Generic.IEnumerable<T>。 对于查询表达式,基础类型是 System.Linq.IQueryable。 对于异步表达式,基础类型是 AsyncAsync 对象表示计算结果所要执行的工作。 例如,调用 Async.RunSynchronously 来执行计算并返回结果。

自定义操作

你可以对计算表达式定义自定义操作,并将自定义操作用作计算表达式中的运算符。 例如,可以在查询表达式中包含查询运算符。 定义自定义操作时,必须在计算表达式中定义 Yield 和 For 方法。 若要定义自定义操作,请将其放入计算表达式的生成器类中,然后应用 CustomOperationAttribute。 此属性将字符串作为参数,该参数是要在自定义操作中使用的名称。 此名称出现在计算表达式的左大括号开头的范围内。 因此,不应在此块中使用与自定义操作同名的标识符。 例如,避免在查询表达式中使用 alllast 等标识符。

使用新的自定义操作扩展现有生成器

如果你已经有一个生成器类,其自定义操作可以从此生成器类的外部扩展。 扩展必须在模块中声明。 命名空间不能包含扩展成员,除非在定义类型的同一文件和同一命名空间声明组中。

以下示例展示了现有 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

可以重载自定义操作。 有关详细信息,请参阅 F# RFC FS-1056 - Allow overloads of custom keywords in computation expressions(F# RFC FS-1056 - 允许在计算表达式中重载自定义关键字)。

高效编译计算表达式

通过谨慎使用称为可恢复代码的低级功能,可以将暂停执行的 F# 计算表达式编译为高效的状态机。 可恢复代码记录在 F# RFC FS-1087 中并用于任务表达式

通过使用包含 InlineIfLambda 属性的内联函数,同步的 F# 计算表达式(即,它们不会暂停执行)也可以编译为高效的状态机。 F# RFC FS-1098 中提供了示例。

F# 编译器对列表表达式、数组表达式和序列表达式进行了特殊处理,以确保生成高性能代码。

另请参阅