Skip to content
On this page

22. 柯里化与偏函数

看到网上有很多讲 柯里化(curring)偏函数应用的( partial function application),但大部分都没有明了的区分二者(例如,在柯里化里面讲的却是偏函数应用)。本文查询了相关资料,结合自身的理解,从两者特点和区别的角度来记录一下。

1. 柯里化

1.1 定义

维基百科对于柯里化的定义

In mathematics and computer science, currying is the technique of converting a function that takes multiple arguments into a sequence of functions that each takes a single argument.——Currying

翻译:在数学和计算机科学中,柯里化就是一种将 接收多个参数的函数 转换为一系列 只接收单一参数的一元函数 的技术。

函数与元

元是指函数参数的个数,比如一个带有两个参数的函数被称为二元函数。

talk is cheap, show me the code.

这是一个普通的多参求和函数:

js
const add = (x, y, z) => x + y + z
1

根据柯里化定义,转换为如下形式:最初的函数 curriedAdd 只接收单一参数,返回的函数 也都只接收一个函数。(这里利用了闭包的特性,函数作用域外能够访问之前传入的参数。)

js
function curriedAdd(x) {
  return function (y) {
    return function (z) {
      return x + y + z
    }
  }
}
1
2
3
4
5
6
7

这样写太麻烦了,我们使用箭头函数改写:

js
const curriedAdd = x => y => z => x + y + z
1

于是,调用这个函数的时候就符合柯里化的定义了:

js
let res = curriedAdd(1)(2)(3) // 6
1

你也可以逐步调用:

js
let f = curriedAdd(1)
let g = f(2)
let h = g(3) // 6
1
2
3

上述方法是直接定义一个柯里化函数,我们也可以通过一个 “柯里化生成器” curring 来进行转换,相当于一个柯里化加工厂。

js
const add = (x, y, z) => x + y + z

const curring = fn => x => y => z => fn(x, y, z)

const curriedAdd = curring(add) // 使用 curring 加工为柯里化函数
curriedAdd(1)(2)(3) // 6
1
2
3
4
5
6

1.2 小试牛刀

  1. 定义 _push,实现如下效果。
js
_push('a')('b')('c') // ['a', 'b', 'c']
1

参考答案:

js
const _push = a => b => c => [a, b, c]
1
  1. 定义 add,实现如下计算结果。
js
10 + add()           // 10
10 + add(1)          // 11
10 + add(1)(2)       // 13
10 + add(1)(2)(3)    // 16
10 + add(1)(2)(3)(4) // 20
1
2
3
4
5

参考答案:

js
const add = (...args) => {
  const f = add.bind(null, ...args)
  f.toString = () => args.reduce((a, b) => a + b, 0)
  return f
}
1
2
3
4
5
  1. 定义 mul,实现如下计算结果。
js
1 * mul(1)          // 1
1 * mul(1)(2)       // 2
1 * mul(1)(2)(3)    // 6
1 * mul(1)(2)(3)(4) // 24
1
2
3
4

参考答案:

js
const mul = (...args) => {
  const f = mul.bind(null, ...args)
  f.valueOf = () => args.reduce((a, b) => a * b, 1)
  return f
}
1
2
3
4
5

注意

这里暂时没有提到类似 add(1)(2,3) 或者 add(1, 2)(3) 这样的形式,因为本文想区分下严格柯里化和偏函数,事实上这种单一和多参混合模式的函数分解属于一种柯里化和偏函数的结合,具体见后文偏函数与柯里化的比较。

2. 偏函数

2.1 定义

TIP

维基百科: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. ——Partial application

翻译:在计算机科学中,局部应用(偏函数/部分偏函数应用)是指先将 某个多参函数的一些参数固定,然后返回一个新的更小元函数(形参更少),这个函数可以 接收剩余的一些参数

先上代码:

js
const add = (x) => (y, z) => x + y + z
let gn = add(1) // 固定一个参数
gn(2, 3) // 6,接收剩余参数
1
2
3

你可以通过使用 bind 方法实现一个偏函数:

js
const add = (x, y) => x + y
const addOne = add.bind(null, 10)
addOne(20) // 30
1
2
3

同样的,也可以设置一个 “偏函数加工厂” partial 的形式将普通函数转换为偏函数:

js
const add = (x, y, z) => x + y + z // 原函数

const partial = (fn, x) => (y, z) => fn(x, y, z) // 加工函数

const gn = partial(add, 1) // 固定了 1 这个参数,剩余两个函数可以继续接收
gn(2, 3) // 6
1
2
3
4
5
6

2.2 对比和联系

注意

从定义和代码示例可以看出,偏函数和柯里化非常相似,但是又不相同,网上很多例子都不区分,秉着严谨求知的精神,我还是区分开来。维基百科上也着重提出了这点:“Currying is related to, but not the same as, partial application.”

初步可以看出,两者的区别:

  • 柯里化将函数分成了 一个个只接收单一参数的单元函数
  • 偏函数将函数分成 一个个多元函数,但每一个多元函数的形参个数都要小于原函数的参数个数

两者又有联系:

  • 柯里化是一种特殊的偏函数,当偏函数(部分函数应用)分解的所有更小元函数都是一元函数时,就变成了柯里化函数。

由此可以看出,柯里化函数要求比偏函数更加严格:

  • 柯里化函数要求分解的所有函数都是一元的,且 柯里化只允许参数从左往右依次传递
  • 偏函数比较自由,要求更小元函数,且 偏函数允许使用占位符, 传递方向不一定从左到右

一元和更小元的区别上面已经讲了,接下来谈谈参数传入的顺序问题。

2.3 参数传入顺序

在柯里化函数中,实参只能按照顺序一个个传入,如下:

js
const curring_sub = x => y => x - y
const res = curring_sub(20)(10) // 先传 20,再传 10
res1 // 10
1
2
3

但在偏函数中,前面的参数可以先不传(使用临时变量代替),先传后面的参数:

js
const partial_sub = (_, x) => _ => _ - x
let _ = {}
let res = partial_sub(_, 10)(20) // 第一个参数先保留,之后再传 20
res // 10
1
2
3
4

到底区分不区分柯里化和偏函数

面试中如果被问到了偏函数,那是肯定需要和柯里化区分开来的。另外,实际上,也有很多人认为偏函数就是一种高级的柯里化,即可以分解为更小元函数而不一定是一元函数。在应用场景中,怎么用更优雅就怎么用,也不严格区分。当然,这都是个人理解。面试大多问的也都是概念或者简单的,遇到了就全部当成高级柯里化或者柯里化和偏函数的综合吧。

2.4 小试牛刀

  1. 定义 add,实现以下效果。
js
add(1, 2, 3, 4, 5) // 15
add(1, 2)(3, 4, 5) // 15
add(1, 2)(3)(4, 5) // 15
add(1)(2, 3)(4)(5) // 15
add(1, 2, 3, 4, 5) // 15
1
2
3
4
5

注意:没有任何其他转换,函数返回的直接是一个数字类型。

参考答案
js
// 柯里化辅助函数
function curring(fn) {
  // 这种方法相当于把后续调用的参数通过 args 都收集起来
  return function _fn(...args) {
    // 当前参数总数达=原来函数参数数量时,则执行函数并返回结果
    // fn.length 是函数传入参数的个数
    if (args.length === fn.length) {
      return fn.call(this, ...args)
    }

    // 还没调用完,继续把后面调用的函数的参数添加到新的返回的函数中
    return (...remain) => {
      return _fn.call(this, ...args, ...remain)
    }
  }
}

// 原始函数
function add(x, y, z, m, n) {
  return x + y + z + m + n
}

// 柯里化后的函数
const curried_add = curring(add)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  1. 定义 add,实现以下效果。
js
+add()        // 0
+add(1)       // 1
+add(1)(2)    // 3
+add(1, 2)    // 3
+add(1, 2)(3) // 6
+add(1)(2)(3) // 6
1
2
3
4
5
6

注意:有个单元运算符 + 表示进行了强制类型转换。

参考答案
js
const add = (...args) => {
  let f = add.bind(null, ...args)
  f.valueOf = () => args.reduce((a, b) => a + b, 0)
  return f
}
1
2
3
4
5
  1. 定义 add,实现以下效果。
js
add(1)(2)()       // 3
add(1)(2)(3)()    // 1
add(1, 2)(3)()    // 6
add(1, 2, 3, 4)() // 6
add(1, 2, 3)(4)() // 0
1
2
3
4
5

注意:没有任何其他转换,函数返回的直接是一个数字类型。

参考答案
js
const sum = (...args) => {
  return args.reduce((a, b) => a + b, 0)
}

const curring = fn => {
  const args = []
  return function _fn(...rest) {
    if (rest.length === 0) {
      let res = fn(...args)
      args.length = 0 // 清空参数列表,进行下次运算
      return res
    } else {
      args.push(...rest)
      return _fn
    }
  }
}

const add = curring(sum)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

3. 应用场景

以下应用场景也不严格区分柯里化和偏函数了,你可以认为是柯里化及高级柯里化的应用,也可以认为是偏函数的应用,或者是两者的综合利用。

3.1. 参数复用

现在你要实现一个正则检验函数,要求根据传入的正则表达式 regExptxt 来判断 txt 是否满足该正则式。

你可能会这样实现:

js
function check(regExp, txt) {
  return regExp.test(txt)
}
1
2
3

然后调用:

js
// 检测数字
check(/^\d+$/, 123)
check(/^\d+$/, 456) 
check(/^\d+$/, 789) 
// 检测全是小写字母
check(/^[a-z]+$/, 'abc') 
check(/^[a-z]+$/, 'ddd') 
check(/^[a-z]+$/, 'mnt') 
1
2
3
4
5
6
7
8

发现没,每次检测不同类型的字符串的正则参数都不一样,这样每次都要写一遍参数,多麻烦呀!

我们可以使用 偏函数 解决这个问题,把重复参数先固化起来,然后再去接收剩余参数,这样就实现了 参数复用

js
// 偏函数(特殊柯里化)复用参数
const currring_check = function (regExp) {
  return function (txt) {
    return regExp.test(txt)
  }
}

const checkNumber = currring_check(/^\d+$/)
const checkLower = currring_check(/^[a-z]+$/)
1
2
3
4
5
6
7
8
9

使用的时候,没有冗余参数:

js
// 检测数字
checkNumber(123)
checkNumber(456)
checkNumber(789)
// 检测全是小写字母
checkLower('abc')
checkLower('ddd')
checkLower('mnt')
1
2
3
4
5
6
7
8

3.2 提前确认

举例说明:对于事件监听方法(addEventListenerattachEvent)的兼容,提前确定了会走哪一个方法,避免之后每次绑定事件都进行判断

js
const whichEvent = (function () {
if (window.addEventListener) {
  return function (element, type, listener, useCapture) {
    element.addEventListener(
      type,
      function (e) {
        listener.call(element, e)
      },
      useCapture
    )
  }
} else if (window.attachEvent) {
  return function (element, type, handler) {
    element.attachEvent('on' + type, function (e) {
      handler.call(element, e)
    })
  }
}
})()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

使用方法:

js
let p = document.querySelector('p')
whichEvent(p, 'click', function () {
    alert('click p')
})
1
2
3
4

3.3 延迟执行

这里的延迟执行算是覆盖了大部分柯里化的应用场景,第一次执行函数,不立即返回最终的结果。上述面试题很好的实现了这一点。