【问题标题】:Why is this F# sequence function not tail recursive?为什么这个 F# 序列函数不是尾递归的?
【发布时间】:2011-05-30 20:24:21
【问题描述】:

披露:这出现在我维护的 F# 随机测试框架 FsCheck 中。我有一个解决方案,但我不喜欢它。此外,我不明白这个问题 - 它只是被规避了。

(单子,如果我们要使用大词的话)序列的一个相当标准的实现是:

let sequence l = 
    let k m m' = gen { let! x = m
                       let! xs = m'
                       return (x::xs) }
    List.foldBack k l (gen { return [] })

其中 gen 可以被选择的计算构建器替换。不幸的是,该实现消耗了堆栈空间,因此如果列表足够长,最终堆栈溢出。问题是:为什么?我知道原则上 foldBack 不是尾递归,但 F# 团队的聪明兔子已经在 foldBack 实现中规避了这一点。计算生成器实现有问题吗?

如果我将实现更改为以下,一切都很好:

let sequence l =
    let rec go gs acc size r0 = 
        match gs with
        | [] -> List.rev acc
        | (Gen g)::gs' ->
            let r1,r2 = split r0
            let y = g size r1
            go gs' (y::acc) size r2
    Gen(fun n r -> go l [] n r)

为了完整起见,Gen 类型和计算构建器可以在 in the FsCheck source 找到

【问题讨论】:

    标签: f# tail-recursion tail-call-optimization


    【解决方案1】:

    基于 Tomas 的回答,让我们定义两个模块:

    module Kurt = 
        type Gen<'a> = Gen of (int -> 'a)
    
        let unit x = Gen (fun _ -> x)
    
        let bind k (Gen m) =     
            Gen (fun n ->       
                let (Gen m') = k (m n)       
                m' n)
    
        type GenBuilder() =
            member x.Return(v) = unit v
            member x.Bind(v,f) = bind f v
    
        let gen = GenBuilder()
    
    
    module Tomas =
        type Gen<'a> = Gen of (int -> ('a -> unit) -> unit)
    
        let unit x = Gen (fun _ f -> f x)
    
        let bind k (Gen m) =     
            Gen (fun n f ->       
                m n (fun r ->         
                    let (Gen m') = k r        
                    m' n f))
    
        type GenBuilder() =
            member x.Return v = unit v
            member x.Bind(v,f) = bind f v
    
        let gen = GenBuilder()
    

    为了简化一点,让我们将你原来的序列函数重写为

    let rec sequence = function
    | [] -> gen { return [] }
    | m::ms -> gen {
        let! x = m
        let! xs = sequence ms
        return x::xs }
    

    现在,无论sequence 是根据Kurt.gen 还是Tomas.gen 定义的,sequence [for i in 1 .. 100000 -&gt; unit i] 都将运行完成。问题不在于 sequence 在使用您的定义时导致堆栈溢出,而是从调用 sequence 返回的函数在调用 it 时导致堆栈溢出。

    要了解为什么会这样,让我们​​根据底层的一元操作扩展sequence 的定义:

    let rec sequence = function
    | [] -> unit []
    | m::ms ->
        bind (fun x -> bind (fun xs -> unit (x::xs)) (sequence ms)) m
    

    内联 Kurt.unitKurt.bind 值并疯狂简化,我们得到

    let rec sequence = function
    | [] -> Kurt.Gen(fun _ -> [])
    | (Kurt.Gen m)::ms ->
        Kurt.Gen(fun n ->
                let (Kurt.Gen ms') = sequence ms
                (m n)::(ms' n))
    

    现在希望清楚为什么调用 let (Kurt.Gen f) = sequence [for i in 1 .. 1000000 -&gt; unit i] in f 0 会溢出堆栈:f 需要对结果函数的序列和求值进行非尾递归调用,因此每次递归调用都会有一个堆栈帧。

    Tomas.unitTomas.bind 内联到sequence 的定义中,我们得到以下简化版本:

    let rec sequence = function
    | [] -> Tomas.Gen (fun _ f -> f [])
    | (Tomas.Gen m)::ms ->
        Tomas.Gen(fun n f ->  
            m n (fun r ->
                let (Tomas.Gen ms') = sequence ms
                ms' n (fun rs ->  f (r::rs))))
    

    对此变体的推理很棘手。您可以凭经验验证它不会因某些任意大的输入而破坏堆栈(正如 Tomas 在他的回答中所显示的那样),并且您可以逐步进行评估以说服自己相信这一事实。但是,堆栈消耗取决于传入的列表中的 Gen 实例,对于本身不是尾递归的输入,可能炸毁堆栈:

    // ok
    let (Tomas.Gen f) = sequence [for i in 1 .. 1000000 -> unit i]
    f 0 (fun list -> printfn "%i" list.Length)
    
    // not ok...
    let (Tomas.Gen f) = sequence [for i in 1 .. 1000000 -> Gen(fun _ f -> f i; printfn "%i" i)]
    f 0 (fun list -> printfn "%i" list.Length)
    

    【讨论】:

    • 现在很清楚了。谢谢你写得非常详细。
    【解决方案2】:

    你是对的 - 你得到堆栈溢出的原因是 monad 的 bind 操作需要是尾递归的(因为它用于在折叠期间聚合值)。

    FsCheck 中使用的 monad 本质上是一个状态 monad(它保存当前的生成器和一些数字)。我稍微简化了一下,得到了类似的东西:

    type Gen<'a> = Gen of (int -> 'a)
    
    let unit x = Gen (fun n -> x)
    
    let bind k (Gen m) = 
        Gen (fun n -> 
          let (Gen m') = k (m n) 
          m' n)
    

    这里,bind 函数不是尾递归的,因为它调用k 然后做更多的工作。您可以将 monad 更改为 continuation monad。它被实现为一个接受状态的函数和一个 continuation - 一个以结果作为参数调用的函数。对于这个 monad,您可以使 bind 尾递归:

    type Gen<'a> = Gen of (int -> ('a -> unit) -> unit)
    
    let unit x = Gen (fun n f -> f x)
    
    let bind k (Gen m) = 
        Gen (fun n f -> 
          m n (fun r -> 
            let (Gen m') = k r
            m' n f))
    

    以下示例不会堆栈溢出(在原始实现中也是如此):

    let sequence l = 
      let k m m' = 
        m |> bind (fun x ->
          m' |> bind (fun xs -> 
            unit (x::xs)))
      List.foldBack k l (unit [])
    
    let (Gen f) = sequence [ for i in 1 .. 100000 -> unit i ]
    f 0 (fun list -> printfn "%d" list.Length)
    

    【讨论】:

    • Hmmm....bind 不是尾 recursive 在任何一种情况下,因为它不是递归的开始。此外,在这两种情况下,对Gen 构造函数的调用都处于尾部位置。我认为这个解释是不够的。
    • 非常感谢您收看这位 Tomas。然而,正如 kvb 所说,这提出的问题比它回答的要多。特别是,如果 bind 不是以连续传递样式编写的,计算表达式是否会使编译器失去使用 bind 编写的函数的“尾递归性”?这是否本质上意味着几乎所有现实世界的计算构建器都需要继续传递?
    猜你喜欢
    • 2011-07-13
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2017-01-28
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多