原文地址:
上一篇中,我们说明了包装类型的概念以及与computation expression的关系。在这一篇中,我们将介绍什么类型是合适的包装类型。
什么样的类型可以是包装类型?
每个computation expression必须要有相应的包装类型,那么什么样的类型可以作为包装类型呢?对包装类型是否有特殊的限制?
有一个通用的原则为:
- 任何带有泛型参数的类型均可以用作包装类型
例如,你可以使用Option<T>, DbResult<T>等作为包装类型。也可以使用限制了类型参数的包装类型,如Vector<int>。
但是对于其他泛型类型如List<T>或者IEnumerable<T>如何呢?事实上,它们也可以被用作包装类型,我们一会就可以看到。
非泛型包装类型是否可行?
是否可以使用一个不带泛型参数的包装类型?
例如,在以前的例子中我们见过一个string上的加法,如"1" + "2"。我们不能聪明地将string看成一个int的包装类型吗?这很酷,是吧
我们来试一试,可是借助Bind和Return的签名帮助我们来实现。
- Bind函数输入为一个元组。元组的第一部分是一个包装类型(这个例子中是string),第二部分是一个函数,这个函数以一个非包装类型作为输入,并将输入转变为一个包装类型(T -> M<U>)。这个例子中,函数签名是int -> string。
- Return以一个非包装类型作为输入(这个例子中为int)并将输入转变为一个包装类型,这个例子中,Return签名为int -> string。
以上函数签名如何指导实现过程?
“包装”函数的实现,int -> string,是很简单的,就是int类型的“toString”方法。
Bind函数必须去包装一个string为一个int,然后将这个int传入continuation函数f ,我们可以使用int.Parse函数实现这个去包装操作。
如果Bind函数无法对一个string去包装,因为这个string不是一个有效数字,此时如何处理?这种情况下,绑定函数必须仍然返回一个包装类型(这里是string),所以我们可以只返回一个string如“error”。
builder类的实现如下
type StringIntBuilder() = member this.Bind(m, f) = let b,i = System.Int32.TryParse(m) match b,i with | false,_ -> "error" | true,i -> f i member this.Return(x) = sprintf "%i" xlet stringint = new StringIntBuilder()
现在我们可以尝试使用
let good = stringint { let! i = "42" let! j = "43" return i+j }printfn "good=%s" good
如果有一个string无效,那么会发生什么
let bad = stringint { let! i = "42" let! j = "xxx" return i+j }printfn "bad=%s" bad
看起来不错——在我们的工作流中,将strings看成ints。
但是等下,有问题。
我们给这个工作流一个输入,对输入进行去包装(使用let!),然后立即复包装它(使用return),这其中没有做其他任何事情。会发生什么情况?
let g1 = "99"let g2 = stringint { let! i = g1 return i }printfn "g1=%s g2=%s" g1 g2
以上这段代码没有问题。输入g1和输出g2是相同的值,如我们所期望一样。
但是如果是字符串转换为int时发生错误的情况呢?
let b1 = "xxx"let b2 = stringint { let! i = b1 return i }printfn "b1=%s b2=%s" b1 b2
这种情况下,我们得到一个跟期望不同的行为。输入b1和输出b2不是相同的值。我们引入了不一致问题。
这在实际中会是一个问题吗?我不清楚,但是我将避免它,使用一个不同的方法,如options,在所有情况都是一致的。
工作流使用包装类型的原则
有个问题,如下代码所示,这两段代码有什么不同,它们的行为是否不同?
// fragment before refactoringmyworkflow { let wrapped = // some wrapped value let! unwrapped = wrapped return unwrapped } // refactored fragment myworkflow { let wrapped = // some wrapped value return! wrapped }
答案是否定的,即它们的行为不应该不同。唯一的不同是在第二个例子中,unwrapped值已经被重构了,直接返回wrapped值。
但是正如我们在前一小节中所见,如果不小心则会引入不一致问题。故,任何一种实现都应该遵循一些标准原则,总结如下:
原则1:如果以一个非包装类型值开始,然后包装它(使用return),然后去包装它(使用bind),那么总是可以回到初始的非包装类型值
这个原则以及下一个原则的关注点为:当包装和去包装值的时候不会丢失信息。
用代码表示这个原则如下
myworkflow { let originalUnwrapped = something // wrap it let wrapped = myworkflow { return originalUnwrapped } // unwrap it let! newUnwrapped = wrapped // assert they are the same assertEqual newUnwrapped originalUnwrapped }
原则2: 如果以一个包装类型值开始,然后去包装这个值(使用bind),然后包装它(使用return),则总是可以回到初始的包装类型值。
这个原则跟上面的stringInt工作流一致。
用代码表示则如下
myworkflow { let originalWrapped = something let newWrapped = myworkflow { // unwrap it let! unwrapped = originalWrapped // wrap it return unwrapped } // assert they are the same assertEqual newWrapped originalWrapped }
原则3:如果创建一个子工作流,那它必须产生与主工作流相同的结果,就好像是将逻辑嵌入到主工作流中。
这个原则要求正确组合。
用代码演示则如下
// inlinedlet result1 = myworkflow { let! x = originalWrapped let! y = f x // some function on x return! g y // some function on y }// using a child workflow ("extraction" refactoring)let result2 = myworkflow { let! y = myworkflow { let! x = originalWrapped return! f x // some function on x } return! g y // some function on y }// ruleassertEqual result1 result2
将“列表”作为包装类型
之前提过List<T>或者IEnumerable<T>可以作为包装类型,但是怎么实现呢?在包装类型和非包装类型之间没有一对一的对应关系。
这正是“包装类型”类比有一点点误导的地方。我们回想一下bind,bind是一种将一个表达式的输出与另一个表达式的输入联系起来的方法。
我们已经看到,bind函数去包装一个类型,然后将continuation函数f 应用到这个去包装后的值上。但是没有任何规定说只能有一个未包装值。没有理由说我们不能依次应用continuation函数到一个list的每一项上。
也就是说,我们能够写一个bind,这个bind的输入参数为由一个列表以及一个continuation函数f 组成的元组,且continuation函数f 每次处理这个列表中的一个元素,如下
bind( [1;2;3], fun elem -> // expression using a single element )
有了这个概念后,我们可以将一些bind链接起来如下
let add = bind( [1;2;3], fun elem1 -> bind( [10;11;12], fun elem2 -> elem1 + elem2 ))
但是我们忽略了一些重要的东西。传入bind的continuation函数f 必须要符合某种函数签名,即有一个未包装类型作为输入参数,并产生一个包装类型的输出。
换句话说,continuation函数f 产生的结果必须总是一个新列表(因为类型包装M必须相同,而这里用列表来包装类型)
bind( [1;2;3], fun elem -> // expression using a single element, returning a list )
这样,我们则必须将上面那个链接起来的代码写成如下形式,其中elem1+elem2的结果被放入一个列表中
let add = bind( [1;2;3], fun elem1 -> bind( [10;11;12], fun elem2 -> [elem1 + elem2] // a list! ))
所以我们bind方法的逻辑类似这样
let bind(list,f) = // 1) for each element in list, apply f // 2) f will return a list (as required by its signature) // 3) the result is a list of lists
现在又已经导致另一个问题了。因为continuation函数f 必须返回一个列表类型,而对作为输入参数的列表的每个元素应用函数f,则产生一个“列表的列表”,“列表的列表”不好,我们需要将它们转成简单的一阶列表。
不过这已经很简单了,因为已经有一个模块函数能做到,即concat
故将以上相关代码放到一起,我们有
let bind(list,f) = list |> List.map f |> List.concatlet added = bind( [1;2;3], fun elem1 -> bind( [10;11;12], fun elem2 -> // elem1 + elem2 // error. [elem1 + elem2] // correctly returns a list. ))
现在我们知道了bind工作机制,就能够自己创建一个“列表工作流”
- Bind对传入的列表的每一个元素应用continuation函数f,然后将“列表的列表”展平,得到一个一阶列表。List.collect就是一个能做到如此的库函数。
- Return将未包装类型转为包装类型。这意味着将返回值包装成列表。
type ListWorkflowBuilder() = member this.Bind(list, f) = list |> List.collect f member this.Return(x) = [x]let listWorkflow = new ListWorkflowBuilder()
let added = listWorkflow { let! i = [1;2;3] let! j = [10;11;12] return i+j }printfn "added=%A" addedlet multiplied = listWorkflow { let! i = [1;2;3] let! j = [10;11;12] return i*j }printfn "multiplied=%A" multiplied
结果显示第一个集合中的每个元素,其中第一个集合由第二个集合中的每个元素组成。
val added : int list = [11; 12; 13; 12; 13; 14; 13; 14; 15]val multiplied : int list = [10; 11; 12; 20; 22; 24; 30; 33; 36]
非常奇妙,我们完全隐藏了列表枚举的逻辑,只暴露了工作流本身。
“for”语法糖
如果将列表和序列特别对待,我们可以增加一个语法糖:用一个更自然的东西代替let!
用for..in..do表达式代替let!
// let versionlet! i = [1;2;3] in [some expression]// for..in..do versionfor i in [1;2;3] do [some expression]
为了让F#编译器能做到这点,我们需要增加一个For方法到我们到build类。For方法与一般的Bind方法的实现相同,但是要求接收一个序列类型(Bind函数对包装类型则没有限制为序列类型)
type ListWorkflowBuilder() = member this.Bind(list, f) = list |> List.collect f member this.Return(x) = [x] member this.For(list, f) = this.Bind(list, f)let listWorkflow = new ListWorkflowBuilder()
以下是使用方法
let multiplied = listWorkflow { for i in [1;2;3] do for j in [10;11;12] do return i*j }printfn "multiplied=%A" multiplied
LINQ和“list工作流”
这个 for element in collection do 看起熟悉吗?它非常接近于LINQ的from element in collection...语法。事实上,LINQ使用基本相同的方法在后台实现将一个查询表达式如from element in collection... 转为实际的调用方法。
F#中,bind使用 形如 List.collect函数。LINQ中与List.collect等价的是 SelectMany扩展方法。如果知道SelectMany的工作原理,就可以实现相同的查询。参见Jon Skeet的博客 a
“包装类型”本质
本篇我们已经见过很多包装类型了,并且已经说明每个computation expression必须有相对应的包装类型。但是,还记得一开始的那个logging例子吗?那个例子中没有包装类型,有let!在后台执行的逻辑,但是输入类型与输出类型相同,类型没有被改变。
简单来说,可以将任意类型看作是自身的包装类型,但是,也可以从一个 更深的层次理解这一点。
让我们回过头去考虑一下包装类型如List<T>到底是什么。
如果有一个类型如List<T>,实际上这个类型不是一个真正的类型。List<int>是真正的类型,List<string>也是真正的类型,但是List<T>本身是不完整的,它缺少一个能变成真正类型的参数。
一种方法是将List<T>看成一个函数,而不是一个类型,它是类型的抽象世界的一个函数,而不是值的具体世界的一个函数,但是正如那些将一个值映射到另一个值的函数一样,List<T>,其输入为类型(如int或者string),输出为其他类型(如List<int>或List<string>)。List<T>跟其他函数一样,它有一个参数,即“类型参数”,.net开发者所谓的泛型在计算机科学就是“参数多态”。
一旦我们掌握了函数的概念,即,从一个类型产生另一个类型(称为”类型构造器“),就可以明白当说一个包装类型时,我们指的是一个类型构造器。
但是,如果包装类型仅仅是一个函数,它将一个类型映射到另一个类型,那么,可以确定将一个类型映射到同样的类型的函数也符合吗?嗯,没错。“identity”函数符合我们的定义,可以被用作computation expression的包装类型。
回到代码中来,我们可以定义一个“identity”工作流,它非常简单
type IdentityBuilder() = member this.Bind(m, f) = f m member this.Return(x) = x member this.ReturnFrom(x) = xlet identity = new IdentityBuilder()let result = identity { let! x = 1 let! y = 2 return x + y }
有了这些概念,我们可以知道先前讨论的logging的例子就是一个添加了打印log信息的“identity”工作流。
总结
本篇涵盖了很多主题,希望能对包装类型有个更清楚的认识。我们了解到如何在实际中使用包装类型。
总结一下本篇中的几个关键点:
- computation expression的主要作用是去包装一个类型以及复包装。
- 可以很容易组合computation expression,因为Return的输出匹配Bind的输入,都是包装类型
- 每个computation expression必须有一个相关的包装类型
- 任何带有一个泛型参数的类型都可以被用作包装类型,即使是列表也是如此。
- 当创建工作流时,需要确保工作流的实现满足三个有关包装、去包装以及组合的原则。