javascript

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

  • суббота, 3 февраля 2018 г. в 03:12:53
https://habrahabr.ru/post/348170/
  • JavaScript


Функциональный подход потихоньку-полегоньку проник почти во все современные языки программирования. Тогда как одни элементы оттуда, вроде монад («всего лишь моноид в категории эндофункторов, в чем проблема?») – очень спорные для мэйнстрима, другие – вроде преобразований 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() не возьмет – даже не будет обсчитываться.

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

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