Computation expressions: Introduction
本文仅为对原文的翻译,主要是记录以方便以后随时查看。原文地址为http://fsharpforfunandprofit.com/posts/computation-expressions-intro/
背景
是时候揭开计算表达式(Computation expression)的神秘面纱了。现有的解释说明都令人难以理解。比如查阅MSDN官方说明,则对初学者来说虽然简单明确,却对理解没有什么太大帮助。例如当你看到如下代码
{| let! pattern = expr in cexpr |}
它只是如下方法调用的一个简单的语法糖:
builder.Bind(expr, (fun pattern -> {| cexpr |}))
但,这是个什么鬼?
实战
首先看一段简单的代码,然后再用Computation expression实现同样的功能。
let log p = printfn "expression is %A" p let loggedWorkflow = let x = 42 log x let y = 43 log y let z = x + y log z //return z
运行结果为
expression is 42 expression is 43 expression is 85
看起来很简单,但是每次都显式的调用log语句还是挺烦的,考虑隐藏这些log语句的方法,这时候,Computation expression派上用场。
首先定义一个类型
type LoggingBuilder() = let log p = printfn "expression is %A" p member this.Bind(x, f) = log x f x member this.Return(x) = x
先不用管这段代码中的Bind和Return成员方法,后面会给出解释。接着看如下代码,实现刚才的log功能
let logger = new LoggingBuilder() let loggedWorkflow = logger { let! x = 42 let! y = 43 let! z = x + y return z }
运行这段代码可以获得跟刚才同样的输出结果,但是很明显,刚才代码中重复的log语句已经被隐藏了。
安全除法
现在让我们看一个经典的例子。
假如要除以一系列的数,即一个接一个将这些数作为除数,但是这些数其中可能有0。如何处理?抛出一个异常会使代码丑陋,使用option类型好像是一个不错的方法。
先定义一个帮助函数,实现除法功能并返回一个int option,正常情况下则为Some,否则为None。然后将这些除法过程链接起来,并且在每个除法过程后判定除法是否成功(返回Some),只有在成功的时候才会继续下一个除法过程。帮助函数如下
let divideBy bottom top = if bottom = 0 then None else Some(top/bottom)
注意第一个参数为除数,故我们可以将除法表达式写成 12 |> divideBy 3 (表示12/3)的形式,这样更容易将整个除法过程串联起来。
看一个具体的实例,用三个数依次去除一个初始数
let divideByWorkflow init x y z = let a = init |> divideBy x match a with | None -> None // give up | Some a' -> // keep going let b = a' |> divideBy y match b with | None -> None // give up | Some b' -> // keep going let c = b' |> divideBy z match c with | None -> None // give up | Some c' -> // keep going //return Some c'
函数调用
let good = divideByWorkflow 12 3 2 1 let bad = divideByWorkflow 12 3 0 1
bad变量为None,因为有一个除数为0
注意到以上例子中,返回结果必须为int option,不能返回int。然后,这个例子中的连续测试除法是否失败的代码依然丑陋,考虑使用computation expression。
我们定义一个新的类型如下,并实例化
type MaybeBuilder() = member this.Bind(x, f) = match x with | None -> None | Some a -> f a member this.Return(x) = Some x let maybe = new MaybeBuilder()
重写除以一系列数的工作流的函数,隐藏了之前的判断分支逻辑
let divideByWorkflow init x y z = maybe { let! a = init |> divideBy x let! b = a |> divideBy y let! c = b |> divideBy z return c }
测试以上函数,可得到同样的结果
let good = divideByWorkflow 12 3 2 1
let bad = divideByWorkflow 12 3 0 1
链接“or else” 测试
上一个例子中,我们在某一步除法执行成功后才继续执行下一个除法,但是,有时候的控制流程并非如此,而是“or else”,即,第一个事情没有成功,则尝试第二个事情,第二个事情如果也失败,则尝试第三个事情,依次类推。
看一个简单例子。假设我们有三个字典,并且我们想查找某一键对应的值,查询每一个字典的结果可能是成功或者失败。
let map1 = [ ("1","One"); ("2","Two") ] |> Map.ofList let map2 = [ ("A","Alice"); ("B","Bob") ] |> Map.ofList let map3 = [ ("CA","California"); ("NY","New York") ] |> Map.ofList let multiLookup key = match map1.TryFind key with | Some result1 -> Some result1 // success | None -> // failure match map2.TryFind key with | Some result2 -> Some result2 // success | None -> // failure match map3.TryFind key with | Some result3 -> Some result3 // success | None -> None // failure
这个查询函数的使用如下
multiLookup "A" |> printfn "Result for A is %A" multiLookup "CA" |> printfn "Result for CA is %A" multiLookup "X" |> printfn "Result for X is %A"
代码运行良好,但同样查询函数multiLookup的定义代码太烦,简化一下,首先定义一个bulider类如下
type OrElseBuilder() = member this.ReturnFrom(x) = x member this.Combine (a,b) = match a with | Some _ -> a // a succeeds -- use it | None -> b // a fails -- use b instead member this.Delay(f) = f() let orElse = new OrElseBuilder()
重写查询函数
let map1 = [ ("1","One"); ("2","Two") ] |> Map.ofList let map2 = [ ("A","Alice"); ("B","Bob") ] |> Map.ofList let map3 = [ ("CA","California"); ("NY","New York") ] |> Map.ofList let multiLookup key = orElse { return! map1.TryFind key return! map2.TryFind key return! map3.TryFind key }
使用示例代码如下,运行后发现结果同期望一样
multiLookup "A" |> printfn "Result for A is %A" multiLookup "CA" |> printfn "Result for CA is %A" multiLookup "X" |> printfn "Result for X is %A"
带回调的异步调用
.net中异步操作的标准方法是使用AsyncCallback delegate,这在异步操作完成时被调用。
举个例子,网页下载
open System.Net let req1 = HttpWebRequest.Create("http://tryfsharp.org") let req2 = HttpWebRequest.Create("http://google.com") let req3 = HttpWebRequest.Create("http://bing.com") req1.BeginGetResponse((fun r1 -> // 请求1异步获取响应,完成后,请求2异步获取响应,完成后,请求3异步获取响应 use resp1 = req1.EndGetResponse(r1) printfn "Downloaded %O" resp1.ResponseUri req2.BeginGetResponse((fun r2 -> use resp2 = req2.EndGetResponse(r2) printfn "Downloaded %O" resp2.ResponseUri req3.BeginGetResponse((fun r3 -> use resp3 = req3.EndGetResponse(r3) printfn "Downloaded %O" resp3.ResponseUri ),null) |> ignore ),null) |> ignore ),null) |> ignore
以上代码使用太多的BeginGetResponse和EndGetResponse,以及嵌套的lambda,使得代码阅读费力。
事实上,在需要连接回调函数的代码中,管理这种级联方法总是显得困难,这甚至被称为 "Pyramid of Doom"(尽管 none of the solutions are very elegant, IMO)
当然,在F#中我们将不再写类似的代码,因为F#有内建的async computation expression ,这简化了代码。
open System.Net let req1 = HttpWebRequest.Create("http://tryfsharp.org") let req2 = HttpWebRequest.Create("http://google.com") let req3 = HttpWebRequest.Create("http://bing.com") async { use! resp1 = req1.AsyncGetResponse() printfn "Downloaded %O" resp1.ResponseUri use! resp2 = req2.AsyncGetResponse() printfn "Downloaded %O" resp2.ResponseUri use! resp3 = req3.AsyncGetResponse() printfn "Downloaded %O" resp3.ResponseUri } |> Async.RunSynchronously
在这个系列的后面部分会看到 async 工作流是如何实现的。
总结
至此我们见到一些简单的computation expression的例子。
- logging例子中,我们想在每一步中添加一些自定义的逻辑,如打印log信息。
- 安全除法例子中,我们想更为优雅的处理除法出错的情况,以便我们更加专注其他一些事情。
- 在多字典查询例子中,我们想在第一次查询字典成功后就结束并返回。
- 最后在异步操作例子中,我们想隐藏大段的回调函数的代码。
这些例子的一个共同点就是在每个表达式中,computation expression做了一些后台的事情。
打一个不是很好的比方,可以把computation expression想象成SVN或者Git的一个提交后钩子,或者数据库的每次更新后被调用的触发器。这就是computation expression,它可以隐藏一些代码,从而让我们更专注于业务逻辑。
至于computation expression和workflow(工作流)之间的区别,我使用computation expression表示{...}和let!语法,而workflow(工作流)表示具体实现。当然,不是所有computation expression的实现都是工作流,例如,说“async”工作流或者“maybe”工作流是合适的,但是说“seq”工作流就显得不太合适。
也就是说,如下面的代码
maybe { let! a = x |> divideBy y let! b = a |> divideBy w let! c = b |> divideBy z return c }
可以说maybe是我们使用的工作流,而{ let! a = .... return c }是computation expression
附:
讲state type的文章。