JavaScript函数柯里化的一些思考

1. 高阶函数的坑

在学习柯里化之前,我们首先来看下面一段代码:

var f1 = function(x){
    return f(x);
};
f1(x);

很多同学都能看出来,这些写是非常傻的,因为函数f1f是等效的,我们直接令var f1 = f;就行了,完全没有必要包裹那么一层。

但是,下面一段代码就未必能够看得出问题来了:

var getServerStuff = function(callback){
  return ajaxCall(function(json){
    return callback(json);
  });
};

这是我摘自《JS函数式编程指南》中的一段代码,实际上,利用上面的规则,我们可以得出callback与函数

function(json){return callback(json);};

是等价的,所以函数可以化简为:

var getServerStuff = function(callback){
  return ajaxCall(callback);
};

继续化简:

var getServerStuff = ajaxCall;

如此一来,我们发现那么长一段程序都白写了。

函数既可以当参数,又可以当返回值,是高阶函数的一个重要特性,但是稍不留神就容易踩到坑里。

时间: 2019-02-24阅读: 525标签: 柯里化

时间: 2019-07-10阅读: 132标签: 编程前言

2. 函数柯里化(curry)

言归正传,什么是函数柯里化?函数柯里化(curry)就是只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。听得很绕口,其实很简单,其实就是将函数的变量拆分开来调用:f(x,y,z) -> f(x)(y)(z)

对于最开始的例子,按照如下实现,要传入两个参数,f1调用方式是f1(f,x)

var f1 = function(f,x){
    return f(x);
};

注意,由于f是作为一个函数变量传入,所以f1变成了一个新的函数。

我们将f1变化一下,利用闭包可以写成如下形式,则f1调用方式变成了f1(f)(x),而且得到的结果完全一样。这就完成了f1的柯里化。

var f1 = function(f){
    return function(x){
        return f(x);
    }
};
var f2 = f1(f);
f2(x);

其实这个例子举得不恰当,细心的同学可能会发现,f1虽然是一个新函数,但是f2f是完全等效的,绕了半天,还是绕回来了。

这里有一个很经典的例子:

['11', '11', '11'].map(parseInt) //[ 11, NaN, 3 ]
['11', '11', '11'].map(f1(parseInt)) //[ 11, 11, 11 ]

由于parseInt接受两个参数,所以直接调用会有进制转换的问题,参考“不愿相离”的文章。

var f2 = f1(parseInt)f2parseInt由原来的接受两个参数变成了只接受一个参数的新函数,从而解决这个进制转换问题。通过我们的f1包裹以后就能够运行出正确的结果了。

有同学觉得这个不算柯里化的应用,我觉得还是算吧,各位同学可以一起来讨论下。

我们经常说在Javascript语言中,函数是“一等公民”,它们本质上是十分简单和过程化的。可以利用函数,进行一些简单的数据处理,return结果,或者有一些额外的功能,需要通过使用闭包来实现,最后经常会return匿名函数。

函数式编程在前端已经成为了一个非常热门的话题。在最近几年里,我们看到非常多的应用程序代码库里大量使用着函数式编程思想。

3. 函数柯里化进一步思考

如果说上一节的例子中,我们不是直接运行f(x),而是把函数f当做一个参数,结果会怎样呢?我们来看下面这个例子:

假设f1返回函数gg的作用域指向xs,函数f作为g澳门新葡萄京娱乐场 ,的参数。最终我们可以写成如下形式:

var f1 = function(f,xs){
    return g.call(xs,f);
};

实际上,用f1来替代g.call(xxx)的做法叫反柯里化。例如:

var forEach = function(xs,f){
    return Array.prototype.forEach.call(xs,f);
};
var f = function(x){console.log(x);};
var xs = {0:'peng',1:'chen',length:2};
forEach(xs,f);

反curring就是把原来已经固定的参数或者this上下文等当作参数延迟到未来传递。
它能够在很大程度上简化函数,前提是你得习惯它。

抛开反柯里化,如果我们要柯里化f1怎么办?

使用闭包,我们可以写成如下形式:

var f1 = function(f){
    return function(xs){
        return g.call(xs,f);
    }
};
var f2 = f1(f);
f2(xs);

f传入f1中,我们就可以得到f2这个新函数。

只传给函数一部分参数通常也叫做局部调用(partial
application),能够大量减少样板文件代码(boilerplate code)。

当然,函数f1传入的两个参数不一定非得包含函数+非函数,可能两个都是函数,也可能两个都是非函数。

我个人觉得柯里化并非是必须的,而且不熟悉的同学阅读起来可能会遇到麻烦,但是它能帮助我们理解JS中的函数式编程,更重要的是,我们以后在阅读类似的代码时,不会感到陌生。知乎上罗宸同学讲的挺好:

并非“柯里化”对函数式编程有意义。而是,函数式编程在把函数当作一等公民的同时,就不可避免的会产生“柯里化”这种用法。所以它并不是因为“有什么意义”才出现的。当然既然存在了,我们自然可以探讨一下怎么利用这种现象。

练习:

// 通过局部调用(partial apply)移除所有参数
var filterQs = function(xs) {
  return filter(function(x){ return match(/q/i, x);  }, xs);
};
//这两个函数原题没有,是我自己加的
var filter = function(f,xs){
    return xs.filter(f);
};
var match = function(what,x){
    return x.match(what);
};

分析:函数filterQs的作用是:传入一个字符串数组,过滤出包含’q’的字符串,并组成一个新的数组返回。

我们可以通过如下步骤得到函数filterQs

a. filter传入的两个参数,第一个是回调函数,第二个是数组,filter主要功能是根据回调函数过滤数组。我们首先将filter函数柯里化:

var filter = function(f){
    return function (xs) {
        return xs.filter(f);
    }
};

b.
其次,filter函数传入的回调函数是matchmatch的主要功能是判断每个字符串是否匹配what这个正则表达式。这里我们将match也柯里化:

var match = function(what){
    return function(x){
        return x.match(what);
    }
};
var match2 = match(/q/i);

创建匹配函数match2,检查字符串中是否包含字母q。

c. 把match2传入filter中,组合在一起,就形成了一个新的函数:

var filterQs =  filter(match2);
var xs = ['q','test1','test2'];
filterQs(xs);

从这个示例中我们也可以体会到函数柯里化的强大。所以,柯里化还有一个重要的功能:封装不同功能的函数,利用已有的函数组成新的函数。

如果你对函数式编程有一定了解,函数柯里化(function
currying)是不可或缺的,利用函数柯里化,可以在开发中非常优雅的处理复杂逻辑。

本文将略去那些晦涩难懂的概念介绍,重点展示在 JavaScript
中到底什么是函数式的代码、声明式与命令式代码的区别、以及常见的函数式模型都有哪些?

4. 函数柯里化的递归调用

函数柯里化还有一种有趣的形式,就是函数可以在闭包中调用自己,类似于函数递归调用。如下所示:

function add( seed ) {
    function retVal( later ) {
        return add( seed + later );
    }
    retVal.toString = function() {
        return seed;
    };
    return retVal;
}
console.log(add(1)(2)(3).toString()); // 6

add函数返回闭包retVal,在retVal中又继续调用add,最终我们可以写成add(1)(2)(3)(...)这样柯里化的形式。
关于这段代码的解答,知乎上的李宏训同学回答地很好:

每调用一次add函数,都会返回retValue函数;调用retValue函数会调用add函数,然后还是返回retValue函数,所以调用add的结果一定是返回一个retValue函数。add函数的存在意义只是为了提供闭包,这个类似的递归调用每次调用add都会生成一个新的闭包。

函数柯里化

一、什么是函数式编程

5. 函数组合(compose)

函数组合是在柯里化基础上完成的:

var compose = function(f,g) {
  return function(x) {
    return f(g(x));
  };
};
var f1 = compose(f,g);
f1(x);

将传入的函数变成两个,通过组合的方式返回一个新的函数,让代码从右向左运行,而不是从内向外运行。

函数组合和柯里化有一个好处就是pointfree。

pointfree
模式指的是,永远不必说出你的数据。它的意思是说,函数无须提及将要操作的数据是什么样的。一等公民的函数、柯里化(curry)以及组合协作起来非常有助于实现这种模式。

// 非 pointfree,因为提到了数据:name
var initials = function (name) {
  return name.split(' ').map(compose(toUpperCase, head)).join('. ');
};

// pointfree
var initials = compose(join('. '), map(compose(toUpperCase, head)), split(' '));

initials("hunter stockton thompson");
// 'H. S. T'

柯里化(Currying),维基百科上的解释是,把接受多个参数的函数转换成接受一个单一参数的函数先看一个简单例子

函数式编程是一种编程范式,主要是利用函数把运算过程封装起来,通过组合各种函数来计算结果。函数式编程意味着你可以在更短的时间内编写具有更少错误的代码。举个简单的例子,假设我们要把字符串:functional
programming is great 变成每个单词首字母大写,我们可以这样实现:

 // 柯里化 var foo = function(x) { return function(y) { return x + y } } foo(3)(4) // 7 // 普通方法 var add = function(x, y) { return x + y; } add(3, 4) //7 
var string = 'functional programming is great';var result = string .split(' ') .map(v = v.slice(0, 1).toUpperCase() + v.slice(1)) .join(' ');

本来应该一次传入两个参数的add函数,柯里化方法,变成每次调用都只用传入一个参数,调用两次后,得到最后的结果。

上面的例子先用 split 把字符串转换数组,然后再通过 map
把各元素的首字母转换成大写,最后通过 join 把数组转换成字符串。
整个过程就是join(map(split(str))),体现了函数式编程的核心思想:通过函数对数据进行转换

再看看,一道经典的面试题。

由此我们可以得到,函数式编程有两个基本特点:

编写一个sum函数,实现如下功能: console.log(sum(1)(2)(3)) // 6.

通过函数来对数据进行转换通过串联多个函数来求结果二、对比声明式与命令式

直接套用上面柯里化函数,多加一层return

命令式:我们通过编写一条又一条指令去让计算机执行一些动作,这其中一般都会涉及到很多繁杂的细节。命令式代码中频繁使用语句,来完成某个行为。比如
for、if、switch、throw 等这些语句。

 function sum(a) { return function(b) { return function(c) { return a + b + c; } } }

声明式:我们通过写表达式的方式来声明我们想干什么,而不是通过一步一步的指示。表达式通常是某些函数调用的复合、一些值和操作符,用来计算出结果值。

当然,柯里化不是为了解决面试题,它是应函数式编程而生

//命令式var CEOs = [];for(var i = 0; i  companies.length; i++){ CEOs.push(companies[i].CEO)}//声明式var CEOs = companies.map(c = c.CEO);

如何实现

从上面的例子中,我们可以看到声明式的写法是一个表达式,无需关心如何进行计数器迭代,返回的数组如何收集,它指明的是做什么,而不是怎么做。函数式编程的一个明显的好处就是这种声明式的代码,对于无副作用的纯函数,我们完全可以不考虑函数内部是如何实现的,专注于编写业务代码。

还是看看上面的经典面试题。如果想实现sum(1)(2)(3)(4)(5)…(n)就得嵌套n-1个匿名函数,

三、常见特性无副作用

 function sum(a) { return function(b) { ... return function(n) { } } } 

指调用函数时不会修改外部状态,即一个函数调用 n 次后依然返回同样的结果。

看起来并不优雅,如果我们预先知道有多少个参数要传入,可以利用递归方法解决

var a = 1;// 含有副作用,它修改了外部变量 a// 多次调用结果不一样function test1() { a++ return a;}// 无副作用,没有修改外部状态// 多次调用结果一样function test2(a) { return a + 1;}
 var add = function(num1, num2) { return num1 + num2; } // 假设 sum 函数调用时,传入参数都是标准的数字 function curry(add, n) { var count = 0, arr = []; return function reply(arg) { arr.push(arg); if ( ++count = n) { //这里也可以在外面定义变量,保存每次计算后结果 return arr.reduce(function(p, c) { return p = add(p, c); }, 0) } else { return reply; } } } var sum = curry(add, 4); sum(4)(3)(2)(1) // 10

透明引用

如果调用次数多于约定数量,sum就会报错,我们就可以设计成类似这样

指一个函数只会用到传递给它的变量以及自己内部创建的变量,不会使用到其他变量。

sum(1)(2)(3)(4)(); // 最后传入空参数,标识调用结束,
var a = 1;var b = 2;// 函数内部使用的变量并不属于它的作用域function test1() { return a + b;}// 函数内部使用的变量是显式传递进去的function test2(a, b) { return a + b;}

只需要简单修改下curry函数

不可变变量

function curry(add) { var arr = []; return function reply() { var arg = Array.prototype.slice.call(arguments); arr = arr.concat(arg); if (arg.length === 0) { // 递归结束条件,修改为 传入空参数 return arr.reduce(function(p, c) { return p = add(p, c); }, 0) } else { return reply; } } } console.log(sum(4)(3)(2)(1)(5)()) // 15

指的是一个变量一旦创建后,就不能再进行修改,任何修改都会生成一个新的变量。使用不可变变量最大的好处是线程安全。多个线程可以同时访问同一个不可变变量,让并行变得更容易实现。
由于 JavaScript 原生不支持不可变变量,需要通过第三方库来实现。 (如
Immutable.js,Mori 等等)

简洁版实现

var obj = Immutable({ a: 1 });var obj2 = obj.set('a', 2);console.log(obj); // Immutable({ a: 1 })console.log(obj2); // Immutable({ a: 2 })

上面针对具体问题,引入柯里化方法解答,回到如何实现创建柯里化函数的通用方法。同样先看简单版本的方法,以add方法为例,代码来自《JavaScript高级程序设计》

函数是一等公民

 function curry(fn) { var args = Array.prototype.slice.call(arguments, 1); return function() { var innerArgs = Array.prototype.slice.call(arguments); var finalArgs = args.concat(innerArgs); return fn.apply(null, finalArgs); };}function add(num1, num2) { return num1 + num2;}var curriedAdd = curry(add, 5);var curriedAdd2 = curry(add, 5, 12);alert(curriedAdd(3)) // 8alert(curriedAdd2()) // 17

我们常说函数是JavaScript的”第一等公民”,指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。下文将要介绍的闭包、高阶函数、函数柯里化和函数组合都是围绕这一特性的应用

加强版实现

四、常见的函数式编程模型1.闭包(Closure)

上面add函数,可以换成任何其他函数,经过curry函数处理,都可以转成柯里化函数。这里在调用curry初始化时,就传入了一个参数,而且返回的函数curriedAdd,curriedAdd2也没有被柯里化。要想实现更加通用的方法,在柯里化函数真正调用时,再传参数,

如果一个函数引用了自由变量,那么该函数就是一个闭包。何谓自由变量?自由变量是指不属于该函数作用域的变量(所有全局变量都是自由变量,严格来说引用了全局变量的函数都是闭包,但这种闭包并没有什么用,通常情况下我们说的闭包是指函数内部的函数)。

function curry(fn) { ... }function add(num1, num2) { return num1 + num2;}var curriedAdd = curry(add);curriedAdd(3)(4) // 7

闭包的形成条件:

每次调用curry返回的函数,也被柯里化,可以继续传入一个或多个参数进行调用,

存在内、外两层函数内层函数对外层函数的局部变量进行了引用

跟上面sum(1)(2)(3)(4)非常类似,利用递归就可以实现。关键是递归的出口,这里不能是传入一个空参数的调用,
而是原函数定义时,参数的总个数,柯里化函数调用时,满足了原函数的总个数,就返回计算结果,否则,继续返回柯里化函数

闭包的用途:可以定义一些作用域局限的持久化变量,这些变量可以用来做缓存或者计算的中间量等。

原函数的入参总个数,可以利用length属性获得

// 简单的缓存工具// 匿名函数创造了一个闭包const cache = (function() { const store = {}; return { get(key) { return store[key]; }, set(key, val) { store[key] = val; } }}());console.log(cache) //{get: ƒ, set: ƒ}cache.set('a', 1);cache.get('a'); // 1
function add(num1, num2) { return num1 + num2;}add.length // 2

上面例子是一个简单的缓存工具的实现,匿名函数创造了一个闭包,使得 store
对象 ,一直可以被引用,不会被回收。

结合上面的代码,

闭包的弊端:持久化变量不会被正常释放,持续占用内存空间,很容易造成内存浪费,所以一般需要一些额外手动的清理机制。

 var curry = function(f) { var len = f.length; return function t() { var innerLength = arguments.length, args = Array.prototype.slice.call(arguments); if (innerLength = len) { // 递归出口,f.length return f.apply(undefined, args) } else { return function() { var innerArgs = Array.prototype.slice.call(arguments), allArgs = args.concat(innerArgs); return t.apply(undefined, allArgs) } } } } // 测试一下 function add(num1, num2) { return num1 + num2; } var curriedAdd = curry(add); add(2)(3); //5 // 一个参数 function identity(value) { return value; } var curriedIdentify = curry(identify); curriedIdentify(4) // 4

2.高阶函数

到此,柯里化通用函数可以满足大部分需求了。

函数式编程倾向于复用一组通用的函数功能来处理数据,它通过使用高阶函数来实现。高阶函数指的是一个函数以函数为参数,或以函数为返回值,或者既以函数为参数又以函数为返回值

在使用 apply 递归调用的时候,默认传入 undefined,
在其它场景下,可能需要传入 context, 绑定指定环境

高阶函数经常用于:

实际开发,推荐使用lodash.curry, 具体实现,可以参考下curry源码

抽象或隔离行为、作用,异步控制流程作为回调函数,promises,monads等创建可以泛用于各种数据类型的功能部分应用于函数参数(偏函数应用)或创建一个柯里化的函数,用于复用或函数复合。接受一个函数列表并返回一些由这个列表中的函数组成的复合函数。

使用场景

JavaScript 语言是原生支持高阶函数的,
例如Array.prototype.map,Array.prototype.filter 和
Array.prototype.reduce
是JavaScript中内置的一些高阶函数,使用高阶函数会让我们的代码更清晰简洁。

讲了这么多curry函数的不同实现方法,那么实现了通用方法后,在那些场景下可以使用,或者说使用柯里化函数是否可以真实的提高代码质量,下面总结一下使用场景

map

参数复用在《JavaScript高级程序设计》中简单版的curry函数中

map()
方法创建一个新数组,其结果是该数组中的每个元素都调用一个提供的函数后返回的结果。map
不会改变原数组。

var curriedAdd = curry(add,5)

假设我们有一个包含名称和种类属性的对象数组,我们想要这个数组中所有名称属性放在一个新数组中,如何实现呢?

在后面,使用curriedAdd函数时,默认都复用了5,不需要重新传入两个参数

// 不使用高阶函数var animals = [ { name: "Fluffykins", species: "rabbit" }, { name: "Caro", species: "dog" }, { name: "Hamilton", species: "dog" }, { name: "Harold", species: "fish" }, { name: "Ursula", species: "cat" }, { name: "Jimmy", species: "fish" }];var names = [];for (let i = 0; i  animals.length; i++) { names.push(animals[i].name);}console.log(names); //["Fluffykins", "Caro", "Hamilton", "Harold", "Ursula", "Jimmy"]

// 使用高阶函数var animals = [ { name: "Fluffykins", species: "rabbit" }, { name: "Caro", species: "dog" }, { name: "Hamilton", species: "dog" }, { name: "Harold", species: "fish" }, { name: "Ursula", species: "cat" }, { name: "Jimmy", species: "fish" }];var names = animals.map(x=x.name);console.log(names); //["Fluffykins", "Caro", "Hamilton", "Harold", "Ursula", "Jimmy"]

延迟执行上面传入多个参数的sum(1)(2)(3),就是延迟执行的最后例子,传入参数个数没有满足原函数入参个数,都不会立即返回结果。

filter

类似的场景,还有绑定事件回调,更使用bind()方法绑定上下文,传入参数类似,

filter()
方法会创建一个新数组,其中包含所有通过回调函数测试的元素。filter
为数组中的每个元素调用一次 callback 函数, callback 函数返回 true
表示该元素通过测试,保留该元素,false 则不保留。filter
不会改变原数组,它返回过滤后的新数组。

 addEventListener('click', hander.bind(this, arg1,arg2...)) addEventListener('click', curry(hander)) 

假设我们有一个包含名称和种类属性的对象数组。
我们想要创建一个只包含狗(species: “dog”)的数组。如何实现呢?

延迟执行的特性,可以避免在执行函数外面,包裹一层匿名函数,curry函数作为回调函数就有很大优势。

// 不使用高阶函数var animals = [ { name: "Fluffykins", species: "rabbit" }, { name: "Caro", species: "dog" }, { name: "Hamilton", species: "dog" }, { name: "Harold", species: "fish" }, { name: "Ursula", species: "cat" }, { name: "Jimmy", species: "fish" }];var dogs = [];for (var i = 0; i  animals.length; i++) { if (animals[i].species === "dog") dogs.push(animals[i]);}console.log(dogs); 

// 使用高阶函数var animals = [ { name: "Fluffykins", species: "rabbit" }, { name: "Caro", species: "dog" }, { name: "Hamilton", species: "dog" }, { name: "Harold", species: "fish" }, { name: "Ursula", species: "cat" }, { name: "Jimmy", species: "fish" }];var dogs = animals.filter(x = x.species === "dog");console.log(dogs); // {name: "Caro", species: "dog"}// { name: "Hamilton", species: "dog" }

函数式编程中,作为compose, functor, monad 等实现的基础

reduce

有人说柯里化是应函数式编程而生,它在里面出现的概率就非常大了,在JS
函数式编程指南中,开篇就介绍了柯里化的重要性。

reduce
方法对调用数组的每个元素执行回调函数,最后生成一个单一的值并返回。
reduce 方法接受两个参数:1)reducer 函数(回调),2)一个可选的
initialValue。

关于额外开销

假设我们要对一个数组的求和:

函数柯里化可以用来构建复杂的算法 和 功能,
但是滥用也会带来额外的开销。

// 不使用高阶函数const arr = [5, 7, 1, 8, 4];let sum = 0;for (let i = 0; i  arr.length; i++) { sum = sum + arr[i];}console.log(sum);//25

// 使用高阶函数const arr = [5, 7, 1, 8, 4];const sum = arr.reduce((accumulator, currentValue) = accumulator + currentValue,0);console.log(sum)//25

从上面实现部分的代码中,可以看到,使用柯里化函数,离不开闭包,
arguments, 递归。

3.函数柯里化

闭包,函数中的变量都保存在内存中,内存消耗大,有可能导致内存泄漏。递归,效率非常差,arguments,
变量存取慢,访问性很差

柯里化又称部分求值,柯里化函数会接收一些参数,然后不会立即求值,而是继续返回一个新函数,将传入的参数通过闭包的形式保存,等到被真正求值的时候,再一次性把所有传入的参数进行求值。

来自:

// 普通函数function add(x,y){ return x + y;}add(1,2); // 3// 函数柯里化var add = function(x) { return function(y) { return x + y; };};var increment = add(1);increment(2);// 3

这里我们定义了一个 add 函数,它接受一个参数并返回一个新的函数。调用 add
之后,返回的函数就通过闭包的方式记住了 add
的第一个参数。那么,我们如何来实现一个简易的柯里化函数呢?

function curryIt(fn) { // 参数fn函数的参数个数 var n = fn.length; var args = []; return function(arg) { args.push(arg); if (args.length  n) { return arguments.callee; // 返回这个函数的引用 } else { return fn.apply(this, args); } };}function add(a, b, c) { return [a, b, c];}var c = curryIt(add);var c1 = c(1);var c2 = c1(2);var c3 = c2(3);console.log(c3); //[1, 2, 3]

由此我们可以看出,柯里化是一种“预加载”函数的方法,通过传递较少的参数,得到一个已经记住了这些参数的新函数,某种意义上讲,这是一种对参数的“缓存”,是一种非常高效的编写函数的方法!

4.函数组合 (Composition)

前面提到过,函数式编程的一个特点是通过串联函数来求值。然而,随着串联函数数量的增多,代码的可读性就会不断下降。函数组合就是用来解决这个问题的方法。假设有一个
compose
函数,它可以接受多个函数作为参数,然后返回一个新的函数。当我们为这个新函数传递参数时,该参数就会「流」过其中的函数,最后返回结果。

//两个函数的组合var compose = function(f, g) { return function(x) { return f(g(x)); };};//或者var compose = (f, g) = (x = f(g(x)));var add1 = x = x + 1;var mul5 = x = x * 5;compose(mul5, add1)(2);// =15 

发表评论

电子邮件地址不会被公开。 必填项已用*标注