Раздел 28 · Clojure

Библиотека последовательностей: map, filter, reduce и ещё три десятка

middle~15 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Главная мысль урока: почти любую задачу над коллекцией в Clojure решает цепочка готовых функций из clojure.core. Циклы с изменяемым счётчиком тут не нужны. Нужно уметь назвать, что ты хочешь получить, и найти функцию под это намерение.

Геймдизайнер «Хроник Пограничья» пишет в чат: «Какие предметы чаще всего падают? Сколько урона наносит каждый класс? Когда игрок впервые что-то нашёл?» Под рукой лог событий за день, вектор мап. Его же разбирал аналитик в курсе аналитика, только там был Python.

На JavaScript ты бы завёл пустой объект counts, пробежал for по событиям и увеличивал counts[item]. В Clojure это одна функция:

keep :item взял поле :item у каждого события и выбросил nil, то есть события без предмета. frequencies посчитал, сколько раз встретилось каждое значение. Ни одной изменяемой переменной. Этот лог пригодится нам дальше, в каждом примере он задан заново.

Три кита

Три функции встречаются в коде на Clojure чаще остальных. Ты их уже видел, теперь точнее:

  • map превращает каждый элемент в новый. Сколько вошло, столько вышло.
  • filter оставляет элементы, на которых предикат истинен. Вышло столько же или меньше. Его зеркало remove выбрасывает такие элементы.
  • reduce сворачивает всю коллекцию в одно значение: число, мапу, вектор.

reduce в последнем примере это свёртка с начальным значением: аккумулятор начинается с пустой мапы {}, функция получает его и очередной удар и возвращает новый аккумулятор. (fnil + 0) подставляет 0, если у игрока ещё нет суммы: (+ nil 12) упал бы. Деструктуризация {:keys [player amount]} из урока про деструктуризацию прямо в аргументах.

reduce умеет всё, через него можно выразить и map, и filter. Но если для задачи есть специальная функция, бери её: (frequencies items) читается сразу, а свёртку с fnil надо разбирать.

Карта по намерению

В clojure.core больше сотни функций над последовательностями. Запоминать их списком бесполезно. Полезно спрашивать себя: что я хочу получить на выходе? Вот главные, сгруппированные по ответу:

НамерениеФункцииЧто на выходе
Преобразовать каждыйmap, mapv, map-indexed, keep, mapcatстолько же или больше элементов
Отобратьfilter, remove, take, drop, take-while, drop-while, distinct, dedupeменьше элементов
Свернуть в одноreduce, count, some, every?, apply max, max-keyодно значение
Посчитать и сгруппироватьfrequencies, group-by, update-valsмапа
Разрезатьpartition, partition-all, partition-by, split-withпоследовательность кусков
Упорядочитьsort, sort-by, reverseте же элементы в другом порядке
Переложитьinto, vec, set, zipmapдругая коллекция
Накопить по шагамreductionsвсе промежуточные значения свёртки

Если намерение есть, а функцию не помнишь, в REPL помогает apropos: он ищет функции по части имени. Полная карта в официальной шпаргалке, а примеры к каждой функции на clojuredocs.org.

Посчитать и сгруппировать

Вернёмся к вопросу геймдизайнера про урон по классам. group-by раскладывает события по корзинам: ключ корзины даёт функция, корзина это вектор событий. Потом update-vals превращает каждую корзину в сумму:

Связка group-by плюс update-vals в Clojure то же, что GROUP BY плюс агрегат в SQL. Ключом группы может быть любая функция: :class, :player или #(quot (:ts %) 60) для разбивки по минутам.

Упорядочить и выбрать лучшее

«Топ предметов» это частоты, отсортированные по убыванию. sort-by принимает функцию-ключ и, если нужно, функцию сравнения:

Мапа как последовательность это пары ключ-значение, у пары есть key и val. Вторая строка сортирует по вектору [минус число, предмет]: векторы сравниваются поэлементно, поэтому при равном числе находок предметы встают по алфавиту. Так сортируют по нескольким полям сразу, juxt из урока про функции собирает ключ. Зачем вторая строка, если первая дала тот же порядок? Первая этот порядок не обещает: :potion и :sword равны по числу, и их взаимный порядок зависит от устройства мапы. В отчёте, который сверяют с прошлой неделей, такая случайность недопустима.

max-key выбирает элемент с наибольшим ключом, min-key с наименьшим. Самое раннее событие игрока это (apply min-key :ts события).

Разрезать

Иногда нужна не группировка по значению, а нарезка по порядку. Три функции режут последовательность на куски:

partition-all режет на куски фиксированного размера, последний может быть короче. partition 2 1 даёт скользящее окно из пар соседей, а (map - (rest ts) ts) сразу считает паузы между соседними событиями: map с двумя коллекциями берёт по элементу из каждой. partition-by начинает новый кусок, когда меняется значение функции. reductions показывает каждый шаг свёртки, это накопленный урон за бой.

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

Развернуть вложенное

Сундук хранит несколько предметов, лог хранит сундуки. Чтобы получить плоский список предметов, нужен mapcat: он делает map и склеивает получившиеся коллекции в одну:

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

Функции урока, которые возвращают последовательность (map, filter, keep, mapcat, partition и соседи), ленивые, исключение только sort и sort-by. Как в прошлом уроке, они ничего не считают, пока результат не попросят. Цепочка из пяти map и filter над логом в миллион событий не создаёт пять копий лога сразу.

Попробуй руками

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

Проверь себя

Задание

Дальше

Цепочки из пяти функций, вложенных друг в друга, читать тяжело: (take 2 (sort-by ... (frequencies (keep ...)))). В следующем уроке потоковые макросы -> и ->> развернут их в конвейер, который читается сверху вниз.

домашка

Домашка