资讯专栏INFORMATION COLUMN

【进阶 6-2 期】深入高阶函数应用之柯里化

stackvoid / 963人阅读

摘要:引言上一节介绍了高阶函数的定义,并结合实例说明了使用高阶函数和不使用高阶函数的情况。我们期望函数输出,但是实际上调用柯里化函数时,所以调用时就已经执行并输出了,而不是理想中的返回闭包函数,所以后续调用将会报错。

引言

上一节介绍了高阶函数的定义,并结合实例说明了使用高阶函数和不使用高阶函数的情况。后面几部分将结合实际应用场景介绍高阶函数的应用,本节先来聊聊函数柯里化,通过介绍其定义、比较常见的三种柯里化应用、并在最后实现一个通用的 currying 函数,带你认识完整的函数柯里化。

有什么想法或者意见都可以在评论区留言,下图是本文的思维导图,高清思维导图和更多文章请看我的 Github。

柯里化 定义

函数柯里化又叫部分求值,维基百科中对柯里化 (Currying) 的定义为:

在数学和计算机科学中,柯里化是一种将使用多个参数的函数转换成一系列使用一个参数的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

用大白话来说就是只传递给函数一部分参数来调用它,让它返回一个新函数去处理剩下的参数。使用一个简单的例子来介绍下,最常用的就是 add 函数了。

// 木易杨
const add = (...args) => args.reduce((a, b) => a + b);

// 传入多个参数,执行 add 函数
add(1, 2) // 3

// 假设我们实现了一个 currying 函数,支持一次传入一个参数
let sum = currying(add);
// 封装第一个参数,方便重用
let addCurryOne = sum(1);
addCurryOne(2) // 3
addCurryOne(3) // 4
实际应用

1、延迟计算

我们看下面的部分求和例子,很好的说明了延迟计算这个情况。

// 木易杨
const add = (...args) => args.reduce((a, b) => a + b);

// 简化写法
function currying(func) {
    const args = [];
    return function result(...rest) {
        if (rest.length === 0) {
          return func(...args);
        } else {
          args.push(...rest);
        	return result;
        }
    }
}

const sum = currying(add);

sum(1,2)(3); // 未真正求值
sum(4); 		 // 未真正求值
sum(); 			 // 输出 10

上面的代码理解起来很容易,就是「用闭包把传入参数保存起来,当传入参数的数量足够执行函数时,就开始执行函数」。上面的 currying 函数是一种简化写法,判断传入的参数长度是否为 0,若为 0 执行函数,否则收集参数。

另一种常见的应用是 bind 函数,我们看下 bind 的使用。

// 木易杨
let obj = {
  name: "muyiy"
}
const fun = function () {
  console.log(this.name);
}.bind(obj);

fun(); // muyiy

这里 bind 用来改变函数执行时候的上下文,但是函数本身并不执行,所以本质上是延迟计算,这一点和 call / apply 直接执行有所不同。

我们看下 bind 模拟实现,其本身就是一种柯里化,我们在最后的实现部分会发现,bind 的模拟实现和柯理化函数的实现,其核心代码都是一致的。

以下实现方案是简化版实现,完整版实现过程和代码解读请看我之前写的一篇文章,【进阶3-4期】深度解析bind原理、使用场景及模拟实现。

// 木易杨
// 简化实现,完整版实现中的第 2 步
Function.prototype.bind = function (context) {
    var self = this;
    // 第 1 个参数是指定的 this,截取保存第 1 个之后的参数
		// arr.slice(begin); 即 [begin, end]
    var args = Array.prototype.slice.call(arguments, 1); 

    return function () {
        // 此时的 arguments 是指 bind 返回的函数调用时接收的参数
        // 即 return function 的参数,和上面那个不同
      	// 类数组转成数组
        var bindArgs = Array.prototype.slice.call(arguments);
      	// 执行函数
        return self.apply( context, args.concat(bindArgs) );
    }
}

2、动态创建函数

有一种典型的应用情景是这样的,每次调用函数都需要进行一次判断,但其实第一次判断计算之后,后续调用并不需要再次判断,这种情况下就非常适合使用柯里化方案来处理。即第一次判断之后,动态创建一个新函数用于处理后续传入的参数,并返回这个新函数。当然也可以使用惰性函数来处理,本例最后一个方案会有所介绍。

我们看下面的这个例子,在 DOM 中添加事件时需要兼容现代浏览器和 IE 浏览器(IE < 9),方法就是对浏览器环境进行判断,看浏览器是否支持,简化写法如下。

// 简化写法
function addEvent (type, el, fn, capture = false) {
    if (window.addEventListener) {
        el.addEventListener(type, fn, capture);
    }
    else if(window.attachEvent){
        el.attachEvent("on" + type, fn);
    }
}

但是这种写法有一个问题,就是每次添加事件都会调用做一次判断,那么有没有什么办法只判断一次呢,可以利用闭包和立即调用函数表达式(IIFE)来处理。

const addEvent = (function(){
    if (window.addEventListener) {
        return function (type, el, fn, capture) {
            el.addEventListener(type, fn, capture);
        }
    }
    else if(window.attachEvent){
        return function (type, el, fn) {
            el.attachEvent("on" + type, fn);
        }
    }
})();

上面这种实现方案就是一种典型的柯里化应用,在第一次的 if...else if... 判断之后完成部分计算,动态创建新的函数用于处理后续传入的参数,这样做的好处就是之后调用就不需要再次计算了。

当然可以使用惰性函数来实现这一功能,原理很简单,就是重写函数。

function addEvent (type, el, fn, capture = false) {
  	// 重写函数
    if (window.addEventListener) {
        addEvent = function (type, el, fn, capture) {
            el.addEventListener(type, fn, capture);
        }
    }
    else if(window.attachEvent){
        addEvent = function (type, el, fn) {
            el.attachEvent("on" + type, fn);
        }
    }
  	// 执行函数,有循环爆栈风险
  	addEvent(type, el, fn, capture); 
}

第一次调用 addEvent 函数后,会进行一次环境判断,在这之后 addEvent 函数被重写,所以下次调用时就不会再次判断环境,可以说很完美了。

3、参数复用

我们知道调用 toString() 可以获取每个对象的类型,但是不同对象的 toString() 有不同的实现,所以需要通过 Object.prototype.toString() 来获取 Object 上的实现,同时以 call() / apply() 的形式来调用,并传递要检查的对象作为第一个参数,例如下面这个例子。

function isArray(obj) { 
    return Object.prototype.toString.call(obj) === "[object Array]";
}

function isNumber(obj) {
    return Object.prototype.toString.call(obj) === "[object Number]";
}

function isString(obj) {
    return Object.prototype.toString.call(obj) === "[object String]";
}

// Test
isArray([1, 2, 3]); // true
isNumber(123); // true
isString("123"); // true

但是上面方案有一个问题,那就是每种类型都需要定义一个方法,这里我们可以使用 bind 来扩展,优点是可以直接使用改造后的 toStr

const toStr = Function.prototype.call.bind(Object.prototype.toString);

// 改造前
[1, 2, 3].toString(); // "1,2,3"
"123".toString(); // "123"
123.toString(); // SyntaxError: Invalid or unexpected token
Object(123).toString(); // "123"

// 改造后
toStr([1, 2, 3]); 	// "[object Array]"
toStr("123"); 		// "[object String]"
toStr(123); 		// "[object Number]"
toStr(Object(123)); // "[object Number]"

上面例子首先使用 Function.prototype.call 函数指定一个 this 值,然后 .bind 返回一个新的函数,始终将 Object.prototype.toString 设置为传入参数,其实等价于 Object.prototype.toString.call()

实现 currying 函数

我们可以理解所谓的柯里化函数,就是封装「一系列的处理步骤」,通过闭包将参数集中起来计算,最后再把需要处理的参数传进去。那如何实现 currying 函数呢?

实现原理就是「用闭包把传入参数保存起来,当传入参数的数量足够执行函数时,就开始执行函数」。上面延迟计算部分已经实现了一个简化版的 currying 函数。

下面我们来实现一个更加健壮的的 currying 函数。

// 木易杨
function currying(fn, length) {
  length = length || fn.length; 	// 注释 1
  return function (...args) {			// 注释 2
    return args.length >= length	// 注释 3
    	");this, args)			// 注释 4
      : currying(fn.bind(this, ...args), length - args.length) // 注释 5
  }
}

// Test
const fn = currying(function(a, b, c) {
    console.log([a, b, c]);
});

fn("a", "b", "c") // ["a", "b", "c"]
fn("a", "b")("c") // ["a", "b", "c"]
fn("a")("b")("c") // ["a", "b", "c"]
fn("a")("b", "c") // ["a", "b", "c"]

注释 1:第一次调用获取函数 fn 参数的长度,后续调用获取 fn 剩余参数的长度

注释 2:currying 包裹之后返回一个新函数,接收参数为 ...args

注释 3:新函数接收的参数长度是否大于等于 fn 剩余参数需要接收的长度

注释 4:满足要求,执行 fn 函数,传入新函数的参数

注释 5:不满足要求,递归 currying 函数,新的 fn 为 bind 返回的新函数(bind 绑定了 ...args 参数,未执行),新的 length 为 fn 剩余参数的长度

上面使用的是 ES5 和 ES6 的混合语法,那我不想使用 call/apply/bind 这些方法呢,自然是可以的,看下面的 ES6 极简写法,更加简洁也更加易懂。

// 参考自 segmentfault 的@大笑平 
const currying = fn =>
    judge = (...args) =>
        args.length >= fn.length
            ");(...arg) => judge(...args, ...arg)

// Test
const fn = currying(function(a, b, c) {
    console.log([a, b, c]);
});

fn("a", "b", "c") // ["a", "b", "c"]
fn("a", "b")("c") // ["a", "b", "c"]
fn("a")("b")("c") // ["a", "b", "c"]
fn("a")("b", "c") // ["a", "b", "c"]

如果你还无法理解,看完下面例子你就更加容易理解了,要求实现一个 add 方法,需要满足如下预期。

add(1, 2, 3) // 6
add(1, 2)(3) // 6
add(1)(2)(3) // 6
add(1)(2, 3) // 6

我们可以看到,计算结果就是所有参数的和,如果我们分两次调用时 add(1)(2),可以写出如下代码。

function add(a) {
  return function(b) {
  	return a + b;
  }
}

add(1)(2) // 3

add 方法第一次调用后返回一个新函数,通过闭包保存之前的参数,第二次调用时满足参数长度要求然后执行函数。

如果分三次调用时 add(1)(2)(3),可以写出如下代码。

function add(a) {
  return function(b) {
    return function (c) {
    	return a + b + c;
    }
  }
}
console.log(add(1)(2)(3)); // 6

前面两次调用每次返回一个新函数,第三次调用后满足参数长度要求然后执行函数。

这时候我们再来看 currying 实现函数,其实就是判断当前参数长度够不够,参数够了就立马执行,不够就返回一个新函数,这个新函数并不执行,并且通过 bind 或者闭包保存之前传入的参数。

// 注释同上
function currying(fn, length) {
  length = length || fn.length; 	
  return function (...args) {			
    return args.length >= length	
    	");this, args)			
      : currying(fn.bind(this, ...args), length - args.length) 
  }
}
扩展:函数参数 length

函数 currying 的实现中,使用了 fn.length 来表示函数参数的个数,那 fn.length 表示函数的所有参数个数吗?并不是。

函数的 length 属性获取的是形参的个数,但是形参的数量不包括剩余参数个数,而且仅包括第一个具有默认值之前的参数个数,看下面的例子。

((a, b, c) => {}).length; 
// 3

((a, b, c = 3) => {}).length; 
// 2 

((a, b = 2, c) => {}).length; 
// 1 

((a = 1, b, c) => {}).length; 
// 0 

((...args) => {}).length; 
// 0

const fn = (...args) => {
  console.log(args.length);
} 
fn(1, 2, 3)
// 3

所以在柯里化的场景中,不建议使用 ES6 的函数参数默认值。

const fn = currying((a = 1, b, c) => {
  console.log([a, b, c]); 
}); 

fn();
// [1, undefined, undefined]

fn()(2)(3); 
// Uncaught TypeError: fn(...) is not a function

我们期望函数 fn 输出 [1, 2, 3],但是实际上调用柯里化函数时 ((a = 1, b, c) => {}).length === 0,所以调用 fn() 时就已经执行并输出了 [1, undefined, undefined],而不是理想中的返回闭包函数,所以后续调用 fn()(2)(3) 将会报错。

小结

我们通过定义认识了什么是柯里化函数,并且介绍了三种实际的应用场景:延迟计算、动态创建函数、参数复用,然后实现了强大的通用化 currying 函数,不过更像是柯里化 (currying) 和偏函数 (partial application) 的综合应用,并且在最后介绍了函数的 length,算是意外之喜。

定义:柯里化是一种将使用多个参数的函数转换成一系列使用一个参数的函数,并且返回接受余下的参数而且返回结果的新函数的技术

实际应用

延迟计算:部分求和、bind 函数

动态创建函数:添加监听 addEvent、惰性函数

参数复用:Function.prototype.call.bind(Object.prototype.toString)

实现 currying 函数:用闭包把传入参数保存起来,当传入参数的数量足够执行函数时,就开始执行函数

函数参数 length:获取的是形参的个数,但是形参的数量不包括剩余参数个数,而且仅包括第一个具有默认值之前的参数个数

参考资料

JavaScript 专题之函数柯里化

JavaScript 专题之惰性函数

柯里化在工程中有什么好处");

文章穿梭机

【进阶 6-1 期】JavaScript 高阶函数浅析

【进阶 5-3 期】深入探究 Function & Object 鸡蛋问题

【进阶 5-2 期】图解原型链及其继承优缺点

【进阶 5-1 期】重新认识构造函数、原型和原型链

交流

进阶系列文章汇总如下,觉得不错点个Star,欢迎 加群 互相学习。

github.com/yygmind/blo…

我是木易杨,公众号「高级前端进阶」作者,跟着我每周重点攻克一个前端面试重难点。接下来让我带你走进高级前端的世界,在进阶的路上,共勉!

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/6862.html

相关文章

  • 进阶 6-3 深入浅出节流函数 throttle

    摘要:引言上一节我们详细聊了聊高阶函数之柯里化,通过介绍其定义和三种柯里化应用,并在最后实现了一个通用的函数。第二种方案来实现也存在一个问题,因为定时器是延迟执行的,所以事件停止触发时必然会响应回调,所以时无法生效。 引言 上一节我们详细聊了聊高阶函数之柯里化,通过介绍其定义和三种柯里化应用,并在最后实现了一个通用的 currying 函数。这一小节会继续之前的篇幅聊聊函数节流 thrott...

    baishancloud 评论0 收藏0
  • 进阶3-1】JavaScript深入史上最全--5种this绑定全面解析

    摘要:在严格模式下调用函数则不影响默认绑定。回调函数丢失绑定是非常常见的。因为直接指定的绑定对象,称之为显示绑定。调用时强制把的绑定到上显示绑定无法解决丢失绑定问题。 (关注福利,关注本公众号回复[资料]领取优质前端视频,包括Vue、React、Node源码和实战、面试指导) 本周正式开始前端进阶的第三期,本周的主题是this全面解析,今天是第9天。 本计划一共28期,每期重点攻克一个面试重...

    xavier 评论0 收藏0
  • SegmentFault 技术周刊 Vol.16 - 浅入浅出 JavaScript 函数式编程

    摘要:函数式编程,一看这个词,简直就是学院派的典范。所以这期周刊,我们就重点引入的函数式编程,浅入浅出,一窥函数式编程的思想,可能让你对编程语言的理解更加融会贯通一些。但从根本上来说,函数式编程就是关于如使用通用的可复用函数进行组合编程。 showImg(https://segmentfault.com/img/bVGQuc); 函数式编程(Functional Programming),一...

    csRyan 评论0 收藏0
  • react进阶系列:高阶组件详解(三)

    摘要:在前端基础进阶八深入详解函数的柯里化一文中,我有分享柯里化相关的知识。虽然说高阶组件与柯里化都属于比较难以理解的知识点,但是他们组合在一起使用时并没有新增更多的难点。 可能看过我以前文章的同学应该会猜得到当我用New的方法来举例学习高阶组件时,接下来要分享的就是柯里化了。高阶组件与函数柯里化的运用是非常能够提高代码逼格的技巧,如果你有剩余的精力,完全可以花点时间学习一下。 在前端基础进...

    zhangxiangliang 评论0 收藏0
  • 进阶3-4】深度解析bind原理、使用场景及模拟实现

    摘要:返回的绑定函数也能使用操作符创建对象这种行为就像把原函数当成构造器,提供的值被忽略,同时调用时的参数被提供给模拟函数。 bind() bind() 方法会创建一个新函数,当这个新函数被调用时,它的 this 值是传递给 bind() 的第一个参数,传入bind方法的第二个以及以后的参数加上绑定函数运行时本身的参数按照顺序作为原函数的参数来调用原函数。bind返回的绑定函数也能使用 n...

    guyan0319 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<