F# 编程/计算表达式
F#:计算表达式 |
计算表达式是 F# 中最难理解,但也是功能最强大的语言结构。
计算表达式灵感来自 Haskell 单子,而 Haskell 单子又源于范畴论中的数学单子概念。为了避免所有关于单子的抽象技术和数学理论,简单来说,"单子"是一个听起来很吓人的词,意思是执行这个函数并将它的返回值传递给另一个函数。
- 注意: F# 的设计者使用术语 "计算表达式" 和 "工作流",因为它们比 "单子" 听起来不那么晦涩难懂,并且因为单子和计算表达式虽然相似,但并不是完全相同的东西。本书作者更喜欢 "单子",以强调 F# 和 Haskell 之间的平行关系(严格来说,只是一个有趣的五美元词)。
Haskell 中的单子
Haskell 很有趣,因为它是一种函数式编程语言,所有语句都是延迟执行的,这意味着 Haskell 不会计算值,直到它们真正需要时才会计算。虽然这赋予了 Haskell 一些独特的功能,例如定义"无限" 数据结构的能力,但也使得难以推断程序的执行过程,因为你无法保证代码行会按照任何特定的顺序执行(甚至是否执行)。
因此,用 Haskell 来完成需要按顺序执行的操作是一项相当大的挑战,这包括任何形式的 I/O、在多线程代码中获取锁对象、读写套接字,以及任何可能对应用程序中其他内存位置产生副作用的操作。Haskell 使用名为单子的东西来管理顺序操作,它可以用来模拟不可变环境中的状态。
用 F# 可视化单子
为了可视化单子,让我们看一些用命令式风格编写的日常 F# 代码
let read_line() = System.Console.ReadLine()
let print_string(s) = printf "%s" s
print_string "What's your name? "
let name = read_line()
print_string ("Hello, " + name)
我们可以重新编写 read_line
和 print_string
函数,使它们接受一个额外的参数,即我们的计算完成后要执行的函数。最后我们会得到类似下面的东西
let read_line(f) = f(System.Console.ReadLine())
let print_string(s, f) = f(printf "%s" s)
print_string("What's your name? ", fun () ->
read_line(fun name ->
print_string("Hello, " + name, fun () -> () ) ) )
如果你能理解这一点,那么你就能理解任何单子。
当然,说为什么要以这种受虐狂的方式编写代码呢?它所做的只是将 "Hello, Steve" 打印到控制台! 也是完全合理的。毕竟,我们所知和喜爱的 C#、Java、C++ 或其他语言会严格按照指定顺序执行代码——换句话说,单子解决了 Haskell 中在命令式语言中根本不存在的问题。因此,单子设计模式在命令式语言中几乎是未知的。
然而,单子偶尔对模拟难以用命令式风格捕获的计算很有用。
Maybe 单子
一个众所周知的单子,Maybe 单子,代表一个短路计算,如果计算的任何部分失败,它应该 "退出"。用一个简单的例子来说,假设我们要写一个函数,要求用户输入 3 个介于 0 到 100(含)之间的整数——如果用户在任何时候输入了一个非数字或超出我们范围的输入,整个计算都应该被中止。传统上,我们可能会用以下代码来表示这种程序
let addThreeNumbers() =
let getNum msg =
printf "%s" msg
// NOTE: return values from .Net methods that accept 'out' parameters are exposed to F# as tuples.
match System.Int32.TryParse(System.Console.ReadLine()) with
| (true, n) when n >= 0 && n <= 100 -> Some(n)
| _ -> None
match getNum "#1: " with
| Some(x) ->
match getNum "#2: " with
| Some(y) ->
match getNum "#3: " with
| Some(z) -> Some(x + y + z)
| None -> None
| None -> None
| None -> None
- 注意: 诚然,这个程序的简单性——获取几个整数——是荒谬的,还有很多更简洁的方法可以编写这个代码,只需预先获取所有值。然而,你可以想象
getNum
是一个相对昂贵的操作(也许它会对数据库执行查询、通过网络发送和接收数据、初始化一个复杂的数据结构),而编写这个程序最有效的方式要求我们在遇到第一个无效值时就退出。
这段代码很丑陋,而且冗余。但是,我们可以通过将其转换为单子风格来简化这段代码
let addThreeNumbers() =
let bind(input, rest) =
match System.Int32.TryParse(input()) with
| (true, n) when n >= 0 && n <= 100 -> rest(n)
| _ -> None
let createMsg msg = fun () -> printf "%s" msg; System.Console.ReadLine()
bind(createMsg "#1: ", fun x ->
bind(createMsg "#2: ", fun y ->
bind(createMsg "#3: ", fun z -> Some(x + y + z) ) ) )
神奇之处在于 bind
方法。我们从函数 input
中提取返回值,并将它(或绑定它)作为第一个参数传递给 rest
。
为什么要使用单子?
上面的代码对于实际使用来说仍然过于繁琐和冗长,但是单子在模拟难以按顺序捕获的计算方面特别有用。例如,多线程代码在命令式风格下 notoriously 难以编写;但是用单子风格编写起来却异常简洁和容易。让我们修改上面的 bind 方法,如下所示
open System.Threading
let bind(input, rest) =
ThreadPool.QueueUserWorkItem(new WaitCallback( fun _ -> rest(input()) )) |> ignore
现在我们的 bind 方法将在自己的线程中执行函数。使用单子,我们可以在安全、命令式风格下编写多线程代码。以下是在 fsi 中演示这种技术的示例
> open System.Threading
open System.Text.RegularExpressions
let bind(input, rest) =
ThreadPool.QueueUserWorkItem(new WaitCallback( fun _ -> rest(input()) )) |> ignore
let downloadAsync (url : string) =
let printMsg msg = printfn "ThreadID = %i, Url = %s, %s" (Thread.CurrentThread.ManagedThreadId) url msg
bind( (fun () -> printMsg "Creating webclient..."; new System.Net.WebClient()), fun webclient ->
bind( (fun () -> printMsg "Downloading url..."; webclient.DownloadString(url)), fun html ->
bind( (fun () -> printMsg "Extracting urls..."; Regex.Matches(html, @"http://\S+") ), fun matches ->
printMsg ("Found " + matches.Count.ToString() + " links")
)
)
)
["http://www.google.com/"; "http://microsoft.com/"; "http://www.wordpress.com/"; "http://www.peta.org"] |> Seq.iter downloadAsync;;
val bind : (unit -> 'a) * ('a -> unit) -> unit
val downloadAsync : string -> unit
>
ThreadID = 5, Url = http://www.google.com/, Creating webclient...
ThreadID = 11, Url = http://microsoft.com/, Creating webclient...
ThreadID = 5, Url = http://www.peta.org, Creating webclient...
ThreadID = 11, Url = http://www.wordpress.com/, Creating webclient...
ThreadID = 5, Url = http://microsoft.com/, Downloading url...
ThreadID = 11, Url = http://www.google.com/, Downloading url...
ThreadID = 11, Url = http://www.peta.org, Downloading url...
ThreadID = 13, Url = http://www.wordpress.com/, Downloading url...
ThreadID = 11, Url = http://www.google.com/, Extracting urls...
ThreadID = 11, Url = http://www.google.com/, Found 21 links
ThreadID = 11, Url = http://www.peta.org, Extracting urls...
ThreadID = 11, Url = http://www.peta.org, Found 111 links
ThreadID = 5, Url = http://microsoft.com/, Extracting urls...
ThreadID = 5, Url = http://microsoft.com/, Found 1 links
ThreadID = 13, Url = http://www.wordpress.com/, Extracting urls...
ThreadID = 13, Url = http://www.wordpress.com/, Found 132 links
有趣的是,Google 开始在第 5 个线程上下载,并在第 11 个线程上完成。此外,第 11 个线程在某个时候被 Microsoft、Peta 和 Google 共享。每次调用 bind
时,我们都会从 .NET 的线程池中取出一个线程,当函数返回时,该线程会被释放回线程池,另一个线程可能会再次拾取它——异步函数在其生命周期中可能会在任意数量的线程之间跳转,这是完全有可能的。
这种技术非常强大,以至于它以异步工作流的形式烘焙到了 F# 库中。
计算表达式从根本上来说与上面看到的概念相同,尽管它们将单子语法的复杂性隐藏在厚厚的一层语法糖后面。单子是一种特殊的类,它必须具有以下方法:Bind
、Delay
和 Return
。
我们可以像下面这样重新编写我们之前描述的 Maybe 单子
type MaybeBuilder() =
member this.Bind(x, f) =
match x with
| Some(x) when x >= 0 && x <= 100 -> f(x)
| _ -> None
member this.Delay(f) = f()
member this.Return(x) = Some x
我们可以在 fsi 中测试这个类
> type MaybeBuilder() =
member this.Bind(x, f) =
printfn "this.Bind: %A" x
match x with
| Some(x) when x >= 0 && x <= 100 -> f(x)
| _ -> None
member this.Delay(f) = f()
member this.Return(x) = Some x
let maybe = MaybeBuilder();;
type MaybeBuilder =
class
new : unit -> MaybeBuilder
member Bind : x:int option * f:(int -> 'a0 option) -> 'a0 option
member Delay : f:(unit -> 'a0) -> 'a0
member Return : x:'a0 -> 'a0 option
end
val maybe : MaybeBuilder
> maybe.Delay(fun () ->
let x = 12
maybe.Bind(Some 11, fun y ->
maybe.Bind(Some 30, fun z ->
maybe.Return(x + y + z)
)
)
);;
this.Bind: Some 11
this.Bind: Some 30
val it : int option = Some 53
> maybe.Delay(fun () ->
let x = 12
maybe.Bind(Some -50, fun y ->
maybe.Bind(Some 30, fun z ->
maybe.Return(x + y + z)
)
)
);;
this.Bind: Some -50
val it : int option = None
单子很强大,但是超过两个或三个变量,嵌套函数的数量就会变得难以管理。F# 提供了语法糖,允许我们以更可读的方式编写相同的代码。工作流使用 builder { comp-expr }
的形式进行评估。例如,以下代码片段是等效的
带糖语法 | 去糖语法 |
---|---|
let maybe = new MaybeBuilder()
let sugared =
maybe {
let x = 12
let! y = Some 11
let! z = Some 30
return x + y + z
}
|
let maybe = new MaybeBuilder()
let desugared =
maybe.Delay(fun () ->
let x = 12
maybe.Bind(Some 11, fun y ->
maybe.Bind(Some 30, fun z ->
maybe.Return(x + y + z)
)
)
)
|
- 注意:你可能已经注意到,带糖语法与用于声明序列表达式
seq { expr }
的语法惊人地相似。这不是巧合。在 F# 库中,序列被定义为计算表达式,并被用作计算表达式。异步工作流 是你在学习 F# 时会遇到的另一个计算表达式。
带糖形式读起来就像正常的 F#。代码 let x = 12
按预期工作,但是 let!
在做什么?注意我们说 let! y = Some 11
,但是值 y
的类型是 int option
而不是 int
。构造 let! y = ...
调用一个名为 maybe.Bind(x, f)
的函数,其中值 y
被绑定到传递到 f
函数中的参数。
类似地,return ...
调用一个名为 maybe.Return(x)
的函数。几个新关键字会去糖化成其他一些构造,包括你在序列表达式中已经见过的 yield
和 yield!
,以及一些新的关键字,比如 use
和 use!
。
这个 fsi 示例展示了使用计算表达式语法,我们的 maybe 单子是多么容易使用
> type MaybeBuilder() =
member this.Bind(x, f) =
printfn "this.Bind: %A" x
match x with
| Some(x) when x >= 0 && x <= 100 -> f(x)
| _ -> None
member this.Delay(f) = f()
member this.Return(x) = Some x
let maybe = MaybeBuilder();;
type MaybeBuilder =
class
new : unit -> MaybeBuilder
member Bind : x:int option * f:(int -> 'a0 option) -> 'a0 option
member Delay : f:(unit -> 'a0) -> 'a0
member Return : x:'a0 -> 'a0 option
end
val maybe : MaybeBuilder
> maybe {
let x = 12
let! y = Some 11
let! z = Some 30
return x + y + z
};;
this.Bind: Some 11
this.Bind: Some 30
val it : int option = Some 53
> maybe {
let x = 12
let! y = Some -50
let! z = Some 30
return x + y + z
};;
this.Bind: Some -50
val it : int option = None
这段代码与去糖化的代码做的事情相同,只是它更容易阅读得多。
根据F# 规范,工作流可以用以下成员定义
成员 | 描述 |
---|---|
member Bind : M<'a> * ('a -> M<'b>) -> M<'b>
|
必需成员。用于在计算表达式中去糖化 let! 和 do! 。 |
member Return : 'a -> M<'a>
|
必需成员。用于在计算表达式中去糖化 return 。 |
member Delay : (unit -> M<'a>) -> M<'a>
|
必需成员。用于确保计算表达式中的副作用在预期的时间内执行。 |
member Yield : 'a -> M<'a>
|
可选成员。用于在计算表达式中去糖化 yield 。 |
member For : seq<'a> * ('a -> M<'b>) -> M<'b>
|
可选成员。用于在计算表达式中去糖化 for ... do ... 。M<'b> 可以选择为 M<unit> |
member While : (unit -> bool) * M<'a> -> M<'a>
|
可选成员。用于在计算表达式中去糖化 while ... do ... 。M<'b> 可以选择为 M<unit> |
member Using : 'a * ('a -> M<'b>) -> M<'b> when 'a :> IDisposable
|
可选成员。用于在计算表达式中去糖化 use 绑定。 |
member Combine : M<'a> -> M<'a> -> M<'a>
|
可选成员。用于在计算表达式中去糖化顺序。第一个 M<'a> 可以选择为 M<unit> |
member Zero : unit -> M<'a>
|
可选成员。用于在计算表达式中去糖化 if/then 的空 else 分支。 |
member TryWith : M<'a> -> M<'a> -> M<'a>
|
可选成员。用于在计算表达式中去糖化空 try/with 绑定。 |
成员 TryFinally : M<'a> -> M<'a> -> M<'a>
|
可选成员。用于在计算表达式中对 try/finally 绑定进行反糖化。 |
这些糖化结构的反糖化如下
结构 | 反糖化形式 |
---|---|
let pat = expr in cexpr
|
let pat = expr in cexpr
|
let! pat = expr in cexpr
|
b.Bind(expr, (fun pat -> cexpr))
|
return expr
|
b.Return(expr)
|
return! expr
|
b.ReturnFrom(expr)
|
yield expr
|
b.Yield(expr)
|
yield! expr
|
b.YieldFrom(expr)
|
use pat = expr in cexpr
|
b.Using(expr, (fun pat -> cexpr))
|
use! pat = expr in cexpr
|
b.Bind(expr, (fun x -> b.Using(x, fun pat -> cexpr))
|
do! expr in cexpr
|
b.Bind(expr, (fun () -> cexpr))
|
for pat in expr do cexpr
|
b.For(expr, (fun pat -> cexpr))
|
while expr do cexpr
|
b.While((fun () -> expr), b.Delay( fun () -> cexpr))
|
if expr then cexpr1 else cexpr2
|
if expr then cexpr1 else cexpr2
|
if expr then cexpr
|
if expr then cexpr else b.Zero()
|
cexpr1
|
b.Combine(cexpr1, b.Delay(fun () -> cexpr2))
|
try cexpr with patn -> cexprn
|
b.TryWith(expr, fun v -> match v with (patn:ext) -> cexprn | _ raise exn)
|
try cexpr finally expr
|
b.TryFinally(cexpr, (fun () -> expr))
|
计算表达式有什么用?
[edit | edit source]F# 鼓励一种称为面向语言编程的编程风格来解决问题。与通用编程风格相比,面向语言编程的核心是程序员识别他们想要解决的问题,然后编写特定于领域的迷你语言来解决问题,最后在新的迷你语言中解决问题。
计算表达式是 F# 程序员用来设计迷你语言的几种工具之一。
令人惊讶的是,计算表达式和类似单子的结构在实践中经常出现。例如,Haskell 用户组 收集了常见和不常见的单子,包括那些计算整数分布和解析文本的单子。另一个重要的例子,软件事务内存的 F# 实现,在 hubFS 上介绍。
其他资源
[edit | edit source]- Haskell.org: 关于单子的一切 - Haskell 中另一个单子的集合。