Трансдьюсеры в JS – так ли уж необходимы?

Функциональный подход потихоньку-полегоньку проник почти во все современные языки программирования. Тогда как одни элементы оттуда, вроде монад («всего лишь моноид в категории эндофункторов, в чем проблема?») — очень спорные для мэйнстрима, другие — вроде преобразований map, reduce, filter — стали стандартом де-факто.

image


При всех своих плюсах святая троица map/filter/reduce — в JS не очень экономно работает с памятью. Грандиозный архитектурный костыль — трансдьюсеры — успешно запортирован с Clojure на JS, и поражает неофитов своей непонятностью, при этом вроде как решает проблему с излишним выделением памяти.

При чтении документации на Transducers.js (протокол для трансдьюсеров) меня не оставляло стойкое чувство дежавю — где-то что-то похожее я видел. Ага — итераторы и генераторы на MDN! Все вменяемые браузеры и серверные рантаймы уже их умеют (гусары, молчать про Ишака!).

Не так давно я экспериментировал с этими вещами, чтобы построить нечто, что может потоково обрабатывать данные в JS — без создания промежуточных массивов.

Итак — поехали.

Чтобы было стильно, модно, молодежно — спионерим из интернетов две функции:

const compose = (...fns) => x => fns.reduceRight((c, f) => f(c), x)


Пространные комментарии излишни — классическая композиция функций: compose (f, g, k) это f (g (k (x))). Ух, вроде со скобками не слажал.

И вторая (тут у нас про функциональщину, помним?):

const curry = (fn, a = []) => (...args) => 
        a.length + args.length >= fn.length 
            ? fn(...a, ...args) 
            : curry(fn, [...a, ...args]);


Превращает функцию с кучкой аргументов в функцию одного аргумента. Для условного f (a, b) вызов curry (f) вернет функцию g — обертку для f, которую можно вызвать как g (a, b), так и g (a)(b). Главное, чтобы оборачиваемая функция имела стабильное количество аргументов (никаких аргументов со значениями по умолчанию).

Теперь переизобретем функцию map, используя генераторы.

function *_gmap(f, a) {
    for (let i of a)  yield f(i);
}

const gmap = curry(_gmap);


Код элементарный, проходимся по входу a функцией f (a) и ответ выкладываем наружу. Можно вызвать как gmap (f, a), так и gmap (f)(a). Что это дает — можно сохранить частично примененный gmap (f) в переменную, а когда понадобится — переиспользовать.

Теперь фильтрация:

function *_gfilter(p, a) {
    for (let i of a)
        if (p(i)) yield i;
}

const gfilter = curry(_gfilter);


Тоже можно вызвать как сразу — gfilter (f, a), так и в лучших традициях функциональщины — gfilter (f)(a).

Чтоб было проще, еще пара примитивных функций (лиспом навеяло):

function *ghead(a) {
    for (let i of a) {
        yield i;
        break;
    }
}

function *gtail(a) {
    let flag = false;
    for (let i of a) {
        if (flag) {
            yield i;
        } else {
            flag = true;
        }
    }
}


ghead (a) возвращает первый элемент от входа, gtail (a) — всё, кроме первого.

Ну и небольшой пример, как это всё может быть использовано:

let values = [3, 4, 5, 6, 7, 8, 9];

const square = x => x * x;
const squareNfirst = compose(ghead, gmap(square));

let x = [...squareNfirst(values)];


В переменную x попадет массив из одного элемента.

const moreThan5 = gfilter(x => x > 5);

let xxx = [...moreThan5(values)];


Общая идея такая — на вход gmap и gfilter можно скормить как массив, так и нечто, реализующее iterable protocol –, а на выходе тоже будет итератор, который в ES6 можно развернуть в обычный массив через три точки (let x = […squareNfirst (values)]).

А что же reduce, можете спросить? Универсального подхода тут не будет, или использовать классический [].reduce (f, init), или вот так:

function _greduce(f, i, a) {
    let c = i;
    for(let v of a) c = f(c, v);
    return c;
}

const greduce = curry(_greduce);


greduce (f, i, a) свернет входящий массив или итератор в одно значение.

Пример:

const mixFn = compose(greduce((c, v) => c + v, 0), square, moreThan5);

let yyy = [...mixFn(values)];


Функция-композит последовательно отсечет из входа числа больше 5, потом полученные элементы возведет в квадрат, и напоследок просуммирует с помощью reduce.

Зачем вся эта возня?


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

Ну и плюс функциональненько, это сейчас модно :)

Надеюсь, что подобный подход поможет вам сэкономить немножко памяти при обработке много-десятко-мегабайтных массивов. Иначе не стоит и велосипедить.

© Habrahabr.ru