你知道什么是函数式编程吗?

同面向对象编程一样,函数式编程也是一种编程范式。范式可以简单理解为一种规范和模式,一种方法论。js 虽然不是纯粹的函数式语言,但毕竟 js 中函数是一等公民,其也支持函数式编程。这里我把对函数式编程的学习整理成文,巩固知识的同时做个分享。

纯函数(Pure Function)

纯函数是个非常重要的概念,它属于函数式编程的基础。

定义

因为百度百科尚未收录词条“纯函数”,所以我们来看看维基百科的定义,当一个函数符合下列条件时,就称之为纯函数:

  • 此函数在相同的输入值时,需产生相同的输出。函数的输出和输入值以外的其他隐藏信息或状态无关,也和由 I/O 设备产生的外部输出无关。

我们可以把上面这段话分成 2 个小点:

① 输入值相同,输出值就相同

当函数的参数一样时,不管调用几次,在何处调用,输出的结果都是一样的。比如下面这个例子中,函数 fn 就不是纯函数,因为第 5 行和第 7 行两次调用时输入的参数都是 2,但输出的结果却不一样。

let a = 1
function fn(b) {
  return a + b
}
console.log(fn(2)) // 3
a = 3
console.log(fn(2)) // 5

如果改成 function fn(a, b) { return a + b },让函数的输出值与外部变量无关,fn 就是一个纯函数。

② 与 I/O 设备产生的外部输出无关

比如某个函数内部,会返回用户键盘的输出值,那么该函数也不是一个纯函数。

  • 该函数不能有语义上可观察的函数副作用,诸如“触发事件”,使输出设备输出,或更改输出值以外物件的内容等。

比如下面这个例子中,函数 fn 的执行导致函数外部的变量 obj 的 name 属性发生了改变,这就是产生了可观察的副作用,所以 fn 不是纯函数。

const obj = { name: 'Jay' }
function fn(obj) {
  obj.name = 'Chaim'
}
fn(obj)
console.log(obj) // { name: 'Chaim' }

如果想让 fn 是个纯函数,可以修改一下,通过展开语法对传入的 obj 进行一次浅拷贝,然后再修改 name 进行属性覆盖,最后返回出去新的对象。这样在第 8 行打印 obj 时可以看到 obj 并没有被改变:

let obj = { name: 'Jay' }
function fn(obj) {
  return {
    ...obj,
    name: 'Chaim'
  }
}
fn(obj)
console.log(obj) // {name: 'Jay'}
obj = fn(obj)
console.log(obj) // { name: 'Chaim' }

如果像下面这样,虽然修改了 obj.name,但是 obj 是函数内部定义的变量,外部不可观察,所以 fn 是一个纯函数:

function fn() {
  const obj = { name: 'Jay' }
  obj.name = 'Chaim'
}

其它的副作用还有比如触发了 click 事件,或是刷新了浏览器;调用了 DOM API 更改了页面;发送了 ajax 请求等;严格意义上来说甚至包括了 console.log() 打印,因为它输出了函数返回值以外的内容。

优势

假设我们的项目很大,是由多人协作完成的,那么如果某个全局变量 a 在多个地方使用,而某个人在某个地方调用了一个非纯函数修改了变量 a,就容易导致 bug 的出现,而且很难调试。所以,纯函数的优势在于可以让人安心的编写和使用函数,而不用担心会不会导致某个函数外部的变量被修改从而引发意外情况。

柯里化(Currying)

定义

这次我们直接看一下百度百科的定义(和维基百科的定义基本上一字不差):

在计算机科学中,柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。

先举个柯里化的小例子,之后再进一步的解释下何为柯里化:

// 原函数
function fn(a, b, c) {
  return a + b + c
}
console.log(fn(1, 2, 3)) // 6

// 柯里化后的新函数
function fn1(a) {
  return function fn2(b) {
    return function fn3(c) {
      return a + b + c
    }
  }
}
console.log(fn1(1)(2)(3)) // 6

现在结合案例按照个人理解翻译一下百度百科的定义:首先柯里化是一种技术,描述的是一个过程。这个过程就是把一个原本接收了多个参数的函数(fn),变成了一个接收单一参数(a)(或者是几个参数也行,只要个数比原本的多个参数少)的新函数(fn1)。并且这个新函数的返回值又是一个新的函数(fn2)——它接收余下的参数,并且会返回结果——如果它接收的只是余下参数的一部分(b),那么将继续返回新函数(fn3),新函数继续接收单个或多个余下参数(c),直至所有参数都被接收,最后返回结果。注意,我们之所以可以在 fn3 中拿到 a 和 b,是因为闭包的存在。可以看到,原本直接 fn(1, 2, 3) 调用的方式在柯里化后变成了 fn1(1)(2)(3) 这样的多次调用的形式。

上例中的 fn1 可以用箭头函数的形式简化写法:

// 使用箭头函数
const fn1 = a => b => c => a + b + c
console.log(fn1(1)(2)(3)) // 6

意义

将函数 fn 柯里化为 fn1 看上去毫无意义,那么什么情况下的柯里化是有意义的呢?

让函数的职责单一

在平常的项目中,无论是否使用了函数式编程,我们都希望每一个函数处理的问题尽可能的单一,而不是一个函数处理一堆的事情,这样更方便代码的复用和维护。 假设现在有个函数需要传入多个参数,每个参数都分别需要进行一定的处理,此时我们就可以使用柯里化,让函数的职责变得单一。

逻辑(参数)的复用

比如有如下函数 fn,用于返回周杰伦在某一年发行的专辑的名字:

const fn = (singer, year, album) => 
  `歌手${singer},在${year}年,发行的专辑是《${album}》`
console.log(fn('Jay', 2000, 'Jay'))
console.log(fn('Jay', 2001, '范特西'))
console.log(fn('Jay', 2002, '八度空间'))

我们发现每一次调用,都需要重复传入参数 Jay,运用柯里化则可以解决这个问题:

const fn = singer => year => album =>
  `歌手${singer},在${year}年,发行的专辑是《${album}》`
const fn1 = fn('Jay')
fn1(2000)('Jay')
console.log(fn1(2000)('Jay'))
console.log(fn1(2001)('范特西'))
console.log(fn1(2002)('八度空间'))

通过柯里化后得到的 fn,每次只接收一个参数,然后返回接收下一个参数的新的函数。那么我们就可以用变量 fn1 保存处理了 singer 参数的函数,之后只需要调用 fn1 并传入 year,得到新函数后再传入 album 调用即可。这样就实现了逻辑的复用,解决了原本例 4.1 中每次调用函数 fn 都需要重复传入参数 Jay 的问题。

延迟运行

像在之前的文章中我们自己定义的 myBind,就是一种柯里化后的函数。

自动柯里化函数

现在我们来封装一个函数 currying,传递给它一个普通函数作为参数,它就能返回将该普通函数柯里化后的新函数:

function currying(fn) {
  function recursiveFn(...theArgs) {
    if (theArgs.length >= fn.length) {
      return fn.apply(this, theArgs)
    } else {
      return function (...restArgs) {
        return recursiveFn.call(this, ...theArgs, ...restArgs)
      }
    }
  }
  return recursiveFn
}

测试代码如下:

function fn(singer, year, album) {
  console.log(this) // Number {2}
  return `歌手${singer},在${year}年,发行的专辑是《${album}》`
}
const foo = currying(fn)
console.log(foo('Jay', 2000).call(2, 'Jay')) // 歌手Jay,在2000年,发行的专辑是《Jay》

这里做几点说明:

  1. currying 函数的参数肯定是一个函数(fn),并且返回值是一个新函数(foo);
  2. 我们需要判断传给柯里化后的新函数(foo)的参数(theArgs)的个数是否大于或等于原函数(fn)形参的个数(函数的 length 属性指明函数的形参个数,所以可以通过 fn.length 得到)。如果是,那么直接通过 apply 或 call 调用原函数,以确保 this 的指向的正确性。比如我们调用 foo 时指定了 this 为 1:console.log(foo.call(1, 'Jay', 2000, 'Jay')),如果在 currying 函数的第 4 行不用 apply 绑定 this,那么测试代码第 2 行打印的 this 在浏览器中将指向 window。
  3. 请注意 currying 函数的第 7 行,是需要写上 return 的,不然像 foo('Jay')(2001)('范特西') 这样调用时会报错“foo(…)(…) is not a function”,因为 foo('Jay')(2001) 得到的结果将是默认返回 undefined,所以无法再次调用。

组合函数(Compose Function)

组合函数是一种对函数的使用技巧。如果某个数据需要依次经过多个函数处理,那么我们就可以将这多个函数组合起来,变成一个组合函数。这样每次处理该数据时就只需要调用一次组合函数,而不用每次都调用多个函数。下面实现一个可以将多个函数变为组合函数的方法:

function compose(...fns) {
  if (fns.length < 2) {
    throw new Error('请传入至少 2 个函数作为参数')
  }
  fns.forEach(fn => {
    if (typeof fn !== 'function') {
      throw new TypeError('参数必须为函数')
    }
  })
  return function (...theArgs) {
    let result = fns[0].apply(this, theArgs)
    let index = 0
    while (fns.length > ++index) {
      result = fns[index].call(this, result)
    }
    return result
  }
}

在 compose 函数的第 2 ~ 9 行,我们做了一些边界判断,看看是否传入了 2 个或以上的函数作为参数。如果参数没有问题,那么就返回一个新函数,在第 11 行先调用传入的第一个函数,使用 apply 调用是为了确保 this 指向的正确性且 theArgs 为数组。得到结果后使用一个 while 循环,让传入 compose 的剩余的函数依次执行,参数为上一个函数执行的结果(result),最后返回 result。

function add(num) {
  return num + 100
}
function multiply(num) {
  return num * 2
}
console.log(multiply(add(10))) // 220
const composeFn = compose(add, multiply)
console.log(composeFn(10)) // 220

第 7 行和第 9 行结果一致,说明组合函数 compose 是有效的。当然,我们实现的 compose 只是一个简单版本,比如没有考虑如果传入的函数是异步函数的情况。