Как ленивые последовательности реализованы в Clojure? - PullRequest
29 голосов
/ 14 июля 2010

Мне нравится Clojure. Что меня беспокоит в языке, так это то, что я не знаю, как реализованы ленивые последовательности или как они работают.

Я знаю, что ленивые последовательности оценивают только те элементы в запрашиваемой последовательности. Как это сделать?

  • Что делает ленивые последовательности настолько эффективными, что они не потребляют много стек
  • Почему вы можете обернуть рекурсивные вызовы в ленивую последовательность и нет больше получить стек над потоком для больших вычислений?
  • Какие ресурсы потребляют ленивые последовательности, чтобы делать то, что делает?
  • В каких случаях ленивые последовательности неэффективны?
  • В каких ситуациях ленивые последовательности наиболее эффективны?

Ответы [ 3 ]

32 голосов
/ 14 июля 2010

Давайте сделаем это.

Я знаю, что ленивые последовательности оценивают только элементы в запрашиваемой последовательности, как это происходит?

Ленивые последовательности (отныне LS, потому что я LP или Lazy Person) состоят из частей. Голова , или часть (и), поскольку на самом деле 32 элемента оцениваются одновременно, начиная с Clojure 1.1 и, я думаю, 1.2) последовательности, которая была оценена, сопровождается чем-то, называемым thunk , который в основном представляет собой порцию информации (воспринимайте ее как остальную часть вашей функции, которая создает последовательность, без оценки), ожидающую вызова . Когда он вызывается, thunk оценивает, сколько от него требуется, и создается новый thunk с контекстом по мере необходимости (сколько уже было вызвано, чтобы он мог возобновить с того места, где он был раньше).

Итак, вы (take 10 (whole-numbers)) - предположим, whole-numbers - это ленивая последовательность целых чисел. Это означает, что вы заставляете оценку thunks 10 раз (хотя внутренне это может быть небольшой разницей в зависимости от оптимизации.

Что делает ленивые последовательности настолько эффективными, что они не потребляют много стека?

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

Если последовательность не ленива, часто она держится за голову, которая занимает кучу места. Если он ленив, он вычисляется, а затем отбрасывается, поскольку он не требуется для последующих вычислений.

Почему вы можете обернуть рекурсивные вызовы в ленивую последовательность и больше не получать стек поверх потока для больших вычислений?

См. Предыдущий ответ и учтите: макрос lazy-seq ( из документации ) будет

will invoke the body only the first time seq
is called, and will cache the result and return it on all subsequent
seq calls.

Проверьте функцию filter для классного LS, который использует рекурсию:

(defn filter
  "Returns a lazy sequence of the items in coll for which
  (pred item) returns true. pred must be free of side-effects."
  [pred coll]
  (let [step (fn [p c]
                 (when-let [s (seq c)]
                   (if (p (first s))
                     (cons (first s) (filter p (rest s)))
                     (recur p (rest s)))))]
    (lazy-seq (step pred coll))))

Какие ресурсы ленивые последовательности потребляют для выполнения своих задач?

Я не совсем уверен, что вы спрашиваете здесь. LS требуют памяти и циклов процессора. Они просто не бьют по стеку и не заполняют его результатами вычислений, необходимых для получения элементов последовательности.

В каких случаях ленивые последовательности неэффективны?

Когда вы используете небольшие последовательности, которые быстро вычисляются и не будут использоваться часто, то делать его LS неэффективно, потому что для его создания требуется еще пара символов.

На полном серьезе, , если вы не пытаетесь сделать что-то чрезвычайно работоспособным, ЛС - это путь.

В каких случаях ленивые последовательности наиболее эффективны?

Когда вы имеете дело с огромными секвенциями и используете их только по частям, именно тогда вы получаете наибольшую выгоду от их использования.

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

16 голосов
/ 14 июля 2010

Я знаю, что ленивые последовательности оценивают только элементы в запрашиваемой последовательности, как это происходит?

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

Я знаю, что ленивые последовательности оценивают только те элементы в запрашиваемой последовательности, как это происходит?

Что делает ленивые последовательности настолько эффективными, что они не потребляют много стека?

Какие ресурсы потребляют ленивые последовательности, чтобы делать то, что делает?

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

Почему вы можете обернуть рекурсивные вызовы в ленивую последовательность и больше не получать переполнение стека для больших вычислений?

Во-первых, как упомянул dbyrne, вы можете очень хорошо получить SO при работе с ленивыми последовательностями, если самим thunks нужно выполнить код с очень глубоко вложенной структурой вызовов.

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

Ключевое понимание заключается в том, что ленивая последовательность - это объект, который при первом создании не содержит никаких элементов (как всегда делает строгая последовательность); когда функция возвращает ленивую последовательность, только этот «ленивый объект последовательности» возвращается вызывающей стороне, прежде чем произойдет какое-либо форсирование. Таким образом, кадр стека, используемый вызовом, который возвратил ленивую последовательность, выталкивается до того, как произойдет любое форсирование. Давайте посмотрим на пример функции производителя:

(defn foo-producer [] ; not tail recursive...
  (lazy-seq
    (cons :foo        ; because it returns the value of the cons call...
           (foo-producer)))) ; which wraps a non-tail self-call

Это работает, потому что lazy-seq возвращает немедленно , таким образом, (cons :foo (foo-producer)) также немедленно возвращается, и кадр стека, используемый внешним вызовом foo-producer, немедленно извлекается. Внутренний вызов foo-producer скрыт в части rest последовательности, которая является thunk; если / когда этот thunk форсирован, он ненадолго израсходует свой собственный кадр в стеке, но затем немедленно вернется, как описано выше и т. д.

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

В каких случаях ленивые последовательности неэффективны?

В каких случаях ленивые последовательности наиболее эффективны?

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

Однако ленивые последовательности включают конвейерный режим обработки данных:

(->> (lazy-seq-producer)               ; possibly (->> (range)
     (a-lazy-seq-transformer-function) ;               (filter even?)
     (another-transformer-function))   ;               (map inc))

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

8 голосов
/ 14 июля 2010

Первоначально, ленивые последовательности в Clojure оценивались по пунктам, так как они были необходимы.Фрагментированные последовательности были добавлены в Clojure 1.1 для улучшения производительности.Вместо поэтапной оценки «порции» 32 элементов оцениваются одновременно.Это уменьшает накладные расходы, которые несет ленивая оценка.Кроме того, это позволяет clojure использовать преимущества базовых структур данных.Например, PersistentVector реализован в виде дерева из 32 массивов элементов.Это означает, что для доступа к элементу вы должны пройти по дереву, пока не будет найден соответствующий массив.При использовании фрагментированных последовательностей целые массивы захватываются за раз.Это означает, что каждый из 32 элементов может быть извлечен до того, как дерево должно быть повторно пройдено.

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

Почему вы можете обернуть рекурсивные вызовы в ленивую последовательность и больше не получать переполнение стека для больших вычислений?

У вас есть пример того, что вы имеете в виду?Если у вас есть рекурсивная привязка к lazy-seq, это может определенно вызвать переполнение стека .

...