函数柯里化
最近遇到了一个奇怪的题目,实现以下效果:
sum(2, 3).result = 5;
sum(2, 3)(4, 5).result = 14;
sum(1, 2)(3).result = 6;
这个题目看起来很奇怪,其实就是个函数柯里化加上个脑筋急转弯,这里记录一下这个题目的思路,如果不懂柯里化,看完对柯里化的介绍之后可以先自己想一下怎么实现这个效果,反正作者想了半个小时才缓过神来原来竟然如此简单,归根结底还是理论充足,但是见识少了。
什么是柯里化
柯里化, 即 Currying 的音译。 Currying 是编译原理层面实现多参函数的一个技术,Currying ——只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。。
在说JavaScript 中的柯里化前,可以聊一下原始的 Currying 是什么,又从何而来。
在编码过程中,身为码农的我们本质上所进行的工作就是——将复杂问题分解为多个可编程的小问题。
Currying 为实现多参函数提供了一个递归降解的实现思路——把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数,在某些编程语言中(如 Haskell),是通过 Currying 技术支持多参函数这一语言特性的。
对于Javascript语言来说,我们通常说的柯里化函数的概念,与数学和计算机科学中的柯里化的概念并不完全一样。
在数学和计算机科学中的柯里化函数,一次只能传递一个参数;
而我们Javascript实际应用中的柯里化函数,可以传递一个或多个参数。
1 | //普通函数 |
柯里化的用途
参数复用
柯里化实际是把简答的问题复杂化了,但是复杂化的同时,我们在使用函数时拥有了更加多的自由度。 而这里对于函数参数的自由处理,正是柯里化的核心所在。 柯里化本质上是降低通用性,提高适用性。
它的用途也可以叫做参数复用,也就是说你前面传入的参数会对后面生成的函数产生影响。
比如,如果我们不使用柯里化去封装校验函数,它是这样的
1 | function checkByRegExp(regExp,string) { |
这个函数每次传入的数都不会对后面产生影响。
但是如果使用柯里化的方式:
1 | //进行柯里化 |
你前面传入的正则表达式会对接下来的计算结果产生影响。
延迟执行
延迟执行也是 Currying 的一个重要使用场景,Currying延迟求值的特性需要用到 JavaScript 中的作用域——说得更通俗一些,我们需要使用作用域来保存上一次传进来的参数。同样 bind 和箭头函数也能实现同样的功能。
在前端开发中,一个常见的场景就是为标签绑定 onClick 事件,同时考虑为绑定的方法传递参数。
柯里化工具函数
1 | /** |
答案
柯里化
1 | function sum() { |
没错,这个脑筋急转弯就是函数也是个对象,直接就塞一个result上去就好。
Thunk函数(2020.06.03更新)
最近在看Generator函数的时候发现了有一种函数叫做Thunk函数,它和柯里化函数长得很像,在这里记录一下,主要内容来自于阮一峰老师的博客,稍加修改和注释。
参数的求值策略
Thunk函数早在上个世纪60年代就诞生了。
那时,编程语言刚刚起步,计算机学家还在研究,编译器怎么写比较好。一个争论的焦点是"求值策略",即函数的参数到底应该何时求值。
1
2
3
4
5
6
7 var x = 1;
function f(m){
return m * 2;
}
f(x + 5)
上面代码先定义函数 f,然后向它传入表达式 x + 5 。请问,这个表达式应该何时求值?
一种意见是"传值调用"(call by value),即在进入函数体之前,就计算 x + 5 的值(等于6),再将这个值传入函数 f 。C语言就采用这种策略。
1
2
3 f(x + 5)
// 传值调用时,等同于
f(6)
另一种意见是"传名调用"(call by name),即直接将表达式 x + 5 传入函数体,只在用到它的时候求值。Hskell语言采用这种策略。
1
2
3 f(x + 5)
// 传名调用时,等同于
(x + 5) * 2
**传值调用和传名调用,哪一种比较好?回答是各有利弊。**传值调用比较简单,但是对参数求值的时候,实际上还没用到这个参数,有可能造成性能损失。
1
2
3
4
5 function f(a, b){
return b;
}
f(3 * x * x - 2 * x - 1, x);
上面代码中,函数 f 的第一个参数是一个复杂的表达式,但是函数体内根本没用到。对这个参数求值,实际上是不必要的。
因此,有一些计算机学家倾向于"传名调用",即只在执行时求值。
Thunk 函数的含义
编译器的"传名调用"实现,往往是将参数放到一个临时函数之中,再将这个临时函数传入函数体。这个临时函数就叫做 Thunk 函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 function f(m){
return m * 2;
}
f(x + 5);
// 等同于
var thunk = function () {
return x + 5;
};
function f(thunk){
return thunk() * 2;
}
上面代码中,函数 f 的参数 x + 5 被一个函数替换了。凡是用到原参数的地方,对 Thunk 函数求值即可。
这就是 Thunk 函数的定义,它是"传名调用"的一种实现策略,用来替换某个表达式。
JavaScript 语言的 Thunk 函数
JavaScript 语言是传值调用,它的 Thunk 函数含义有所不同。在 JavaScript 语言中,Thunk 函数替换的不是表达式,而是多参数函数,将其替换成单参数的版本,且只接受回调函数作为参数。
1
2
3
4
5
6
7
8
9
10
11
12 // 正常版本的readFile(多参数版本)
fs.readFile(fileName, callback);
// Thunk版本的readFile(单参数版本)
var readFileThunk = Thunk(fileName);
readFileThunk(callback);
var Thunk = function (fileName){
return function (callback){
return fs.readFile(fileName, callback);
};
};
上面代码中,fs 模块的 readFile 方法是一个多参数函数,两个参数分别为文件名和回调函数。经过转换器处理,它变成了一个单参数函数,只接受回调函数作为参数。这个单参数版本,就叫做 Thunk 函数。
任何函数,只要参数有回调函数,就能写成 Thunk 函数的形式。下面是一个简单的 Thunk 函数转换器。
1
2
3
4
5
6
7
8
9 var Thunk = function(fn){
return function (){
var args = Array.prototype.slice.call(arguments);
return function (callback){
args.push(callback);
return fn.apply(this, args);
}
};
};
使用上面的转换器,生成 fs.readFile 的 Thunk 函数。
1
2 var readFileThunk = Thunk(fs.readFile);
readFileThunk(fileA)(callback);
Thunkify 模块
生产环境的转换器,建议使用 Thunkify 模块。
首先是安装。
1 $ npm install thunkify
使用方式如下。
1
2
3
4
5
6
7 var thunkify = require('thunkify');
var fs = require('fs');
var read = thunkify(fs.readFile);
read('package.json')(function(err, str){
// ...
});
Thunkify 的源码与上一节那个简单的转换器非常像。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26 function thunkify(fn){
return function(){
var args = new Array(arguments.length);
var ctx = this;
for(var i = 0; i < args.length; ++i) {
args[i] = arguments[i];
}
return function(done){
var called;
args.push(function(){
if (called) return;
called = true;
done.apply(null, arguments);
});
try {
fn.apply(ctx, args);
} catch (err) {
done(err);
}
}
}
};
它的源码主要多了一个检查机制,变量 called 确保回调函数只运行一次。这样的设计与下文的 Generator 函数相关。请看下面的例子。
1
2
3
4
5
6
7
8
9 function f(a, b, callback){
var sum = a + b;
callback(sum);
callback(sum);
}
var ft = thunkify(f);
ft(1, 2)(console.log);
// 3
上面代码中,由于 thunkify 只允许回调函数执行一次,所以只输出一行结果。
Generator 函数的流程管理
你可能会问, Thunk 函数有什么用?回答是以前确实没什么用,但是 ES6 有了 Generator 函数,Thunk 函数现在可以用于 Generator 函数的自动流程管理。
以读取文件为例。下面的 Generator 函数封装了两个异步操作。
1
2
3
4
5
6
7
8
9
10 var fs = require('fs');
var thunkify = require('thunkify');
var readFile = thunkify(fs.readFile);
var gen = function* (){
var r1 = yield readFile('/etc/fstab');
console.log(r1.toString());
var r2 = yield readFile('/etc/shells');
console.log(r2.toString());
};
上面代码中,yield 命令用于将程序的执行权移出 Generator 函数,那么就需要一种方法,将执行权再交还给 Generator 函数。
这种方法就是 Thunk 函数,因为它可以在回调函数里,将执行权交还给 Generator 函数。为了便于理解,我们先看如何手动执行上面这个 Generator 函数。
1
2
3
4
5
6
7
8
9
10
11 var g = gen();
var r1 = g.next();
r1.value(function(err, data){
if (err) throw err;
var r2 = g.next(data);
r2.value(function(err, data){
if (err) throw err;
g.next(data);
});
});
上面代码中,变量 g 是 Generator 函数的内部指针,表示目前执行到哪一步。next 方法负责将指针移动到下一步,并返回该步的信息(value 属性和 done 属性)。
仔细查看上面的代码,可以发现 Generator 函数的执行过程,其实是将同一个回调函数,反复传入 next 方法的 value 属性。这使得我们可以用递归来自动完成这个过程。
Thunk 函数的自动流程管理
Thunk 函数真正的威力,在于可以自动执行 Generator 函数。下面就是一个基于 Thunk 函数的 Generator 执行器。
1
2
3
4
5
6
7
8
9
10
11
12
13 function run(fn) {
var gen = fn();
function next(err, data) {
var result = gen.next(data);
if (result.done) return;
result.value(next);
}
next();
}
run(gen);
上面代码的 run 函数,就是一个 Generator 函数的自动执行器。内部的 next 函数就是 Thunk 的回调函数。 next 函数先将指针移到 Generator 函数的下一步(gen.next 方法),然后判断 Generator 函数是否结束(result.done 属性),如果没结束,就将 next 函数再传入 Thunk 函数(result.value 属性),否则就直接退出。
有了这个执行器,执行 Generator 函数方便多了。不管有多少个异步操作,直接传入 run 函数即可。当然,前提是每一个异步操作,都要是 Thunk 函数,也就是说,跟在 yield 命令后面的必须是 Thunk 函数。
1
2
3
4
5
6
7
8 var gen = function* (){
var f1 = yield readFile('fileA');
var f2 = yield readFile('fileB');
// ...
var fn = yield readFile('fileN');
};
run(gen);
上面代码中,函数 gen 封装了 n 个异步的读取文件操作,只要执行 run 函数,这些操作就会自动完成。这样一来,异步操作不仅可以写得像同步操作,而且一行代码就可以执行。
Thunk 函数并不是 Generator 函数自动执行的唯一方案。因为自动执行的关键是,必须有一种机制,自动控制 Generator 函数的流程,接收和交还程序的执行权。回调函数可以做到这一点,Promise 对象也可以做到这一点。
使用柯里化函数封装重复逻辑(2020.10.10更新)
最近看了一些vue的源码,它其中对于平台的判断逻辑会封装在柯里化函数之中,通过传入平台配置,返回一个新的函数,这样每次调用返回的函数就可以了,就可以省略每次对于平台的判断。
吸收了这个思想,加上最近的工作中需要封装一些工具类,用于封装针对特定的数据集的CRUD,比如user和userProfile。
1 | const createRepositoryUtilForModelProfile = (mainModel, profileModel, foreignKey) => { |
参考链接: