Blog icon indicating copy to clipboard operation
Blog copied to clipboard

JavaScript专题之函数柯里化

Open mqyqingfeng opened this issue 8 years ago • 142 comments

定义

维基百科中对柯里化 (Currying) 的定义为:

In mathematics and computer science, currying is the technique of translating the evaluation of a function that takes multiple arguments (or a tuple of arguments) into evaluating a sequence of functions, each with a single argument.

翻译成中文:

在数学和计算机科学中,柯里化是一种将使用多个参数的一个函数转换成一系列使用一个参数的函数的技术。

举个例子:

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

// 执行 add 函数,一次传入两个参数即可
add(1, 2) // 3

// 假设有一个 curry 函数可以做到柯里化
var addCurry = curry(add);
addCurry(1)(2) // 3

用途

我们会讲到如何写出这个 curry 函数,并且会将这个 curry 函数写的很强大,但是在编写之前,我们需要知道柯里化到底有什么用?

举个例子:

// 示意而已
function ajax(type, url, data) {
    var xhr = new XMLHttpRequest();
    xhr.open(type, url, true);
    xhr.send(data);
}

// 虽然 ajax 这个函数非常通用,但在重复调用的时候参数冗余
ajax('POST', 'www.test.com', "name=kevin")
ajax('POST', 'www.test2.com', "name=kevin")
ajax('POST', 'www.test3.com', "name=kevin")

// 利用 curry
var ajaxCurry = curry(ajax);

// 以 POST 类型请求数据
var post = ajaxCurry('POST');
post('www.test.com', "name=kevin");

// 以 POST 类型请求来自于 www.test.com 的数据
var postFromTest = post('www.test.com');
postFromTest("name=kevin");

想想 jQuery 虽然有 $.ajax 这样通用的方法,但是也有 $.get 和 $.post 的语法糖。(当然 jQuery 底层是否是这样做的,我就没有研究了)。

curry 的这种用途可以理解为:参数复用。本质上是降低通用性,提高适用性。

可是即便如此,是不是依然感觉没什么用呢?

如果我们仅仅是把参数一个一个传进去,意义可能不大,但是如果我们是把柯里化后的函数传给其他函数比如 map 呢?

举个例子:

比如我们有这样一段数据:

var person = [{name: 'kevin'}, {name: 'daisy'}]

如果我们要获取所有的 name 值,我们可以这样做:

var name = person.map(function (item) {
    return item.name;
})

不过如果我们有 curry 函数:

var prop = curry(function (key, obj) {
    return obj[key]
});

var name = person.map(prop('name'))

我们为了获取 name 属性还要再编写一个 prop 函数,是不是又麻烦了些?

但是要注意,prop 函数编写一次后,以后可以多次使用,实际上代码从原本的三行精简成了一行,而且你看代码是不是更加易懂了?

person.map(prop('name')) 就好像直白的告诉你:person 对象遍历(map)获取(prop) name 属性。

是不是感觉有点意思了呢?

第一版

未来我们会接触到更多有关柯里化的应用,不过那是未来的事情了,现在我们该编写这个 curry 函数了。

一个经常会看到的 curry 函数的实现为:

// 第一版
var curry = function (fn) {
    var args = [].slice.call(arguments, 1);
    return function() {
        var newArgs = args.concat([].slice.call(arguments));
        return fn.apply(this, newArgs);
    };
};

我们可以这样使用:

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

var addCurry = curry(add, 1, 2);
addCurry() // 3
//或者
var addCurry = curry(add, 1);
addCurry(2) // 3
//或者
var addCurry = curry(add);
addCurry(1, 2) // 3

已经有柯里化的感觉了,但是还没有达到要求,不过我们可以把这个函数用作辅助函数,帮助我们写真正的 curry 函数。

第二版

// 第二版
function sub_curry(fn) {
    var args = [].slice.call(arguments, 1);
    return function() {
        return fn.apply(this, args.concat([].slice.call(arguments)));
    };
}

function curry(fn, length) {

    length = length || fn.length;

    var slice = Array.prototype.slice;

    return function() {
        if (arguments.length < length) {
            var combined = [fn].concat(slice.call(arguments));
            return curry(sub_curry.apply(this, combined), length - arguments.length);
        } else {
            return fn.apply(this, arguments);
        }
    };
}

我们验证下这个函数:

var fn = curry(function(a, b, c) {
    return [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"]

效果已经达到我们的预期,然而这个 curry 函数的实现好难理解呐……

为了让大家更好的理解这个 curry 函数,我给大家写个极简版的代码:

function sub_curry(fn){
    return function(){
        return fn()
    }
}

function curry(fn, length){
    length = length || 4;
    return function(){
        if (length > 1) {
            return curry(sub_curry(fn), --length)
        }
        else {
            return fn()
        }
    }
}

var fn0 = function(){
    console.log(1)
}

var fn1 = curry(fn0)

fn1()()()() // 1

大家先从理解这个 curry 函数开始。

当执行 fn1() 时,函数返回:

curry(sub_curry(fn0))
// 相当于
curry(function(){
    return fn0()
})

当执行 fn1()() 时,函数返回:

curry(sub_curry(function(){
    return fn0()
}))
// 相当于
curry(function(){
    return (function(){
        return fn0()
    })()
})
// 相当于
curry(function(){
    return fn0()
})

当执行 fn1()()() 时,函数返回:

// 跟 fn1()() 的分析过程一样
curry(function(){
    return fn0()
})

当执行 fn1()()()() 时,因为此时 length > 2 为 false,所以执行 fn():

fn()
// 相当于
(function(){
    return fn0()
})()
// 相当于
fn0()
// 执行 fn0 函数,打印 1

再回到真正的 curry 函数,我们以下面的例子为例:

var fn0 = function(a, b, c, d) {
    return [a, b, c, d];
}

var fn1 = curry(fn0);

fn1("a", "b")("c")("d")

当执行 fn1("a", "b") 时:

fn1("a", "b")
// 相当于
curry(fn0)("a", "b")
// 相当于
curry(sub_curry(fn0, "a", "b"))
// 相当于
// 注意 ... 只是一个示意,表示该函数执行时传入的参数会作为 fn0 后面的参数传入
curry(function(...){
    return fn0("a", "b", ...)
})

当执行 fn1("a", "b")("c") 时,函数返回:

curry(sub_curry(function(...){
    return fn0("a", "b", ...)
}), "c")
// 相当于
curry(function(...){
    return (function(...) {return fn0("a", "b", ...)})("c")
})
// 相当于
curry(function(...){
     return fn0("a", "b", "c", ...)
})

当执行 fn1("a", "b")("c")("d") 时,此时 arguments.length < length 为 false ,执行 fn(arguments),相当于:

(function(...){
    return fn0("a", "b", "c", ...)
})("d")
// 相当于
fn0("a", "b", "c", "d")

函数执行结束。

所以,其实整段代码又很好理解:

sub_curry 的作用就是用函数包裹原函数,然后给原函数传入之前的参数,当执行 fn0(...)(...) 的时候,执行包裹函数,返回原函数,然后再调用 sub_curry 再包裹原函数,然后将新的参数混合旧的参数再传入原函数,直到函数参数的数目达到要求为止。

如果要明白 curry 函数的运行原理,大家还是要动手写一遍,尝试着分析执行步骤。

更易懂的实现

当然了,如果你觉得还是无法理解,你可以选择下面这种实现方式,可以实现同样的效果:

function curry(fn, args) {
    var length = fn.length;

    args = args || [];

    return function() {

        var _args = args.slice(0),

            arg, i;

        for (i = 0; i < arguments.length; i++) {

            arg = arguments[i];

            _args.push(arg);

        }
        if (_args.length < length) {
            return curry.call(this, fn, _args);
        }
        else {
            return fn.apply(this, _args);
        }
    }
}


var fn = curry(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"]

或许大家觉得这种方式更好理解,又能实现一样的效果,为什么不直接就讲这种呢?

因为想给大家介绍各种实现的方法嘛,不能因为难以理解就不给大家介绍呐~

第三版

curry 函数写到这里其实已经很完善了,但是注意这个函数的传参顺序必须是从左到右,根据形参的顺序依次传入,如果我不想根据这个顺序传呢?

我们可以创建一个占位符,比如这样:

var fn = curry(function(a, b, c) {
    console.log([a, b, c]);
});

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

我们直接看第三版的代码:

// 第三版
function curry(fn, args, holes) {
    length = fn.length;

    args = args || [];

    holes = holes || [];

    return function() {

        var _args = args.slice(0),
            _holes = holes.slice(0),
            argsLen = args.length,
            holesLen = holes.length,
            arg, i, index = 0;

        for (i = 0; i < arguments.length; i++) {
            arg = arguments[i];
            // 处理类似 fn(1, _, _, 4)(_, 3) 这种情况,index 需要指向 holes 正确的下标
            if (arg === _ && holesLen) {
                index++
                if (index > holesLen) {
                    _args.push(arg);
                    _holes.push(argsLen - 1 + index - holesLen)
                }
            }
            // 处理类似 fn(1)(_) 这种情况
            else if (arg === _) {
                _args.push(arg);
                _holes.push(argsLen + i);
            }
            // 处理类似 fn(_, 2)(1) 这种情况
            else if (holesLen) {
                // fn(_, 2)(_, 3)
                if (index >= holesLen) {
                    _args.push(arg);
                }
                // fn(_, 2)(1) 用参数 1 替换占位符
                else {
                    _args.splice(_holes[index], 1, arg);
                    _holes.splice(index, 1)
                }
            }
            else {
                _args.push(arg);
            }

        }
        if (_holes.length || _args.length < length) {
            return curry.call(this, fn, _args, _holes);
        }
        else {
            return fn.apply(this, _args);
        }
    }
}

var _ = {};

var fn = curry(function(a, b, c, d, e) {
    console.log([a, b, c, d, e]);
});

// 验证 输出全部都是 [1, 2, 3, 4, 5]
fn(1, 2, 3, 4, 5);
fn(_, 2, 3, 4, 5)(1);
fn(1, _, 3, 4, 5)(2);
fn(1, _, 3)(_, 4)(2)(5);
fn(1, _, _, 4)(_, 3)(2)(5);
fn(_, 2)(_, _, 4)(1)(3)(5)

写在最后

至此,我们已经实现了一个强大的 curry 函数,可是这个 curry 函数符合柯里化的定义吗?柯里化可是将一个多参数的函数转换成多个单参数的函数,但是现在我们不仅可以传入一个参数,还可以一次传入两个参数,甚至更多参数……这看起来更像一个柯里化 (curry) 和偏函数 (partial application) 的综合应用,可是什么又是偏函数呢?下篇文章会讲到。

专题系列

JavaScript专题系列目录地址:https://github.com/mqyqingfeng/Blog

JavaScript专题系列预计写二十篇左右,主要研究日常开发中一些功能点的实现,比如防抖、节流、去重、类型判断、拷贝、最值、扁平、柯里、递归、乱序、排序等,特点是研(chao)究(xi) underscore 和 jQuery 的实现方式。

如果有错误或者不严谨的地方,请务必给予指正,十分感谢。如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

mqyqingfeng avatar Aug 11 '17 01:08 mqyqingfeng

日常学习

liuxinqiong avatar Aug 18 '17 01:08 liuxinqiong

好难理解啊☹️

FrontToEnd avatar Aug 22 '17 02:08 FrontToEnd

segmentfault 的@大笑平 补充的高颜值写法:

var curry = fn =>
    judge = (...args) =>
        args.length === fn.length
            ? fn(...args)
            : (arg) => judge(...args, arg)

mqyqingfeng avatar Aug 22 '17 05:08 mqyqingfeng

专题系列专不知不觉更新了这么多,我得好好抽个时间系统学习一下😂

jawil avatar Aug 22 '17 05:08 jawil

@jawil 哈哈~ 还有五篇就要完结了,可以等完结后一起看~

mqyqingfeng avatar Aug 22 '17 05:08 mqyqingfeng

没有说道 什么场景下使用 柯里化

yangchongduo avatar Oct 10 '17 05:10 yangchongduo

var name = person.map(function (item) {
    return item.name;
})
const fn = function (item) {
    return item.name;
}
person.map(fn)

PS:这样也可以参数复用

yangchongduo avatar Oct 10 '17 06:10 yangchongduo

@yangchongduo 柯里化的应用更多在函数式编程中,可以看 JS 函数式编程指南,在 《JavaScript专题之函数组合》这篇文章中也有涉及部分。

mqyqingfeng avatar Oct 11 '17 02:10 mqyqingfeng

@yangchongduo 这样确实可以做到参数复用,其实跟

var name = person.map(prop('name'))

是一样的,不过如果我们要获得其他属性呢?比如 'age'、'friends' 等,如果使用 fn 的话,还需要写多个 fn,而如果使用 prop 函数,就可以直接使用 prop('age') prop('friend')

mqyqingfeng avatar Oct 11 '17 02:10 mqyqingfeng

高颜值写法的 judge = (...args) 用的好巧妙

heyunjiang avatar Oct 11 '17 06:10 heyunjiang

function curry1(fn, args) { var length = fn.length; args = args || []; return function (...arguments) { args = [...args, ...arguments] return args.length < length ? curry1.call(this, fn, args) : fn.apply(this, args); } }

yangchongduo avatar Oct 19 '17 07:10 yangchongduo

值得一看~

LiuYashion avatar Oct 31 '17 03:10 LiuYashion

深受启发,这样理解柯里化 :用闭包把参数保存起来,当参数的数量足够执行函数了,就开始执行函数,有没有毛病

hlmjack avatar Oct 31 '17 03:10 hlmjack

@hlmjack 正是如此~ o( ̄▽ ̄)d

mqyqingfeng avatar Oct 31 '17 05:10 mqyqingfeng

在更易懂的写法里return curry.call(this, fn, _args);,为什么要用this呢,感觉用null是不是也可以呢

fi3ework avatar Nov 03 '17 08:11 fi3ework

@fi3ework 之所以写成 this 是因为希望根据环境的不同而设置不同的 this 值,我写了一个示例:

function curry(fn, args) {
    var length = fn.length;

    args = args || [];

    return function() {

        var _args = args.slice(0),

            arg, i;

        for (i = 0; i < arguments.length; i++) {

            arg = arguments[i];

            _args.push(arg);

        }
        if (_args.length < length) {
            return curry.call(this, fn, _args);
        }
        else {
            return fn.apply(this, _args);
        }
    }
}


var fn = curry(function(a, b, c) {
    console.log(this)
});

var obj = {
    value: 1,
    fn: fn
}

obj.fn(1, 2, 3);

这个时候的 this 的值为 obj 对象,如果设置成 null ,就会变成打印 window 对象

mqyqingfeng avatar Nov 08 '17 02:11 mqyqingfeng

终于理解这句话了,“用闭包把参数保存起来,当参数的数量足够执行函数了,就开始执行函数”

xiaobinwu avatar Nov 09 '17 02:11 xiaobinwu

我有个问题想请教下。 function (a, b, c){} 的参数有三个,fn('a', 'b', 'c', 'd') 正常工作,但 fn('a', 'b', 'c', 'd')('a') 就出错了。我知道这是因为 function (a, b, c){} 的参数个数,而导致的错误。 如果我想 fn('a', 'b',...n个参数)('a') 这样该如何写一个curry函数呢。

bepromising avatar Nov 25 '17 17:11 bepromising

@bepromising fn('a', 'b', 'c', 'd')('a') 是因为 fn('a', 'b', 'c', 'd') 就已经执行了该函数,该函数如果没有再返回一个函数,就肯定报错,说这并不是一个函数。

至于 fn('a', 'b',...n个参数)('a'),我不清楚为什么还要再传个 'a' 呢?

mqyqingfeng avatar Nov 27 '17 05:11 mqyqingfeng

柯里化 是真的绕啊

BugHshine avatar Dec 01 '17 10:12 BugHshine

@BugHshine 是这篇写得不好,日后修订时会重写

mqyqingfeng avatar Dec 05 '17 01:12 mqyqingfeng

第三版的代码是不是有bug呢,我试了一个稍微长一点的例子:

var _ = {}
var ary = curry(function(a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z) {
  return [a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z]
})
console.log(ary(1,_,_)(_)(5,6,7,8,9,10)(11,12,13,14)(15,_,_,18,_)(20,21,22,_)(24,25,26)(2,3,4)(16)(17,19)(23))

结果输出是不对的,node下:

[Function]

chrome下:

ƒ () { 
              var _args = args.slice(0),
                  _holes = holes.slice(0),
                  argsLen = args.length,
                  holesLen = holes.length,
                  a…

顺便我自己用es6写了一个稍微简洁一点的,对应原文第三版,可能参数多的情况下性能会有问题,但感觉一般情况下更好理解一些

// hole为自己的指定占位符
function curry(fn, hole) {
  const __len = fn.length
  let args = [],
  return function h() {
    // 先把参数放入args数组
    args = [...args, ...Array.from(arguments)]
    // 如果长度超过原有函数参数列表长度,表示有占位
    let holeNum = args.length - __len
    // 第一个占位符对应的肯定是__len位置的变量,循环将所有占位符替换
    for (let i = 0; i < holeNum; i++) {
      args[args.indexOf(hole)] = args[__len]
      args.splice(__len, 1)
    }
    // 如果没有占位符且参数数目已经够了
    if (args.length < __len || args.indexOf(hole) > -1) {
      return h
    } else {
      return fn.apply(null, args)
    }
  }
}

经测试上面的例子可以输出正确的结果

izuomeng avatar Dec 06 '17 10:12 izuomeng

@izuomeng 这个是最终的版本吗?我这边有报错

<!DOCTYPE html>
<html>

<head>
    <title></title>
</head>

<body>
    <script>
    // hole为自己的指定占位符
    function curry(fn, hole) {
        const __len = fn.length
        let args = [];
        return function h() {
            // 先把参数放入args数组
            args = [...args, ...Array.from(arguments)]
            // 如果长度超过原有函数参数列表长度,表示有占位
            let holeNum = args.length - __len
            // 第一个占位符对应的肯定是__len位置的变量,循环将所有占位符替换
            for (let i = 0; i < holeNum; i++) {
                args[args.indexOf(hole)] = args[__len]
                args.splice(__len, 1)
            }
            // 如果没有占位符且参数数目已经够了
            if (args.length < __len || args.indexOf(hole) > -1) {
                return h
            } else {
                return fn.apply(null, args)
            }
        }
    }

    var _ = {};

    var fn = curry(function(a, b, c, d, e) {
        console.log([a, b, c, d, e]);
    });

    fn(1, 2, 3, 4, 5);
    fn(_, 2, 3, 4, 5)(1);
    fn(1, _, 3, 4, 5)(2);
    fn(1, _, 3)(_, 4)(2)(5);
    fn(1, _, _, 4)(_, 3)(2)(5);
    fn(_, 2)(_, _, 4)(1)(3)(5)
    </script>
</body>

</html>
default

mqyqingfeng avatar Dec 07 '17 02:12 mqyqingfeng

少了一行,抱歉😄

// hole为传入的占位符
function curry(fn, hole) {
  const __len = fn.length
  let args = [];
  return function h() {
      // 先把参数放入args数组
      args = [...args, ...Array.from(arguments)]
      // 如果长度超过原有函数参数列表长度,表示有占位
      let holeNum = args.length - __len
      // 第一个占位符对应的肯定是__len位置的变量,循环将所有占位符替换
      for (let i = 0; i < holeNum; i++) {
          args[args.indexOf(hole)] = args[__len]
          args.splice(__len, 1)
      }
      // 如果没有占位符且参数数目已经够了
      if (args.length < __len || args.indexOf(hole) > -1) {
          return h
      } else {
          fn.apply(null, args)
          return args = []
      }
  }
}

izuomeng avatar Dec 07 '17 02:12 izuomeng

@izuomeng 这个占位的 curry 比你想的要复杂一点…… 因为对于 fn(1, _, 3)(_, 4)(2)(5); 这样的例子,结果应该是 [1, 2, 3, 4, 5],如果使用这种方法的话,结果会是 [1, 2, 3, 5, 4]

mqyqingfeng avatar Dec 07 '17 03:12 mqyqingfeng

哦哦,看了几组执行结果才发现这个占位是边执行边填补的,我把他理解成从超出参数列表列表长度之后才开始填补的了,谢谢指教🙏

izuomeng avatar Dec 07 '17 03:12 izuomeng

@izuomeng 但是你说的问题还是存在的,而且现在的写法也很繁琐,我也在想更加简洁易懂的写法~

mqyqingfeng avatar Dec 07 '17 03:12 mqyqingfeng

请教下博住,第三版的填占位符是怎样一个规律。。。。

Pa-wN avatar Dec 23 '17 03:12 Pa-wN

我现在才知道 function 的 length 竟然就是参数的长度,太水了 😄

很喜欢你的第二版,逻辑很清晰,很赞

iiicon avatar Feb 02 '18 09:02 iiicon

@iiicon 这算是个很细的知识啦,我也是很晚才知道的~

mqyqingfeng avatar Feb 08 '18 10:02 mqyqingfeng