Как в Clojure реализованы ленивые последовательности?

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

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

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

person mudge    schedule 14.07.2010    source источник
comment
Я пытался понять, что происходит в этом посте, когда я начал изучать Clojure в августе прошлого года - примерно 5 месяцев назад, и я не мог понять вопросы и ответы этого поста. Теперь то, что обсуждается в этом посте, имеет для меня смысл! :-) Просто хочу поделиться с другими изучающими Clojure тем, что изучение концепций в Clojure может занять время, но не прекращайте его изучать.   -  person Jay Somedon    schedule 26.01.2014


Ответы (3)


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

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

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

Итак, вы (take 10 (whole-numbers)) - предполагаете, что whole-numbers - это ленивая последовательность целых чисел. Это означает, что вы принудительно выполняете оценку преобразователей 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 вместо не-LS с точки зрения удобства, простоты понимания (как только вы их освоите), рассуждений о вашем коде и скорости.

person Isaac    schedule 14.07.2010
comment
Держание за голову не тратит стек. Кучу потребляет. - person Michał Marczyk; 14.07.2010
comment
Цитата из вашего ответа выше: Если последовательность не ленивая, часто она держится за голову, что занимает пространство стека. (См. Статью Что делает ленивые последовательности настолько эффективными, что они не потребляют много пули стека.) - person Michał Marczyk; 14.07.2010
comment
Держаться за голову означает держаться за голову ленивого seq; ваша первая паста, конечно, дает SO (из-за растущего контекста управления), но обычно вы не скажете, что она держится за свою голову. Отсюда мой комментарий. Также для второй вставки: не следует смешивать хвостовую рекурсию и lazy-seq. Здесь это не так важно - lazy-seq просто не добавляет ценности, но на самом деле ничего не вредит - но в целом это может привести к проблемам (или, скорее, к lazy-seq нереализации своего потенциала). Подробности см. В моем ответе (я разместил его в первую очередь для обсуждения этой проблемы). - person Michał Marczyk; 14.07.2010
comment
Ах, достаточно справедливо; Спасибо за разъяснения. Моя терминология еще не совсем в курсе. Что касается лишнего lazy-seq, я не должен был публиковать это в качестве примера, поскольку, как вы говорите, он ничего не делает. Еще раз спасибо за комментарии! - person Isaac; 14.07.2010
comment
Еще один комментарий по поводу первой проблемы: любой производитель строгой последовательности будет удерживать заголовок созданной последовательности, потому что он должен создать ее сразу. Вероятно, это причина, по которой это выражение обычно не используется в контексте строгих последовательностей (потому что оно не добавляет никакой информации), и причина, по которой я неправильно понял ваше намерение. - person Michał Marczyk; 14.07.2010
comment
(Хм, опубликовал комментарий перед тем, как закончить его ... продолжение сверху :) Когда путаница прояснилась, я, конечно, согласен с сутью того, что вы хотели сказать. - person Michał Marczyk; 14.07.2010
comment
Абсолютно согласен; извините за путаницу. Вот лучший (pastie.org/1044535) пример, показывающий lazy-seq, который фактически добавляет функции. В последнее время я слишком много делал хвостовую рекурсию ... Еще раз спасибо, Михал. - person Isaac; 14.07.2010

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

person Michał Marczyk    schedule 14.07.2010

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

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

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

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

person dbyrne    schedule 14.07.2010
comment
Посмотрите мой ответ на ленивую рекурсивную последовательность. - person Isaac; 14.07.2010
comment
Думаю, я был немного неясен. Я понимаю рекурсивные ленивые последовательности, но не был уверен, почему Mudge беспокоился, что они вызовут переполнение стека. Лень по самой своей природе предотвращает переполнение стека. - person dbyrne; 14.07.2010
comment
Ах, понятно - имеет смысл. Извинения. - person Isaac; 14.07.2010