Трансдьюсеры в 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() не возьмет – даже не будет обсчитываться.

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

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

    Similar posts

    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 26

      +5
      Мое ИМХО. Мне только одно не нравиться. Вот есть в проекте боевом код:

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


      Согласитесь, но как по мне читабельность стремится к нулю. Сколько надо времени, чтоб сходу сказать что она делает и как? Особенно радует когда такое надо дебажить, да и еще если что-то где-то упало, и надо отдебажить и сделать хотфикс.

        –5
        Поэтому надо писать комментарии, имярек!
          +6
          нужно писать такой код который не нуждается в комментировании.
          +2
          1) А теперь возьмите, и напишите нормальные имена вместо fn, a, args. Потом вынесите условие из тернарника в переменную с говорящим именем. И вообще, разверните тернарник в явный if/else, потому что он сложный.
          2) Добавьте JSDoc с пояснением зачем это нужно. Имя curry является стандартом де-факто и облегчает переход между языками программирования, но нарушает концепцию говорящих имен. Так, к примеру C# в свое время говнили за то, что map называется Select, filter называется Where, а reduce называется Aggregate.
          3) Обратите внимание на то, что функция curry либо входит в стандартную библиотеку функциональных языков программирования, либо ненужен. Вы не должны видеть такого кода в проекте, потому что этот код должен быть объявлен в какой-то библиотеке.
          4) Занимательный прикол: каррирование можно делать и без curry.
            –1
            Так, к примеру C# в свое время говнили за то, что map называется Select, filter называется Where, а reduce называется Aggregate.

            Ничего, что reduce на самом деле fold?

            +2

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

              0

              Ну во-первых, тут всего одно условие в 3 строчки, все вполне читаемо. А во-вторых, такие функции, как правило, не пишутся прямо в проекте, а выносятся либо в библиотеку (а еще лучше — юзаются уже написанные вместо своих велосипедов), либо в отдельную часть, чтобы один раз написать и дальше просто использовать, не заботясь о внутренностях.

              +1
              И стоит ли такая экономия, потери понимания кода, особенно сторонними кодерами? Интересно было бы увидеть бенчмарки.
                0

                Как я отметил в конце статьи, это пойдет только если у вас проблемы с потреблением памяти. Это не серебряная пуля. Процессинг данных через map, filter, reduce — декларативный и понятный. Код с compose может быть непривычным, но фактически удобный, гибкий (с for не сравнить), читается легко.


                Бенчмарки не делал, самому интересно. Скорее всего сольет вчистую классике на for, на больших объемах должен быть производительнее, чем цепочка методов над array. Может на досуге потестирую, напишу отдельно заметку.

                +3
                Чего только не придумают, лишь бы только i++ вручную не писать.
                  0

                  Композиция дает модульность обработки. Мне кажется, у вас ирония?

                  0
                  Классы в JS — так ли уж необходимы?
                    0
                    А почему, интересно, нет? Никто не навязывает их. Можете использовать, моежете писать на прототипах.
                    ИМХО, читаемость и структуризация куда удобнее с использованием классов. Да и наследование нагляднее.
                      0
                      Во-первых, классы это те же прототипы. Всего лишь синтаксис и кстати не самый удобный. Во-вторых, кроме прямого наследования есть и другие способы повторного использования кода.
                        0
                        Поэтому я и не написал, что это «разные вещи». Это приятный сахар. Но на вкус и цвет…
                    +2
                    Нет. Трандьюсеры нужны не только для этого. Они также нужны для того, чтобы полностью разнести логику обработки данных и логику работы с коллекцией. Мне очень жалко, что изначальное обоснование необходимости трандьюсеров просто проходит мимо всех туториалов

                    1) Изначальный пост о введении transducers в clojure ("Transducers are coming") говорит, что они приходят в «core» и «core.async». К примеру, ваши хваленные итераторы не могут работать с асинхронными источниками данных (rxjs, к примеру). Кейсы, когда нужен реюз одного алгоритма и для массива, и для асинхронного потока — есть, но весьма редки. Если у вас такого нет, то вы можете не использовать трандьюсеры с чистой душой.

                    2) Трандьюсеры очень серьезно помогают дизайну языка. Рича Хикки просто задолбало то, что для каждого вида коллекции нужно реализовывать набор методов поразительно схожий по коду. Если вы не пишите свой язык и не пишите свои коллекции, то вы можете не обращать внимания на транcдьюcеры с чистой душой. Они просто придут к вам внезапно. Если выживут, конечно.

                    3) На всякий случай. Изначально в clojure были ленивые последовательности и все методы работы с коллекциями прекрасно с ними работали. Это чтобы вы не думали, что их из-за незнания итераторов ввели.
                      0

                      Спасибо за информативный комментарий. До статьи Рича к стыду своему не добрался, лисп все же мне далек. В обучалках да, нюансы, которые вы описали — потерялись. Моя идея была в том, что экономить память при преобразовании потока можно и более привычным способом.

                        +1
                        К примеру, ваши хваленные итераторы не могут работать с асинхронными источниками данных

                        Итераторы — нет, но генераторы-то ведь да?

                        И вообще, мне показалось, что статья не про «Трансдьюсеры — кака», а про «Трансдьюсеры это те же генераторы». И если вы не можете вкурить про трансдьюсеры, вкурите про генераторы и пользуйтесь на здоровье. То же самое, только с перламутровыми пуговицами.

                        Мне вот лично тоже кажется, что генераторы понять проще, чем трансдьюсеры.
                          –1
                          И вообще, мне показалось, что статья не про «Трансдьюсеры — кака», а про «Трансдьюсеры это те же генераторы». И если вы не можете вкурить про трансдьюсеры, вкурите про генераторы и пользуйтесь на здоровье. То же самое, только с перламутровыми пуговицами.

                          В этом то и дело, что трансдьюсеры != генераторы. Трансдьюсеры мощнее и гибче. А код на генераторах писать более привычно. По своей сути генераторы являются кастрированной специализированной do-нотацией. Генераторы — это сахар. Трансдьюсеры сахаром не являются.

                          Вы вообще гляньте на заголовок и вступление статьи. Начало вовсе не о том, что ленивую обработку можно сделать через генераторы. Вот скажите: эта статья у вас не вызвала ощущение, что трансдьюсеры придумали упоротые астронавты? На всякий случай, перечитайте свой комментарий.

                          Итераторы — нет, но генераторы-то ведь да?

                          Да, есть такой proposal. Он позволяет писать асинхронный код на генераторах, не спорю. Пожалуй, я криво сформулировал начальное предложение. Возможно даже похоже на то, как сформулировал автор поста вступление :-).

                          Но я немного не о том говорил. Я говорил о том, что трансдьюсеры — это механизм, обеспечивающий реюз кода при обработке синхронного источника данных и асинхронного источника данных. Если вы считаете, что на генераторах можно делать реюз логики обработки данных, то пример в студию (без костылей)

                          Мне вот лично тоже кажется, что генераторы понять проще, чем трансдьюсеры.

                          Эм… Лол? Очень голословное и двоякое утверждение.

                          Посему есть вопросики:
                          1: Кому проще-то? Какой бэкграунд должен быть у человека? Мы же не спорим о том, что будет проще для абстрактных пекарей? Вы же не основываете свое личное мнение на «сложных» статьях, которые пишутся для тусовки, в которую вы не входите?
                          2: Проще понять концепцию или код, который получается?
                          3: Вы уверены, что с ростом количества кода вы все равно будете понимать как работает код на генераторах лучше, чем код на трансдьюсерах?
                          4: Откуда у вас уверенность, что вы понимаете как работает код написанный на генераторах? Не подменяете ли вы реальное понимание на ложное интуитивное?
                            0
                            Я только по части бэкграунда. Мне тоже проще понять генераторный подход. Фишка в том, что он из мира императивных конструкций, откуда большинство программистов пришли. Опять же, человек говорит про свое личное мнение — так что про голословность это вы зря.

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

                            И концепция, и код трансдьюсеров — не очень просты. Transducers.js тому подтверждение. И про понимание, многие вещи мы понимаем и пытаемся воспринимать интуитивно. Иначе элементарно не хватит внимания на более сложные вещи. Это и есть абстракция, в конце концов.
                              +1
                              Про голословность таки зря. Совсем не заметил приписку про личное мнение.
                              JS всё-таки скорее императивный язык
                              Вы может удивитесь сколько людей считают JS функциональным языком. Однако, хотел бы отметить, что функциональный подход не накладывает требования на отсутствие императивщины. И никаких обид, сам считаю js нихрена не функциональным :-)

                              Мне тоже проще понять генераторный подход
                              Я постараюсь объяснить поподробнее, что мне не нравится в вашем утверждении.

                              Вкратце, что такое трансдьюсер. Это функция, которая принимает элемент коллекции и выдает указание что делать дальше для постройки новой коллекции. Это абстракция над map/filter без привязки к входной и выходной коллекции. А генератор это абстракция над map/filter с привязкой к входной коллекции (если она есть) и без привязки к выходной коллекции. Ужасно большое понимание требуется для первого и никакого для второго, да :-)

                              У вас в статье написаны библиотечные функции. Мне кажется стремным предъявлять к библиотечному коду требования простоты. Просто я очень сомневаюсь, что вы легко понимаете код браузера. Да блин, библиотеки существуют, чтобы делать какие-то типичные задачи наилучшим образом, а не самым понятным. Я надеюсь, это снимет вопрос о понимании map/filter.

                              Касательно генераторов. Когда у вас есть map/filter, то количество кода на генераторах стремится к минимуму. И никто вам не запрещает писать генератор. Мы же тут не говорим о том, чтобы брать микроскоп для забивания гвоздей.

                              И вот когда у вас есть уже готовая библиотека, то вопрос о сложности превращается: а насколько легко это использовать? И вы легко можете написать вместо transduce функции mapIntoArray(sourceCollection, algo), mapIntoSet(soureCollection, algo) и т.д.

                              Я просто реально не понимаю где вы видите сложность этого подхода. Я вижу только то, что у transducers ужасная документация и идиотское именование.
                            0
                            Кстати, асинхронные итераторы тоже есть как proposal
                          0

                          В начале посмотрев на код и слова что "комментарии излишни" подумал что я совсем дно. Пока не дошел до комментариев:)

                            0

                            Трансдьюсеры это трансформаторы потоков (с pull или push интерфейсами). Конечно нет необходимости использовать интерфейсы потоков из Clojure в JS, где свои интерфейсы. В этой статье функции трансформируют ES итераторы. Но это так же потоковый интерфейс.


                            Термин трансдьюсер использовался как трансформатор потоков задолго до появления их в Clojure и для других интерфейсов.


                            Тут я немного описывал это с большими деталями. И тут с добавлением асинхронных генераторов.

                              0
                              const mixFn = compose(greduce((c, v) => c + v, 0), square, moreThan5);
                              let yyy = [...mixFn(values)];
                              

                              greduce(..) вернет число, а не итератор. spread-оператор не нужен для записи в переменную.
                                0
                                да, слажал, поправлю

                              Only users with full accounts can post comments. Log in, please.