Трансд'сер в JavaScript. Частина друга

першої частини ми зупинилися на наступному специфікації: Трансд'юсером — це функція приймає функцію
step
, і повертає нову функцію
step
.

step⁰ → step1

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

result⁰, item → result1

Щоб отримати новий поточний результат у функції
step1
, потрібно викликати функцію
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 → result1 | reduced(result1)

Але функція
_.reduce
вже не зможе обробляти таку версію трансдьюсеров. Доведеться написати нову.

function reduce(coll, fn, seed) {
var result = seed;
for (var i = 0; i < coll.length; i++) {
result = fn(result, стригти[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 initialisedTransducer = transducer(append); // У момент виклику цієї функції створюються стану.
// initialisedTransducer містить у собі лічильник,
// та його (initialisedTransducer) слід використовувати тільки в рамках
// цього циклу обробки колекції, після чого знищити.
return reduce(coll, initialisedTransducer, 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⁰ → result1 *
result⁰, item → result1 | reduced(result1)

* я не впевнений тут може повертатися reduced(result1), з виступу Річа це не ясно. Будемо поки вважати, що не може.


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

function transduce(transducer, append, seed, coll) {
var initialisedTransducer = transducer(append);
var result = reduce(coll, initialisedTransducer, seed);
return initialisedTransducer(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⁰ → result1
result⁰, item → result1 | reduced(result1)

Оновимо функції
transduce()
та
append()


function transduce(transducer, append, coll) {
var initialisedTransducer = transducer(append);
var seed = initialisedTransducer();
var result = reduce(coll, initialisedTransducer, seed);
return initialisedTransducer(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.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.