F# 函数式编程之 - 面向铁道编程
原文 https://fsharpforfunandprofit.com/posts/recipe-part2/
参考 https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/results
这是关于 F# 的一个最受欢迎的网站里,最受欢迎的一篇文章《Railway oriented programming》。
代码不长,先看代码吧,我在代码后面写讲解。
type Request = {Name:string; Email:string}
let validateName request =
match request with
| {Name=name; Email=_} when name = "" ->
Error "name must not be blank"
| _ -> Ok request
let validateEmail =
function
| {Name=_; Email=email} when email = "" -> Error "email must not be blank"
| request -> Ok request
let test1() =
let validate =
Result.bind validateName >> Result.bind validateEmail
let result1 = validate (Ok {Name="abc"; Email="a@c"})
printfn "%A" result1
let result2 = validate (Ok {Name="abc"; Email=""})
printfn "%A" result2
test1()
安装了 .NET SDK 后,复制上面的代码粘贴到文件中,保存为 railway.fsx, 在控制台使用命令 dotnet fsi railway.fsx
即可运行。
这段代码的目的是对 Request 进行验证,并优雅地处理错误。
为了保持简单,我们只做了两个简单的验证,但现实中可能需要对同一个 Request 进行很多个验证,每一步都可能产生错误,因此必须想办法优雅地处理错误。
在函数式编程中,如果函数 f1 的输出恰好可以作为函数 f2 的输入参数,那么 f1 和 f2 就可以直接拼接起来变成 f3.
因此,只要我们想办法让每一个验证函数的输入、输出都相同,就能轻松地把它们拼接起来。
一个可行的办法就是采用标准库里的 Result.bind 函数 (https://github.com/dotnet/fsharp/blob/main/src/fsharp/FSharp.Core/result.fs)
bind 函数是本文开头那段代码的关键,也是 Railway oriented programming 的关键所在!
关于 Result.bind 函数
这个函数接受两个参数: fn 和 result。
其中 result 的类型是 Result, 它有两种可能: Ok 或 Error。
当 result 是 Ok 时,就用函数 fn 去处理它;当 result 是 Error 时,则不会执行 fn。
最后,bind 函数也返回一个 Result。
简单来说,bind 的作用是确保我们总能输入一个 Result, 经过 fn 处理后,又总能输出一个 Result。
关于 validateName 和 validateEmail
在理解了 bind 函数的作用后,接下来的事情就非常容易理解了。
请看 validateName 和 validateEmail, 其中 validateEmail 用了一个语法糖 function
, 其实它和 validateName 里的 match...with
的作用是完全一样的,我在这里只是顺便介绍一下这个语法糖而已。
这两个函数虽然都输出一个 Result, 但它们的输入参数都是 Request 而不是 Result, 因此它们无法直接拼接起来。
此时,我们使用 bind, 看看会得到什么:(注意看了,神奇的事情即将发生)
let validate1 = Result.bind validateName
let validate2 = Result.bind validateEmail
由于 bind 的类型是 fn -> Result -> Result
(其中 fn 是一个函数,该函数的返回值也是一个 Result)
因此,当我们喂给它一个 fn 函数时,它就会变成 Result -> Result
!
也就是说,validate1 是 Result -> Result
, validate2 也是 Result -> Result
!
也就是说,它们被 bind 了一下,就神奇地统一了输入输出,现在它们可以直接拼接了:
let validate = Result.bind validateName >> Result.bind validateEmail
为什么叫做 “面向铁道编程”?
在原文里有配图,说明了这种编程模式与铁道的相似之处,如有兴趣请看原文。
在这里我只说重点:这种铁道有两条轨道,一条是 Ok, 一条是 Error。
数据就像旅客,函数就像站点,数据在起点先走在 Ok 轨道上,每到一站就进行一些处理(数据被函数处理,再传递至下一个函数),如果发生错误,就切换到 Error 轨道。
一个重要的特性是:Error 轨道就像快车道,一旦切换到 Error 轨道,就再也不停站,直达终点。
结语
说到这里,一切迷雾已经解开,请回头再看本文开头那段代码,相信你现在已经可以轻松理解它了。
更新、补充:另一种铁道拼接方法
我们还可以自定义一个运算符 >=>
来进行拼接。
let (>=>) f1 f2 x =
match f1 x with
| Ok y -> f2 y
| Error z -> Error z
let test2() =
let validate =
validateName >=> validateEmail
let result1 = validate {Name="abc"; Email="a@c"}
printfn "%A" result1
{Name="abc"; Email=""}
|> validate
|> printfn "%A"
test2()
这里,关注的重点是 validate 函数,它由两个签名相同的函数拼接而成,最终 validate 的签名也与其中的每一个函数相同。
比如在这个例子中,validateName 与 validateEmail 的签名都是 Request -> Result, 因此拼接后的 validate 也一样是 Request -> Result.
方便好用的 Result.map 函数
前面我们介绍过 Result.bind 函数,而 Result.map 与它类似。
bind 的作用是将一个函数 'a -> Result 改造成 Result -> Result, 而 map 的作用则是将一个普通的(与 Result 完全不搭边的)函数 'a -> 'b 改造成 Result -> Result。例如:
// Request -> Request
let canonicalizeEmail input =
{ input with Email = input.Email.Trim().ToLower() }
// Result -> Result
let validateAndProcess =
Result.bind validateName
>> Result.bind validateEmail
>> Result.map canonicalizeEmail
(注:本文为了表达上的简洁和理解上的方便,有很多地方不够严谨,更详细更严谨的内容请看原文。)