前端基础进阶(十):深入详解函数的柯里化
柯里化是函数的一个高级应用,通过上一个章节的学习我们知道,接收函数作为参数的函数,都可以叫做高阶函数。这一章我们要学习的柯里化,其实就是高阶函数的一种特殊用法。
柯里化是指这样一个函数(假设叫做createCurry),他接收函数A作为参数,运行后能够返回一个新的函数。并且这个新的函数能够处理函数A的剩余参数。
这样的定义可能不太好理解,我们可以通过下面的例子配合解释。
有一个接收三个参数的函数A。
1 | function A(a, b, c) { |
假如有一个已经封装好了的柯里化通用函数createCurry。它接收bar作为参数,能够将A转化为柯里化函数,返回结果就是这个被转化之后的函数。
1 | var _A = createCurry(A); |
那么_A作为createCurry运行的返回函数,它能够处理A的剩余参数。因此下面的运行结果都是等价的。
1 | _A(1, 2, 3); |
函数A被createCurry转化之后得到柯里化函数_A,_A能够处理A的所有剩余参数。因此柯里化也被称为部分求值。
在简单的场景下,可以不用借助柯里化通用式来转化得到柯里化函数,我们凭借眼力自己封装。
例如有一个简单的加法函数,它能够将自身的三个参数加起来并返回计算结果。
1 | function add(a, b, c) { |
那么add函数的柯里化函数_add则可以如下:
1 | function _add(a) { |
当然,靠眼力封装的柯里化函数自由度偏低,柯里化通用式具备更加强大的能力。需要知道如何去封装这样一个柯里化的通用式。
首先通过_add可以看出,柯里化函数的运行过程其实是一个参数的收集过程,将每一次传入的参数收集起来,并在最里层里面处理。在实现createCurry时,可以借助这个思路来进行封装。
封装如下:
1 | // 简单实现,参数只能从右到左传递 |
尽管已经做了足够详细的注解,但是理解起来可能并不是那么容易,因此建议大家用点耐心多阅读几遍。这个createCurry函数的封装借助闭包与递归,实现了一个参数收集,并在收集完毕之后执行所有参数的一个过程。
聪明的读者可能已经发现,把函数经过createCurry转化为一个柯里化函数,最后执行的结果,不是正好相当于执行函数自身吗?柯里化是不是把简单的问题复杂化了?
如果你能够提出这样的问题,那么说明你确实已经对柯里化有了一定的了解。柯里化确实是把简答的问题复杂化了,但是复杂化的同时,我们使用函数拥有了更加多的自由度。而这里对于函数参数的自由处理,正是柯里化的核心所在。
举一个非常常见的例子。
如果我们想要验证一串数字是否是正确的手机号,按照普通的思路来做,大家可能是这样封装,如下:
1 | function checkPhone(phoneNumber) { |
而如果想要验证是否是邮箱呢?这么封装:
1 | function checkEmail(email) { |
我们还可能会遇到验证身份证号,验证密码等各种验证信息,因此在实践中,为了统一逻辑,我们会封装一个更为通用的函数,将用于验证的正则与将要被验证的字符串作为参数传入。
1 | function check(targetString, reg) { |
但是这样封装之后,在使用时又会稍微麻烦一点,因为会总是输入一串正则,这样就导致了使用时的效率低下。
1 | check(/^1[34578]\d{9}$/, '14900000088'); |
这个时候,我们可以借助柯里化,在check的基础上再做一层封装,以简化使用。
1 | var _check = createCurry(check); |
最后在使用的时候就会变得更加直观与简洁了。
1 | checkPhone('183888888'); |
经过这个过程我们发现,柯里化能够应对更加复杂的逻辑封装。当情况变得多变,柯里化依然能够应付自如。虽然柯里化在一定程度上将问题复杂化了,也让代码更加不容易理解,但是柯里化在面对复杂情况下的灵活性却让我们不得不爱。
这个案例本身情况还算简单,不能够特别明显的凸显柯里化的优势,当然主要目的在于借助这个案例帮助大家了解柯里化在实践中的用途。
继续来思考一个例子。这个例子与map有关。在高阶函数的章节中,我们分析了封装map方法的思考过程。由于我们没有办法确认一个数组在遍历时会执行什么操作,因此只能将调用for循环的这个统一逻辑封装起来,而具体的操作则通过参数传入的形式让使用者自定义。这就是map函数。
实践中我们常常会发现,在某个项目中,针对于某一个数组的操作其实是固定的,同样的操作,可能会在项目的不同地方调用很多次。
这个时候,我们就可以在map函数的基础上,进行二次封装,以简化使用。假如这个在项目中会调用多次的操作是将数组的每一项都转化为百分比 1 –> 100%。
普通思维下我们可以这样来封装。
1 | function getNewArray(array) { |
而如果借助柯里化来二次封装这样的逻辑,则会如下实现:
1 | function _map(func, array) { |
如果项目中的固定操作是希望对数组进行一个过滤,找出数组中的所有Number类型的数据。借助柯里化思维我们可以这样做:
1 | function _filter(func, array) { |
我采用了与check例子不一样的思维方向来想大家展示我们在使用柯里化时的想法。目的是想告诉大家,柯里化能够帮助我们应对更多更复杂的场景。
不得不承认,这些例子都太简单了,简单到使用柯里化的思维来处理他们显得有一点多此一举,而且变得难以理解。在未来你的实践中,如果你发现用普通的思维封装一些逻辑慢慢变得困难,不妨想一想在这里学到的柯里化思维,应用起来,柯里化足够强大的自由度一定能给你一个惊喜。
不建议在任何情况下以炫技为目的的去使用柯里化,在柯里化的实现中,柯里化虽然具有了更多的自由度,但同时柯里化通用式里调用了arguments对象,使用了递归与闭包,因此柯里化的自由度是以牺牲了一定的性能为代价换来的。只有在情况变得复杂时,才是柯里化大显身手的时候。
额外知识补充:
在前端面试中,你可能会遇到这样一个涉及到柯里化的题目。
1 | // 实现一个add方法,使计算结果能够满足如下预期: |
这个题目的目的是想让add执行之后返回一个函数能够继续执行,最终运算的结果是所有出现过的参数之和。而这个题目的难点则在于参数的不固定。我们不知道函数会执行几次。因此不能使用上面封装的createCurry的通用公式来转换一个柯里化函数。只能自己封装,那么怎么办呢?在此之前,补充2个非常重要的知识点。
第一个要补充的知识点是ES6函数的不定参数。假如有一个数组,希望把这个数组中所有的子项展开传递给一个函数作为参数。那么我们应该怎么做?
1 | // 大家可以思考一下,如果将args数组的子项展开作为add的参数传入 |
在ES5中,我们可以借助之前学过的apply来达到我们的目的。
1 | add.apply(null, args); // 105 |
而在ES6中,提供了一种新的语法来解决这个问题,那就是不定参。写法如下:
1 | add(...args); // 105 |
第二个要补充的知识点是函数的隐式转换。当我们直接将函数参与其他的计算时,函数会默认调用toString方法,直接将函数体转换为字符串参与计算。
1 | function fn() { return 20 } |
我们可以重写函数的toString方法,让函数参与计算时,输出想要的结果。
1 | function fn() { return 20; } |
除此之外,重写函数的valueOf方法也能够改变函数的隐式转换结果。
1 | function fn() { return 20; } |
当同时重写函数的toString方法与valueOf方法时,最终的结果会取valueOf方法的返回结果。
1 | function fn() { return 20; } |
补充了这两个知识点之后,我们可以尝试完成之前的题目了。add方法的实现仍然会是一个参数的收集过程。当add函数执行到最后时,仍然返回的是一个函数,我们可以通过定义toString/valueOf的方式,让这个函数可以直接参与计算,并且转换的结果是我们想要的。而且它本身也仍然可以继续执行接收新的参数。实现方式如下。
1 | function add() { |
1 | // 其实上栗中的add方法,就是下面这个函数的柯里化函数,只不过我们并没有使用通用式来转化,而是自己封装 |