Почему эта функция последовательности F # не является хвостовой рекурсией?

Раскрытие информации: это появилось в FsCheck, платформе случайного тестирования F#, которую я поддерживаю. У меня есть решение, но оно мне не нравится. Более того, я не понимаю проблемы - ее просто обошли.

Довольно стандартная реализация (монадической, если мы собираемся использовать длинные слова) последовательности:

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 и построитель вычислений можно найти в FsПроверить источник


person Kurt Schelfthout    schedule 30.05.2011    source источник


Ответы (2)


Основываясь на ответе Томаса, давайте определим два модуля:

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 [for i in 1 .. 100000 -> unit i] будет выполняться до завершения независимо от того, определено ли sequence в терминах Kurt.gen или Tomas.gen. Проблема не в том, что sequence вызывает переполнение стека при использовании ваших определений, а в том, что функция, возвращаемая вызовом sequence, вызывает переполнение стека при вызове это.

Чтобы понять, почему это так, давайте расширим определение sequence с точки зрения лежащих в его основе монадических операций:

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

Вставив значения Kurt.unit и Kurt.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 -> unit i] in f 0 переполняет стек: f требует не-хвостового рекурсивного вызова для упорядочивания и оценки результирующей функции, поэтому для каждого рекурсивного вызова будет один кадр стека.

Вместо этого, вставив Tomas.unit и Tomas.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))))

Рассуждать об этом варианте сложно. Вы можете эмпирически убедиться, что он не взорвет стек для некоторых произвольно больших входных данных (как показывает Томас в своем ответе), и вы можете выполнить оценку, чтобы убедиться в этом факте. Однако потребление стека зависит от 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)
person kvb    schedule 07.07.2011
comment
Теперь это совершенно ясно. Спасибо за очень подробную запись. - person Kurt Schelfthout; 08.07.2011

Вы правы - причина, по которой вы получаете переполнение стека, заключается в том, что операция bind монады должна быть хвостовой рекурсией (поскольку она используется для агрегирования значений во время свертывания).

Монада, используемая в FsCheck, по сути является монадой состояния (она хранит текущий генератор и некоторое число). Я немного упростил и получил что-то вроде:

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, а затем выполняет дополнительную работу. Вы можете изменить монаду на монаду-продолжение. Он реализован как функция, которая принимает состояние и продолжение — функцию, которая вызывается с результатом в качестве аргумента. Для этой монады можно сделать хвост 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)
person Tomas Petricek    schedule 30.05.2011
comment
Хммм.... bind не является хвостовым рекурсивным в любом случае, поскольку он не рекурсивен с самого начала. Кроме того, в обоих случаях вызов конструктора Gen находится в хвостовой позиции. Я не думаю, что этого объяснения достаточно. - person kvb; 31.05.2011
comment
Большое спасибо за то, что посмотрели на этого Томаса. Однако, как говорит kvb, это вызывает немного больше вопросов, чем дает ответов. В частности, есть ли что-то в вычислительных выражениях, из-за чего компилятор теряет хвостовую рекурсию функций, написанных с использованием связывания, если связывание не написано в стиле передачи продолжения? Означает ли это, что почти любой построитель вычислений в реальном мире должен передавать продолжение? - person Kurt Schelfthout; 31.05.2011