Рекурсия по списку s-выражений в Clojure - PullRequest
13 голосов
/ 08 ноября 2011

Чтобы установить некоторый контекст, я нахожусь в процессе изучения Clojure и разработки Lisp в целом. На моем пути к Лиспу я в настоящее время прорабатываю серию «Little», чтобы укрепить основы функционального программирования и рекурсивного решения. В «Маленьком интриганке» я проработал многие из упражнений, однако, я изо всех сил пытаюсь преобразовать некоторые из них в Clojure. Точнее говоря, я изо всех сил пытаюсь преобразовать их в «recur», чтобы включить TCO. Например, вот реализация на основе Clojure для функции «происшествия *» (из 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-выражений слишком глубоко.

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

Вот как далеко я доберусь, если я попытаюсь провести рефакторинг с использованием «recur» вместе с параметром аккумулятора:

(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 решение из-за отсутствия TCO у JVM? Если последнее, вообще говоря, каков должен быть общий шаблон для использования программами Clojure, который должен повторяться в списке S-выражений? Что бы это ни стоило, я видел метод multi / w / lazy-seq (см. Стр. 151 в Halloway's Programming Clojure) для «Заменить рекурсию на лень» - но я не уверен, как применить этот шаблон к этому примеру, в котором я не пытаюсь составить список, но вычисляю одно целое значение.

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

Ответы [ 2 ]

11 голосов
/ 08 ноября 2011

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

Однако, и я должен предварять это предупреждением о спойлере, есть способ сохранить тот же рекурсивный алгоритм, не подчиняясь прихотям стека 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))))))))

Вместо того, чтобы стек неявно создавался нашими вызовами нехвостых функций, мы связываем «то, что осталось сделать» после каждого вызова 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-* и ожидать, что он даст ответ, мы можем заставить его немедленно вернуть thunk. Когда мы вызываем этот блок, он отключается и выполняет некоторую работу вплоть до рекурсивного вызова, который, в свою очередь, возвращает другой блок. Это батутный стиль, и функция, которая «отскакивает» от наших громов, - 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 - настоящая звезда здесь. Он позволяет вам определить свой алгоритм с ясностью естественной рекурсии и с помощью преобразований, сохраняющих корректность, эффективно выразить его на любом хосте, который имеет один цикл и кучу.

7 голосов
/ 08 ноября 2011

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

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

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

Edit

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

(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)))
...