Статьи

Лень в Clojure — некоторые мысли

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

(defn savings-balance
[& accounts]
(->> accounts
(filter #(= (:type %) ::savings))
(map :balance)
(apply +)))

Это одна итерация коллекции для фильтра, а другая для карты?

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

user> (def lazy-balance
(->> accounts
(filter #(= (:type %) ::savings))
(map #(do (println "getting balance") (:balance %)))))
#'user/lazy-balance

Ленивый баланс не был оценен — ​​у нас еще нет printlns. Только когда мы форсируем оценку, она вычисляется ..

user> lazy-balance
(getting balance
getting balance
200 300)

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

Лень улучшает композиционность . С ленью, последовательности Clojure и функции более высокого порядка на них существенно улучшают циклы, так что вы можете преобразовать их все сразу. Как Кейл Гиббард защищает лень в Haskell своими комментариями к этому потоку LtU «Это лень, которая позволяет вам думать о структурах данных как о структурах управления».

Clojure не так ленив, как Haskell. И, следовательно, преимущества также не столь распространены. Haskell, по умолчанию ленивый, компилятор может позволить себе проводить агрессивные оптимизации, такие как переупорядочивание операций и преобразований, которые Clojure не может. Благодаря чистоте Haskell, которая гарантирует отсутствие побочных эффектов, оптимизация обезлесения, такая как слияние потоков, создает плотные петли и минимизирует распределение кучи. Но я слышал, что Clojure 1.2 будет иметь еще несколько оптимизаций на уровне компилятора, сосредоточенных вокруг лени его последовательностей.

Лень заставляет вас думать по-другому. Я написал ранее поств этом контексте с Haskell в качестве эталонного языка. В последнее время я занимался программированием на Clojure. Многие из моих наблюдений с Хаскеллом применимы и к Clojure. Вы должны помнить идиомы и лучшие практики, которые требует лень. И во многих случаях они могут не показаться вам очевидными. На самом деле с Clojure вам нужно знать реализацию абстракции, чтобы гарантировать, что вы получаете преимущества ленивых последовательностей.

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

На днях я работал над универсальным бродягом, который просматривает некоторые рекурсивные структуры данных для некоторой обработки дерьма. Я использовал прогулкуиз clojure.walk, но позже понял, что для seqs он делает doall, который реализует последовательность — еще одна ленивая ошибка, которая застала меня врасплох. Но мне действительно нужно было заглянуть в реализацию, чтобы узнать ее.

Возможность взаимодействия с Java — одно из преимуществ Clojure. Однако вы должны знать о подводных камнях использования структур данных Java с ленивой парадигмой Clojure. Рассмотрим следующий пример, где я помещаю все учетные записи в java.util.concurrent.LinkedBlockingQueue.

(import '(java.util.concurrent LinkedBlockingQueue))
(def myq (new LinkedBlockingQueue))
(doto myq (.put acc-1) (.put acc-2) (.put acc-3))

Теперь рассмотрим следующий фрагмент, который делает что-то в очереди.

(let [savings-accounts (filter #(= (:type %) ::savings) myq)]
(.clear myq)
(.addAll myq savings-accounts))

Должно работать .. верно? Не! фильтр ленивый и, следовательно, сберегательные счета пусты в блоке let. Затем мы очищаем myq, и когда мы делаем addAll, он завершается неудачей, так как сберегательные счета все еще пусты. Решение состоит в том, чтобы использовать doall, который уносит лень и реализует фильтрованную последовательность.

(let [savings-accounts (doall (filter #(= (:type %) ::savings) myq))]
(.clear myq)
(.addAll myq savings-accounts))

Конечно, лень в последовательностях Clojure — это то, что добавляет силы вашим абстракциям. Однако вы должны быть осторожны по двум причинам:

  • Clojure as a language is not lazy by default in totality (unlike Haskell) and hence laziness may get mixed up with strict evaluation leading to surprising and unoptimized consequences.
  • Clojure interoperates with Java, which has mutable data structures and strict evaluation. Like the situation I described above with LinkedBlockingQueue, sometimes it’s always safe to bite the bullet and do things the Java way.

From http://debasishg.blogspot.com/2010/05/laziness-in-clojure-some-thoughts.html