image


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


В этой статье мы покажем возможности преобразователей для создания эффективной функции трансформации данных, которые не создают временных массивов. Временные коллекции создаются для функций map и filter соединеных друг с другом. Это потому, что эти функции, возвращаясь к новой коллекции, выдают результат следующей функции.


Представьте, что у вас база в 1000000 человек, и вам нужно создать сабсет “имен женщин старше 18 лет, которые живут в Нидерландах”. Существуют различные способы решения этой проблемы, но начну с цепочек.


const ageAbove18 = (person) => person.age > 18;
const isFemale = (person) => person.gender === ‘female’;
const livesInTheNetherlands = (person) => person.country === ‘NL’;
const pickFullName = (person) => person.fullName;
const output = bigCollectionOfData
  .filter(livesInTheNetherlands)
  .filter(isFemale)
  .filter(ageAbove18)
  .map(pickFullName);

Ниже представлен пример решения задачи с помощью цепочечного подхода, который создает временные массивы. Представьте затраты на тройное прохождение цикла более, чем в 1000000 записей!


image

Конечно, отфильтрованные коллекции будут несколько сокращены, но это, все еще довольно затратно.


Однако главный момент заключается в том, что map и filter могут быть определены с помощью reduce. Давайте попытаемся реализовать приведенный выше код в формате сокращений.


const mapReducer = (mapper) => (result, input) => {
  return result.concat(mapper(input));
};
const filterReducer = (predicate) => (result, input) => {
  return predicate(input) ? result.concat(input) : result;
};
const personRequirements = (person) => ageAbove18(person)
  && isFemale(person)
  && livesInTheNetherlands(person);
const output = bigCollectionOfData
  .reduce(filterReducer(personRequirements), [])
  .reduce(mapReducer(pickFullName), []);

И более того, Мы можем еще более упростить сокращение (filterReducer) с помощью композиции функций.


filterReducer(compose(ageAbove18, isFemale, livesInTheNetherlands));

При использовании такого подхода мы уменьшаем (хаха!) количество временных массивов. Ниже представлен пример трансформации при использовании сокращающего подхода.


image

Прелестно, не правда ли? Но мы говорили о трансдьюсерах. Где же наши трансдьюсеры?
Получается, filterReducer и mapReducer, которые мы создали, сокращают функцию. Это можно выразить как:


reducing-function :: result, input -> result

Трансдьюсеры являются функциями, которые принимают сокращающие функции и возвращают сокращающие функции. Это может быть выражено следующим образом:


transducer :: (result, input -> result) -> (result, input -> result)

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


Построим свои собственные трансдьюсеры.


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


const mapTransducer = (mapper) => (reducingFunction) => {
  return (result, input) => reducingFunction(result, mapper(input));
}
const filterTransducer = (predicate) => (reducingFunction) => {
  return (result, input) => predicate(input)
    ? reducingFunction(result, input)
    : result;
}

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


const concatReducer = (result, input) => result.concat(input);
const lowerThan6 = filterTransducer((value) => value < 6);
const double = mapTransducer((value) => value * 2);
const numbers = [1, 2, 3];
// Using Ramda's compose here
const xform = R.compose(double, lowerThan6);
const output = numbers.reduce(xform(concatReducer), []); // [2, 4]

Функция “concatReducer” называется функцией итератора. Он будет вызываться на каждой итерации и будет отвечать за преобразование выходной функции трансдьюсера.


В этом примере мы просто конкатенируем результат. Поскольку каждый трансдьюсер принимает только функцию сокращения, мы не можем использовать value.concat.


Когда мы составляем несколько преобразователей в одну функцию, чаще всего это называется xform трансдьюсер. Поэтому, когда вы увидите это где-то, вы будете знать, что это значит.


Составление нескольких преобразователей.


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


Потребуется немного поразмыслить, чтобы понять, почему это именно так: учитывая, что наш трансдьюсер double возвращает функцию редьюсирования, и lowerThan6 также возвращает функцию редьюсирования, при соединении оных, значение double будет передано в lowerThan6 и в результате получим функцию lowerThan6. Таким образом, double — результат композиции а порядок оценки применяется слева направо.


Я создал пример, так что вы можете взглянуть на это сами.


Использование RamdaJS для оптимизации читабельности.


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


const lowerThan6 = R.filter((value) => value < 6);
const double = R.map((value) => value * 2);
const numbers = [1, 2, 3];
const xform = R.compose(double, lowerThan6);
const output = R.into([], xform, numbers); // [2,4]

Ramda дает возможность использовать ее мапы и фильтры. Это потому, что внутренний редьюсирующий метод Ramda использует встроенный Transducer Protocol.


“Цель этого протокола заключается в том, что во всех JavaScript реализациях трансдьюсера, взаимодействие происходит независимо от поверхностного уровня API. Он вызывает преобразователи независимо от контекста источников их ввода и вывода, и указывает только суть трансформации с точки зрения отдельного элемента.
Поскольку трансдьюсеры отделены от источников их ввода и вывода, они могут быть использованы во многих различных процессах — коллекциях, стримах, каналах, обозревателях и т. д. Преобразователи компонуют напрямую, без привязки к вводу или созданию промежуточных агрегатов”.

Заключение


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


И особенно продуктивно они применяемы при преобразовании больших массивов данных, но вы можете также использовать тот же преобразователь для работы с единичной записью.


Больше информации по данной теме можно найти в следующих статьях

Комментарии (7)


  1. JSmitty
    31.01.2018 17:44

    код копипастите вместе с ошибками из оригинала:

    const filterReducer (predicate) => (result, input) => {
      return predicate(input) ? result.concat(input) : result;
    };
    

    Пропущено "=" после filterReducer.

    И игру слов потеряли (там, где «хаха»).


    1. VladimirChe Автор
      31.01.2018 18:53

      Спасибо, исправился :)
      Про "(хаха!)", это издержки буквального перевода, где акцент стоит именно на слове, после которого идет "(хаха!)" :)


  1. dom1n1k
    01.02.2018 01:00

    По-моему, превосходный способ затруднить чтение, понимание и отладку кода.
    Если производительность критична — проще использовать обычный процедурный подход. В противном случае привычную функциональщину filter/map/etc.


    1. taujavarob
      02.02.2018 00:48

      dom1n1k

      По-моему, превосходный способ затруднить чтение, понимание и отладку кода.

      JSmitty JSmitty
      Пропущено "=" после filterReducer.

      Никакие пропуски, в данном случае, чего либо в этом коде никак не влияют ни на отладку ни на понимание этого когда.


  1. ggo
    01.02.2018 10:16
    +1

    Быстрее то стало? Тестировали?


    1. Juribiyan
      01.02.2018 12:35

      Полагаю, прирост производительности будет примерно как у Lazy.js, в основе которого лежит та же идея. У них на сайте есть сравнения.


  1. faiwer
    01.02.2018 10:45
    +2

    Безотносительно трансдьюсеров, мне очень сильно по глазам бьёт когда я вижу, что-то вроде:


    const mapReducer = (mapper) => (result, input) => {
      return result.concat(mapper(input));
    };

    Нужно понимать, что .concat создаёт новый array. Каждый раз. Т.е. вообще всегда. И чем больше array.length, тем дольше эта операция длится. У нас не haskell. Такие операции ооочень дорогие. Наверняка это учтено в rambda и там используется мутабельный подход (не уверен, не проверял). Но в ваших примерах как раз идеалистический иммутабельный подход.


    Дык какой смысл рассуждать о преимуществах таких вот трандьюсеров на коленках, если на реальных больших массивах, они будут в сотни тысяч раз медленнее императивного for-of цикла? У вас же аллокационный ад по перекладыванию всего массива в каждом звене-reducer-е. Зачем тогда рассуждать о какой-то производительности при этом, если вы своими же руками её просто уничтожили?


    P.S. лечится это тем, что reducer просто использует .push вместо .concat и возвращает тот же массив, что и получил.
    P.S.S. ничего против редьюсеров и трансдьюсеров не имею, просто нельзя их так идеалистично готовить, они при этом теряют всякий смысл и превращаются в пародию.