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

问题描述

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

你能做 Ramda 风格的 data last 函数吗?

他或她指的是 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,3]);  // [1,9]

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

// 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,9]
mapSquare([4,5,6]);  // [16,25,36]

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

为什么 Underscore 不这样做?造成这种情况的原因有很多,我将其放在脚注中。2 尽管如此,使 Underscore 表现得像 Ramda 是函数式编程中的一项有趣练习。在下面的回答中,我将向您展示如何仅用几行代码就可以做到这一点。


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

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

  • 当 Jeremy Ashkenas 从 DocumentCloud(连同 Backbone)中提取出常见模式时,Underscore 诞生了。碰巧的是,数据最后部分应用程序和柯里化都不是该应用程序中的常见模式。
  • 将 Underscore 从数据优先更改为数据最后会破坏很多代码
  • 在部分应用中最后提供数据并不是普遍规则;首先提供数据同样可以想象。因此,data-last 并不是从根本上更好,它只是进行了不同的权衡。
  • 虽然柯里化很好,但它也有一些缺点:它增加了开销并修复了函数数量(除非你使函数变得懒惰,这会增加更多的开销)。与 Ramda 相比,Underscore 更适用于可选和可变参数,并且更喜欢制作增加开销的功能 opt-in,而不是认启用它们。

解决方法

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

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,9]
L.filter(isOdd,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,preArgs = [],thisArg) {
    if (!_.isUndefined(thisArg)) f = f.bind(thisArg);
    const applied = _.partial.apply(null,args,this);
        } else {
            return applied.apply(this,args);
        }
    });
}

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

const curriedMap = curry(_.map);

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

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

Ramda 库省略了可选的上下文参数,因此您需要向 R.map 传递正好两个而不是三个参数。我们可以编写一个由 dataLastcurry 组成的函数,并且可以选择性地调整数量,以使 Underscore 函数的行为与其 Ramda 函数完全一样:

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

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

ramdaMap(square,3]);
ramdaMap(square)([1,3]);
ramdaMap(_,3])(square);
// all [1,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,3]);    // [1,9]
R_.filter(isOdd)([1,3]

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

变化

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

function curryLazy(f,[f].concat(preArgs));
    return _.restArguments(function(args) {
        if (args.length > 0) {
            return curryLazy(applied,this);
        } else {
            return applied.call(this);
        }
    });
}

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

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

我们现在可以根据需要向每个函数传递尽可能多或尽可能少的参数。我们只需要附加一个不带参数的额外调用来强制求值:

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

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

LR_.filter([1,false,'','yes'])();            // [1,'yes']
LR_.filter(isOdd)([1,3]
LR_.filter(_,3])(isOdd)();                // [1,3]
LR_.filter(window.confirm)(window)([1,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 的所有参数灵活性。


参考文献: