More on wrapper types

原文地址: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 = "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 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( [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.concat

let 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" added

let 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 version
let! i = [1;2;3] in [some expression]

// for..in..do version
for 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 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 = 1
    let! y = 2
    return x + y
    } 

有了这些概念,我们可以知道先前讨论的logging的例子就是一个添加了打印log信息的“identity”工作流。

总结

本篇涵盖了很多主题,希望能对包装类型有个更清楚的认识。我们了解到如何在实际中使用包装类型。

总结一下本篇中的几个关键点:

  • computation expression的主要作用是去包装一个类型以及复包装。
  • 可以很容易组合computation expression,因为Return的输出匹配Bind的输入,都是包装类型
  • 每个computation expression必须有一个相关的包装类型
  • 任何带有一个泛型参数的类型都可以被用作包装类型,即使是列表也是如此。
  • 当创建工作流时,需要确保工作流的实现满足三个有关包装、去包装以及组合的原则。
posted @ 2015-11-26 11:33  gaoshoufenmu  阅读(262)  评论(0编辑  收藏  举报