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

(注:本文为了表达上的简洁和理解上的方便,有很多地方不够严谨,更详细更严谨的内容请看原文。)

posted @ 2020-12-11 16:25  cmdOptionKana  阅读(490)  评论(0编辑  收藏  举报