Open yinguangyao opened 7 years ago
随着 React/Redux 的火热,函数式编程也逐渐被带入了前端的应用领域,甚至还诞生了 elm、ClojureScript 等基于 JavaScript 的函数式语言。熟练掌握这节课的内容,对后续学习函数式编程会有一定帮助。
高阶函数也是函数式编程中的一个概念,使用范围比较广泛。在现在很火的 React 中,高阶组件就是基于高阶函数发展而来。 先看一下高阶函数的定义:
高阶函数,又称算子(运算符)或泛函,包含多于一个箭头的函数。 在数学和计算机科学中,高阶函数是至少满足下列一个条件的函数: 接受一个或多个函数作为输入 输出一个函数
高阶函数,又称算子(运算符)或泛函,包含多于一个箭头的函数。 在数学和计算机科学中,高阶函数是至少满足下列一个条件的函数:
举一个简单的例子:
const add = function(x, y, f) { return f(x) + f(y); }
这个 add 函数就是一个高阶函数,它接收了另一个 f 函数。 而在 ES5 中出现的 forEach、map、some、every 等函数也属于高阶函数,他们都接收了一个匿名函数作为参数:
add
f
forEach
map
some
every
const arr = [1, 2, 3]; const iterator = function(item, index) { console.log(item); } arr.forEach(iterator);
下面是维基百科对偏函数 (Partial application) 的定义:
In computer science, partial application (or partial function application) refers to the process of fixing a number of arguments to a function, producing another function of smaller arity.
翻译一下,意思就是在计算机科学中,部分应用程序(或者部分功能应用程序)是指固定一个函数的一些参数,然后产生另一个更小元的函数。
那么什么是元呢?元就是函数参数的个数,比如带有两个参数的函数被称为二元函数。
偏函数是函数式编程中的一部分,使用偏函数可以冻结那些预先确定的参数来缓存函数参数。在运行的时候,当获得需要的剩余参数后,可以将他们解冻,传递到最终的参数中,从而使用最终确定的所有参数去调用函数。
简单来说就是,把一个函数的某些参数给固定住(也就是设置默认值),返回一个新的函数,调用这个新函数会更简单。
举个比较简单的例子,下面的 sum_add_1 就是一个偏函数。
sum_add_1
function sum(a, b) { return a + b; } // 正确调用 sum(2, 3); // 5 const sum_add_1 = partial(sum, 1); sum_add_1(2); // 3 sum_add_1(3); // 4
那么怎么实现这个 partial 方法呢?实际上使用原生的 bind 方法就能产生一个偏函数。
partial
bind
const sum_add_1 = sum.bind(null, 1);
可是 bind 函数中一般需要传入上下文给第一个参数,我们这里可以实现一个无关上下文的 partial 函数。 由于 partial 函数执行后返回了一个新的函数,那么它一定是个高阶函数。可以考虑如下实现:
const partial = (func, ...args) => { return (...rest) => { return func.apply(this, [...args, ...rest]) } }
在 JS 的函数式编程中,柯里化是一个很重要的概念,这个概念在我们实际开发中也经常会用到。
函数柯里化的意思就是你可以一次传很多参数给 curry 函数,也可以分多次传递,curry 函数每次都会返回一个函数去处理剩下的参数,一直到返回最后的结果。
curry
这里还是举几个例子来说明一下:
// 普通方式 var add1 = function(a, b, c){ return a + b + c; } // 柯里化 var add2 = function(a) { return function(b) { return function(c) { return a + b + c; } } }
这里每次传入参数都会返回一个新的函数,这样一直执行到最后一次返回 a+b+c 的值。 但是这种实现还是有问题的,这里只有三个参数,如果哪天产品经理告诉我们需要改成100次?我们就重新写100次?这很明显不符合开闭原则,所以我们需要对函数进行一次修改。
var add = function() { var _args = []; return function() { if(arguments.length === 0) { return _args.reduce(function(a, b) { return a + b; }) } [].push.apply(_args, arguments); return arguments.callee; } } var sum = add(); sum(100, 200)(300); sum(400); sum(); // 1000
我们通过判断下一次是否传进来参数来决定函数是否运行,如果继续传进了参数,那我们继续把参数都保存起来,等运行的时候全部一次性运行,这样我们就初步完成了一个柯里化的函数。
这里只是一个求和的函数,如果换成求乘积呢?我们是不是又需要重新写一遍?仔细观察一下我们的 add 函数,如果我们将if里面的代码换成一个函数执行代码,是不是就可以变成一个通用函数了?
var curry = function(fn) { var _args = []; return function() { if(arguments.length === 0) { return fn.apply(fn, _args); } [].push.apply(_args, arguments); return arguments.callee; } } var multi = function() { return [].reduce.call(arguments, function(a, b) { return a + b; }) } var add = curry(multi); add(100, 200, 300)(400); add(1000); add(); // 2000
在之前的方法上面,我们进行了扩展,这样我们就已经实现了一个比较通用的柯里化函数了。 也许你想问,我不想每次都使用那个丑陋的括号结尾怎么办?
var curry = function(fn) { var len = fn.length, args = []; return function() { Array.prototype.push.apply(args, arguments) var argsLen = args.length; if(argsLen < len) { return arguments.callee; } return fn.apply(fn, args); } } var add = function(a, b, c) { return a + b + c; } var adder = curry(add) adder(1)(2)(3)
这里根据函数 fn 的参数数量进行判断,直到传入的数量等于 fn 函数需要的参数数量才会返回 fn 函数的最终运行结果,和上面那种方法原理其实是一样的,但是这两种方式都太依赖参数数量了。 我在简书还看到别人的另一种递归实现方法,实现思路和我类似。
// 简单实现,参数只能从右到左传递 function createCurry(func, args) { var arity = func.length; var args = args || []; return function() { var _args = [].slice.call(arguments); [].push.apply(_args, args); // 如果参数个数小于最初的func.length,则递归调用,继续收集参数 if (_args.length < arity) { return createCurry.call(this, func, _args); } // 参数收集完毕,则执行func return func.apply(this, _args); } }
这里是对参数个数进行了计算,如果需要无限参数怎么办?比如下面这种场景。
add(1)(2)(3)(2); add(1, 2, 3, 4, 5);
这里主要有一个知识点,那就是函数的隐式转换,涉及到 toString 和 valueOf 两个方法,如果直接对函数进行计算,那么会先把函数转换为字符串,之后再参与到计算中,利用这两个方法我们可以对函数进行修改。
toString
valueOf
var num = function() { } num.toString = num.valueOf = function() { return 10; } var anonymousNum = (function() { // 10 return num; }())
经过修改,我们的函数最终版是这样的。
var curry = function(fn) { var func = function() { var _args = [].slice.call(arguments, 0); var func1 = function() { [].push.apply(_args, arguments) return func1; } func1.toString = func1.valueOf = function() { return fn.apply(fn, _args); } return func1; } return func; } var add = function() { return [].reduce.call(arguments, function(a, b) { return a + b; }) } var adder = curry(add) adder(1)(2)(3)
那么我们说了那么多,柯里化究竟有什么用呢?
在很多场景下,我们需要的函数参数很可能有一部分一样,这个时候再重复写就比较浪费了,我们提前加载好一部分参数,再传入剩下的参数,这里主要是利用了闭包的特性,通过闭包可以保持着原有的作用域。
var match = curry(function(what, str) { return str.match(what); }); match(/\s+/g, "hello world"); // [ ' ' ] match(/\s+/g)("hello world"); // [ ' ' ] var hasSpaces = match(/\s+/g); // function(x) { return x.match(/\s+/g) } hasSpaces("hello world"); // [ ' ' ] hasSpaces("spaceless"); // null
上面例子中,使用 `hasSpaces 函数来保存正则表达式规则,这样可以有效的实现参数的复用。
这个其实也是一种惰性函数的思想,我们可以提前执行判断条件,通过闭包将其保存在有效的作用域中,来看一种我们平时写代码常见的场景。
var addEvent = function(el, type, fn, capture) { if (window.addEventListener) { el.addEventListener(type, function(e) { fn.call(el, e); }, capture); } else if (window.attachEvent) { el.attachEvent("on" + type, function(e) { fn.call(el, e); }); } };
在这个例子中,我们每次调用 addEvent 的时候都会重新进行if语句进行判断,但是实际上浏览器的条件不可能会变化,你判断一次和判断N次结果都是一样的,所以这个可以将判断条件提前加载。
addEvent
var addEventHandler = function(){ if (window.addEventListener) { return function(el, sType, fn, capture) { el.addEventListener(sType, function(e) { fn.call(el, e); }, (capture)); }; } else if (window.attachEvent) { return function(el, sType, fn, capture) { el.attachEvent("on" + sType, function(e) { fn.call(el, e); }); }; } } var addEvent = addEventHandler(); addEvent(document.body, "click", function() {}, false); addEvent(document.getElementById("test"), "click", function() {}, false);
但是这样做还是有一种缺点,因为我们无法判断程序中是否使用了这个方法,但是依然不得不在文件顶部定义一下 addEvent,这样其实浪费了资源,这里有一种更好的解决方法。
var addEvent = function(el, sType, fn, capture){ if (window.addEventListener) { addEvent = function(el, sType, fn, capture) { el.addEventListener(sType, function(e) { fn.call(el, e); }, (capture)); }; } else if (window.attachEvent) { addEvent = function(el, sType, fn, capture) { el.attachEvent("on" + sType, function(e) { fn.call(el, e); }); }; } }
在 addEvent 函数里面对其重新赋值,这样既解决了每次运行都要判断的问题,又解决了必须在作用域顶部执行一次造成浪费的问题。
上面我们介绍过函数柯里化,从字面意思上来理解,反柯里化恰恰和柯里化相反,是为了扩大适用范围,创建一个应用范围更广的函数。使本来只有特定对象才适用的方法,扩展到更多的对象。 看下面一个例子,我们给函数增加一个反柯里化的方法。
Function.prototype.unCurry = function() { const self = this; return function() { return Function.prototype.call.apply(self, arguments); } }
通过反柯里化方法,甚至可以让对象使用数组的 push 方法:
push
const obj = {}; const push = Array.prototype.push.unCurry(); push(obj, 1, 2, 3); console.log(obj); // { 0: 1, 1: 2, 2: 3}
但是直接在函数原型上面修改不太好,这里可以实现一个更加通用的反柯里化方法。
const unCurry= function(fn) { return function(target, ...rest) { return fn.apply(target, rest); } };
使用方法和原来的类似:
const obj = {}; const push = unCurry(Array.prototype.push); push(obj, 1, 2, 3); console.log(obj); // { 0: 1, 1: 2, 2: 3}
简单理解,柯里化就是对高阶函数进行降阶处理,而反柯里化增加反过来扩大使用范围。
// 柯里化 function(a)(b) -> function(a)(b) // 反柯里化 target.func(a, b) -> unCurry(func)(target, a, b)
反柯里化的好处就是将原本只有 target 能使用的方法借了出来,可以给更多对象来使用。 我们在开发中,经常会借用 Object.prototype.toString 来检测一个变量的类型,这也是反柯里化的用法之一。
target
Object.prototype.toString
const num = 1, str = '2', obj = {}, arr = [], nul = null; const toString = unCurry(Object.prototype.toString.call); toString.call(nul); // "[object Null]" toString.call(num); // "[object Number]" toString.call(str); // "[object String]" toString.call(arr); // "[object Array]"
前言
随着 React/Redux 的火热,函数式编程也逐渐被带入了前端的应用领域,甚至还诞生了 elm、ClojureScript 等基于 JavaScript 的函数式语言。熟练掌握这节课的内容,对后续学习函数式编程会有一定帮助。
1. 高阶函数
高阶函数也是函数式编程中的一个概念,使用范围比较广泛。在现在很火的 React 中,高阶组件就是基于高阶函数发展而来。 先看一下高阶函数的定义:
举一个简单的例子:
这个
add
函数就是一个高阶函数,它接收了另一个f
函数。 而在 ES5 中出现的forEach
、map
、some
、every
等函数也属于高阶函数,他们都接收了一个匿名函数作为参数:2. 偏函数
下面是维基百科对偏函数 (Partial application) 的定义:
翻译一下,意思就是在计算机科学中,部分应用程序(或者部分功能应用程序)是指固定一个函数的一些参数,然后产生另一个更小元的函数。
那么什么是元呢?元就是函数参数的个数,比如带有两个参数的函数被称为二元函数。
偏函数是函数式编程中的一部分,使用偏函数可以冻结那些预先确定的参数来缓存函数参数。在运行的时候,当获得需要的剩余参数后,可以将他们解冻,传递到最终的参数中,从而使用最终确定的所有参数去调用函数。
简单来说就是,把一个函数的某些参数给固定住(也就是设置默认值),返回一个新的函数,调用这个新函数会更简单。
举个比较简单的例子,下面的
sum_add_1
就是一个偏函数。那么怎么实现这个
partial
方法呢?实际上使用原生的bind
方法就能产生一个偏函数。可是
bind
函数中一般需要传入上下文给第一个参数,我们这里可以实现一个无关上下文的partial
函数。 由于partial
函数执行后返回了一个新的函数,那么它一定是个高阶函数。可以考虑如下实现:3. 柯里化
在 JS 的函数式编程中,柯里化是一个很重要的概念,这个概念在我们实际开发中也经常会用到。
3.1 定义
函数柯里化的意思就是你可以一次传很多参数给
curry
函数,也可以分多次传递,curry
函数每次都会返回一个函数去处理剩下的参数,一直到返回最后的结果。这里还是举几个例子来说明一下:
3.2 柯里化求和函数
这里每次传入参数都会返回一个新的函数,这样一直执行到最后一次返回 a+b+c 的值。 但是这种实现还是有问题的,这里只有三个参数,如果哪天产品经理告诉我们需要改成100次?我们就重新写100次?这很明显不符合开闭原则,所以我们需要对函数进行一次修改。
我们通过判断下一次是否传进来参数来决定函数是否运行,如果继续传进了参数,那我们继续把参数都保存起来,等运行的时候全部一次性运行,这样我们就初步完成了一个柯里化的函数。
3.3 通用柯里化函数
这里只是一个求和的函数,如果换成求乘积呢?我们是不是又需要重新写一遍?仔细观察一下我们的
add
函数,如果我们将if里面的代码换成一个函数执行代码,是不是就可以变成一个通用函数了?在之前的方法上面,我们进行了扩展,这样我们就已经实现了一个比较通用的柯里化函数了。 也许你想问,我不想每次都使用那个丑陋的括号结尾怎么办?
这里根据函数 fn 的参数数量进行判断,直到传入的数量等于 fn 函数需要的参数数量才会返回 fn 函数的最终运行结果,和上面那种方法原理其实是一样的,但是这两种方式都太依赖参数数量了。 我在简书还看到别人的另一种递归实现方法,实现思路和我类似。
这里是对参数个数进行了计算,如果需要无限参数怎么办?比如下面这种场景。
这里主要有一个知识点,那就是函数的隐式转换,涉及到
toString
和valueOf
两个方法,如果直接对函数进行计算,那么会先把函数转换为字符串,之后再参与到计算中,利用这两个方法我们可以对函数进行修改。经过修改,我们的函数最终版是这样的。
那么我们说了那么多,柯里化究竟有什么用呢?
3.4 预加载
在很多场景下,我们需要的函数参数很可能有一部分一样,这个时候再重复写就比较浪费了,我们提前加载好一部分参数,再传入剩下的参数,这里主要是利用了闭包的特性,通过闭包可以保持着原有的作用域。
上面例子中,使用 `hasSpaces 函数来保存正则表达式规则,这样可以有效的实现参数的复用。
3.5 动态创建函数
这个其实也是一种惰性函数的思想,我们可以提前执行判断条件,通过闭包将其保存在有效的作用域中,来看一种我们平时写代码常见的场景。
在这个例子中,我们每次调用
addEvent
的时候都会重新进行if语句进行判断,但是实际上浏览器的条件不可能会变化,你判断一次和判断N次结果都是一样的,所以这个可以将判断条件提前加载。但是这样做还是有一种缺点,因为我们无法判断程序中是否使用了这个方法,但是依然不得不在文件顶部定义一下
addEvent
,这样其实浪费了资源,这里有一种更好的解决方法。在
addEvent
函数里面对其重新赋值,这样既解决了每次运行都要判断的问题,又解决了必须在作用域顶部执行一次造成浪费的问题。4. 反柯里化
上面我们介绍过函数柯里化,从字面意思上来理解,反柯里化恰恰和柯里化相反,是为了扩大适用范围,创建一个应用范围更广的函数。使本来只有特定对象才适用的方法,扩展到更多的对象。 看下面一个例子,我们给函数增加一个反柯里化的方法。
通过反柯里化方法,甚至可以让对象使用数组的
push
方法:但是直接在函数原型上面修改不太好,这里可以实现一个更加通用的反柯里化方法。
使用方法和原来的类似:
简单理解,柯里化就是对高阶函数进行降阶处理,而反柯里化增加反过来扩大使用范围。
反柯里化的好处就是将原本只有
target
能使用的方法借了出来,可以给更多对象来使用。 我们在开发中,经常会借用Object.prototype.toString
来检测一个变量的类型,这也是反柯里化的用法之一。5. 推荐阅读