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

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

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

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

(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 из-за отсутствия совокупной стоимости владения JVM? Если последнее, вообще говоря, каким должен быть общий шаблон для программ Clojure, которые должны повторяться по списку S-выражений? Как бы то ни было, я видел, как использовался метод multi method w / lazy-seq (стр. 151 книги Halloway "Programming Clojure" для справки) для "Replace Recursion with Laziness" - но я не уверен, как применить этот шаблон в этом примере, в котором я не пытаюсь создать список, а вычисляю одно целое значение.

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


person Paul Evans    schedule 08.11.2011    source источник
comment
Чтобы прояснить ситуацию, я не верю, что код, представленный в Little Schemer для происходит *, может быть оптимизирован для хвостового вызова в Scheme.   -  person dnolen    schedule 08.11.2011


Ответы (2)


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

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

person acfoltzer    schedule 08.11.2011
comment
Большое спасибо за столь подробный ответ. И да, я на 100% согласен с комментарием о том, что не нужно беспокоиться о том, чтобы детали JVM мешали изучению концепций (мышление на основе рекурсии) из TLS. На высоком уровне я понимаю ваше решение (э-э, шаблон) - хотя я признаю, что, поскольку я еще не копался в CPS (и в том, что касается батута), мне понадобится время, чтобы полностью обернуть голову вокруг вашего кода :) С учетом сказанного, хорошо знать, что в Clojure есть возможность создавать решения, которые соответствуют урокам TLS. - person Paul Evans; 08.11.2011
comment
Рад помочь. Во многих смыслах TLS и его братья и сестры предоставляют вам инструменты для использования уроков в любой среде. Итак, ваш вопрос довольно четкий (я, кстати, поделился этой страницей с Дэном, который охарактеризовал ее как «Круто; очень круто»). - person acfoltzer; 08.11.2011

Вы не можете сделать это с фиксированным объемом памяти. Вы можете использовать стек или кучу; это решение, которое вы должны принять. Если бы я писал это на 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)))
person amalloy    schedule 08.11.2011
comment
Аааа - это решение имеет смысл и по-прежнему отражает суть урока Little Schemer (алгоритм, по сути, тот же). Так будет ли справедливо сказать, что этот бит представляет собой решение использовать кучу (вместо стека)? Еще раз спасибо. - person Paul Evans; 08.11.2011
comment
Честно говоря, иногда сложно рассуждать о ленивых последовательностях, и вас можно простить за то, что вы ошиблись. Этот алгоритм фактически потребляет стек, потому что каждый уровень map может полностью вернуться только тогда, когда уровень ниже него будет завершен. Например, (occurs 'x (nth (iterate list 'x) 1000)) не хватает места в стеке. Изменить: на моей машине, во всяком случае. - person amalloy; 08.11.2011
comment
@PaulEvans Я добавил версию, которая вместо этого использует кучу - как это часто бывает, за ней труднее следить, потому что вы, по сути, управляете своим собственным стеком и сохраняете его в куче. - person amalloy; 08.11.2011
comment
Спасибо за разъяснения по вариантам использования стека и кучи. Это очень полезно и обязательно поможет преобразовать решения Little Schemer в Clojure. - person Paul Evans; 08.11.2011