J. Hesters
J. Hesters

Reputation: 14768

Ramda.js transducers: average the resulting array of numbers

I'm currently learning about transducers with Ramda.js. (So fun, yay! 🎉)

I found this question that describes how to first filter an array and then sum up the values in it using a transducer.

I want to do something similar, but different. I have an array of objects that have a timestamp and I want to average out the timestamps. Something like this:

const createCheckin = ({
  timestamp = Date.now(), // default is now
  startStation = 'foo',
  endStation = 'bar'
} = {}) => ({timestamp, startStation, endStation});

const checkins = [
  createCheckin(),
  createCheckin({ startStation: 'baz' }),
  createCheckin({ timestamp: Date.now() + 100 }), // offset of 100
];

const filterCheckins = R.filter(({ startStation }) => startStation === 'foo');
const mapTimestamps = R.map(({ timestamp }) => timestamp);

const transducer = R.compose(
  filterCheckins,
  mapTimestamps,
);

const average = R.converge(R.divide, [R.sum, R.length]);

R.transduce(transducer, average, 0, checkins);
// Should return something like Date.now() + 50, giving the 100 offset at the top.

Of course average as it stands above can't work because the transform function works like a reduce.

I found out that I can do it in a step after the transducer.

const timestamps = R.transduce(transducer,  R.flip(R.append), [], checkins);
average(timestamps);

However, I think there must be a way to do this with the iterator function (second argument of the transducer). How could you achieve this? Or maybe average has to be part of the transducer (using compose)?

Upvotes: 0

Views: 260

Answers (2)

Scott Christopher
Scott Christopher

Reputation: 6516

As a first step, you can create a simple type to allow averages to be combined. This requires keeping a running tally of the sum and number of items being averaged.

const Avg = (sum, count) => ({ sum, count })

// creates a new `Avg` from a given value, initilised with a count of 1
Avg.of = n => Avg(n, 1)

// takes two `Avg` types and combines them together
Avg.append = (avg1, avg2) =>
  Avg(avg1.sum + avg2.sum, avg1.count + avg2.count)

With this, we can turn our attention to creating the transformer that will combine the average values.

First, a simple helper function that allow values to be converted to our Avg type and also wraps a reduce function to default to the first value it receives rather than requiring an initial value to be provided (a nice initial value doesn't exist for averages, so we'll just use the first of the values instead)

const mapReduce1 = (map, reduce) =>
  (acc, n) => acc == null ? map(n) : reduce(acc, map(n))

The transformer then just needs to combine the Avg values and then pull resulting average out of the result. n.b. The result needs to guard for null values in the case where the transformer is run over an empty list.

const avgXf = {
  '@@transducer/step': mapReduce1(Avg.of, Avg.append),
  '@@transducer/result': result =>
    result == null ? null : result.sum / result.count
}

You can then pass this as the accumulator function to transduce, which should produce the resulting average value.

transduce(transducer, avgXf, null, checkins)

Upvotes: 2

Scott Sauyet
Scott Sauyet

Reputation: 50787

I'm afraid this strikes me as quite confused.

I think of transducers as a way of combining the steps of a composed function on sequences of values so that you can iterate the sequence only once.

average makes no sense here. To take an average you need the whole collection.

So you can transduce the filtering and mapping of the values. But you will absolutely need to then do the averaging separately. Note that filter then map is a common enough pattern that there are plenty of filterMap functions around. Ramda doesn't have one, but this would do fine:

const filterMap = (f, m) => (xs) =>
  xs .flatMap (x => f (x) ? [m (x)] : [])

which would then be used like this:

filterMap (
  propEq ('startStation', 'foo'), 
  prop ('timestamp')
) (checkins)

But for more complex sequences of transformations, transducers can certainly fit the bill.


I would also suggest that when you can, you should use lift instead of converge. It's a more standard FP function, and works on a more abstract data type. Here const average = lift (divide) (sum, length) would work fine.

Upvotes: 1

Related Questions