阅读 260
如何在JavaScript中使用Currying和Composition

如何在JavaScript中使用Currying和Composition

今天晚上的一次谈话让我思考并重新审视了我以前玩过的一个概念--咖喱。但是这一次,我想和大家一起探讨这个问题

咖喱的概念不是一个新的概念,但它非常有用。它也是函数式编程的基础,而且是以更多的模块化方式思考函数的一种途径。

组合的概念,即通过组合函数来创建更大、更复杂、更有用的函数,这看起来很直观,但也是函数式编程的一个关键组成部分。

当我们开始组合它们时,一些有趣的事情就会发生。让我们来看看这可能是怎么回事。

咖喱,任何人?

咖喱函数与其他函数的作用基本相同,但你处理它们的方式有点不同。

假设我们想要一个可以检查两点之间距离的函数:例如,{x1, y1}{x2, y2} 。这方面的公式有点复杂,但没有什么是我们不能处理的。

两点之间的距离公式,是勾股定理的一个应用。

通常情况下,调用我们的函数可能是这样的。

const distance = (start, end) => Math.sqrt( Math.pow(end.x-start.x, 2) + Math.pow(end.y-start.y, 2) );

console.log( distance( {x:2, y:2}, {x:11, y:8} );
// logs 10.816653826391969
复制代码

现在,调用一个函数就是强迫它一次只接受一个参数。因此,我们不是像distance( start, end ) ,而是像这样调用它:distance(start)(end) 。每个参数都是单独传入的,每个函数的调用都会返回另一个函数,直到所有参数都被提供。

展示可能比解释更容易,所以让我们把上面的距离函数看成是一个咖喱的函数。

const distance = function(start){
  // we have a closed scope here, but we'll return a function that
  //  can access it - effectively creating a "closure".
  return function(end){
    // now, in this function, we have everything we need. we can do
    //  the calculation and return the result.
    return Math.sqrt( Math.pow(end.x-start.x, 2) + Math.pow(end.y-start.y, 2) );
  }
}

console.log( distance({x:2, y:2})({x:11, y:8});
// logs 10.816653826391969 again
复制代码

为了得到同样的结果,这似乎是一个非常大的工作!我们可以缩短它的时间。我们_可以_通过使用ES6的箭头函数来缩短它。

const distancewithCurrying = 
        (start) => 
          (end) => Math.sqrt( Math.pow(end.x-start.x, 2) +
                              Math.pow(end.y-start.y, 2) );
复制代码

缩进是为了增加可读性,并不影响可运行性

但是,除非我们开始以一种更抽象的方式来考虑我们的函数,否则这似乎是一个没有实际收益的喧哗。

记住,函数只能返回一个东西。虽然我们可以提供任何数量的参数,但我们只能返回一个单一的值,无论是数字、数组、对象,还是函数。我们只能得到一个东西。而现在,有了curried函数,我们有了一个只能接收一个东西的函数。这里可能有一个联系。

碰巧的是,咖喱函数的力量来自于能够组合和_编排_它们。

考虑一下我们的距离公式--如果我们正在写一个 "夺旗 "游戏,并且快速而容易地计算每个玩家与旗子的距离可能是有用的。我们可能有一个球员的数组,每个球员都包含一个{x, y} 的位置。有了{x,y} 值的数组,一个可重复使用的函数就会变得非常方便。让我们用这个想法来玩一下。

const players = [
  {
    name: 'Alice',
    color: 'aliceblue',
    position: { x: 3, y: 5}
  },{
    name: 'Benji',
    color: 'goldenrod',
    position: { x: -4, y: -4}
  },{
    name: 'Clarissa',
    color: 'firebrick',
    position: { x: -2, y: 8}
  }
];
const flag = { x:0, y:0};
复制代码

这就是我们的设置:我们有一个起始位置,flag ,我们有一个球员数组。我们定义了两个不同的函数来计算差异,让我们看看差异。

// Given those, let's see if we can find a way to map 
//  out those distances! Let's do it first with the first
//  distance formula.
const distances = players.map( player => distance(flag, player.position) );
/***
 * distances == [
 *   5.830951894845301, 
 *   5.656854249492381, 
 *   8.246211251235321
 * ]
 ***/

// using a curried function, we can create a function that already
//  contains our starting point.
const distanceFromFlag = distanceWithCurrying(flag);
// Now, we can map over our players to extract their position, and
//  map again with that distance formula:
const curriedDistances = players.map(player=>player.position)
                                .map(distanceFromFlag)
/***
 * curriedDistances == [
 *   5.830951894845301, 
 *   5.656854249492381, 
 *   8.246211251235321
 * ]
 ***/
复制代码

所以在这里,我们用我们的distanceCurried 函数来应用一个参数,即起点。这返回了一个函数,该函数需要另一个参数,即结束点。通过对球员的映射,我们可以创建一个新的数组,里面_只有_我们需要的数据,然后把这些数据传给我们的curried函数!这是一个强大的工具。

这是一个强大的工具,但可能需要一些时间来适应。但通过创建咖喱函数并与其他函数组合,我们可以从更小、更简单的部分创建一些非常复杂的函数。

如何组合咖喱函数

能够映射卷曲函数是非常有用的,但你也会发现它们的其他伟大用途。这就是 "函数式编程 "的开端:编写小型的、纯粹的函数,作为这些原子位正常运行,然后像积木一样组合它们。

让我们来看看我们如何利用咖喱函数,并将它们组合成更大的函数。接下来的探索将涉及到过滤函数。

首先,要做一点基础工作。Array.prototype.filter() ,ES6的过滤函数,允许我们定义一个回调函数,它接收一个或多个输入值,并根据该值返回一个真或假。这里有一个例子。

// a source array,
const ages = [11, 14, 26, 9, 41, 24, 108];
// a filter function. Takes an input, and returns true/false from it.
function isEven(num){
  if(num%2===0){
    return true;
  } else {
    return false;
  }
}
// or, in ES6-style:
const isEven = (num) => num%2===0 ? true : false;
// and applied:
console.log( ages.filter(isEven) );
// [14, 26, 24, 108]
复制代码

过滤一个数组中的偶数值

现在,这个过滤函数,isEven ,是以一种非常特殊的方式编写的:它接收一个值(或多个值,如果我们想包括数组的索引,例如),执行某种内部的胡闹,并返回一个真或假。每次都是如此。

这就是 "过滤器回调函数 "的本质,尽管它并不是过滤器所独有的--Array.prototype.everyArray.prototype.some 也使用同样的风格。一个回调函数针对一个数组的每个成员进行测试,回调函数接收一些值并返回真或假。

让我们创建几个更有用的过滤器函数,但这次要更高级一点。在这种情况下,我们可能想对我们的函数进行一些 "抽象",让我们使它们更容易重复使用。

例如,一些有用的函数可能是isEqualToisGreaterThan 。这些函数更高级,因为它们需要_两个_值:一个定义为比较的一个项(称之为comparator ),另一个来自_被_比较的数组(我们称之为value )。下面是更多的代码。

// we write a function that takes in a value...
function isEqualTo(comparator){
  // and that function *returns* a function that takes a second value
  return function(value){
    // and we simply compare those two values.
    return value === comparator;
  }
}
// again, in ES6:
const isEqualTo = (comparator) => (value) => value === comparator;
复制代码

从现在开始,我将坚持使用ES6版本,除非有一个特别有挑战性的理由将代码扩展到经典版本。继续前进。

const isEqualTo = (comparator) => (value) => value === comparator;
const isGreaterThan = (comparator) => (value) => value > comparator;

// and in application:
const isSeven = isEqualTo(7);
const isOfLegalMajority = isGreaterThan(18);
复制代码

所以,前两个函数是我们的curried函数。它们期望有一个单参数,并返回一个同样期望有一个单参数的函数。

基于这两个单参数函数,我们做一个简单的比较。后面的两个,isSevenisOfLegalMajority ,只是这两个函数的实现。

到目前为止,我们还没有弄得很复杂,也没有涉及到什么,我们可以再多留一些小的。

// function to simply invert a value: true <=> false
const isNot = (value) => !value;

const isNotEqual = (comparator) => (value) => isNot( isEqual(comparator)(value) );
const isLessThanOrEqualTo = (comparator) => (value) => isNot( isGreaterThan(comparator)(value) );
复制代码

在这里,我们有一个效用函数,简单地反转了一个值的_真实性_,isNot 。利用这一点,我们可以开始组成更大的作品:我们把我们的比较器和值,通过isEqual 函数运行,然后我们isNot 这个值,说isNotEqual

这是合成的开始,让我们公平一点--它看起来绝对是愚蠢的。为了得到这个,写这么多可能有什么用。

// all of the building blocks...
const isGreaterThan = (comparator) => (value) => value > comparator;
const isNot = (value) => !value;
const isLessThanOrEqualTo = (comparator) => (value) => isNot( isGreaterThan(comparator)(value) );

// simply to get this?
const isTooYoungToRetire = isLessThanOrEqualTo(65)

// and in implementation:
const ages = [31, 24, 86, 57, 67, 19, 93, 75, 63];
console.log(ages.filter(isTooYoungToRetire)

// is that any cleaner than:
console.log(ages.filter( num => num <= 65 ) )
复制代码

"在这种情况下,最终的结果是非常相似的,所以它并没有真正为我们节省什么。事实上,考虑到前三个函数中的设置,它比简单地做比较要花费更多时间来构建!"

而这是事实。我不会争论这一点。但这只是看到了一个更大的难题中的一小块。

  • 首先,我们正在编写的代码更具有_自我记录_性。通过使用富有表现力的函数名称,我们能够一眼就看出我们正在过滤ages ,以获取数值isTooYoungToRetire 。我们不是在看数学,而是在看描述。
  • 第二,通过使用非常小的原子函数,我们能够孤立地测试每一块,确保它每次的表现都完全相同。以后,当我们重复使用这些小函数时,我们可以确信它们会起作用--当我们的函数复杂度增加时,我们就不用再测试每个小部分了。
  • 第三,通过创建抽象函数,我们以后可能会在其他项目中找到它们的应用。建立一个功能组件库是一个非常强大的资产,我强烈建议培养这个资产。

说了这么多,我们还可以把这些小的函数,开始把它们组合成越来越大的片段。现在让我们来试试:有了isGreaterThanisLessThan ,我们就可以写一个漂亮的isInRange 函数!

const isInRange = (minComparator) 
                 => (maxComparator)
                   => (value) => isGreaterThan(minComparator)(value)
                              && isLessThan(maxComparator)(value)

const isTwentySomething = isInRange(19)(30);
复制代码

这很好--我们现在有了一个一次性测试多个条件的手段。但是看看这个,它似乎并没有很好的自我文档化。中间的&& 并不可怕,但我们可以做得更好。

也许我们可以_再_写_一个_函数,一个我们可以称之为and() 的函数。and 函数可以接受任何数量的条件,并针对一个给定的值对它们进行测试。这将是很有用的,而且是可扩展的。

const and = (conditions) = 
             (value) => conditions.every(condition => condition(value) )

const isInRange = (min)
                 =>(max) 
                  => and([isGreaterThan(min), isLessThan(max) ])
复制代码

因此,and 函数可以接受任何数量的过滤函数,并且只在它们对一个给定值都为真时返回真。最后的那个isInRange 函数和前面的函数做的事情完全一样,但它似乎更易读,而且可以自我记录。

此外,它允许我们结合任何数量的函数:假设我们想得到20和40之间的偶数,我们只需将上面的isEven 函数与我们的isInRange 函数结合起来,使用and ,就能简单地工作。

回顾一下

通过使用卷曲函数,我们能够干净地将函数组合在一起。我们可以将一个函数的输出直接连接到下一个函数的输入,因为这两个函数现在都只接受一个参数。

通过使用组合,我们可以将较小的函数或curried函数组合成更大、更复杂的结构,并确信最小的部分都在按预期工作。

这有很多东西需要消化,而且是一个很深的概念。但是,如果你花时间多加探索,我想你会开始看到我们还没有触及的应用,你可能会代替我写下一篇这样的文章!


Tobias Parent

托比亚斯家长

阅读该作者的更多文章


如果这篇文章对你有帮助,请推送给我。

免费学习代码。freeCodeCamp的开源课程已经帮助超过40,000人获得了作为开发者的工作。开始吧

文章分类
阅读
文章标签