Трансдьюсеры были анонсированы еще в далеком 2014, с тех пор по ним было написано немалое количество статей (раз, два), но ни после одной статьи я не мог сказать, что понимаю трансдьюсеры кристально ясно. После каждой статьи у меня возникало ощущение, что я приблизительно понимаю что-то сложное, но оно все равно оставалось сложным. А потом однажды в голове что-то щелкнуло: "я ведь уже видел этот паттерн, только он назывался иначе!"


Задача:


Есть массив scores, содержащий результаты проведенных мною игр в футбол в виде объекта с полями:


  • gameID — номер игры;
  • my — количество очков у меня;
  • others — количество очков у моего противника.

Необходимо найти номера первых двух выигранных мною игр.


Исходные данные для задачи (ответ для таких данных — [1, 3]):


const scores = [
  { gameID: 0, my: 1, others: 2 },
  { gameID: 1, my: 2, others: 1 },
  { gameID: 2, my: 0, others: 3 },
  { gameID: 3, my: 3, others: 2 },
  { gameID: 4, my: 3, others: 1 },
  { gameID: 5, my: 0, others: 0 },
  { gameID: 6, my: 4, others: 1 },  
]

Решение №1. Императивный цикл


Давайте начнем издалека, вернемся в те далекие дни, когда мы писали обычные императивные циклы и мучились изменяемым состоянием (соглашусь, что в данном примере это не вызывает каких-либо проблем):


const result = []

// Пробегаемся по всем играм, пока количество результатов не достигнет двух.
for (let i = 0; i < scores.length && result.length < 2; i++) {
  const { gameID, my, others } = scores[i]

  if (my > others) {
    result.push(gameID)
  }
}

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


Решение №2. Array#map и Array#filter


Это уже решение иммутабельное, более выразительное, "современное".


const result =
  scores
    .filter(({ my, others }) => my > others) // выбираем выигрышные
    .map(({ gameID }) => gameID)             // получаем их номер
    .slice(0, 2)                             // берем первые два

Именно так решило бы большинство js-разработчиков в наши дни, полагаю. Но у этого решения есть одна важная проблема: если в предыдущем решении мы делали всего один проход, да и тот не до конца, то сейчас у нас уже аж два полных прохода. Если бы у нас scores содержал миллион элементов, то предикат в filter вызывался бы миллион раз, функция в map применилась бы меньшее, но все равно большое число раз, а в конце мы все равно берем всего лишь два первых элемента. Конечно, преждевременная оптимизация — зло, но это уже ни в какие ворота.


Решение №3. Свертка


Через свертки можно определить [почти] любую операцию над массивами. В этом решении у нас всего один проход:


const result = scores.reduce((result, { my, others, gameID }) => {
  // Если игра выиграна и количество результатов меньше двух,
  // то добавляем номер в результаты.
  if (my > others && result.length < 2) {
    return result.concat([gameID])
  }

  return result
}, [])

Это чем-то напоминает решение с циклами, но тут мы передаем промежуточное состояние явно и иммутабельно. Но проблема осталась — вместо двух проходов у нас теперь один, но он все равно полный, то есть при миллионе элементов мы пройдемся по всему миллиону, даже если нужное число результатов мы уже получили, ведь у стандартного reduce нет возможности выйти из цикла через break. Давайте тогда напишем свой reduce, из которого можно выйти, если вернуть reduced-значение (идею позаимствовал из clojure).


// Класс-обертка для итогового значения аккумулятора.
class Reduced {
  constructor(value) {
    this.value = value
  }
}

const isReduced = value =>
  value instanceof Reduced

const reduced = value =>
  isReduced(value) ? value : new Reduced(value)

// Рекурсивная реализация reduce с проверкой аккумулятора на reduced-значение.
const reduce = (reducer, state, array) => {
  // Если аккумулятор - reduced-контейнер со значением,
  // то распаковываем и возвращаем значение, завершая цикл.
  if (isReduced(state)) {
    return state.value
  }

  if (array.length === 0) {
    return state
  }

  // Рекурсивно вызовем reduce для хвоста массива,
  // применяя первый элемент к состоянию.
  const [x, ...xs] = array
  return reduce(reducer, reducer(state, x), xs)
}

const result = reduce((result, { my, others, }) => {
  if (my > others) {
    if (result.length < 2) {
      result = result.concat(gameID)
    }

    // Завершаем цикл, если набрано нужно число результатов.
    if (result.length >= 2) {
      result = reduced(result)
    }
  }

  return result
}, [], scores)

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


Решение №4. Декомпозиция редьюсера, трансдьюсеры


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


  1. filterWins фильтрует игры по статусу, то есть пропускает дальше по цепочке только выигранные игры.
  2. mapGameID достает из игры ее номер.
  3. firstTwo проверяет количество полученных результатов. Если их меньше двух, то вызывает следующую функцию, получает новые результаты, а потом завершает цикл, если набрано нужное количество.
  4. appendToResult добавляет номер игры в массив с результатами и возвращает его.

// next - следующая функция в цепочке
const filterWins = next => (result, score) =>
  // Если игра выигрышная, то передаем ее дальше по цепочке
  score.my > score.others
    ? next(result, score)
    : result

const mapGameID = next => (result, { gameID }) =>
  // Передаем дальше номер игры
  next(result, gameID)

// Эта функция позволяет пропустить через себя ограниченное число элементов.
const firstTwo = next => {
  // Мы используем замыкания для добавления локального состояния к нашим редьюсерам.
  // n - количество элементов, которые нужно пропустить дальше для добавления в
  // массив результатов.
  let n = 2

  return (result, score) => {
    // Если есть свободные места, пропускаем игру дальше и уменьшаем их количество.
    if (n > 0) {
      result = next(result, score)
      n -= 1
    }

    // Если свободных мест нет, завершаем цикл.
    if (n <= 0) {
      result = reduced(result)
    }

    return result
  }
}

const appendToResult = (result, gameID) =>
  result.concat([gameID])

const result = reduce(
  // Последовательно трансформируем редьюсер, применяя наши функции к
  // редьюсеру, создавая тем самым цепочку редьюсеров.
  filterWins(mapGameID(firstTwo(appendToResult))),
  [],
  scores,
)

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


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


Те, кто слышит понятие middleware впервые, могут ознакомится с ним тут: express.js, laravel, а так же я пробовал вчера объяснить его своими словами: мой пост


В данном решении у нас три трансдьюсера: filterWins, mapGameID и firstTwo, и мы их по последовательно применяем к редьюсеру appendToResult, создавая все более и более сложный редьюсер.


А теперь давайте унифицируем наши трансдьюсеры:


// pred - предикат - функция, которая принимает значение и возвращает true или false.
// Если предикат возвращает true, то пропускаем значение по цепочке дальше, иначе
// возвращаем неизмененный аккумулятор.
const filter = pred => next => (acc, x) =>
  pred(x) ? next(acc, x) : acc

// mapper - функция, которая преобразует один элемент в другой.
const map = mapper => next => (acc, x) =>
  next(acc, mapper(x))

// Принимает редьюсер и создает новый, который выполнится только n раз.
// После n-ого выполнения цикл завершится.
const take = n => next => (acc, x) => {
  if (n > 0) {
    acc = next(acc, x)
    n -= 1
  }

  if (n <= 0) {
    acc = reduced(acc)
  }

  return acc
}

Заодно переименуем редьюсер:


const append = (xs, x) =>
  xs.concat([x])

А так же добавим хэлпер для композиции функций, чтобы убрать лишние скобки:


const compose = (...fns) => x =>
  fns.reduceRight((x, fn) => fn(x), x)

Добавим хэлпер, который применит транcдьюсер к редьюсеру и вызовет reduce с получившимся редьюсером:


const transduce = (transducer, reducer, init, array) =>
  reduce(transducer(reducer), init, array)

Ну и наконец решим задачу с помощью всех этих функций:


const firstTwoWins = compose(
  filter(({ my, others }) => my > others),
  map(({ gameID }) => gameID),
  take(2),
)

const result = transduce(firstTwoWins, append, [], scores)

Получилось красивое, быстрое, функциональное, иммутабельное, готовое к переиспользованию решение.


Вывод:


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


Писать весь код для работы с трансдьюсерами каждый раз вручную не придется, ведь трансдьюсеры уже давно реализованы во многих библиотеках для обработки данных (например в ramda.js или стандартной библиотеке clojure)


Ramda:


const scores = [
  { gameID: 0, my: 1, others: 2 },
  { gameID: 1, my: 2, others: 1 },
  { gameID: 2, my: 0, others: 3 },
  { gameID: 3, my: 3, others: 2 },
  { gameID: 4, my: 3, others: 1 },
  { gameID: 5, my: 0, others: 0 },
  { gameID: 6, my: 4, others: 1 },  
]

{
  const firstTwoWins = compose(
    filter(({ my, others }) => my > others),
    pluck("gameID"),
    take(2),
  )

  const result = transduce(firstTwoWins, flip(append), [], scores)
}

// Или еще лучше
{
  const firstTwoWins = into([], compose(
    filter(({ my, others }) => my > others),
    pluck("gameID"),
    take(2),
  ))

  const result = firstTwoWins(scores)
}

Clojure:


(def scores [{:game-id 0, :my 1, :others 2}
             {:game-id 1, :my 2, :others 1}
             {:game-id 2, :my 0, :others 3}
             {:game-id 3, :my 3, :others 2}
             {:game-id 4, :my 3, :others 1}
             {:game-id 5, :my 0, :others 0}
             {:game-id 6, :my 4, :others 1}])

(defn win? [{:keys [my others]}]
  (> my others))

(def first-two-wins
  (comp (filter win?)
        (map :game-id)
        (take 2)))

(def result (transduce first-two-wins conj [] scores)) ;; [1 3]
Поделиться с друзьями
-->

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


  1. artemonster
    01.04.2017 15:28

    а где шутка?


    1. smallplushbear
      01.04.2017 15:59
      +2

      "… Вы пишете на этом языке лисп, а потом собственно программу" Грэм кажется.
      Или сразу пишите на лиспе. :)


  1. elmigranto
    01.04.2017 16:47

    Условие выхода в for некорректно, запятая это не && и, если побед в массиве нет, цикл не остановится никогда.


    1. kana-desu
      01.04.2017 16:56

      Исправил, спасибо.


  1. sshikov
    01.04.2017 17:03
    +1

    Честно говоря, не встречался раньше с таким названием паттерна, как middleware. Возможно, это ограниченнойсть моих познаний, но наверное ссылочка бы не помешала.


    1. kana-desu
      01.04.2017 17:34
      +1

      Добавил ссылки на описание с документации express.js, laravel, а так же на свое приблизительно объяснение.


      1. sshikov
        01.04.2017 17:57
        +3

        Я теперь понял, о чем вы. Пожалуй, в классическом понимании, это описание ближе к паттерну chain of responsibility, например.

        Просто у термина middleware есть и другое, значительно более широко известное употребление, когда этим словом называют нечно (сервер или сервис), между backend и frontend, базой и web, ну и так далее. И там это не паттерн вообще.


        1. kana-desu
          01.04.2017 18:30
          +2

          Под паттерном я тут понимаю не паттерн проектирования, а просто какой-то повторяющийся кусок кода/повторяющаяся сигнатура. В данном случае под middleware я понимаю что-то вроде обертки, которая принимает функцию и возвращает новую с такой же сигнатурой, но с дополнительной логикой.


          1. sshikov
            01.04.2017 21:10
            +1

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

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

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


            1. VolCh
              03.04.2017 07:43

              Это просто функция высшего порядка, ничего больше.

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


              1. sshikov
                03.04.2017 08:44

                Ну да, один из видов. На входе редьюсеры (несколько штук), на выходе один редьюсер, результат их композиции. Насчет «той же сигнатуры» я кстати не был бы так уверен.

                А то определение, что вы написали — это и есть формулировка термина «функция высшего порядка». Один в один.


                1. VolCh
                  03.04.2017 10:14

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


                1. kana-desu
                  03.04.2017 10:15
                  +1

                  На входе всегда один редьсер. А вот с помощью операции композиции функций (не трандьюсеров) мы можем скомбинировать несколько, но мы можем сделать это и без нее по цеопчке t3(t2(t1(r)))


                  1. sshikov
                    03.04.2017 10:26

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


      1. redfs
        01.04.2017 20:35
        +2

        Еще мне нравится, как middleware объясняют на сайте Slim Там очень хорошая схема, сразу все понятно.

        Middleware
        image


  1. Horizontal
    01.04.2017 18:09
    +3

    <zanuda_mode>

    Очевидно, что Решение №2 следует записать в виде:

    const result =
      scores
        .filter(({ my, others }) => my > others) // выбираем выигрышные
        .slice(0, 2)                             // берем первые два
        .map(({ gameID }) => gameID)             // получаем их номер
    

    чтобы убрать «лишний» полный проход для получения gameID.

    </zanuda_mode>


    1. funca
      01.04.2017 18:15
      +1

      тут первый проход фильтром по всей коллекции тоже лишний.


      1. kana-desu
        01.04.2017 18:28
        +1

        А вот можно по-подробнее? Если сделать сперва .slice(0, 2), то мы получим первые две игры независимо от того, выигранные они или нет.


        1. funca
          01.04.2017 20:10
          +1

          Если в списке 100500 элементов и 2 выигрышных находятся в самом вначале, то неленивый filter все равно пройдет всю коллекцию, прежде чем отдаст результат в map, а тот в slice.

          const result =
            scores
              .filter(({ my, others }) => my > others)
              .map(({ gameID }) => gameID)
              .slice(0, 2)
          

          Если поменять местами map и slice, выигрыш будет. Но это все равно не самый оптимальный вариант. Реализация на базе композиции функций, описанная в статье, переберет лишь необходимое количество элементов.
          const result = transduce(compose(
            filter(({ my, others }) => my > others),
            map(({ gameID }) => gameID),
            take(2)
          ), append, [], scores)
          

          Чисто внешне код отличается не сильно.


    1. kana-desu
      01.04.2017 18:27
      +1

      Да, так и есть. Я довольно долго работал с ленивыми коллекциями и генераторами, в результате чего привык сначала делать все операции (зачастую над бесконечным списком), а после чего брать нужное количество результатов.


  1. funca
    01.04.2017 18:19
    +2

    Есть какое-то смутное подозрение, что если эту штуковину (функцию-трансдьсер с аргументом next) вывернуть наизнанку, должен получиться знакомый всем yield, с тем же самым next(), только уже внутри (объекта-генератора) %)


    1. aamonster
      01.04.2017 23:12
      +2

      Ещё бы. Если переписать это на Хаскеле (ленивость же!) — внезапно окажется, что тут фигурирует единственный тип сущностей: функция, делающая из одного ленивого списка другой. И генераторы — наиболее понятный аналог в некоторых языках...


      1. kana-desu
        01.04.2017 23:43

        Собственно, я переписывал это на хаскеле, но без нормальной реализации take.


        Заголовок спойлера

        image


        1. aamonster
          02.04.2017 08:52

          Ужас какой. По моим представлениям — должно быть в точности решение 2.


          1. kana-desu
            02.04.2017 09:20
            +1

            Из-за ленивости на хаскеле действительно эти трансдьсеры не нужны (если не нужно переиспользовать трансформации) и на js такое действительно можно написать только через генераторы, вы правы.


            x |> f = f x
            
            main =
                [1..]
                    |> filter even
                    |> map (* 2)
                    |> take 3
                    |> print -- [4, 8, 12]

            Несколько недель назад я такое тоже писал:
            image


            1. funca
              02.04.2017 19:56

              Интересно, от цикла for в каждой функции в варианте с yield можно как-нибудь избавится?


              1. kana-desu
                02.04.2017 20:21

                Не думаю, ведь вынести куда-то yield нельзя, разве нет? Лучше просто использовать какую-нибудь стримовую библиотеку типа xstream


  1. slonopotamus
    02.04.2017 01:13
    +5

    Теперь вопрос — в чём смысл этого всего? Ну допустим вы героически преодолели функциональщину чтобы она не убивала кхерам производительность, добившись результата идентичного (наверное) изначальному циклу. А ради чего? Увеличение количества уровней абстракции?


    1. HaMI
      02.04.2017 01:44

      Сравните решение 1 и решение 2 – решение 2 и есть «функциональщина» и оно намного более читаемо. При условии, что вы знаете набор очень простых примитивов(.map, .filter, .reduce и производные)

      Касательно всех остальных решений – вполне согласем с вашим мнением


    1. kana-desu
      02.04.2017 08:56
      +3

      Увеличение уровня абстракции в данном случае помогает использовать трансдьюсеры со всеми сворачиваемыми типами (массивы, стримы в rxjs, множества, словари) независимо от того, реализовали ли они функции map/filter/take. Чисто теоретически, если бы трансдьюсеры бы встроили в js, то из всех библиотек типа rxjs/xstream все эти трансформации можно было бы выпилить, оставив только reduce, потому что универсальные трансдьюсеры будут работать однообразно везде.


    1. Nakosika
      02.04.2017 13:05
      +1

      В смысле зачем map reduce и другие в принципе? Так чтобы эти тупые циклы не писать. Они ведь написаны уже, пора кинуть их на свалку и не писать велосипед в каждом файле по нескольку раз. Плюс целиком эта функциональная конструкция получается более надежной, т. к. количество if/else меньше то меньше вероятность сделать ошибку.


  1. lgorSL
    02.04.2017 01:16
    +1

    К слову со своей колокольни: в scala можно у коллекций вызвать метод view — вернётся обёртка над коллекцией и операции типа map, filter и т.п. начнут работать лениво.


    scores
        .view                                // благодаря этой строчке появляется "ленивость"
        .filter(x => x.my > x.others)        // выбираем выигрышные
        .map(_.gameId)                       // получаем их номер
        .take(2)                             // берем первые два

    причём результат — тоже view, "содержащий" 2 элемента и вычисляющий их в самый последний момент.


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


    1. kana-desu
      02.04.2017 08:52
      +1

      Да, можно, в js тоже есть библиотеки с ленивыми коллекциями, а в clojure все трансформации по умолчанию ленивы (но при этом ленивые трансформации проигрывают по скорости трансдьюсерам.


      Но работа с трансформациями как с объектом первого класса — фишка трансдьюсеров, которая позволяет
      а) переиспользовать их
      б) использовать одни и те же трансдьсеры для любых типов данных, которые поддерживают свертки (Foldable), скажем, стримы в rxjs (те же самые трансдьюсеры будут прекрасно работать и там), а значит из этих библиотек можно было бы спокойно выпилить все трансформации, если бы трансдьюсеры были бы нативными и общеиспользуемыми (маловероятное будущее, но в clojure стремятся к этому)


      image


    1. Vadem
      03.04.2017 13:46

      На C#, кстати, большинство методов для работы с коллекциями ленивые, так что такой код будет работать «из коробки»:

      scores
        .Where(score => score.my > score.others)
        .Select(score => score.gameID)
        .Take(2);
      


  1. HaMI
    02.04.2017 01:16
    +4

    Если использовать lodash (а его очень много людей использует), то лишнего прохода не будет благодаря Lazy Evaluation

      _(scores)
        .filter(({ my, others }) => my > others) // выбираем выигрышные
        .map(({ gameID }) => gameID)             // получаем их номер
        .take(2)
        .value()
    


    Но главное, ваш пример выглядит так будто вы очень простой(чем проще – тем надежнее) и короткий код (пусть даже не оптимальный, хотя lodash это исправляет) который работает, заменили на какой-то «матан», который в придачу еще и длинее. Зачем?


    1. kana-desu
      02.04.2017 08:44
      +5

      Как я уже сказал, писать все эти трансдьюсеры с нуля не придется. Почти во всех своих проектах я использую ramda (как аналог lodash, только функциональный), который уже имеет реализацию трансдьюсеров (пример в статье есть). Мало того, трансдьюсеры — паттерн намного проще, чем ленивые вычисления (в lodash). Да и и сама ramda намного проще и немагичнее, чем lodash. Так же трансдьюсеры позволяют переиспользовать код, потому что позволяют работать с трансформациями как с объектом первого класса, а потом применять их.


      Еще важно то, что трансдьюсеры работают где угодно, где есть reduce, то есть эти трансьдеюсеры будут работать что с lodash, что с ramda, что со стримами (при условии конечно, что они не зависят от реализации reduce, как функция take). Из этого следует вывод, что всякие xstream/rxjs можно было упросить, убрав оттуда filter/map/прочее и оставив только reduce.


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


      Ну и для сравнения с циклами: читать цепочку трансформация (будь то просто Array#map, или трансдьюсеры) проще, чем циклы с внутренней логикой, так как это декларативно.


      1. HaMI
        02.04.2017 12:48

        Вы не обижайтесь, но начните обосновывать свои утверждения – а то церковь свидетелей Трансдюсера выходит
        >паттерн намного проще, чем ленивые вычисления (в lodash)
        вас же не просят их имплементировать

        >Да и и сама ramda намного проще и немагичнее, чем lodash
        ни чем не обоснованое утверждение

        >трансдьюсеры работают где угодно
        это круто, но зачем? единственное здравое объяснение, которое приходит в голову – тестирование. Но это не проблема

        >убрав оттуда filter/map/прочее и оставив только reduce.
        я либо что-то не понимаю – либо вы что-то не договариваете.
        возьмем простой кусок

        const H = require('highland')
        
        H(process.stdin)
          .map(x => 'something' + b)
          .pipe(process.stdout)
        


        из вашего утверждения выходит что я .map(и любые другие преобразования) могу выразить через .reduce, но ведь .reduce возраващает нам результа когда «пройдет» по всей коллекции – а тут конца коллекции как бы нет.

        Я не поленился и проверил:

        const H = require('highland')
        
        const xf = require('transducers-js').map(H.add('b'));
        
        H(process.stdin)
          .transduce(xf)
          .pipe(process.stdout)
        


        И что интересно – он работает абсолютно аналогично первому варианту. Я что-то не понимаю про reduce или вы плохо объяснили?

        И самое главное: вы так и не объяснили – зачем все это? вы говорите: простой паттерн, который позволяет добится большой эффективности. Обоснуйте это утверждение,

        пока что как в вашем примере так и в моем – мы эффективно увеличили количество строк кода и количество зависимостей


        1. kana-desu
          02.04.2017 14:02
          +2

          вас же не просят их имплементировать

          они в понимании проще, чем ленивые коллекции, ведь это просто вызов функций по цепочке. Или вы к тому, что не обязательно понимать то, что используешь?


          ни чем не обоснованое утверждение

          да, это субъективное мнение после чтения исходного кода обеих библиотек


          это круто, но зачем? единственное здравое объяснение, которое приходит в голову – тестирование. Но это не проблема

          уменьшение повторения кода?


          Про последний пункт насчет того, зачем это нужно: трансдьсеры частично решают ту же проблему, что и ленивые коллекции в lodash. Но решение через трансдьюсеры проще хотя бы потому, что тут больше нет довольно сложных ленивых коллекций, нет лишний преобразований с _ и value, нет методов, только функции, и это самый обычный reduce. Вроде как обосновал, нет? (простой паттерн — ведь это просто последовательное выполнение функций, большая эффективность — не выполняются лишние трансформации).


          Ramda:


          const result = into([], compose(
            filter(({ my, others }) => my > others),
            pluck("gameID"),
            take(2),
          ), scores)

          Lodash:


            _(scores)
              .filter(({ my, others }) => my > others)
              .map("gameID")
              .take(2)
              .value()

          Да, обычный цикл конечно проще, но обычный цикл еще и мутабелен, и императивен, ну а (субъективно) читать императивный код сложнее, чем декларативный.


          Я мало что знаю про highload, но в xstream свертка вместо возвращения значение при завершении стрим возвращает новый, состоящий из результатов сверток после каждого элемента, то есть


          ------1-----1--2----1----1------
            fold((acc, x) => acc + x, 3)
          3-----4-----5--7----8----9------

          const map = fn => next => (acc, x) => next(acc, fn(x))
          const filter = pred => next => (acc, x) => pred(x) ? next(acc, x) : acc
          const compose = (f, g) => x => f(g(x))
          const append = (arr, x) => arr.concat([x])
          const isOdd = x => x % 2 === 1
          const inc = x => x + 1
          const double = x => x * 2
          
          const xs = xstream.default
          
          const button = document.querySelector("button")
          
          const clicks$ = xs.create({
            start(listener) {
              button.addEventListener("click", () => listener.next())
            },
            stop() {
              button.removeEventListener("click", next)
            }
          }).fold(inc, 0)
          
          const doubledOdds$ = clicks$.fold(compose(
            filter(isOdd),
            map(double),
          )(append), [])
          
          doubledOdds$.addListener({
            next(x) { console.log(x) }
          }) // [2, 6, 10, ...]

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


          Пока писал это, понял, что я недостаточно компетентен в этой области. Все, что могу сделать — дать ссылку на статью про трансдьюсеры от Рича Хикки — их создателя — https://clojure.org/reference/transducers. Там же есть ссылка на пост в блоге и видео.


  1. SDSWanderer
    02.04.2017 02:24
    +1

    На правах изврата:


    const win = ({ my, others }) => my > others
    const firstResult = scores.findIndex(win)
    const secondResult = firstResult + 1 + scores.slice(firstResult + 1).findIndex(win)

    В реальном проекте я бы конечно использовал первый или второй вариант.


  1. ticafro
    02.04.2017 08:35
    +1

    Что скажете про этот вариант?

    function archaeological(arr, {filter, map, take}) {
        let acc = 0,
            res = []
    
        for (let i = 0; i < arr.length; i++) {
          if (filter(arr[i])) {acc += 1; res.push(map(arr[i]))}
          if (take(acc)) return res
        }
      }
    
      const result = archaeological(scores, {
        filter({my, others}) {return my > others},
        take(x) {return x == 2},
        map({gameID, my}) {return {gameID, my}} })
    


    1. kana-desu
      02.04.2017 08:36
      +3

      Нууу, это выглядит странно


      1. ticafro
        02.04.2017 12:10
        +1

        в смысле странно? безопасный, легкий вариант для продакшена в 6 строчек кода..)
        немного сахарка для вызова функции

        const result = archaeological(scores, {
          filter: ({my, others}) => {return my > others},
          take  : 2,
          map   : 'gameID' })
        
        


  1. dotCypress
    02.04.2017 10:11
    +3

    Отличная статья, кратко и со смыслом!

    Поделюсь своим велосипедом:
    Телеграм боты на трансдьюсерах


  1. APXEOLOG
    02.04.2017 11:19

    Немного смутила постановка задачи.


    Необходимо найти номера первых двух выигранных мною игр

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


  1. funca
    02.04.2017 13:14
    +1

    const take
    const take = n => next => (acc, x) => {
      if (n > 0) {
        acc = next(acc, x)
        n -= 1
      }
    
      if (n <= 0) {
        acc = reduced(acc)
      }
    
      return acc
    }
    


    1. kana-desu
      02.04.2017 14:13
      +2

      Как реализовать пустое перечисление, которое не вернет ни одно элемента?

      Нужно просто возвращать acc. reduce требует начальное состояние, именно ого и вернётся для пустого перечисления. При отстуствии начального состояния он берет первый элемент и при пустом массиве Array#reduce кидает исключение.


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


      • init — не принимает аргументов и должна вернуть начальное состояние. Зачастую просто вызывает next, то есть делегирует init на следуюущий трансдьюсер.
      • step — это то, что в статье — два аргумента: аккумулятор и элемент, возвращает аккумулятор
      • result — принимает один аргумент — аккумулятор, срабатывает после завершения reduce.

      Кажется такая реализация take не композится сама с собой. Если вызвать take(1)(take(1)), то результат будет обернут в Reduced дважды.

      Да, это действительно так, спасибо за замечание. Кложурная версия вместо reduced использует ensure-reduced, которая работает как identity, если значение уже обернуто. Исправлю.


      1. funca
        02.04.2017 19:53

        `take(0)` это допустимая операция для редьюсера? из реализации следует, что технически вызвать можно, но результат будет такой же как у `take(1)`. т.е. вернется не начальное значение, а результат вызова предыдущей функции. поэтому не понятно баг или фича.


        1. kana-desu
          02.04.2017 20:05
          +1

          take(0) вернет начальное состояние, потому что не пройдет первая проверка if (n > 0) и take вернет аккумулятор (а это и есть начальное состояние, если take до этого не выполнялся) вместо отправки аргументов по цепочке дальше.


          Другое дело, что все предыдущие редьюсеры выполнятся впустую.


    1. kana-desu
      02.04.2017 14:17
      +1

      Работать со значением в самом трансдьюсере тоже становится не просто:

      Да, это действительно проблема. Но проблема скорее в моей реализации функции reduce, можно попробовать придумать другой способ завершать её выполнение, но у меня идей нет. Можно сделать Reduced каким-нибудь прокси.


  1. corr256
    02.04.2017 14:19

    Отличная статья. Теперь когда кто-то предложит мне использовать трансдьюсеры, сразу будет послан лесом.


    1. kana-desu
      02.04.2017 14:20
      +2

      Рад, что статья хоть как-то оказалась вам полезна )


  1. Akuma
    02.04.2017 22:08
    -1

    Вместо пяти строк элементарного цикла, который будет работать очень шустро, вы предлагаете использать prev => next => (a,b,c) => (d,e,f) ад и 100500 строчек кода? :)

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


    1. kana-desu
      02.04.2017 22:25
      +3

      а) писать всё это не придется, все это уже реализовано в библиотеках для работы с данными (ramda, clojure.core)
      б) использовать императивные циклы я в принципе не считаю хорошей идей, а трансдьюсеры будут намного быстрее, чем .map/.fiter на массивах с большим количеством данных. Ленивые коллекции (как в хаскеле, кложе, lo-dash) или трандьюсеры тут лучший вариант.


      Но естественно использовать это для мелких задач поиска по массиву в 10 элементов — оверхед.


      1. nexmean
        03.04.2017 08:13

        использовать императивные циклы я в принципе не считаю хорошей идей

        Не завидую я пользователям вашего ПО.


        1. kana-desu
          03.04.2017 08:14
          +3

          К счастью, на языках, на которых я пишу, циклов в принципе нет (Clojure, для развлечения Elm и Haskell)


          1. nexmean
            03.04.2017 10:08
            -2

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


            Мне, если честно и самому нравятся функциональные решения, но(!) нужно понимать, что твоим кодом в первую очередь будут пользоваться и только во вторую — поддерживать. В связи с этим стоит всё же заменять все эти map(), reduce(), filter() и прочее на циклы во всех случаях, кроме тех, где это серьёзно ударит по читаемости и возможности повторно использовать код. Нет, не везде, но в JS точно.


            1. VolCh
              03.04.2017 10:18
              +1

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


            1. faiwer
              03.04.2017 11:15
              +1

              В связи с этим стоит всё же заменять все эти map(), reduce(), filter() и прочее на циклы во всех случаях, кроме тех ...

              Может всё-таки строго наоборот? Писать понятный удобный код пока не упрёшься в производительность. А вот уже затем профилирование и оптимизация. И скорее всего там не замена .map на for ;; будет, а что-нибудь уровня организации кода.


              К тому же не стоит забывать, что js это интерпретируемый язык, в котором за кадром происходит очень много "магии", и все эти callback-и легко могут за-inline-иться.


              Правда назвать вот такой вот код:


              const result = reduce(
                // Последовательно трансформируем редьюсер, применяя наши функции к
                // редьюсеру, создавая тем самым цепочку редьюсеров.
                filterWins(mapGameID(firstTwo(appendToResult))),
                [],
                scores,
              )

              Понятным и красивым у меня язык не поворачивается. Та изначальная for-простыня была куда нагляднее:


              const result = [];
              for(const { gameID, my, others } of scores)
                  if(result.length === 2)
                      break;
                  else if(my > others)
                      result.push(gameID);

              На мой взгляд JS в текущем виде с текущими тенденциями сильно не хватает |>-оператора. Все эти композиции функций с оборачиванием всего написанного до этого скобочками изрядно раздражают. Их, в большинстве случаев, ещё и "из глубины" читать надо, а не слева-направо и сверху-вниз.


              Касательно производительности. Тут мне кажется стоило бы избегать return arr.concat(el) и return Object.assign(obj, {...}) в reducer-ах в пользу мутабельных вариантов, т.к. разница может быть более чем тысячекратной (проверял на object.assign в reduce-цикле). Таки JS пока не Haskell :)


              1. faiwer
                03.04.2017 11:20

                Вот небольшой пример про 2600-кратное ускорение. Проверил его на свежей сборке хрома:


                mutable: 8.73486328125ms
                reduce: 19150.360107421875ms


                1. Maiami
                  03.04.2017 13:46
                  +1

                  Ваш код:

                  const keys = []; for(let i = 0; i < 100000; i += 10) keys.push(i); 
                  
                  console.time('mutable');
                  const obj1 = {};
                  for(let key of keys) obj1[key] = key;
                  console.timeEnd('mutable');
                  
                  console.time('reduce');
                  const obj2 = keys.reduce((o, key) => Object.assign({}, o, </b> [key]: key }), {});
                  console.timeEnd('reduce');
                  

                  Результат:
                  mutable: 3.034ms
                  reduce: 21646.400ms
                  


                  Исправленный код, в котором исправлена «опечатка»:
                  const keys = []; for(let i = 0; i < 100000; i += 10) keys.push(i); 
                  
                  console.time('mutable');
                  const obj1 = {};
                  for(let key of keys) obj1[key] = key;
                  console.timeEnd('mutable');
                  
                  console.time('reduce');
                  const obj2 = keys.reduce((o, key) => Object.assign(o || {}, { [key]: key }), {});
                  console.timeEnd('reduce');
                  

                  Результат:
                  mutable: 3.084ms
                  reduce: 11.763ms
                  


                  Ускорение всего лишь в 4 раза, поэтому можно не отказываться от Object.assign, если кому-то с ним удобнее/нагляднее.

                  Почему так получилось?
                  Вот эта строка:
                  Object.assign({}, o, { [key]: key })

                  Исправлена на такую:
                  Object.assign(o || {}, { [key]: key })

                  Из документации:
                  Метод Object.assign() копирует из исходных объектов в целевой объект

                  Поэтому хоть первый вариант тоже корректный, но он заставляет бесконечно клонировать объект «o» в новый пустой (каждый раз который увеличивается в размерах), вместо того, чтобы просто добавить новый ключ в уже существующий «o».


                  1. faiwer
                    03.04.2017 17:15

                    А почему вы решили, что там опечатка? В этом то суть была. Иммутабельность во имя бога иммутабельности. Аналог .concat, но для объектов. Правда я предполагаю, что concat должен быть таки оптимизированным и на деле мутировать объект (либо иметь хитрую структуру, позволяющую ему иметь часть данных общими в разных переменных), пусть хоть и за кадром, а вот с Object.assign такой фокус не прокатил.


                    1. Maiami
                      03.04.2017 17:54

                      Эээ…
                      Клонировать внутренний объект 10000 раз, только лишь для того, чтобы в результате забрать финальный клон — с трудом верю, что это именно то, что вы хотели сделать.
                      Именно это и делал ваш код (хотя ваш вариант с for делает другое).

                      Но если опечатки нет, и это действительно была ваша цель, то что-то вы не то делаете, к производительности js это ни как не относится, и речи про 2600-кратное ускорение тем более не идет.


                      1. faiwer
                        03.04.2017 18:16
                        -2

                        с трудом верю, что это именно то, что вы хотели сделать

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


                        Примерно также я думаю и про return arr.concat(el) в reduce-ах. Это выглядит несколько дико, не находите? Но упорно в каждой первой статье про reduce и иже с ним я вижу такие куски кода.


                        1. Maiami
                          03.04.2017 18:51
                          +3

                          Вот ваши слова:

                          Касательно производительности. Тут мне кажется стоило бы избегать return arr.concat(el) и return Object.assign(obj, {...}) в reducer-ах в пользу мутабельных вариантов, т.к. разница может быть более чем тысячекратной (проверял на object.assign в reduce-цикле). ...2600-кратное ускорение.

                          И вы приводите пример кода, который специально делает много лишней работы (раз вы говорите что это не опечатка).

                          Потом я привожу исправленный код, и выясняется, что никаких проблем с производительностью нет, Object.assign прекрасно работает в reduce-цикле без потери производительности.


                          1. kana-desu
                            03.04.2017 19:16
                            +1

                            Правильный выход — использовать персистентные структуры данных.


                            1. faiwer
                              03.04.2017 19:27
                              -2

                              @kana-desu, Насколько я понимаю, их в JS пока нет. Скажем персистентный hash-map. Да даже set. А на уровне языка их не воссоздать.


                              1. sshikov
                                04.04.2017 20:31
                                +2

                                Э, почему? Ну т.е. я готов поверить, что может получиться медленно и плохо, но по-идее ничего не должно мешать реализации.


                              1. kana-desu
                                05.04.2017 10:28
                                +2

                                Да, скажем, ClojureScript вполне спокойно реализовал всякие персистентные иммутабельные типы типа векторов, списков, хэш-мапов, множеств на js. Все это прогоняется через google closure compiler и реакт-приложения с иммутабельным стейтом на кложовских типах данных работает быстрее, чем react + redux на pure js-объектах.


                                1. faiwer
                                  05.04.2017 10:32

                                  @kana-desu, спасибо за наводку. Посмотрю как оно там внутри устроено.


                          1. faiwer
                            03.04.2017 19:21

                            Моя вина, ок. В процитированной вами цитате я хотел указать Object.assign({}, obj, т.к. речь вёл про true иммутабельность и ФП, но поторопился. Правда я думал, что это очевидно. Но судя по хейтеру набежавшему на мои комментарии (lol), таки не очевидно. Ок поясню мысль детальнее: в рамках ФП большое значение имеет immutable значения. Reduce в ФП предполагает проброс в него чистой функции. В рамках чистой функции Object.assign(obj... это грубейшая ошибка. Но в рамках здравого смысла это практически единственный допустимый вариант в циклах приличной длины.


                            1. Maiami
                              03.04.2017 19:38

                              Именно поэтому надо избегать Object.assign в reduce, потому что это не тру вэй ФП/чистые_функции, хотя производительность такая же как с вариантом с for-ом?
                              … так бы я написала, если бы то что вы написали имело смысл.

                              Какая разница с точки зрения иммутабельности в коде:
                              Object.assign({}, o, [key]: key }) (медленный вариант)
                              Object.assign(o || {}, [key]: key }) (быстрый вариант)
                              Если в обоих случаях «o» это внутренний объект reduce?

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

                              Еще говорят, что у меня женская логика…

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


                              1. faiwer
                                03.04.2017 19:44
                                -1

                                Какая разница с точки зрения иммутабельности в коде

                                Разница исключительно в подходе. Баланс между разумностью и фанатичностью. Мы с вами говорим об одном и том же, но разными словами. Даже выводы одни и те же.


                                1. Maiami
                                  03.04.2017 19:58
                                  +2

                                  То есть это не вы рекомендовали не использовать Object.assign, потому что якобы он в тысячи раз медленнее в reduce?

                                  Мы с вами говорим об одном и том же, но разными словами. Даже выводы одни и те же.
                                  Ну да, получается мы об «одном и том же», я привожу данные что падения производительности нет, а вы утверждаете что в 2600 раз медленнее. Ну да, видно что вывод один в один.


                                  1. faiwer
                                    03.04.2017 20:01

                                    Maiami, предлагаю продолжить в личку.


                                    1. Maiami
                                      03.04.2017 20:05

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


                                      1. faiwer
                                        03.04.2017 20:22

                                        Поясню, я имел, и имею ввиду ровно следующее: Object.assign({}, o, diff это медленно, очень медленно. И в цикле так поступать нельзя. Примерно та же ситуация с arr.concat(...items) (но тут допускаю наличие оптимизаций). Отсюда вывод, если возникает желание расширять объект в reduce, то лучше либо мутировать объект напрямую, либо через тот же Object.assign, но задав ему первым аргументом сам объект. Таким образом мы нарушаем принцип иммутабельности, но это разумный компромисс, в особенности, учитывая то, что за пределами цельного вызова reduce ни один объект не модифицируется. Насколько я знаю, примерно также себя ведёт монада St в Haskell. Это совпадает с вашими словами? Да.


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


                                        Если даже после ^ ничего непонятно, то поясню совсем уж тезисно:


                                        • Object.assign({}, в цикле ? плохо
                                        • arr.concat(newEl) в цикле, вероятно, тоже плохо
                                        • Object.assign(el, diff — нормально
                                        • точечная мутабельность в функциональном JS — приемлемый компромисс


                                        1. Maiami
                                          03.04.2017 20:47
                                          +2

                                          Нельзя просто написать «Не используйте, это медленно, вот цифры показывающие что в 2600 раз медленнее» и надеяться, что все молча это схавают.

                                          Надоело смотреть на то, как никто не хочет отвечать за свои слова «ой, я не то имел ввиду, ой, меня не так поняли, ой я говорил про сферическое фп в вакуме».


                                          1. faiwer
                                            03.04.2017 20:53

                                            Ну вот откуда в вас столько желчи? :(


                  1. faiwer
                    03.04.2017 17:35
                    -1

                    К слову, в lodash один из моих любимых методов — _.transform, это примерно то же, что и reduce, но с явным уклоном на мутабельность. Не требуется return. В таком случае там было бы


                    .transform((o, key) => { o[key] = key; }, {})

                    К тому же он поддерживает break (return false).


  1. TargetSan
    02.04.2017 23:29

    Я честно говоря так и не понял, чем это лучше Iterable, который гораздо понятней и для реализации поддержки в коллекции, и для построения полностью ленивых трансформаций поверх такой ленивой последовательности — в т.ч. reduce.


    1. sshikov
      03.04.2017 08:59

      Iterable слишком расплывчатый термин. Можете пояснить? Тут речь идет не просто о reduce, а о композиции нескольких редьюсеров, разве iterable это про композицию?


      1. TargetSan
        03.04.2017 11:33

        Другими словами, у вас в качестве базовой операции выступает reduce, а также целая обвязка вокруг неё. Чем такой подход лучше простого поэлементного прохода по коллекции как базовой операции? Какие случаи можно (или значительно проще) записать на трансдьюсерах, но нельзя (значительно сложнее) на итераторах? Пока что я вижу, что трансдьюсеры:


        • требуют нетривиальную логику во вспомогательном коде
        • требуют более нетривиальной работы с пробросом состояния в рекурсивной манере
        • применяются к коллекциям точно также через обёртки

        Или весь вопрос исключительно в получении функционального иммутабельного ленивого аналога итераторов без ленивых коллекций?


        1. sshikov
          03.04.2017 12:07

          Да, это вот все изначально — про reduce и их композицию.

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

          Что касается остального, то кратко ответ нет, это не совсем так. Нетривиальной логики там нет никакой, как впрочем и состояния. Его и в reduce нет, вообще говоря. Редьюсер это чистая функция.

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

          >Или весь вопрос исключительно в получении функционального иммутабельного ленивого аналога итераторов без ленивых коллекций?

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

          Собственно, вот оригинальный анонс: http://blog.cognitect.com/blog/2014/8/6/transducers-are-coming


  1. vba
    03.04.2017 09:33

    Ух, теперь-то у нас все быстро (один проход и ровно до тех пор, пока мы не получим нужное количество элементов), но красиво ли?

    JS коварен, тут не все так быстро как хотелось бы. Все в одном фор цикле будет пожалуй быстрее.


    На мой взгляд трансдьюсеры ака преобразователи как оптимизация, основаны на reduce и если вы понимаете что есть данный оператор и как он работает то вы поймете что такое преобразователь и как он работает.