Skip to content
Advertisement

How can I make Underscore behave like Ramda?

Two days ago, I announced a preview release of Underscore that integrates with the new Node.js way of natively supporting ES modules.1 Yesterday, somebody responded on Twitter with the following question:

Can you do Ramda-style data last functions?

He or she was referring to one of the main differences between Underscore and Ramda. In Underscore, functions typically take the data to be operated on as the first parameter, while Ramda takes them as the last parameter:

import _ from 'underscore';
import * as R from 'ramda';

const square = x => x * x;

// Underscore
_.map([1, 2, 3], square);  // [1, 4, 9]

// Ramda
R.map(square, [1, 2, 3]);  // [1, 4, 9]

The idea behind the data-last order in Ramda is that when doing partial application, the data argument is often supplied last. Taking the data as the last parameter removes the need for a placeholder in such cases:

// Let's create a function that maps `square` over its argument.

// Underscore
const mapSquare = _.partial(_.map, _, square);

// Ramda with explicit partial application
const mapSquare = R.partial(R.map, [square]);

// Ramda, shorter notation through automatic currying
const mapSquare = R.map(square);

// Ramda with currying and placeholder if it were data-first
const mapSquare = R.map(R.__, square)

// Behavior in all cases
mapSquare([1, 2, 3]);  // [1, 4, 9]
mapSquare([4, 5, 6]);  // [16, 25, 36]

As the example shows, it is especially the curried notation that makes data-last attractive for such scenarios.

Why doesn’t Underscore do this? There are several reasons for that, which I put in a footnote.2 Nevertheless, making Underscore behave like Ramda is an interesting exercise in functional programming. In my answer below, I’ll show how you can do this in just a few lines of code.


1 At the time of writing, if you want to try it, I recommend installing underscore@preview from NPM. This ensures that you get the latest preview version. I just published a fix that bumped the version to 1.13.0-1. I will release 1.13.0 as underscore@latest some time in the near future.

2 Reasons for Underscore to not implement data-last or currying:

  • Underscore was born when Jeremy Ashkenas factored out common patterns from DocumentCloud (together with Backbone). As it happens, neither data-last partial application nor currying were common patterns in that application.
  • Changing Underscore from data-first to data-last would break a lot of code.
  • It is not a universal rule that data are supplied last in partial application; supplying the data first is equally imaginable. Thus, data-last isn’t fundamentally better, it’s just making a different tradeoff.
  • While currying is nice, it also has some disadvantages: it adds overhead and it fixes the arity of a function (unless you make the function lazy, which adds more overhead). Underscore works more with optional and variadic arguments than Ramda, and also prefers making features that add overhead opt-in instead of enabling them by default.

Advertisement

Answer

Taking the question very literally, let’s just start with a function that transforms a data-first function into a data-last function:

const dataLast = f => _.restArguments(function(args) {
    args.unshift(args.pop());
    return f.apply(this, args);
});

const dataLastMap = dataLast(_.map);
dataLastMap(square, [1, 2, 3]);  // [1, 4, 9]

We could map dataLast over Underscore to get a data-last version of the entire library:

const L = _.mapObject(_, dataLast);
const isOdd = x => x % 2;

L.map(square, [1, 2, 3]);  // [1, 4, 9]
L.filter(isOdd, [1, 2, 3]);  // [1, 3]

However, we can do better. Ramda-style currying is not too hard to implement, either:

const isPlaceholder = x => x === _;

function curry(f, arity = f.length, preArgs = []) {
    const applied = _.partial.apply(null, [f].concat(preArgs));
    return _.restArguments(function(args) {
        const supplied = _.countBy(args, isPlaceholder)['false'];
        if (supplied < arity) {
            return curry(applied, arity - supplied, args);
        } else {
            return applied.apply(null, args);
        }
    });
}

With just a little bit of extra sophistication, we can even correctly support this bindings:

function curry(f, arity = f.length, preArgs = [], thisArg) {
    if (!_.isUndefined(thisArg)) f = f.bind(thisArg);
    const applied = _.partial.apply(null, [f].concat(preArgs));
    return _.restArguments(function(args) {
        const supplied = _.countBy(args, isPlaceholder)['false'];
        if (supplied < arity) {
            return curry(applied, arity - supplied, args, this);
        } else {
            return applied.apply(this, args);
        }
    });
}

Currying by itself is independent of whether you do data-first or data-last. Here’s a curried version of _.map that is still data-first:

const curriedMap = curry(_.map);

curriedMap([1, 2, 3], square, null);
curriedMap([1, 2, 3])(square, null);
curriedMap([1, 2, 3])(square)(null);
curriedMap([1, 2, 3], square)(null);
curriedMap([1, 2, 3], _, null)(square);
curriedMap(_, _, null)([1, 2, 3], square);
curriedMap(_, _, null)(_, square)([1, 2, 3]);
curriedMap(_, square, _)(_, null)([1, 2, 3]);
// all [1, 4, 9]

Note that I have to pass null every time, because _.map takes an optional third argument that lets you bind the callback to a context. This eager style of currying forces you to pass a fixed number of arguments. In the Variation section below, I’ll show how this can be avoided with a lazy variant of curry.

The Ramda library omits the optional context parameter instead, so you need to pass exactly two instead of exactly three arguments to R.map. We can write a function that composes dataLast and curry and that optionally adjusts the arity, in order to make an Underscore function behave exactly like its Ramda counterpart:

const ramdaLike = (f, arity = f.length) => curry(dataLast(f), arity);

const ramdaMap = ramdaLike(_.map, 2);

ramdaMap(square, [1, 2, 3]);
ramdaMap(square)([1, 2, 3]);
ramdaMap(_, [1, 2, 3])(square);
// all [1, 4, 9]

Mapping this over the entire library requires some administration in order to get a satisfying result, but the result is a surprisingly faithful imitation of Ramda:

const arityOverrides = {
    map: 2,
    filter: 2,
    reduce: 3,
    extend: 2,
    defaults: 2,
    // etcetera, as desired
};

const R_ = _.extend(
    // start with just passing everything through `ramdaLike`
    _.mapObject(_, f => ramdaLike(f)),
    // then replace a subset with arity overrides
    _.mapObject(arityOverrides, (arity, name) => ramdaLike(_[name], arity)),
);

R_.identity(1);               // 1
R_.map(square)([1, 2, 3]);    // [1, 4, 9]
R_.filter(isOdd)([1, 2, 3]);  // [1, 3]

const add = (a, b) => a + b;
const sum = R_.reduce(add, 0);
sum([1, 2, 3]);               // 6

Variation

At the cost of introducing laziness, we can avoid having to fix the arity of a function. This lets us preserve all the optional and variadic parameters from the original Underscore functions, without always needing to supply them, and removes the need for per-function administration when mapping the library. We start with a variant of curry that returns a lazy function instead of an eager one:

function curryLazy(f, preArgs = [], thisArg) {
    if (!_.isUndefined(thisArg)) f = f.bind(thisArg);
    const applied = _.partial.apply(null, [f].concat(preArgs));
    return _.restArguments(function(args) {
        if (args.length > 0) {
            return curryLazy(applied, args, this);
        } else {
            return applied.call(this);
        }
    });
}

This is basically R.curry with a builtin R.thunkify on top. Note that this implementation is actually a bit simpler than the eager variant. On top of that, creating a lazy, Ramda-like port of Underscore is reduced to an elegant oneliner:

const LR_ = _.mapObject(_, _.compose(curryLazy, dataLast));

We can now pass as many or as few arguments to each function as we want. We just have to append an extra call without arguments in order to force evaluation:

LR_.identity(1)();  // 1

LR_.map([1, 2, 3])();                   // [1, 2, 3]
LR_.map(square)([1, 2, 3])();           // [1, 4, 9]
LR_.map(_, [1, 2, 3])(square)();        // [1, 4, 9]
LR_.map(Math.sqrt)(Math)([1, 4, 9])();  // [1, 2, 3]

LR_.filter([1, false, , '', 'yes'])();            // [1, 'yes']
LR_.filter(isOdd)([1, 2, 3])();                   // [1, 3]
LR_.filter(_, [1, 2, 3])(isOdd)();                // [1, 3]
LR_.filter(window.confirm)(window)([1, 2, 3])();  // depends on user

LR_.extend({a: 1})({a: 2, b: 3})();
// {a: 1, b: 3}
LR_.extend({a: 1})({a: 2, b: 3})({a: 4})({b: 5, c: 6})();
// {a: 4, b: 3, c: 6}

This trades some faithfulness to Ramda for faithfulness to Underscore. In my opinion, it is a best of both worlds: data-last currying like in Ramda, with all the parametric flexibility from Underscore.


References:

User contributions licensed under: CC BY-SA
10 People found this is helpful
Advertisement