Как стать автором
Обновить

Комментарии 57

После прочтения Functional Javascript — без underscore\lodash ни одного проекта, а реактивность сейчас тоже куда ни глянь. И если будут удобные мосты между популярными инструментами — это будет победа.
Kifir для моделек например, React — View, и underscore для декларативного описания логики.
Странно, вместо конструкции `Reduced` можно же реализовать `take/limit` на подобии `filter`. Или я что-то упустил?
Ну a интерфейс функций для преобразователей нужно взять аналогичный стандартному `reduce`: `([[CurrentCtx]],[[Item]], [[Accessor]],[[RootCtx]], )`, где `Accessor` это индекс (в случае итерации по массиву).
Да, вы упустили время. take(5), который выполняет полный перебор по коллекции из миллиона элементов — это очень грустно.
Странно, вместо конструкции `Reduced` можно же реализовать `take/limit` на подобии `filter`. Или я что-то упустил?

Тогда остальная часть коллекции тоже будет обрабатываться, хоть и все элементы выбросит take. А коллекции могут быть и бесконечными.
Но мы же можем на нужном шаге вернуть `void 0`, тем самым сообщив нашему итератору, что нужно завершить работу и вернуть коллекцию из предыдущего шага.
/cc mayorovp
В таком случае ваш `void 0` является замаскированным Reduced.
Только в Reduced завернуто значение еще.
Это не играет роли, тут главная идея — специальное значение, которое прерывает итерацию.
Как не играет, значение нужно же вернуть. Вы скажите конкретно откуда вы хотите возвращать этот undefined?
Кроме того вы резервируете undefined для этой цели, а он вполне может использоваться как обычное значение в библиотеке использующей трансдьюсер.
Конкретно я не хочу возвращать undefined. И насчет того, что undefined может использоваться как обычное значение — я тоже согласен. Я говорю лишь о том, что undefined ничем не лучше Reduced.

Окончательное же значение коллекции tenbits предлагает брать с предыдущего шага. Это будет работать и даже ничуть не усложнит take.

Сорри, перепутал. Там result не обязательно будет тот же самый. Можно вернуть new Reduced(step(result, что-то)).
Да, а можно — вернуть просто step(result, что-то) — а следующий шаг отменить, как написал tenbits. Будет то же самое.
Мы тогда на один элемент больше обработаем, а в FRP, например, это может быть важно (позже по времени завершится поток).

И может где-то что-то еще не сойдется, сейчас не приходит в голову.
Позже по времени — не страшно: в конце концов, ждать-то мы его завершения не будем. А вот тот факт, что в таком случае возможен, к примеру, лишний запрос к серверу, действительно печален.
Можем и ждать, завершение потока может что-то значить в FRP иногда.
Больше чем с `Reduced` мы не обработаем, в случае с прерыванием `Reduced` и `void 0` — эквивалентны. Там где мы возвращаем `new Reduced` мы можем вообще ничего не возвращать, поэтому там тот `else` просто удаляется, а там где у нас `isReduced` появится `=== void 0`. как то так
Постойте, сам хоть кто-то попробовал подставить `void 0`? Дружно открываем devtools и копируем:

Код
var array = [1, 2, 3, 4, 5, 6, 7];
(function() {
    var i = 0;
    function reduce(coll, fn, seed) {
        var result = seed;
        for (; i < coll.length; i++) {
            result = fn(result, coll[i]);
            if (Reduced.isReduced(result)) {
                return result.unwrap();
            }
        }
        return result;
    }
    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);
    var result = reduce(array, first5T(append), []);
    console.log('> Reduced. Loops: %d Result: ', ++i, result);
}());

(function() {
    var i = 0;
    function reduce(coll, fn, ctx) {
        var prev = ctx;
        for (; i < coll.length; i++) {
            ctx = fn(ctx, coll[i]);
            if (ctx === void 0) 
                return prev;
            prev = ctx;
        }
        return ctx;
    }
    function take(n) {
        return function(step) {
            var count = 0;
            return function(result, item) {
                if (count++ < n) 
                    return step(result, item);
            }
        }
    }
    var first5T = take(5);
    var result = reduce(array, first5T(append), []);
    console.log('> Void 0. Loops: %d Result: ', ++i, result);
}());

function append(ctx, x) {
    return ctx.concat([x]);
}


У вас take неоптимален. Попробуйте вот этот:
    function take(n) {
        if (n==0) 
            return function(step) { 
                return function(result, item) {
                    return new Result(result);
                }
            }
        else
            return function(step) {
                var count = 0;
                return function(result, item) {
                    if (++count < n)
                        return step(result, item);
                    else
                        return new Reduced(step(result, item));
                }
            }
    }
и добейтесь того же самого поведения с `void 0`

PS
> Reduced. Loops: 5 Result: [1, 2, 3, 4, 5]
> Void 0. Loops: 6 Result: [1, 2, 3, 4, 5]
Также можно, правда `append` нужно сделать мутатором, что кстати и используется в «transducers.js», а иначе, если на каждом шагу создавать новый массив через `concat`, то о никакой производительности на коллекциях даже в тысячу элементов говорить никак нельзя.
function take(n) {
    return function(step) {
        var count = 0;
        return function(result, item) {
            if (++count < n)
                return step(result, item);
            else if (count === n)
                step(result, item);
        }
    }
}
function append(ctx, x) {
    ctx.push(x);
    return ctx;
}


Конечно же с `Reduced` у нас больше возможностей. Для подхода с дополнительными классами такое поведения — это самая нижняя планка, а вот для `void 0` — это максимум, что мы можем выжать. Но нужно ли вводить дополнительные классы/обьекты, если вполне пригодно можно обойтись без них? Хотя этот вопрос спекулятивный, так же как и ваше предложение мне изучить, что такое state.
В таком случае придется все трансдьюсеры писать в стиле мутаторов, даже те, которые изначально таковыми не являлись (к примеру, count). А в таком случае передавать ему первый элемент бессмысленно — то есть надо упрощать дальше.

А дальше мы получим, между прочим, поток. Внезапно :)
Добалю к тому что написал mayorovp.

Резервировать undefined всё таки неправильно, посмотрите мой пример с потоком habrahabr.ru/post/237733/#comment_7998887, я там использую null (потому что результат никуда сохранять не нужно), но кто-то может захотеть использовать undefined, и я считаю он имеет полное право использовать undefined.

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

Думаю идеальным было бы сделать такой редьюсд `{__transducersReduced__: true, wrapped: result}`, тогда мы наложим минимум ограничений, и избавимся от привязки к конкретной библиотеке.
Точнее в примере с потоком уже используется `undefined`. Там все step функции всегда возвращают `undefined`. Я даже и не заметил :)
Для порядка всёже спрошу: а что если у меня изначально коллекция из элементов класса Reduced- как мне её трансдьюсить?)
Нельзя такую коллекцию. Reduced мы зарезервировали, в этом нет ничего страшного.
Фишка `void 0` в том что это даже не значение. Функция может абсолютно ничего не возвращать и это уже будет `result === void 0`. Таким образом не нужно вводить дополнительную «обёртку» `Reduce`, и не нужно каждый раз вызывать instanceof. На коллекциях в миллион элементов это будет существенным улучшением.
Кстати, в LoDash для прерывания используется return false. Оба варианта — слаботипизированные breaker/Reduced :)
Ещё можно использовать объект, ссылка на него будет уникальной, а в новом стандарте вообще есть тип Symbol.

Тут главная разница в том, что Reduced содержит не только метку, но и сам результат, который далее анбоксится. Все остальные варианты перекладывают задачу на управляющую функцию transduce. Вероятно, Ричу более нравится «чистый» подход, с хранением результата, а мы в JS привыкли к флаговым значениям.
Ещё раз, `void 0 / undefined` — это отсутствие какого либо значение, а `null / false / Symbol / Reduced ` — это какое-то значение. В javascript это существенная разница. И учитывая, что нашa runner-функция ожидает `nextValue`, то вполне логично если она ничего не получила, тут же прервёт итерацию и вернёт `currentValue`.
Ещё одним недостатком `Reduced/Symbol` является то, что мы в систему добавляем новый апи, это значит, что наша `take` функция становится зависимой от `Reduced/Symbol`. Сами понимаете чем это чревато, а вот в случае с `void 0` — это лишь вопрос поведения.

В дополнение, `Reduced` вносит State в систему, поэтому я бы не назвал это чистым подходом. Чем меньше состояний тем лучше.
Хм, знаете, я не думаю, что передача Reduced через возвращаемое значение это стейт. Это просто бокс для значения. А вот хранение результата где-то в вызывающей функции и его использовании при появлении void 0, вот это уже стейт.

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

> А вот хранение результата где-то в вызывающей функции

Результат хранится в рамках одной функции, и лишь хранит ссылку на `current`/`next` значения, поэтому это состоянием назвать нельзя, так как обычно подразумевается, что состояние находится в системе (между некоторыми функциями/библиотеками/запросами). И вот когда мы создаем какой-то объект который содержит значение системы (в данном случае `reduced/notReduced`) и передает другим элементам системы — вот это и есть State. А то, что наш `runner` заканчивает работу при `void 0` — так это лишь поведение. `void 0` не содержит в себе никакого значения, поэтому и состоянием быть не может. Хотя тут можно философствовать, что «отсутствие состояния — является состоянием» )
И вот когда мы создаем какой-то объект который содержит значение системы (в данном случае `reduced/notReduced`) и передает другим элементам системы — вот это и есть State
Пожалуйста, изучите, что именно называется состоянием.
Можно :) Но тогда придётся создавать и ловить конкретное исключение, чтобы не маскировать другие исключения в случае, например, каких-то ошибок в логике функции.
Если честно, то не вижу существенных преимуществ перед генераторами. За исключением некоторых деталей, на генераторах можно делать аналогичные вещи:
1. Преждевременное завершение обработки коллекции как внутри генератора (по логике или с помощью посылаемого в генератор значения), так и снаружи — для цикла for-of это break, в остальных случаях просто перестаём вызывать у генератора next
2. Генераторы и компания, работают с любыми коллекциями, главное, чтобы они реализовывали Iterator Protocol
3. Генераторы умеют «выдавать для одного элемента несколько новых (прим. flatten)» — yield * iterator переключит вызываемый код на итерацию по iterator, а когда iterator закончится, вызывающий код вернётся в оригинальный генератор.

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

Пример с генераторами:

function * gen1(collection, limit = NaN) {
    let counter = 0;
    for ( let item of collection ) {
        yield `item is ${ item }`;

        if ( ++counter >= limit ) break;
    }
}


function * gen2(...collections) {
    for ( let collection of collections ) {
        yield * collection;

        if ( (yield 'end collection') == true ) break;
    }
}

let test = gen2( gen1([1, 2, 3, 4, 5, 6, 7], 3), [5, 6, 7], gen1(['a', 'b', 'c']), new Array(999).join('1|').split('|') );
let counter = 0, sendValue = void 0;

do {
    let {value, done} = test.next(sendValue);

    if ( value == 'end collection' ) {
        if ( ++counter > 2 ) sendValue = true;
    }
    else console.log(value);
}
while( done != true );

Вживую
Допустил опечатку в коде: done должен быть объявлен вне цикла do-while.
Полностью правильный код
function * gen1(collection, limit = NaN) {
    let counter = 0;
    for ( let item of collection ) {
        yield `item is ${ item }`;

        if ( ++counter >= limit ) break;
    }
}


function * gen2(...collections) {
    for ( let collection of collections ) {
        yield * collection;

        if ( (yield 'end collection') == true ) break;
    }
}

let test = gen2( gen1([1, 2, 3, 4, 5, 6, 7], 3), [5, 6, 7], gen1(['a', 'b', 'c']), new Array(999).join('1|').split('|') );
let counter = 0, sendValue = void 0;
let value, done;

do {
    ({value, done}) = test.next(sendValue);

    if ( value == 'end collection' ) {
        if ( ++counter > 2 ) sendValue = true;
    }
    else console.log(value);
}
while( done != true );


Код работающий в FireFox
function * gen1(collection, limit = NaN) {
    let counter = 0;
    for ( let item of collection ) {
        yield 'item is ' + item;

        if ( ++counter >= limit ) break;
    }
}


function * gen2(...collections) {
    for ( let collection of collections ) {
        yield * collection;

        if ( (yield 'end collection') == true ) break;
    }
}

let test = gen2( gen1([1, 2, 3, 4, 5, 6, 7], 3), [5, 6, 7], gen1(['a', 'b', 'c']), new Array(999).join('1|').split('|') );
let counter = 0, sendValue = void 0;
let value, done;

do {
    ({value, done}) = test.next(sendValue);

    if ( value == 'end collection' ) {
        if ( ++counter > 2 ) sendValue = true;
    }
    else console.log(value);
}
while( done != true );

Мы с вами в одинаковом положении потому что я с генераторами очень мало знаком пока :)
Подозреваю что многое из этого всего можно сделать на генераторах, да.
Насколько понимаю я, трансдьюсеры — это лишь концепт для формализации композиции функций к цепочкам элементов… Причем чисто функциональный. А генераторы по идее больше перекликаются с FRP (RxJS, Kefir). И разница между ними в том, что генератор нужно просить вернуть следующее значение (poll), а Rx уведомляет о новом доступном элементе (push).
> а Rx уведомляет о новом доступном элементе (push).
А можно пример, как использовать возможности push в трансдьюсерах?
Просто примеры в статье, например
reduce([1, 2, 3, 4, 5, 6, 7], first5T(append), []);    // => [1, 2, 3, 4, 5]

демонстрируют законченные операции над множеством.
Как-то так

function Stream(transducer, subscriber) {
  this._step = transducer(function(result, item) {
    if (arguments.length === 2) {  subscriber(item)  }
  });
}

Stream.prototype.push = function(x) {
  this._step(null, x);
}

stream = new Stream(first5T, function(x) {  console.log('>>', x)  });
stream.push(1);
stream.push(2);
stream.push(3);
stream.push(4);
stream.push(5);
stream.push(6);
stream.push(7);
Пример с потоками навел на мысль… Надо обязательно уточнить, что step() без параметров можно вызывать только один раз — в ответ на аналогичный вызов. Если разрешить обратное, то область применения трансдьюсеров сильно сократится.

Для списков трансдьюсер flatten является обратным к partition — что логично. Но если разрешить трансдьюсеру создавать любое число новых коллекций, то это свойство перестанет выполняться для коллекци общего вида, ведь partition будет знать, как создать произвольную коллекцию благодаря step() — а flatten не будет знать, как ее итерировать!

Более того, если попытаться применить такой partition к потоку — то он насоздает новых потоков, что совершенно бессмысленно, поскольку все элементы в этих потоках появятся раньше, чем возможность на поток подписаться.

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

PS Вот и ответ на вопрос, неоднократно звучавший в комментариях — в чем отличие трансдьюсеров и итераторов по части возможностей. Трансдьюсеры, в отличие от итераторов, абстрагируют только от внешнего уровня коллекции — а итераторы, в отличие от трансдьюсеров, гробят тип возвращаемого значения и не могут быть применены к потокам.
Да, step() без параметров вызывается только 1 раз — вначале. Он вызывается во внешенм коде, сами трансдьюсеры не должны его вызывать (только если их самих вызвали без параметров).

Тоже самое и для step() с одним параметром, его можно, и нужно, вызывать только если ваш step вызвали с одним параметром.
Тоже самое и для step() с одним параметром, его можно, и нужно, вызывать только если ваш step вызвали с одним параметром.
Стоп. А как же раннее прерывание?

Походу, все наши примеры использования Reduced были некорректными. return new Reduced(result) или return new Reduced(step(result, item)) писать нельзя, надо писать return new Reduced(step(result)) и return new Reduced(step(step(result, item))) соответственно.

Кстати! А зачем нам вообще создавать Reduced самим? Пусть он создается «самым глубоким» трансдьюсером при вызове с одним аргументом. Это решит проблему своего Reduced в каждой библиотеке.
При преждевременном завершении обычного завершения уже не будет.
В том-то и проблема. Рассмотрим такой пример: jsfiddle.net/mayorovp/4rj5tk64/1/

Там реализована функция reduceList, которая применяет набор трансдьюсеров к заданному списку, а так же два «образцово-показательных» трансдьюсера: take и append.

Ну а дальше выясняется, что друг с другом они работать не хотят, не смотря на полное соблюдение протокола:
console.log(reduceList([1,2,3,4,5], take(3))); // [1,2,3]
console.log(reduceList([1,2,3], append(6,7))); // [1,2,3,6,7]
console.log(reduceList([1,2,3,4,5], take(3), append(6,7))); // [1,2,3] — Может, мы перепутали аргументы местами?..
console.log(reduceList([1,2,3,4,5], append(6,7), take(3))); // [1,2,3] — WTF?!
Что произошло? Take досрочно прервал цикл — и потому не append не дождался конца коллекции.

Теперь веселье номер два:
console.log(reduceList([1,2,3,4,5], append(), take(3))); // [1,2,3]
console.log(reduceList([1,2,3,4], append(5), take(3))); // [1,2,3]
console.log(reduceList([1,2,3], append(4,5), take(3))); // [1,2,3]
console.log(reduceList([1,2], append(3,4,5), take(3))); // Uncaught TypeError: undefined is not a function — WTF?!
Что произошло? Take прервал цикл — но append этого не понял. Получается, что любые трансдьюсеры, которые вставляют данные в середину потока, обязаны тоже уметь обрабатывать досрочное окончание этого самого потока. Что-то мне от протокола плохо…
В первом примере всё правильно. Если стоит take(3), то должно вернуться 3 элемента не больше. Представьте как это было бы если бы мы постаринке делали с временными коллекциями.

По поводу второго есть в посте:
Еще придется добавлять проверку Reduced.isReduced(result) в трансдьюсеры, которые несколько раз вызывают step (прим. flatten).
А что делать, если я хочу взять первые три элемента коллекции — и добавить к ним в конец еще два? На том же LINQ collection.Take(3).Concat(new[] { 6,7}) — вполне нормальная операция, притом работающая именно так, как и ожидалось.

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

Но в FRP ничего не потеряется, там Reduced всплывет, и мы узнаем что нужно закрывать поток.
Мы это узнаем только в том случае, если закроем поток явно, в обход всей цепочки трансдьюсеров… Скажем, в вашем же примере методу push понадобится прямой доступ к параметру subscriber, чтобы сказать ему о конце потока — потому что до метода _step символ конца потока трансдьюсер take не допустит.
Дописал пример с поддержкой обоих типов завершения, и с начальным вызовом заодно. (не проверял)

function Stream(transducer, onValue, onEnd) {
  this._onEnd = onEnd;
  this._closed = false;
  this._step = transducer(function(result, item) {
    if (arguments.length === 2) {  onValue(item)  }
  });
  this._step();
}

Stream.prototype.push = function(x) {
  if (!this._closed) {
    if (isReduced(this._step(null, x)) {
      this._closed = true;
      this._onEnd();
    }
  }
}

Stream.prototype.close = function() {
  if (!this._closed) {
    this._step(null);
    this._closed = true;
    this._onEnd();
  }
}
Вообще подумалось, что step() и step(result) тоже должны иметь право возвращать Reduced, но у Рича это явно не так для step(), и не понятно для step(result).

image

Наверно нужно уже в исходники кложуры лезть чтобы разобраться.
В команде clojure начали делать трансдьюсеры для js, посмотрел как у них. Короче у вас просто `reduceList()` неправильный. Здесь `if` не нужен (нужно всегда вызывать `step(result)`):

    if (result instanceof Reduced)
        return result.wrapped;
    else
        return step(result);


Причем в статье всё было верно. Там `step(result)` вызывается всегда. Уберу update из статьи.
Собрал в кучу все функции из поста jsfiddle.net/pvavxc04/, только использовал partition как пример функции добавляющей в конец. Всё работатет.
Как и ожидалось, где-то в partition спряталась ошибка :)
jsfiddle.net/pvavxc04/1/ — меняем 5 на 4 в take — и вот результат (точнее, его нет)
Это действительно упрощает ситуацию в целом — однако, трансдьюсеры, добавляющие элементы в конец списка, стали еще сложнее: им теперь надо помнить, не был ли поток прерван.
Почему, нет. jsfiddle.net/pvavxc04/ — я partition не менял, только добавил обработку «начального вызова».
Походу, все наши примеры использования Reduced были некорректными
Наверно вы правы. Спасибо!
Дописал дополнение к статье. Т.е. теперь можно делать take+append и всё будет работать!
Ммм… не совсем понятно, зачем абстрагироваться от коллекций и при этом писать reduce, который работает лишь для массива или объекта, похожего на массив.
Надо бы подумать над этим моментом, а иначе все преимущество вынесенного append (потенциально позволяет собирать другую коллекцию вместо массива) уходит практически в никуда: либо собирай им только массив (скучно!), либо для следующей обработки полученной коллекции придется перегонять ее в массив (скучно вдвойне!).

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

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

То есть для достижения того же результата, достаточно просто остановиться на нотации типа step = function (result, item) {… return {result: new_result, item: new_item};}, договориться о «break» и «continue», т.е. на ряду со специальным значением Reduced, которое в данном случае может выглядеть как {result: new_result} ввести специальное значение Skipped — {result: new_result, skip: true} — которое позволяет перейти на следующий шаг итерации, не заканчивая вычислять результат этой), а потом положить набор таких функций в массив, который дефакто будет трасдьюсером (слово то какое сложное!). Потом на каждом шаге итерации надо просто бежать по этому массиву, вычисляя промежуточный результат. Такая реализация упрощает написание атомарных трансдьюсеров (нет нужды писать функцию, которая возвращает функцию, возвращающую функцию; пишем функцию попроще, которая возвращает функцию типа step).

Пример кода использования для filterMapTake для такой реализации:
transduce([1, 2, 3, 4, 5, 6, 7, 2, 3, 1], [], [
  filter(function (x) { return x < 4}),
  map(function (x) {return -x},
  take(5),
  append
]);

где transduce = function (coll, seed, transducer)
Имхо, такой синтаксис более оправдан; не вижу особого смысла выделять append и усложнять его специальными случаями.
Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории