В первой части мы остановились на следующей спецификации: Трансдьюсер — это функция принимающая функцию step, и возвращающая новую функцию step.

step⁰ → step¹

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

result⁰, item → result¹

Чтобы получить новый текущий результат в функции step¹, нужно вызвать функцию step⁰, передав в нее старый текущий результат и новое значение, которое мы хотим добавить. Если мы не хотим добавлять значение, то просто возвращем старый результат. Если хотим добавить одно значение, то вызываем step⁰, и то что он вернет возвращаем как новый результат. Если хотим добавить несколько значений, то вызываем step⁰ несколько раз по цепочке, это проще показать на примере реализации трансдьюсера flatten:

function flatten() {
  return function(step) {
    return function(result, item) {
      for (var i = 0; i < item.length; i++) {
        result = step(result, item[i]);
      }
      return result;
    }
  }
}

var flattenT = flatten();

_.reduce([[1, 2], [], [3]], flattenT(append), []); // => [1, 2, 3]

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

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

Итак, сейчас мы можем:
  1. Изменять элементы (прим. map)
  2. Пропускать элементы (прим. filter)
  3. Выдавать для одного элемента несколько новых (прим. flatten)


Преждевремен��ое завершение


Но что, если мы хотим прервать весь процесс посередине? Т.е. реализовать take, например. Для этого Рич предлагает заворачивать возвращаемое значение в специальную обертку «reduced».

function Reduced(wrapped) {
  this._wrapped = wrapped;
}
Reduced.prototype.unwrap = function() {
  return this._wrapped;
}
Reduced.isReduced = function(obj) {
  return (obj instanceof Reduced);
}

function take(n) {
  return function(step) {
    var count = 0;
    return function(result, item) {
      if (count++ < n) {
        return step(result, item);
      } else {
        return new Reduced(result);
      }
    }
  }
}

var first5T = take(5);

Если мы хотим завершить процесс, то, вместо того чтобы вернуть очередной result как обычно, возвращаем result, завернутый в Reduced. Сразу обновим сигнатуру функции step:

result⁰, item → result¹ | reduced(result¹)

Но функция _.reduce уже не сможет обрабатывать такую версию трансдьюсеров. Придется написать новую.

function reduce(coll, fn, seed) {
  var result = seed;
  for (var i = 0; i < coll.length; i++) {
    result = fn(result, coll[i]);
    if (Reduced.isReduced(result)) {
      return result.unwrap();
    }
  }
  return result;
}

Теперь можно применить трансдьюсер first5T.

reduce([1, 2, 3, 4, 5, 6, 7], first5T(append), []);    // => [1, 2, 3, 4, 5]


Еще придется добавлять проверку Reduced.isReduced(result) в трансдьюсеры, которые несколько раз вызывают step (прим. flatten). Т.е. если во flatten при очердном вызове step нам вернут результат завернутый в Reduced, мы обязаны завершить свой цикл, и вернуть этот завернутый результат.

Состояние


Еще одна важная деталь, трансдьюсер take имеет состояние. Он запоминает сколько элементов уже через него прошло. ��тобы всё работало правильно, этот счетчик нужно создавать именно в том месте, где он создан в примере (см. var count), т.е. внутри функции, которая возвращает step. Если бы это была, например, глобальная переменная, то мы бы считали элементы для всех трансдьюсеров типа take в одном счетчике, и получали бы неправильный результат.

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

function transduce(transducer, append, seed, coll) {
  var step = transducer(append);  // В момент вызова этой функции создаются состояния.
                                  // step содержит в себе счетчик,
                                  // и его (step) следует использовать только в рамках
                                  // этого цикла обработки коллекции, после чего уничтожить.
  return reduce(coll, step, seed);
}

transduce(first5T, append, [], [1, 2, 3, 4, 5, 6, 7]);    // => [1, 2, 3, 4, 5]


Завершение


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

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

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

В clojure эти две функции объединяются в одну, мы в JavaScript тоже можем так сделать.

function step(result, item) {
  if (arguments.length === 2) { // обычный вызов
    // возвращаем step(result, item) или что вам нужно
  }
  if (arguments.length === 1) { // завершительный вызов
    // Здесь необходимо вызвать step c одним аргументом, чтобы передать завершающий сигнал дальше.
    // Но если мы хотим что-то добавить в коллекцию в конце,
    // то мы должны сначала вызвать step с двумя аргументами, а потом с одним.

    // ничего не добавляем
    return step(result);

    // что-то добавляем
    result = step(result, что-то);
    return step(result);
  }
}

Обновим сигнатуру функции step, теперь у нее два варианта в зависимости от числа аргументов:

result⁰ → result¹ *
result⁰, item → result¹ | reduced(result¹)

* я не уверен может ли ��десь возвращаться reduced(result¹), из выступления Рича это не ясно. Будем пока считать что не может.


Все трансдьюсеры должны поддерживать обе операции — обычный шаг и завершительный вызов. Также функции transduce() и append() придется обновить, добавив поддержку завершительного вызова.

function transduce(transducer, append, seed, coll) {
  var step = transducer(append);
  var result = reduce(coll, step, seed);
  return step(result);
}

function append(result, item) {
  if (arguments.length === 2) {
    return result.concat([item]);
  }
  if (arguments.length === 1) {
    return result;
  }
}


Итак, вот реализация partition (разбивает коллекцию на маленькие коллекции):

function partition(n) {
  if (n < 1) {
    throw new Error('n должен быть не меньше 1');
  }
  return function(step) {
    var cur = [];
    return function(result, item) {
      if (arguments.length === 2) {
        cur.push(item);
        if (cur.length === n) {
          result = step(result, cur);
          cur = [];
          return result;
        } else {
          return result;
        }
      }
      if (arguments.length === 1) {
        if (cur.length > 0) {
          result = step(result, cur);
        }
        return step(result);
      }
    }
  }
}

var by3ItemsT = partition(3);

transduce(by3ItemsT, append, [], [1,2,3,4,5,6,7,8]);   // => [[1,2,3], [4,5,6], [7,8]]


Инициализация


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

Для этого нужно добавить еще один вариант функции step — вообще без параметров. Если step вызывается без параметров, она должна вернуть начальное значение, например пустой массив.

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

Обновим сигнатуру функции step.

→ result
result⁰ → result¹
result⁰, item → result¹ | reduced(result¹)

Обновим функции transduce() и append()

function transduce(transducer, append, coll) {
  var step = transducer(append);
  var seed = step();
  var result = reduce(coll, step, seed);
  return step(result);
}

function append(result, item) {
  if (arguments.length === 2) {
    return result.concat([item]);
  }
  if (arguments.length === 1) {
    return result;
  }
  if (arguments.length === 0) {
    return [];
  }
}

И перепишем для примера генератор трансдьюсеров map.

function map(fn) {
  return function(step) {
    return function(result, item) {
      if (arguments.length === 2) {
        return step(result, fn(item));
      }
      if (arguments.length === 1) {
        return step(result);
      }
      if (arguments.length === 0) {
        return step();
      }
    }
  }
}

Получается мы просто перенесли пустой массив из параметра transduce() внутрь append(), на первый взгляд это ненужное действие, но это дало нам возможность создавать трансдьюсеры, которые добавляют что-то в начало коллекции (как те что добавляют в конец, только наоборот).

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

Итоги


На этом всё. Я пересказал весь доклад Рича Хикки. И, как я понимаю, это пока вообще всё что можно рассказать про трансдьюсеры.

Подытожим еще раз что мы получили. Мы получили универсальный способ создавать операции над коллекциями. Эти операции могут: изменять элементы (map), пропускать элементы (filter), размножать элементы (flatten), иметь стейт (take, partition), преждевременно завершать обработку (take), добавлять что-то в конце (partition) и добавлять что-то вначале. Все эти операции мы можем легко объединять с помощью compose, и использовать как на обычных коллекциях, так, например, и в FRP. Кроме того, это всё будет работать быстро и потреблять мало памяти, т.к. не создается временных коллекций.

Это всё круто! Но как нам начать их использовать? Проблема в том, что чтобы использовать трансдьюсеры по максимуму, JavaScript сообщество должно договориться о спецификации (а мы это умеем, да? :-). Тогда мог бы реализоваться крутой сценарий, при котором библиотеки для работы с коллекциями (underscore и пр.) будут уметь создавать трансдьюсеры, а другие билиотеки, которые не совсем про коллекции (напр. FRP), будут просто поддерживать трансдьюсеры.

Спецификация которую предлагает Рич, на первый взгляд, неплохо ложится на JavaScript, за исключением детали про Reduced. Дело в том, что в Clojure уже есть глобальный Reduced (он там уже давно), а в JavaScript нет. Его, конечно, легко создать, но каждая библиотека, будет создавать свой Reduced. В итоге если я, например, захочу добавить поддержку трансдьюсеров в Kefir.js, мне придется добавлять поддержку трансдьюсеров-underscore, трансдьюсеров-LoDash и т.д. Reduced — это слабое место спецификации предлагаемой Ричем.

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

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