Почему коллекции clojure не реализуют интерфейс ISeq напрямую?

Каждая коллекция в clojure называется "sequable", но на самом деле последовательностями являются только list и cons:

user> (seq? {:a 1 :b 2})
false
user> (seq? [1 2 3])
false    

Все остальные seq-функции сначала преобразуют коллекцию в последовательность и только потом работают с ней.

user> (class (rest {:a 1 :b 2}))
clojure.lang.PersistentArrayMap$Seq

Я не могу делать такие вещи, как:

user> (:b (rest {:a 1 :b 2}))
nil
user> (:b (filter #(-> % val (= 1)) {:a 1 :b 1 :c 2}))
nil

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

Итак, почему коллекции clojure не реализуют интерфейс ISeq напрямую, а все функции seq не возвращают объект того же класса, что и входной объект?


person VitoshKa    schedule 21.04.2014    source источник
comment
Итак, является ли карта последовательным контейнером? (Нет, это не так). Вы можете перебирать элементы карты в некоторой последовательности, но сама карта не является последовательной. Я не очень согласен с такими функциями, как фильтр, которые концептуально не требуют ввода данных в каком-либо конкретном порядке, требуя, чтобы их аргумент был последовательностью, но это не обязательно плохой дизайн.   -  person Cubic    schedule 21.04.2014
comment
@Cubic, вектор — это последовательный контейнер, так почему это не последовательность? (отредактировал вопрос). Для карт и наборов всегда существует неявный порядок. Вот почему seq определен для них. Вопрос в том, если ISeq имеет смысл для карт, наборов и векторов, почему clojure нужны дополнительные внутренние классы Seq для каждой коллекции? И зачем бесполезное принуждение туда-сюда?   -  person VitoshKa    schedule 21.04.2014
comment
@Cubic, кстати, если бы карта реализовывала ISeq напрямую, filter не нужно было бы работать с последовательностью. Типа подтверждает мою точку зрения.   -  person VitoshKa    schedule 21.04.2014


Ответы (4)


Это обсуждалось в группе Google Clojure; см., например, ветку семантики карты от февраля этого года. Я позволю себе повторно использовать некоторые моменты, которые я сделал в своем сообщении в этой ветке ниже, добавляя несколько новых.

Прежде чем я продолжу объяснять, почему я думаю, что дизайн с «раздельной последовательностью» является правильным, я хотел бы отметить, что это естественное решение для ситуаций, когда вы действительно хотите получить вывод, аналогичный вводу, но не являясь явным. об этом существует в виде функции fmap из библиотеки contrib algo.generic. (Однако я не думаю, что это хорошая идея использовать его по умолчанию, по тем же причинам, по которым дизайн базовой библиотеки является хорошим.)

Обзор

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

  1. некоторый способ повторения их ввода;

  2. применение функции к каждому элементу ввода;

  3. производя вывод.

Очевидно, что 2. не вызывает проблем, если мы можем иметь дело с 1. и 3. Итак, давайте посмотрим на них.

Итерация

Для 1. учтите, что самый простой и наиболее производительный способ перебора коллекции обычно не включает выделение промежуточных результатов того же абстрактного типа, что и коллекция. Сопоставление функции с фрагментированной последовательностью с вектором, вероятно, будет намного более эффективным, чем сопоставление функции с последовательностью, создающей «векторы просмотра» (с использованием subvec) для каждого вызова next; последний, однако, является лучшим, что мы можем сделать с точки зрения производительности для next на векторах в стиле Clojure (даже при наличии деревья RRB, которые хороши, когда нам нужна правильная операция подвектора/векторного среза для реализации интересного алгоритма, но делают обходы ужасающе медленными, если мы использовали их для реализации next).

В Clojure специализированные типы seq поддерживают состояние обхода и дополнительные функции, такие как (1) стек узлов для отсортированных карт и наборов (помимо лучшей производительности, это имеет лучшую сложность большого O, чем обходы с использованием dissoc / disj!), (2) текущий индекс + логика для упаковки листовых массивов в куски для векторов, (3) "продолжение" обхода для хэш-карт. Обход коллекции через такой объект просто быстрее, чем любая попытка прохождения через subvec / dissoc / disj.

Предположим, однако, что мы готовы смириться с падением производительности при отображении функции на вектор. Что ж, давайте попробуем фильтровать сейчас:

(->> some-vector (map f) (filter p?))

Здесь есть проблема — нет хорошего способа удалить элементы из вектора. (Опять же, RRB-деревья могли бы помочь в теории, но на практике все нарезки и объединения RRB, связанные с созданием «реального вектора» для операций фильтрации, абсолютно снижают производительность.)

Вот аналогичная проблема. Рассмотрим этот конвейер:

(->> some-sorted-set (filter p?) (map f) (take n))

Здесь мы выиграем от лени (или, скорее, от возможности прекратить фильтрацию и сопоставление раньше времени; здесь есть пункт, связанный с редьюсерами, см. ниже). Очевидно, что take можно заменить на map, но не на filter.

Дело в том, что если filter можно неявно преобразовывать в seq, то это нормально и для map; и аналогичные аргументы могут быть сделаны для других функций последовательности. Как только мы привели аргументы для всех — или почти всех — из них, становится ясно, что для seq также имеет смысл возвращать специализированные seq объекты.

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

Создание вывода

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

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

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

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

Не все последовательно; или, рассмотрим редукторы

Некоторые проблемы с использованием самих типов коллекций при сопоставлении, фильтрации и т. д. не возникают при использовании редюсеров.

Ключевое различие между редьюсерами и последовательностями заключается в том, что промежуточные объекты, созданные clojure.core.reducers/map и его друзьями, производят только объекты-дескрипторы, которые содержат информацию о том, какие вычисления необходимо выполнить в случае, если редюсер действительно будет сокращен. Таким образом, отдельные этапы вычислений могут быть объединены.

Это позволяет нам делать такие вещи, как

(require '[clojure.core.reducers :as r])

(->> some-set (r/map f) (r/filter p?) (into #{}))

Конечно, нам по-прежнему нужно явно указать наш (into #{}), но это всего лишь способ сказать: «Конвейер редюсеров здесь заканчивается; пожалуйста, представьте результат в виде набора». Мы также можем запросить другой тип коллекции (возможно, вектор результатов; обратите внимание, что сопоставление f с набором вполне может привести к дублированию результатов, и в некоторых ситуациях мы можем захотеть их сохранить) или скалярное значение ((reduce + 0)).

Резюме

Основные моменты таковы:

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

  2. seq использует самый быстрый способ итерации;

  3. лучший подход к преобразованию набора путем сопоставления или фильтрации включает использование операции в стиле seq, потому что мы хотим очень быстро выполнять итерацию при накоплении вывода;

  4. таким образом seq делает большой примитив;

  5. map и filter в своем выборе для работы с последовательностями, в зависимости от сценария, могут избежать штрафов за производительность без положительных сторон, выиграть от лени и т. д., но все же могут использоваться для получения результата сбора с into;

  6. таким образом, они тоже являются великими примитивами.

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

person Michał Marczyk    schedule 21.04.2014
comment
Ага! 1) Последовательность — это протокол (маленькая буква «р») для неизменяемого итератора. 2) Итератор, изменяемый или нет, должен воплощать некоторую схему прохождения базовой коллекции и знать, где он в ней находится (состояние). 3) Как и в C++, Java или C#, он должен иметь возможность быть другим объектом, обертывающим коллекцию. 4) Неизменяемые коллекции дают вам возможность использовать коллекцию в качестве собственного итератора (состояния), но это не обязательно хороший выбор. - person Thumbnail; 21.04.2014
comment
Спасибо за подробный ответ. Я думаю, что ваше резюме немного повторяется и сосредоточено исключительно на производительности. Как насчет добавления явных точек на отложенные вычисления и того факта, что в некоторых случаях вывод внутри класса либо бессмысленен, либо нежелателен, например (map + #{1 2 3} '(1 2 3)) или (map #(mod % 2) #{2 6}). - person VitoshKa; 22.04.2014
comment
Все это хорошие аргументы в пользу наличия общего интерфейса последовательности со многими реализациями, обслуживающими различные сценарии производительности. Так хорошо: отдых на векторе не должен возвращать вектор. Но почему сам вектор не может быть ISeq, чья остаточная операция возвращает то же самое, что и (остальное (seq myvector))? - person Jegschemesch; 01.08.2014
comment
Единственное рациональное объяснение, которое я могу понять, заключается в том, что метод rest ISeq предназначен для того, чтобы всегда возвращать один и тот же конкретный тип. Однако, если это не так, я не слышу никаких веских причин, по которым векторы и карты не должны быть последовательностями напрямую. - person Jegschemesch; 01.08.2014
comment
И какой смысл делать векторы и карты в экземплярах ISeq, учитывая тот факт, что все коллекции Clojure уже могут быть переданы в экземпляры map, first и т. д. напрямую, без явных вызовов seq? Например, почему имеет смысл повторять next реализацию фрагментированных последовательностей внутри самого PersistentVector, когда реализация Seqable решает проблему простоты использования простым, СУХИМ и эффективным способом? DRY предоставляет аргумент против этого; каковы аргументы в пользу этого (при условии, что мы согласились с тем, что rest не всегда может возвращать исходный тип)? - person Michał Marczyk; 01.08.2014

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

Функции последовательности (карта, фильтр и т. д.) берут "seqable" объект (что-то, что может создавать последовательность), вызывают для него seq для создания последовательности, а затем работают с этой последовательностью, возвращая новую последовательность. Вам решать, нужно ли вам или как повторно собрать эту последовательность обратно в конкретную коллекцию. В то время как векторы и списки упорядочены, наборы и карты — нет, поэтому последовательности над этими структурами данных должны вычислять и сохранять порядок вне коллекции.

Специализированные функции, такие как mapv, filterv, reduce-kv, позволяют вам оставаться «в коллекции», когда вы знаете, что хотите, чтобы операция возвращала в конце коллекцию, а не последовательность.

person Alex Miller    schedule 21.04.2014

Последовательности — это упорядоченные структуры, а карты и наборы — неупорядоченные. Две одинаковые по значению карты могут иметь разный внутренний порядок. Например:

user=> (seq (array-map :a 1 :b 2))
([:a 1] [:b 2])
user=> (seq (array-map :b 2 :a 1))
([:b 2] [:a 1])

Нет смысла запрашивать rest карты, потому что это не последовательная структура. То же самое касается набора.

Так что насчет векторов? Они упорядочены последовательно, так что потенциально мы можем сопоставить вектор, и действительно есть такая функция: mapv.

Вы можете спросить: почему это не подразумевается? Если я передам вектор map, почему он не вернет вектор?

Ну, во-первых, это будет означать создание исключений для упорядоченных структур, таких как векторы, а Clojure не очень любит делать исключения.

Но что более важно, вы потеряете одно из самых полезных свойств последовательностей: лень. Объединение в цепочку функций seq, таких как map и filter, является очень распространенной операцией, и без лени это было бы намного менее производительно и требовало бы гораздо больше памяти.

person weavejester    schedule 21.04.2014
comment
@OpenLearner Объединение их вместе означает вызов одной функции на выходе другой. Так, например, вы можете объединить их в цепочку следующим образом: (карта #(+ 2%) (отфильтровать четно? [1 2 3 4])). Ленивость гарантирует, что функции не материализуют промежуточные последовательности в памяти. Это может быть важно для более сложных цепочек и более длинных последовательностей. - person Søren Boisen; 29.05.2015

Классы коллекций следуют шаблону фабрики, т. е. вместо реализации ISeq они реализуют Sequable, т. е. вы можете создать ISeq из коллекции, но сама коллекция не является ISeq.

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

Пример в java:

interface ISeq {
    ....
}

class A implements ISeq {

}

class B implements ISeq {

}

static class Helpers {
    /*
        Filter can only work with ISeq, that's what makes it general purpose.
        There is no way it could return A or B objects.
    */
    public static ISeq filter(ISeq coll, ...) { } 
    ...
}
person Ankur    schedule 21.04.2014