原文地址:http://fsharpforfunandprofit.com/posts/computation-expressions-wrapper-types-part2/
上一篇中,我们说明了包装类型的概念以及与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" x let stringint = new StringIntBuilder()
现在我们可以尝试使用
let good =
stringint {
let! i = ""
let! j = ""
return i+j
}
printfn "good=%s" good
如果有一个string无效,那么会发生什么
let bad =
stringint {
let! i = ""
let! j = "xxx"
return i+j
}
printfn "bad=%s" bad
看起来不错——在我们的工作流中,将strings看成ints。
但是等下,有问题。
我们给这个工作流一个输入,对输入进行去包装(使用let!),然后立即复包装它(使用return),这其中没有做其他任何事情。会发生什么情况?
let g1 = ""
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 refactoring
myworkflow {
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:如果创建一个子工作流,那它必须产生与主工作流相同的结果,就好像是将逻辑嵌入到主工作流中。
这个原则要求正确组合。
用代码演示则如下
// inlined
let 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
} // rule
assertEqual result1 result2
将“列表”作为包装类型
之前提过List<T>或者IEnumerable<T>可以作为包装类型,但是怎么实现呢?在包装类型和非包装类型之间没有一对一的对应关系。
这正是“包装类型”类比有一点点误导的地方。我们回想一下bind,bind是一种将一个表达式的输出与另一个表达式的输入联系起来的方法。
我们已经看到,bind函数去包装一个类型,然后将continuation函数f 应用到这个去包装后的值上。但是没有任何规定说只能有一个未包装值。没有理由说我们不能依次应用continuation函数到一个list的每一项上。
也就是说,我们能够写一个bind,这个bind的输入参数为由一个列表以及一个continuation函数f 组成的元组,且continuation函数f 每次处理这个列表中的一个元素,如下
bind( [;;], fun elem -> // expression using a single element )
有了这个概念后,我们可以将一些bind链接起来如下
let add =
bind( [;;], fun elem1 ->
bind( [;;], fun elem2 ->
elem1 + elem2
))
但是我们忽略了一些重要的东西。传入bind的continuation函数f 必须要符合某种函数签名,即有一个未包装类型作为输入参数,并产生一个包装类型的输出。
换句话说,continuation函数f 产生的结果必须总是一个新列表(因为类型包装M必须相同,而这里用列表来包装类型)
bind( [;;], fun elem -> // expression using a single element, returning a list )
这样,我们则必须将上面那个链接起来的代码写成如下形式,其中elem1+elem2的结果被放入一个列表中
let add =
bind( [;;], fun elem1 ->
bind( [;;], 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.concat let added =
bind( [;;], fun elem1 ->
bind( [;;], 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 = [;;]
let! j = [;;]
return i+j
}
printfn "added=%A" added let multiplied =
listWorkflow {
let! i = [;;]
let! j = [;;]
return i*j
}
printfn "multiplied=%A" multiplied
结果显示第一个集合中的每个元素,其中第一个集合由第二个集合中的每个元素组成。
val added : int list = [; ; ; ; ; ; ; ; ]
val multiplied : int list = [; ; ; ; ; ; ; ; ]
非常奇妙,我们完全隐藏了列表枚举的逻辑,只暴露了工作流本身。
“for”语法糖
如果将列表和序列特别对待,我们可以增加一个语法糖:用一个更自然的东西代替let!
用for..in..do表达式代替let!
// let version
let! i = [;;] in [some expression] // for..in..do version
for i in [;;] 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 [;;] do
for j in [;;] 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 helpful blog post
“包装类型”本质
本篇我们已经见过很多包装类型了,并且已经说明每个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) = x let identity = new IdentityBuilder() let result = identity {
let! x =
let! y =
return x + y
}
有了这些概念,我们可以知道先前讨论的logging的例子就是一个添加了打印log信息的“identity”工作流。
总结
本篇涵盖了很多主题,希望能对包装类型有个更清楚的认识。我们了解到如何在实际中使用包装类型。
总结一下本篇中的几个关键点:
- computation expression的主要作用是去包装一个类型以及复包装。
- 可以很容易组合computation expression,因为Return的输出匹配Bind的输入,都是包装类型
- 每个computation expression必须有一个相关的包装类型
- 任何带有一个泛型参数的类型都可以被用作包装类型,即使是列表也是如此。
- 当创建工作流时,需要确保工作流的实现满足三个有关包装、去包装以及组合的原则。