Ramda.js中的"lift"让我困惑不已

29

查看Ramda.js源代码,特别是“lift”函数。

lift

liftN

下面是给出的示例:

var madd3 = R.lift(R.curry((a, b, c) => a + b + c));

madd3([1,2,3], [1,2,3], [1]); //=> [3, 4, 5, 4, 5, 6, 5, 6, 7]

因此,结果的第一个数字很容易确定,abc都是每个数组的第一个元素。对我来说,第二个数字就不那么容易理解了。这些参数是每个数组的第二个值(2、2、undefined),还是第一个数组的第二个值和第二个和第三个数组的第一个值?

即使忽略这里发生的顺序,我也真的看不到它的价值。如果我在没有先进行lift操作的情况下执行此操作,我最终会得到将数组作为字符串连接起来的结果。这似乎有点像flatMap,但我似乎无法理解其背后的逻辑。


4
你可能会受益于阅读有关应用函子(Applicative Functors)的内容。 - Mulan
3个回答

44

Bergi的回答很好。但是考虑这个问题的另一种方式是变得更具体一些。Ramda文档中真正需要包含一个非列表示例,因为列表并不能真正捕捉这个问题。

让我们来看一个简单的函数:

var add3 = (a, b, c) => a + b + c;

这个操作作用于三个数字。但是,如果你有一些包含数字的容器呢?也许我们有一些Maybe。我们不能简单地将它们相加:

const Just = Maybe.Just, Nothing = Maybe.Nothing;
add3(Just(10), Just(15), Just(17)); //=> ERROR!

(好的,这是 JavaScript,它实际上不会在此处引发错误,只是尝试连接它不应该连接的内容...但它肯定不会做你想要的!)

如果我们可以将该函数提升到容器级别,那么我们的生活将更加轻松。Bergi指出的lift3在Ramda中使用liftN(3, fn)实现,还有一个简单使用所提供函数的参数个数的 lift(fn)。因此,我们可以这样做:

const madd3 = R.lift(add3);
madd3(Just(10), Just(15), Just(17)); //=> Just(42)
madd3(Just(10), Nothing(), Just(17)); //=> Nothing()

但这个提升的函数并不知道我们的容器的具体信息,只知道它们实现了ap。Ramda通过类似将函数应用于列表的元组交叉积的方式来为列表实现ap,所以我们也可以这样做:

madd3([100, 200], [30, 40], [5, 6, 7]);
//=> [135, 136, 137, 145, 146, 147, 235, 236, 237, 245, 246, 247]

这就是我对 lift 的理解。它接受一个在某些值的层次上工作的函数,并将其提升为一个在这些值的容器层次上工作的函数。


2
啊,所以如果我理解正确的话,它通过实现.ap来“学习”如何使用容器。我还在ramda-fantasy存储库中看到了Just.prototype.ap = function(m) { return m.map(this.value); };ap是典型的函数式编程东西吗?还是这只是Ramda / JavaScript处理lift的方式? - diplosaurus
1
我找到了这张图片,它确实帮助了我:https://drboolean.gitbooks.io/mostly-adequate-guide/content/images/functormap.png - diplosaurus
在函数式编程语言/库中,ap或类似的东西是很常见的。根据这些FantasyLand规范定义的函数实现lift意味着它将与任何符合FL规范的Applicative类型一起工作。这是一个不错的胜利。 - Scott Sauyet
1
我对此还有些模糊,但我写出了生成madd3示例结果的确切过程:var madd3 = R.lift((a, b, c) => a + b + c); madd3([3, 7], [9, 5], [4, 6]); 这将产生以下结果:_[16, 18, 12, 14, 20, 22, 16, 18]_,计算如下: 3 + 9 + 4,3 + 9 + 6,3 + 5 + 4,3 + 5 + 6,7 + 9 + 4,7 + 9 + 6,7 + 5 + 4,7 + 5 + 6 - hsrob

22
感谢Scott Sauyet和Bergi的答案,我终于理解了。在这个过程中,我感觉还需要一些努力才能把所有的部分拼凑在一起。我将记录一些在这个过程中遇到的问题,希望对某些人有所帮助。
以下是我们试图理解的R.lift的示例:
var madd3 = R.lift((a, b, c) => a + b + c);
madd3([1,2,3], [1,2,3], [1]); //=> [3, 4, 5, 4, 5, 6, 5, 6, 7]

对我而言,在理解它之前,有三个问题需要回答。
  1. Fantasy-landApply规范(我将其称为Apply)以及Apply#ap的作用
  2. Ramda的R.ap实现以及ArrayApply规范有什么关系
  3. 柯里化在R.lift中扮演了什么角色

理解Apply规范

在幻想世界中,当一个对象定义了ap方法时,它实现了Apply规范(该对象还必须通过定义map方法来实现Functor规范)。 ap方法具有以下签名:
ap :: Apply f => f a ~> f (a -> b) -> f b

fantasy-land的类型签名表示法 中:
  • => 声明类型约束,因此上面标记中的 f 指的是类型 Apply
  • ~> 声明方法声明,所以 ap 应该是在 Apply 上声明的函数,它围绕我们称之为 a 的值进行封装(我们将在下面的示例中看到,一些fantasy-land的实现ap与此签名不一致,但思想是相同的)

假设我们有两个对象 vuv = f a; u = f (a -> b)),因此这个表达式是有效的 v.ap(u),这里需要注意一些事情:

  • vu都实现了Applyv保存一个值,u保存一个函数,但它们具有相同的Apply“接口”(这将有助于理解下面的下一节,当涉及到R.apArray时)
  • a和函数a -> bApply一无所知,函数只是转换值a。正是Apply将值和函数放在容器中,而ap则将它们提取出来,在值上调用函数,然后再放回去。

理解RamdaR.ap

R.ap的签名有两种情况:

  1. 应用 f => f (a → b) → f a → f b: 这与上一节中 Apply#ap 的签名非常相似,区别在于如何调用 apApply#ap vs. R.ap)和参数的顺序。
  2. [a → b] → [a] → [b]: 如果我们将 Apply f 替换为 Array,请记住,在上一节中,值和函数必须包装在同一个容器中。这就是为什么当使用 R.apArray 时,第一个参数是函数列表,即使你只想应用一个函数,也要将它放入一个数组中。

让我们来看一个例子,我正在使用ramda-fantasy中的Maybe,它实现了Apply。这里存在一个不一致之处,即Maybe#ap的签名是:ap :: Apply f => f (a -> b) ~> f a -> f b。似乎一些其他的fantasy-land实现也遵循这种方式,但这不应影响我们的理解:

const R = require('ramda');
const Maybe = require('ramda-fantasy').Maybe;

const a = Maybe.of(2);
const plus3 = Maybe.of(x => x + 3);
const b = plus3.ap(a);  // invoke Apply#ap
const b2 = R.ap(plus3, a);  // invoke R.ap

console.log(b);  // Just { value: 5 }
console.log(b2);  // Just { value: 5 }

理解R.lift的例子

R.lift使用数组的例子中,一个具有3个参数的函数被传递给了R.lift: var madd3 = R.lift((a, b, c) => a + b + c);,它如何与三个数组[1, 2, 3], [1, 2, 3], [1]一起工作呢?请注意,它不是柯里化的。

实际上,在R.liftN的源代码中(R.lift委托给它),传入的函数是自动柯里化的,然后它遍历值(在我们的例子中,是三个数组),将其缩减为结果:在每次迭代中,它使用柯里化的函数和一个值(在我们的例子中,是一个数组)调用ap。很难用语言解释清楚,让我们看看等价的代码:

const R = require('ramda');

const madd3 = (x, y, z) => x + y + z;

// example from R.lift
const result = R.lift(madd3)([1, 2, 3], [1, 2, 3], [1]);

// this is equivalent of the calculation of 'result' above,
// R.liftN uses reduce, but the idea is the same
const result2 = R.ap(R.ap(R.ap([R.curry(madd3)], [1, 2, 3]), [1, 2, 3]), [1]);

console.log(result);  // [ 3, 4, 5, 4, 5, 6, 5, 6, 7 ]
console.log(result2);  // [ 3, 4, 5, 4, 5, 6, 5, 6, 7 ]

一旦理解了计算result2的表达式,这个例子就会变得清晰明了。
以下是另一个例子,使用R.liftApply上:
const R = require('ramda');
const Maybe = require('ramda-fantasy').Maybe;

const madd3 = (x, y, z) => x + y + z;
const madd3Curried = Maybe.of(R.curry(madd3));
const a = Maybe.of(1);
const b = Maybe.of(2);
const c = Maybe.of(3);
const sumResult = madd3Curried.ap(a).ap(b).ap(c);  // invoke #ap on Apply
const sumResult2 = R.ap(R.ap(R.ap(madd3Curried, a), b), c);  // invoke R.ap
const sumResult3 = R.lift(madd3)(a, b, c);  // invoke R.lift, madd3 is auto-curried

console.log(sumResult);  // Just { value: 6 }
console.log(sumResult2);  // Just { value: 6 }
console.log(sumResult3);  // Just { value: 6 }

在评论中,Scott Sauyet提出了一个更好的例子(他提供了很多见解,建议你阅读),这个例子更容易理解,至少它指向了一个方向,即R.lift计算Array的笛卡尔积。

var madd3 = R.lift((a, b, c) => a + b + c);
madd3([100, 200], [30, 40, 50], [6, 7]); //=> [136, 137, 146, 147, 156, 157, 236, 237, 246, 247, 256, 257]

希望这能帮到你。

5
这很好。我会留下一些评论,希望能对一些棘手的问题进行阐明。但首先,我有一个建议:文档中的示例不利于理解,这个示例可能会更有帮助:madd3([100, 200], [30, 40, 50], [6, 7]); //=> [136, 137, 146, 147, 156, 157, 236, 237, 246, 247, 256, 257] 。(欢迎提交PR以修复文档)。 - Scott Sauyet
实际上,Maybe#ap 的签名与 FantasyLand 规范所需的相同。本质上,ap 是类型为 f a 的对象的 方法。Ramda 的 ap 函数接受一个 (a -> b) 转换和一个 ApplyX a,并为某个名为 ApplyXApply 实现返回一个 ApplyX b。(这可能比签名中使用的 f 更清晰。)在许多情况下,Ramda 的函数将委托给该对象的方法。 - Scott Sauyet
2
虽然从实现来看,ApplyArray似乎是不同的情况,但这并不完全正确。更清晰的思考方式是,Array可以被视为Apply(以及Functor等等),但由于它们没有内置的ap方法,Ramda会为它们提供一个。Ramda还为Array提供了一个适当的map函数,因为Array.prototype中的一个不遵循适当的规则。在各种地方,Ramda也为ObjectFunction做出类似的事情。 - Scott Sauyet
最后注意,该函数在传入时被柯里化,因为每次调用ap只处理一个参数。如果我们从像(x) => (y) => (z) => x + y + z这样的函数开始,这将是不必要的。 - Scott Sauyet
感谢Scott Sauyet的解释!我已经根据您的建议创建了一个PR来更新liftliftN的示例。 - Dapeng Li
显示剩余7条评论

8

lift/liftN将一个普通的函数提升到Applicative上下文。

// lift1 :: (a -> b) -> f a -> f b
// lift1 :: (a -> b) -> [a] -> [b]
function lift1(fn) {
    return function(a_x) {
        return R.ap([fn], a_x);
    }
}

现在,ap的类型(f (a->b) -> f a -> f b)也不容易理解,但列表示例应该是可以理解的。
有趣的是,在这里你传入一个列表并返回一个列表,所以只要第一个列表中的函数具有正确的类型,就可以重复应用此函数。
// lift2 :: (a -> b -> c) -> f a -> f b -> f c
// lift2 :: (a -> b -> c) -> [a] -> [b] -> [c]
function lift2(fn) {
    return function(a_x, a_y) {
        return R.ap(R.ap([fn], a_x), a_y);
    }
}

而你在示例中隐式使用的 lift3 也是同样的 - 现在它是这样的 ap(ap(ap([fn], a_x), a_y), a_z)


3
жҲ‘и®Өдёәиҝҷдәӣзү№е®ҡж•°йҮҸеҸӮж•°зҡ„еҚҮзә§еҮҪж•°жҜ”Ramdaзҡ„еҸҜеҸҳеҸӮж•°liftжӣҙеҠ зӣҙи§ӮгҖӮSanctuaryжҸҗдҫӣдәҶliftпјҢlift2е’Ңlift3гҖӮ - davidchambers
抱歉Bergi,那个评论是针对原帖作者的。 - Mulan
@naomik:我也是这么想的 :-) - Bergi
谢谢你的回答。我仍在研究ap并理解Applicative上下文是什么意思(我假设是https://github.com/fantasyland/fantasy-land#applicative)。 - diplosaurus
1
@diplosaurus:没错,就是那个。此外,关于这个主题的好文章有:http://learnyouahaskell.com/functors-applicative-functors-and-monoids 和 https://dev59.com/bWw15IYBdhLWcg3wfr9y。在这种情况下(特别是在 Ramda 中),"applicative context" 指的是 List/Array 上下文(正如第二行类型签名所示),但它也可以是另一个 Applicative,比如 Maybe。 - Bergi

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接