Заметки на полях

Transdusers for JS

Автор впервые услышал о такой идеи как 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 назвал трансдьюсер. В противоположность жадного исполнения - редуцирование данных происходит только в самом конце и по правилу, которое задаётся самостоятельно программистом.

Неоспоримые плюсы такой функциональности:

Однако у всего есть цена и у подобного решения тоже. По сравнению с обычными map и filter, понимание которых у читателя сопровождается, может быть, незначительными затруднениями - форма трансдьюсеров не столь очевидна и проста. Однако, помедитировав над ней некоторое время и поняв её, читатель сможет приобрести великолепный инструмент для решения своих задач.

Как это выглядит в Clojure

Аналог примера реализованного в этой статье в 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]

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

That all folks!

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

Ссылочки

“Transducers” by Rich Hickey