Автор впервые услышал о такой идеи как transduser (трансдьюсер), когда его взгляд упал на язык программирования Clojure. И как обычно вначале ничего не понял, а потом как понял (с) и в этой статье попробует передать что же он там понял и чем это может быть полезно. Итак - начинаем начинать…
В одной из прошлых статей были реализованы функции map
, filter
, reduce
, которые помогут нам сегодня.
Напомним как они выглядят map
и filter
так как на их примере будут объяснены трасдьюсеры:
const map = (list, f) => reduce(list, (acc,el) => [...acc, f(el)], [])
const filter = (list, pred) => reduce(list, (acc,el) => pred(el) ? [...acc, el] : acc, [])
Как может убедиться читатель map
и filter
успешно справляются со своей задачей по отдельности, т.е. трансформируют данные и фильтруют ненужные.
Но что если нам нужно их совместить для некоторой задачи, скажем: сначала отфильтровать часть данных, а затем выполнить преобразование на оставшейся части данных?
Простое решение может выглядеть следующим образом:
const filterMap = (list, pred, f) => {
const filtered = filter(list ,pred)
return map(filtered, f)
}
filterMap([1,2,3,4,5,6,7], x => x % 2 == 0, x => x * x) // [ 4, 16, 36 ]
Ок. Но увлечённый читатель может заметить, что map
и filter
действуют жадно, т.е. в процессе выполнения создаются промежуточные данные после каждого действия (filtered
), в которых нет необходимости (особенно, если они представляют собой сотни и сотни мегабайтов или количество действий гораздо больше, чем в примере).
Что можно с этим сделать?
Заметим, что в обоих случаях map
и filter
используют reduce
для агрегации данных. Давайте с помощью техники частичного применения попробуем вынести эту часть из-под ответственности функций, оставив только описание действия, согласно которому данные будут редуцироваться:
const map = (f) => (acc, x) => [...acc, f(x)]
const filter = (pred) => (acc, x) => pred(x) ? [...acc, x] : acc
reduce([1,2,3,4,5,6,7], map(x => x * x), []) // [1,4,9,16,25,36,49]
reduce([1,2,3,4,5,6,7], filter(x => x % 2 == 0), []) // [ 2, 4, 6 ]
Ок, но будет проблематично делать композицию этих функций. К тому же, находчивый читатель может заметить, что в каждой из функций мы всё ещё образуем новую последовательность ([...acc, f(x)]
и [...acc, x]
). Давайте вынесем из наших функций знание о том как формировать результат:
const map = (f) =>
(xf) =>
(acc, x) => xf(acc, f(x))
const filter = (pred) =>
(xf) =>
(acc, x) => pred(x) ? xf(acc, x) : acc
А так же напишем функцию, которая будет редуцировать результат:
const reducer = (acc, x) => [...acc, x]
Проверим:
reduce(
[1,2,3,4,5,6,7],
map(x => x * x)(reducer),
[]
)
// [1,4,9,16,25,36,49]
А теперь давайте сделаем композицию map
и filter
с помощью compose
:
const xForm = compose(
map((x) => x + 1),
filter((x) => x % 2 == 0)
)
Проверим:
reduce([1,2,3,4,5,6,7], xForm(reducer), []) // [ 4, 16, 36 ]
Чтобы наглядно увидеть процесс выполнения добавим console.log
для функций map
, filter
и reducer
. В этом случае, в терминале вывода мы увидим примерно следующее:
mapping 1
filtering 1
mapping 2
filtering 4
reducer acc=[] x=4
mapping 3
filtering 9
mapping 4
filtering 16
reducer acc=[4] x=16
mapping 5
filtering 25
mapping 6
filtering 36
reducer acc=[4,16] x=36
mapping 7
filtering 49
[ 4, 16, 36 ]
Как можно заметить данные сначала проходят все функции в композиции, а только затем редуцируются, не создавая промежуточных структур.
Это и есть то, что создатель Clojure назвал трансдьюсер. В противоположность жадного исполнения - редуцирование данных происходит только в самом конце и по правилу, которое задаётся самостоятельно программистом.
Неоспоримые плюсы такой функциональности:
(f) => (xf) => (acc, x) => ...
Однако у всего есть цена и у подобного решения тоже. По сравнению с обычными map
и filter
, понимание которых у читателя сопровождается, может быть, незначительными затруднениями - форма трансдьюсеров не столь очевидна и проста. Однако, помедитировав над ней некоторое время и поняв её, читатель сможет приобрести великолепный инструмент для решения своих задач.
Аналог примера реализованного в этой статье в clojure выглядел бы следующим образом:
(def xf
(comp
(map #(* % %))
(filter #(zero? (mod % 2)))
))
(def reducer #(conj %1 %2))
(reduce (xf reducer) [] [1 2 3 4 5 6 7]) ;; [4, 16, 36]
Но реализация трансдьюсеров, которая есть в стандартной библиотеке языка конечно же выглядит лучше, работает эффективнее и обладает бОльшими возможностями, чем продемонстрированная автором.
Как и прежде автор надеется, что данная статья окажется полезной или интересной для читателя и будет рад замечаниям, предложениям и словам благодарности.