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 из первой части).Итак, сейчас мы можем:
- Изменять элементы (прим. map)
- Пропускать элементы (прим. filter)
- Выдавать для одного элемента несколько новых (прим. 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.

