Computation expressions and wrapper types
原文地址:http://fsharpforfunandprofit.com/posts/computation-expressions-wrapper-types/
在上一篇中,我们介绍了“maybe”工作流,让我们隐藏了写链接和可选类型的繁杂代码。
典型的“maybe”工作流大概类似
let result = maybe { let! anInt = expression of Option<int> let! anInt2 = expression of Option<int> return anInt + anInt2 }
这里有几个点奇怪的行为:
- 在let!行,等号后边的表达式是一个int option,但是等号左边的却是一个int。let!在将option绑定到左边的值之前已经对option去包装(unwrapped)
- 在return行,则进行相反的动作。被返回的表达式是一个int,但是整个“maybe”工作流的值是一个int option。也就是说,return 将原始的int值包装(wrapped)成一个option
在这一篇中,我们将继续这样的观察,并且将看到computation expression的一个主要用途:隐式的去包装(unwrapped)和复包装(rewrapped)一个值,这个值存储在某种包装类型中。
另一个例子
我们访问一个数据库,并想将结果放到一个Success/Error的联合类型中,如下
type DbResult<'a> = | Success of 'a | Error of string
然后在访问数据库的方法中运用这个类型。以下是一些简单的例子演示如何使用DbResult类型
let getCustomerId name = if (name = "") then Error "getCustomerId failed" else Success "Cust42" let getLastOrderForCustomer custId = if (custId = "") then Error "getLastOrderForCustomer failed" else Success "Order123" let getLastProductForOrder orderId = if (orderId = "") then Error "getLastProductForOrder failed" else Success "Product456"
现在我们想将这些方法调用链接起来。
显式的方法如下,可以看到,每一步都需要进行模式匹配
let product = let r1 = getCustomerId "Alice" match r1 with | Error _ -> r1 | Success custId -> let r2 = getLastOrderForCustomer custId match r2 with | Error _ -> r2 | Success orderId -> let r3 = getLastProductForOrder orderId match r3 with | Error _ -> r3 | Success productId -> printfn "Product is %s" productId r3
非常丑陋的代码。使用computation expression则可以拯救我们。
type DbResultBuilder() = member this.Bind(m, f) = match m with | Error _ -> m | Success a -> printfn "\tSuccessful: %s" a f a member this.Return(x) = Success x let dbresult = new DbResultBuilder()
有了这个类型的帮助,我们可以专注于整体结构而不用考虑一些细节,从而让代码简洁
let product' = dbresult { let! custId = getCustomerId "Alice" let! orderId = getLastOrderForCustomer custId let! productId = getLastProductForOrder orderId printfn "Product is %s" productId return productId } printfn "%A" product'
如果出现错误,这个工作流会漂亮地捕获错误,并告诉我们错误发生的地方,例如
let product'' = dbresult { let! custId = getCustomerId "Alice" let! orderId = getLastOrderForCustomer "" // error! let! productId = getLastProductForOrder orderId printfn "Product is %s" productId return productId } printfn "%A" product''
工作流中包装类型的角色
现在我们已经看到两个工作流了(maybe工作流和dbresult工作流),每个工作流都有自己的包装类型(Option<T>和DbResult<T>)。
这两个工作流并非有什么特别不同的。事实上,每个computation expression必须有相应的包装类型,而这个包装类型的设计通常与我们想要管理的工作流相关。
上面的例子中DbResult类型不仅仅是一个为了能返回值的简单类型,而是在工作流中扮演着关键的角色:存储工作流的当前状态(错误信息或成功时的结果信息)。通过利用这个DbResult类型的不同case(Success或者是Error),dbresult工作流可以为我们做控制管理,并可以在后台执行一些信息(如打印信息)从而让我们专注于大局。
绑定和返回包装类型
再次复习一下Bind和Return方法的定义。
Return的签名as documented on MSDN如下,可以看到,对某种类型T,Return方法仅仅包装这个类型。
member Return : 'T -> M<'T>
说明:在签名中,包装类型常被称为M,故M<int>是应用到int的包装类型,M<string>是应用到string的包装类型,以此类推。
我们已经见过两个使用Return方法的例子了。maybe工作流返回一个Some,它是一个option类型,dbresult工作流返回一个Sucess,它是DbResult类型。
// return for the maybe workflow member this.Return(x) = Some x // return for the dbresult workflow member this.Return(x) = Success x
来看Bind的签名
member Bind : M<'T> * ('T -> M<'U>) -> M<'U>
Bind的输入参数为一个元组M<'T>*('T -> M<'U>),返回M<'U>,即应用到类型U的包装类型。
其中元组有两部分
- M<'T>是类型T的包装类型
- ('T -> M<'U>)是一个函数,以一个未包装的类型T作为输入参数,输出类型为应用到类型U上的包装类型
或者说,Bind函数做的事情为:
- 将一个包装类型参数作为输入
- 将输入参数(M<'T>)去包装化为一个值(类型为T),并对这个值做一些后台逻辑(自定义代码)。
- 应用函数到这个未包装的值(T)上,并产生一个新的包装类型值(M<'U>)
- 即使没有应用这个函数,Bind也必须返回一个类型U的包装类型(M<'U>)(参考前面安全除法中的除法出错的情况,此时没有应用continuation函数,返回的是None)
基于以上的理解,我们给出Bind的方法代码
// return for the maybe workflow member this.Bind(m,f) = match m with | None -> None | Some x -> f x // return for the dbresult workflow member this.Bind(m, f) = match m with | Error _ -> m | Success x -> printfn "\tSuccessful: %s" x f x
在此,确保你已经懂得了Bind方法所做的事情。
最后,给出一张图来帮助理解
- 对Bind方法来说,从一个包装类型值开始(图中m),将它去包装为一个类型T的原始值,然后(可能)应用函数f 到这个值上,并获得一个类型U的包装类型
- 对Return方法来说,从一个值(图中x)开始,简单的包装它并返回之。
类型包装器是泛型
注意到所有函数使用泛型类型(T和U)而不是包装类型,并且自始至终都如此。例如,不能阻止maybe的Bind函数(中的f 函数)以一个int作为输入并返回一个Option<string>,或者以一个string为输入而返回一个Option<bool>,唯一的要求是总是返回一个可选类型Option<something>。
为了更好的理解,我们再看上面的例子,但比起到处使用string,我们将为客户id,订单id和产品id创建专有类型,这意味着每一步将使用不同的类型。
先给出类型定义
type DbResult<'a> = | Success of 'a | Error of string type CustomerId = CustomerId of string type OrderId = OrderId of int type ProductId = ProductId of string
代码几乎相同,除了Success行改用了新类型。
let getCustomerId name = if (name = "") then Error "getCustomerId failed" else Success (CustomerId "Cust42") let getLastOrderForCustomer (CustomerId custId) = if (custId = "") then Error "getLastOrderForCustomer failed" else Success (OrderId 123) let getLastProductForOrder (OrderId orderId) = if (orderId = 0) then Error "getLastProductForOrder failed" else Success (ProductId "Product456")
应用以上函数,则代码变为
let product = let r1 = getCustomerId "Alice" match r1 with | Error e -> Error e | Success custId -> let r2 = getLastOrderForCustomer custId match r2 with | Error e -> Error e | Success orderId -> let r3 = getLastProductForOrder orderId match r3 with | Error e -> Error e | Success productId -> printfn "Product is %A" productId r3
从以上代码可以看出,我们可以预见即将写出来的Bind函数中的第一个continuation函数f 的输入参数类型为string(即“Alice”),输出类型为CustomerId option,而第二个continuation函数f 的输入参数类型为CustomerId,与前一个f 函数的输出类型匹配。故可以知道,Bind函数的输入参数类型为T,输出类型为M<U>,只要continuation中下一个函数的输入参数类型为U就行。
有几点变化值得讨论一下:
首先,底部的printfn使用"%A"格式化器而不是"%s"。这是因为ProductId类型是联合类型。
更为细致地,错误行的代码看起来似乎是不必要的。为啥要写| Error e -> Error e?原因是 -> 左边的错误类型与类型DbResult<CustomerId>或者DbResult<OrderId>匹配,但是右边的错误类型必须为DbResult<ProductId>。故即使两个Error看起来一样,但其实它们是不同的类型
下一步,是builder类型,
type DbResultBuilder() = member this.Bind(m, f) = match m with | Error e -> Error e | Success a -> printfn "\tSuccessful: %A" a f a member this.Return(x) = Success x let dbresult = new DbResultBuilder()
最后我们使用工作流
let product' = dbresult { let! custId = getCustomerId "Alice" let! orderId = getLastOrderForCustomer custId let! productId = getLastProductForOrder orderId printfn "Product is %A" productId return productId } printfn "%A" product'
这一次,每一行的返回值都不同类型(DbResult<CustomerId>, DbResult<OrderId>等),但是因为他们有相同的包装类DbResult,故可以如期望一样正常工作。
最后,给出工作流的一个出错的情况的示例
let product'' = dbresult { let! custId = getCustomerId "Alice" let! orderId = getLastOrderForCustomer (CustomerId "") //error let! productId = getLastProductForOrder orderId printfn "Product is %A" productId return productId } printfn "%A" product''
组合computation expression
我们已经知道每个computation expression都必须要有相应的包装类型。这个包装类型用在Bind和Return中,可以有一个好处:
- Return的输出可以传送给Bind作为输入
或者说,因为工作流返回一个包装类型,并且let!消费一个包装类型,你可以将一个“子”工作流放到let!表达式的右边。
例如,有一个工作流为myworkflow,然后可以写如下代码
let subworkflow1 = myworkflow { return 42 } let subworkflow2 = myworkflow { return 43 } let aWrappedValue = myworkflow { let! unwrappedValue1 = subworkflow1 let! unwrappedValue2 = subworkflow2 return unwrappedValue1 + unwrappedValue2 }
或者以行内的形式运用这个工作流
let aWrappedValue = myworkflow { let! unwrappedValue1 = myworkflow { let! x = myworkflow { return 1 } return x } let! unwrappedValue2 = myworkflow { let! y = myworkflow { return 2 } return y } return unwrappedValue1 + unwrappedValue2 }
如果已经用过async工作流,你可能已经实现过这样的处理,因为async工作流通常包含其他asyncs
let a = async { let! x = doAsyncThing // nested workflow let! y = doNextAsyncThing x // nested workflow return x + y }
介绍“ReturnFrom”
我们已经使用return作为一种包装一个类型并返回这个包装类型的简单方法。
但是,有时候我们的函数已经返回了一个包装类型,我们想直接返回它,return不适合做这个事情,因为它要求一个非包装类型作为输入。
解决方法是采用return!,它采用一个包装类型作为输入并返回这个包装类型。
“builder”类中相应的方法称为ReturnFrom。实现方法通常仅仅是返回这个包装类型(当然,你可以增加额外的代码来实现一些后台逻辑)。
以下是“maybe”工作流的变体,
type MaybeBuilder() = member this.Bind(m, f) = Option.bind f m member this.Return(x) = printfn "Wrapping a raw value into an option" Some x member this.ReturnFrom(m) = printfn "Returning an option directly" m let maybe = new MaybeBuilder()
用法如下,同return比较
// return an int maybe { return 1 } // return an Option maybe { return! (Some 2) }
一个更实际的例子
// using return maybe { let! x = 12 |> divideBy 3 let! y = x |> divideBy 2 return y // return an int } // using return! maybe { let! x = 12 |> divideBy 3 return! x |> divideBy 2 // return an Option }
总结
本篇文章介绍了包装类型以及包装类型与Bind、Return和ReturnFrom方法的关系。
下一篇,我们继续讨论包装类型,包括使用列表作为包装类型。