Рекурсия по списку s-выражений в Clojure

чтобы установить некоторый контекст, я нахожусь в процессе обучения Clojure и развития Lisp в целом. На моем пути к Lisp я в настоящее время работаю над "маленькой" серией, пытаясь укрепить фундамент в функциональном программировании и решении рекурсивных решений. В "маленьком Интригане" я проработал многие упражнения, однако, я немного борюсь, чтобы преобразовать некоторые из них в Clojure. Более конкретно, я изо всех сил пытаюсь преобразовать их в использование "recur", чтобы включить TCO. Например, вот реализация на основе Clojure для функции" occurs* " (от Little Schemer), которая подсчитывает количество вхождений атома, появляющегося в списке S-выражений:

(defn atom? [l]
  (not (list? l)))

(defn occurs [a lst]
  (cond
   (empty? lst) 0
   (atom? (first lst))
    (cond
     (= a (first lst)) (inc (occurs a (rest lst)))
     true (occurs a (rest lst)))
   true (+ (occurs a (first lst))
           (occurs a (rest lst)))))

по сути, (occurs 'abc '(abc (def abc) (abc (abc def) (def (((((abc))))))))) будет оценивать до 5. Очевидная проблема заключается в том, что это определение потребляет кадры стека и взорвет стек, если список S-выражений слишком глубок.

Теперь я понимаю возможность рефакторинга рекурсивных функций для использования параметр accumulator для включения рекурсивного вызова в хвостовое положение (чтобы разрешить TCO), но я борюсь, если этот параметр применим даже к таким ситуациям, как этот.

вот насколько я понимаю, если я пытаюсь рефакторинг это с помощью "повторения" вместе с помощью параметра аккумулятора:

(defn recur-occurs [a lst]
  (letfn [(myoccurs [a lst count]
            (cond
             (empty? lst) 0
             (atom? (first lst))
             (cond
              (= a (first lst)) (recur a (rest lst) (inc count))
              true (recur a (rest lst) count))
             true (+ (recur a (first lst) count)
                     (recur a (rest lst) count))))]
    (myoccurs a lst 0)))

Так, я чувствую, что я почти там, но не совсем. Очевидной проблемой является мое предложение "else", в котором глава списка не является атомом. Концептуально я хочу суммируйте результат повторения над первым элементом списка с результатом повторения над остальной частью списка. Я борюсь в моей голове, о том, как реструктурировать это такое, что повторяется, может быть перемещен в положение хвост.

есть ли дополнительные методы для шаблона "аккумулятор" для достижения того, чтобы ваши рекурсивные вызовы помещались в хвостовое положение, которое я должен применять здесь, или проблема просто более "фундаментальная" и что нет чистого Clojure-based решение из-за отсутствия TCO в JVM? Если последнее, вообще говоря, каким должен быть общий шаблон для использования программ Clojure, которые должны повторяться над списком s-выражений? Для чего это стоит, я видел метод multi w / lazy-seq, используемый (страница 151 "Programming Clojure" Хэллоуэя для справки), чтобы "заменить рекурсию ленью", но я не уверен, как применить этот шаблон к этому примеру, в котором я не пытаюсь построить список, а вычислить одно целое число значение.

заранее Благодарю за любые рекомендации по этому поводу.

2 ответов


во-первых, я должен посоветовать вам не беспокоиться о проблемах реализации, таких как переполнение стека, когда вы пробираетесь через маленький Схематик. Хорошо быть добросовестным в таких вопросах, как отсутствие оптимизации хвостового вызова, когда вы программируете в гневе, но главный смысл книги-научить вас думать рекурсивно. Преобразование примеров стиля передачи аккумуляторов, безусловно, хорошая практика, но это по существу отбрасывание рекурсии в пользу итерация.

однако, и я должен предварить это предупреждением спойлера, есть способ сохранить тот же рекурсивный алгоритм, не подвергаясь капризам стека JVM. Мы можем использовать стиль продолжения, чтобы сделать наш собственный стек в виде дополнительного анонимного аргумента функции k:

(defn occurs-cps [a lst k]
  (cond
   (empty? lst) (k 0) 
   (atom? (first lst))
   (cond
    (= a (first lst)) (occurs-cps a (rest lst)
                                  (fn [v] (k (inc v))))
    :else (occurs-cps a (rest lst) k))
   :else (occurs-cps a (first lst)
                     (fn [fst]
                       (occurs-cps a (rest lst)
                                   (fn [rst] (k (+ fst rst))))))))

вместо стека, создаваемого неявно нашими вызовами функции non-tail, мы связываем "то, что осталось сделать" после каждого вызова occurs, и передать его вместе как следующее продолжение k. Когда мы вызываем его, мы начинаем с k что представляет собой ничего не осталось делать, функция идентификации:

scratch.core=> (occurs-cps 'abc 
                           '(abc (def abc) (abc (abc def) (def (((((abc)))))))) 
                           (fn [v] v))
5

я не буду вдаваться в подробности того, как делать CPS, так как это для более поздней главы TLS. Тем не менее, я отмечу, что это, конечно, еще не работает полностью:

scratch.core=> (def ls (repeat 20000 'foo))          
#'scratch.core/ls
scratch.core=> (occurs-cps 'foo ls (fn [v] v))       
java.lang.StackOverflowError (NO_SOURCE_FILE:0)

CPS позволяет нам перемещать все наши нетривиальные вызовы стека в хвостовое положение, но в Clojure нам нужно сделать дополнительный шаг заменив их на recur:

(defn occurs-cps-recur [a lst k]
  (cond
   (empty? lst) (k 0)
   (atom? (first lst))
   (cond
    (= a (first lst)) (recur a (rest lst)
                             (fn [v] (k (inc v))))
    :else (recur a (rest lst) k))
   :else (recur a (first lst)
                (fn [fst]
                  (recur a (rest lst) ;; Problem
                         (fn [rst] (k (+ fst rst))))))))

увы, это не так: java.lang.IllegalArgumentException: Mismatched argument count to recur, expected: 1 args, got: 3 (core.clj:39). Самый последний recur на самом деле относится к fn прямо над ним, тот, который мы используем, чтобы представить наши продолжения! Мы можем получить хорошее поведение большую часть времени, изменив только это recur на вызов occurs-cps-recur, но патологически-вложенный вход все равно переполнит стек:

scratch.core=> (occurs-cps-recur 'foo ls (fn [v] v))
20000
scratch.core=> (def nested (reduce (fn [onion _] (list onion)) 
                                   'foo (range 20000)))
#'scratch.core/nested
scratch.core=> (occurs-cps-recur 'foo nested (fn [v] v))
Java.lang.StackOverflowError (NO_SOURCE_FILE:0)

вместо того, чтобы позвонить в occurs-* и ожидая, что он даст ответ, мы можем пусть он немедленно ответит ударом. Когда мы вызываем этот удар, он срабатывает и выполняет некоторую работу вплоть до рекурсивного вызова, который, в свою очередь, возвращает другой удар. Это батутный стиль, и функция, которая "отскакивает" от наших ударов, -trampoline. Возврат thunk каждый раз, когда мы делаем рекурсивный вызов, ограничивает наш размер стека одним вызовом за раз, поэтому наш единственный предел-куча:

(defn occurs-cps-tramp [a lst k]
  (fn [] 
    (cond
     (empty? lst) (k 0) 
     (atom? (first lst))
     (cond
      (= a (first lst)) (occurs-cps-tramp a (rest lst)
                                          (fn [v] (k (inc v))))
      :else (occurs-cps-tramp a (rest lst) k))
     :else (occurs-cps-tramp a (first lst)
                             (fn [fst]
                               (occurs-cps-tramp a (rest lst)
                                                 (fn [rst] (k (+ fst rst)))))))))

(declare done answer)

(defn my-trampoline [th]
  (if done
    answer
    (recur (th))))

(defn empty-k [v]
  (set! answer v)
  (set! done true))

(defn run []
  (binding [done false answer 'whocares]
    (my-trampoline (occurs-cps-tramp 'foo nested empty-k))))

;; scratch.core=> (run)                             
;; 1

обратите внимание, что Clojure имеет встроенный trampoline (С ограничения на тип возврата). Используя это вместо этого, нам не нужен специализированный empty-k:

scratch.core=> (trampoline (occurs-cps-tramp 'foo nested (fn [v] v)))
1

батут, безусловно, классная техника, но предпосылкой для батута программы является то, что она должна содержать только хвостовые вызовы; CPS-настоящая звезда здесь. Он позволяет определить ваш алгоритм с ясностью естественной рекурсии и с помощью преобразований, сохраняющих корректность, эффективно выразить его на любом хосте, который имеет один цикл и кучу.


вы не можете сделать это с фиксированным объемом памяти. Вы можете потреблять стек или кучу; это решение, которое вы должны принять. Если бы я писал это в Clojure, я бы сделал это с map и reduce вместо ручной рекурсии:

(defn occurs [x coll]
  (if (coll? coll)
    (reduce + (map #(occurs x %) coll))
    (if (= x coll)
      1, 0)))

обратите внимание, что более короткие решения существуют, если вы используете tree-seq или flatten, но в этот момент большая часть проблемы ушла, так что не так много, чтобы узнать.

редактировать

вот версия, которая не использует стек, вместо этого позволяя своей очереди становиться все больше и больше (используя кучу).

(defn heap-occurs [item coll]
  (loop [count 0, queue coll]
    (if-let [[x & xs] (seq queue)]
      (if (coll? x)
        (recur count (concat x xs))
        (recur (+ (if (= item x) 1, 0)
                  count)
               xs))
      count)))