我怎样才能让 Underscore 表现得像 Ramda?

How can I make Underscore behave like Ramda?

两天前,我宣布 a preview release of Underscore that integrates with the new Node.js way of natively supporting ES modules.1 Yesterday, somebody responded on Twitter 并提出以下问题:

Can you do Ramda-style data last functions?

他或她指的是 Underscore 和 Ramda 之间的主要区别之一。在Underscore中,函数通常将要操作的数据作为第一个参数,而Ramda将它们作为最后一个参数:

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]

Ramda 中数据最后顺序背后的想法是,在进行部分应用时,数据参数通常是最后提供的。在这种情况下,将数据作为最后一个参数就不需要占位符了:

// 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]

如示例所示,特别是 curried 符号使 data-last 对于此类场景具有吸引力。

为什么 Underscore 不这样做?这有几个原因,我将其放在脚注中。2 然而,让 Underscore 像 Ramda 一样运行是函数式编程中的一个有趣练习。在下面的回答中,我将展示如何仅用几行代码就可以做到这一点。


1 在撰写本文时,如果您想尝试一下,我建议从 NPM 安装 underscore@preview。这可确保您获得最新的预览版本。我刚刚发布了一个将版本升级到 1.13.0-1 的修复程序。我将在不久的将来发布 1.13.0 underscore@latest

2 Underscore 不实现 data-last 或 currying 的原因:

从字面上看这个问题,让我们从一个将数据优先函数转换为数据最后函数的函数开始:

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]

我们可以将 dataLast 映射到 Underscore 上以获得整个库的最后数据版本:

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]

但是,我们可以做得更好。 Ramda 风格的柯里化也不太难实现:

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);
        }
    });
}

只需一点额外的复杂性,我们甚至可以正确支持 this 绑定:

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);
        }
    });
}

柯里化本身与您是先数据还是后数据无关。这是 _.map 的柯里化版本,它仍然是数据优先的:

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]

请注意,我每次都必须传递 null,因为 _.map 采用可选的第三个参数,可让您将回调绑定到上下文。这种热切的柯里化风格迫使您传递固定数量的参数。在下面的 Variation 部分,我将展示如何使用 curry.

的惰性变体来避免这种情况

Ramda 库省略了可选的上下文参数,因此您需要将两个而不是三个参数传递给 R.map。我们可以编写一个函数来组合 dataLastcurry 并且可以选择调整元数,以使 Underscore 函数的行为与它的 Ramda 函数完全一样:

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]

将其映射到整个库需要一些管理才能获得令人满意的结果,但结果是对 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

变化

以引入惰性为代价,我们可以避免必须修复函数的元数。这使我们可以保留原始 Underscore 函数中的所有可选参数和可变参数,而无需始终提供它们,并且在映射库时无需对每个函数进行管理。我们从 curry 的变体开始,returns 一个惰性函数而不是一个急切的函数:

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);
        }
    });
}

这基本上是 R.curry,上面有一个内置的 R.thunkify。请注意,此实现实际上比 eager 变体要简单一些。最重要的是,创建一个懒惰的、类似 Ramda 的 Underscore 端口被简化为一个优雅的单行代码:

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

我们现在可以根据需要向每个函数传递任意数量的参数。我们只需要附加一个不带参数的额外调用以强制评估:

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}

这用一些对 Ramda 的忠诚来换取对 Underscore 的忠诚。在我看来,它是两全其美:像 Ramda 中的数据最后柯里化,具有 Underscore 的所有参数灵活性。


参考文献: