JavaScript函数式编程入门

1,354 阅读18分钟

前言

在日常开发中,我们会经常跟函数打交道,比如要写一段复杂逻辑的代码时,我们通常会把它封装成一个函数去处理,可是这就是函数式编程吗?当你发现给函数进行单元测试时纠结半天,有没有想过如何改善呢?当你发现Vue 3.0 编写代码的风格发生很大变化的时候,有没有想过它为啥会这么设计呢?我们看似天天在用函数,但实际上大部分人包括我自己都不知道函数式编程到底是什么,它到底解决了什么问题?

带着这些思考,我们一起来学习吧。

什么是函数式编程

函数式编程(Functional Programming,下文都简称为FP)它是一种编程范式,一种软件开发风格。编程范式常见的有:

  • 面向过程(OPP):如 C 语言
  • 面向对象(OOP): 如 Java、C#
  • 函数式编程(FP):如 JavaScript

FP它把现实世界的事物和事物之间的联系抽象到了程序世界,它的目标在于使用函数抽象作用在数据之上的控制流与操作,它更像数学上的函数即映射关系,比如 y = sin(x)xy之间的关系。

上面这一段话好像很难理解对不对?我们只需要记住上面重点标记的话,来我们来看一个例子就明白了。

比如我们要计算两个值的和,用命令式或过程式编程思路是这样:

let num1 = 2
let num2 = 3
let sum = num1 + num2

console.log(sum)

上面会定义一个num1变量存储第一个数值,num2存储第二个数值,而sum则用来存储两个值计算后的结果。而用FP来改造就是:

function add (n1, n2) {
  return n1 + n2
}
let sum = add(2, 3)
console.log(sum)

它把求和这一个操作使用了一个函数进行了抽象,并且sumn1n2形成了映射关系。这样一对比,大家是不是很容易理解了呢?

总的来说,过程式或命令式编程会告诉计算机 CPU 如何执行某个任务,会修改系统各个状态来计算最终的结果,而FP它会描述一系列的操作,但是不会暴露它们是如何实现的或数据流如何穿过它们。

函数式编程中常见概念

熟悉FP编程思维必定要学习它有哪些基本概念,来帮助我们更好地理解它。我们来看看函数本身有哪些高级特性。

函数中的高级特性

  • 函数是一等公民(First-class Function)

当一门编程语言的函数可以被当作变量一样用时,则称这门语言拥有头等函数。例如,在这门语言中,函数可以被当作参数传递给其他函数,可以作为另一个函数的返回值,还可以被赋值给一个变量。 ——MDN

很明显,在 JS 中我们都知道函数是可以作为变量使用的:

// 函数表达式
const foo =  () => console.log(1)

// 函数作为参数
const filterFn = item => item % 2 === 0
[1, 2, 3].filter(filterFn)

// 函数作为返回值
function bar(a) {
  return () => {
      console.log(a)
  }
}

const baz = bar(1)
baz() // 1

我相信大家在学习这门语言的时候一定有了解过这个特性。

  • 高阶函数

如果函数可以把它本身作为参数传递给另一个函数,或者把函数作为另外一个函数的返回结果,我们可以把这样的函数称为高阶函数

JS数组常见的方法如mapfilterfindreduceforEach等都是高阶函数,高阶函数它可以帮助我们屏蔽细节,把数据操作流程抽象化,我们只需要关注我们的目标。

  • 闭包

闭包算是老生常谈的概念了,它也是函数中特别重要的一个概念。我个人理解是当外部函数进行垃圾回收时,内部函数依然引用了外部函数词法作用域,导致外部函数词法作用域无法被回收,就这样形成了闭包,我们来看一个简单的例子来回顾一下闭包的使用场景.

比如我们要求任意数的幂,我们有两个输入,一个基数和一个幂。我们可以这么封装来提高代码复用能力:

    function makePower(power) {
      return function(number) {
        return Math.pow(number, power)
      }
    }
    
    // 幂为2
    const power2 = makePower(2)
    // 幂为3
    const power3 = makePower(3)

    console.log(power2(4));
    console.log(power3(4));

我们可以看到makePower这个函数有一个power参数变量,它传递给了内部的匿名函数使用,当 makePower这个函数执行完毕后,JS 引擎在垃圾回收时发现它内部函数还引用了一个power变量,函数得不到释放,这时候就形成了闭包。你可以理解为它是一个特殊的词法作用域,外部可以通过这个“秘密通道”可以访问函数内部的变量。我们可以在const power2 = makePower(2)处打一个断点看看:

执行完这一行后,makePower弹出调用栈,power2这个函数存储了一个函数。

image.png

到这一步其实并没有形成闭包,我们再往下执行:

image.png

surprise!当执行到console.log(power2(4));这一行时,它会调用power2这个函数,传递一个4,此时如果垃圾回收正好触发的话,它会发现函数内部居然还引用了一个变量power,本来makePower这个函数都出栈了,执行完了应该被标记清除的,结果还是被精明的垃圾回收机制发现了,于是就形成了闭包。我们可以看到浏览器有一个Clousure(makePower)属性,它里面有一个power变量,这就是闭包环境,只有console.log(power2(4));这一行执行完后,它才会释放掉。

本质上无论何时何地,如果将函数(访问它们各自的词法作用域)当作第一 级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、 Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使 用了回调函数,实际上就是在使用闭包! ——你不知道的JavaScript

很显然,不管是闭包还是高阶函数,它们都是由函数是第一等公民衍生出来的概念,而它们又是FP不可或缺的部分。

纯函数与副作用

  • 纯函数

FP旨在尽可能提高代码的无状态性和不变性,无状态的代码不会破坏或改变全局的状态,怎么理解呢?我们先看看纯函数有哪些特点:

  • 纯函数仅取决于提供的输入,而不依赖任何在函数求值期间或调用间隔时可能发生变化的隐藏状态和外部状态
  • 不会造成超过作用域的变化,例如修改全局对象或引用传递的参数

其实记住一个原则就能准确判断了:相同的输入永远得到相同的输出。比如数组中的slicesplice方法相同的输入调用多次后,splice会改变原数组,而slice则不会。

let numbers = [1, 2, 3, 4, 5]

// 相同的输入 => 相同的输出
console.log(numbers.slice(0, 3)) // [1, 2, 3]
console.log(numbers.slice(0, 3)) // [1, 2, 3]
console.log(numbers.slice(0, 3)) // [1, 2, 3]

// 相同的输入 => 不相同的输出
console.log(numbers.splice(0, 3)) // [1, 2, 3]
console.log(numbers.splice(0, 3)) // [4, 5]
console.log(numbers.splice(0, 3)) // []

那纯函数有哪些优点呢?

  • 可缓存:因为纯函数具有相同输入必有相同输出的特性,所以当调用多次纯函数时,可以缓存起来,提高性能。

比如上面的slice方法被调用了多次其实是没必要的,我们可以实现一个memorize方法来实现缓存,让它多次调用只执行一次。

function memorize(fn) {
  let cache = {}
  return function() {
    // 可以把参数作为缓存对象的key
    const key = JSON.stringify(arguments)
    // 判断缓存
    cache[key] = cache[key] || fn.apply(this, arguments)

    return cache[key]
  }
}

为了测试,我们给slice再包一层函数,在里面打印输出看看:

function slice(arr) {
  console.log('执行');
  return arr.slice()
}

const arr = [1,2,3]
const getSliceWithMemory = memorize(slice)

console.log(getSliceWithMemory(arr)); // 打印 '执行', [ 1, 2, 3 ]
console.log(getSliceWithMemory(arr)); // 打印  [ 1, 2, 3 ]
console.log(getSliceWithMemory(arr)); // 打印  [ 1, 2, 3 ]
console.log(getSliceWithMemory(arr)); // 打印  [ 1, 2, 3 ]
  • 可测试:还是那句话(纯函数具有相同输入必有相同输出的特性),而测试其实就是对函数输入输出的测试,正是因为纯函数的特性,让输入和输出是不变的。

  • 可并行处理:纯函数不需要访问内存中的数据,并行环境下可以运行任何纯函数。(JS 中的并行主要是在 ES6 中增加了 Web Worker

此外,如果一个函数不符合纯函数的要求,那么它就是有副作用的,它会让函数变得有状态,让代码变得不受控制与管理。副作用通常主要是来源下面几个:

  • 函数内部引用了全局变量或者改变了全局变量,比如引用了某个配置文件的变量
  • 用户的输入
  • 抛出没有捕获的异常
  • 数据库访问
  • 屏幕打印

FP的目的就是创建不可变的程序,通过消除外部可见的副作用,来对纯函数的声明式的求值过程。但是我们的程序不可能完全避免副作用的,FP也不限制一切状态的改变,它的目的在于让纯函数把不纯的部分抽离出来,尽可能控制在可控范围内。

柯里化

到目前为止,我们知道了纯函数是无状态的,我们会把一切可变的变量改为参数传递,但是这样会导致参数的个数会越来越多。在FP中,我们可以在不影响原函数的功能下,把参数拆分成一个,然后返回一个新函数等待接收剩余的参数,这就是柯里化。

我们来看看耳熟能详的bind函数如何实现柯里化:

function checkAge(min, age) {
  return age > min
}

// 使用bind实现curry
const check18 = checkAge.bind(null, 18)
const check19 = checkAge.bind(null, 19)

console.log(check18(20))
console.log(check19(20))

我们来实现一个简易的curry函数:

function curry(fn) {
  // 初始可以接收任意长度的参数
  return function curried(...args) {
    // 参数长度与fn参数对比
    if (args.length < fn.length) {
      // 如果初始传入的参数小于fn的参数,说明只传递了一部分参数
      // 返回一个新函数接收剩余参数
      return function() {
        // 递归调用到参数传递完毕
        return curried(...args.concat(Array.from(arguments)))
      }
    } 
    // 说明参数传递完毕,直接执行fn函数
    return fn.apply(this, args)
  }
}

比如我们现在让一个求三个数之和的函数进行柯里化:

function getSum(a, b, c) {
  return a + b + c
}

const curried = curry(getSum)

console.log(curried(1)(2)(3)); // 6
console.log(curried(1, 2)(3)); // 6
console.log(curried(1)(2, 3)); // 6
console.log(curried(1, 2, 3)); // 6

不管参数怎么传递结果都是6,这就是柯里化的妙用,它其实是对参数进行了“缓存”,让函数更加细粒度。

函数组合

柯里化是把多元函数拆分成一元函数,让函数的职责更小更细,而函数组合就是把这些细分函数组合起来,形成一段逻辑。

我们都知道英文里面有名词、动词、介词等,它们都是细分的词性,而它们组合在一次就形成了一段话。细分的函数就是对应的是词性,它们本身是没有任何状态的,而多个细分的函数组合在一起就有了意义了。

我们来看一个例子,获取数组最后一个元素再转化成大写字母。

们用函数组合思想来实现一下。首先我们自己实现一个compose函数,它会从右往左依次执行函数,并把上一个函数的返回值作为下一个函数的输入。

const compose = (...args) => (val) => args.reverse().reduce((acc, cur) => cur(acc), val)

接下来需要定义一些本身无状态的纯函数:

// 数组反转
const reverse = (arr) => arr.reverse()

// 获取数组第一个元素
const getFirst = (arr) => arr[0]

// 将字符串转为大写
const toUpper = (str) => str.toUpperCase()

然后使用compose方法来组合这些函数来实现我们的需求:

const f = compose(toUpper, getFirst, reverse)

console.log(f(['aaa', 'bbb', 'ccc'])) // 'CCC

我们可以看到 toUpper, getFirst, reverse 这三个方法本身与我们的需求没有任何关系,而组合在一起后就实现了最终的结果。

这时候肯定会有人说,这有啥用啊,明明一个很简单的事情搞得这么复杂。但如果我们程序大量使用了上面拆分出来的函数,你可能不会这么想了。你可以理解为整个函数被拆分成一个个小的管道,管道传递的是值,每个管道会有自己的作用。我们可以联想生活中的电缆,它肯定不是一整条连接起来的,而是拆分无数条线缆拼接而成,出问题的时候只需要到对应的地方进行维修即可,函数组合也是这个道理。

假设上述组合的函数中某个部分出现了错误,我们需要定位是哪一个部分出错时,可以借助管道的特性,在中间插入一个 debug管道,看看是哪个地方出错(有点电工那味了~),下面的案例我们来借助lodash中的一些函数来实现。

我们定义一个trace函数,来定位是哪一部分的管道出问题:

// fp 是专门用来函数式编程的一个模块,里面的函数都是纯函数
const fp = require('lodash/fp')

const trace = fp.curry((tag, v) => {
  console.log(tag, v)
  return v
})

还是借助上面的例子,我们来看看如何使用:

// 我们故意写错函数
const getFirst = (arr) => arr

// 排查 flowRight 等同于上面我实现的`compose`函数
const f = fp.flowRight(
  trace('toUpper之后'),
  fp.toUpper,
  trace('getFirst之后'),
  getFirst,
  trace('reverse之后'),
  fp.reverse
)

console.log(f(['aaa', 'bbb', 'ccc'])) 

// reverse之后 [ 'ccc', 'bbb', 'aaa' ]
// getFirst之后 [ 'ccc', 'bbb', 'aaa' ]
// toUpper之后 CCC,BBB,AAA
// CCC,BBB,AAA

trace函数中我们可以看到getFirst之后没有得到我们想要的结果,那么就可以定位出是这个函数出了问题。

函子

看到这里,我们知道了在FP中,我们尽量要使用纯函数来减少副作用带来的缺陷,但是副作用是无法完全避免的,我们只能把副作用控制在可控范围内,此外有关在FP如何做异常处理、异步操作等并没有讲述如何实现。而函子就是解决这些问题的。

函子(英文Factor)它是一个容器,通过普通对象实现,容器包含值和值的变形关系(变形关系就是函数)。该容器内部有一个map方法,map方法可以运行一个函数对值进行处理。下面我们来学习一些常见的函子。

普通Functor函子

根据上面的描述,我们要定义个函数,它需要满足两个特点:

  • 维护一个值,这个值只能在容器内操作(你可以当作私有属性)
  • 有一个map方法,可以向map传递参数对值进行修改,同时能把值映射到另外一个容器

按照要求我们来实现这个函子:

class Container {
  // 私有属性,只能内部访问
  #value
  
  // 单独封装一个创建实例的方法,避免在外面通过`new`来创建对象
  static of(value) {
    return new Container(value)
  }

  constructor(value) {
    this.#value = value
  }
  
  // map方法通过外部传入的`fn`来改变内部的值
  map(fn) {
    return Container.of(fn(this.#value))
  }
  
  // 通过join方法返回函数内部的值
  join() {
    return this.#value
  }
}

还是用上面的例子来说吧(让数组最后一个元素大写),看用函子改写是什么情况:

const fp = require('lodash/fp')

const getFirst = (arr) => arr[0]
const f = Container.of(['aaa', 'bbb', 'ccc'])
  .map(fp.reverse)
  .map(getFirst)
  .map(fp.toUpper)

console.log(f.join()) // 'CCC'

函子本身其实还是一个管道,值在管道里面流动,而且只能在其中一个管道进行修改并传递给下一个管道,但是如果其中一个函子出了异常呢,比如我们传递一个null值时,如何知道是哪一个函子出现了问题呢?如何保证程序正常运行呢?MayBe函子就是解决这个问题的,下面我们来看看。

MayBe函子

MayBe函子主要作用就是对外部的空值进行处理,把副作用控制在允许的范围。它内部需要添加空值判断,下面是MayBe函子的实现。

class MayBe {
  #value

  static of(value) {
    return new MayBe(value)
  }

  constructor(value) {
    this.#value = value
  }
  
  map(fn) {
    return this.isEmpty(this.#value) ? MayBe.of(null) : MayBe.of(fn(this.#value))
  }
  
  // 判断某个值是否为空
  isEmpty(value) {
    return value === null || value === undefined
  }

  join() {
    return this.#value
  }
}

此时,无论你给哪个函子传递空值都不会造成程序异常了。但是,此时又出现一个新问题,我们怎么定位是哪一步产生的空值问题呢?如何捕获函数内部出现的异常呢?

Either函子就是解决这个问题的,下面我们来看看。

Either函子

Either函子由LeftRight函子组成,类似if/else逻辑,当某个函子抛出了异常,由Left存储异常的值,并返回当前函子本身不再继续传递值,Right则正常返回新的函子。

// 处理异常的函子,返回自身实例,不再返回新的函子
class Left {
  #value

  static of(value) {
    return new Left(value)
  }

  constructor(value) {
    this.#value = value
  }

  map(fn) {
    return this
  }

  join() {
    return this.#value
  }
}
// 普通函子,正常返回新函子
class Right {
  #value

  static of(value) {
    return new Right(value)
  }

  constructor(value) {
    this.#value = value
  }

  map(fn) {
    return Right.of(fn(this.#value))
  }

  join() {
    return this.#value
  }
}

假如现在有一个常见需求,我们要解析一段JSON字符串,如果传进来的字符串格式错误,必定会报错,我们可以借助Either函子来处理异常。

function parseJSON(json) {
  try {
    return Right.of(JSON.parse(json))
  } catch (error) {
    return Left.of({ error: error.message })
  }
}

const f = parseJSON("{name: 'cao'}")

console.log(f.join()); // { error: 'Unexpected token n in JSON at position 1' }

IO函子

到这为止,MayBeEither都是对函数执行过程可能出现的异常进行处理的函子,但是如果传递的fn是一个不纯的函数时,它会返回一个影响状态的值并产生副作用。这种情况我们是无法避免的,但是我们可以让它变成“惰性”的,把获取副作用产生的值这个操作交给用户决定,等执行这个函数时才去获取它的值,也就是说函子内部的value可以存放一个函数,下面我们来实现这样的函子。

const fp = require('lodash/fp')

class IO {
  #value

  static of(value) {
    return new IO(() => value)
  }

  // 构造函数接收一个函数
  constructor(fn) {
    this.#value = fn
  }

  map(fn) {
    // 使用组合函数
    return new IO(fp.flowRight(fn, this.#value))
  }

  join() {
    return this.#value()
  }
}

我们再回到那个返回数组最后一个大写的例子:

const getFirst = (arr) => arr[0]
const f = IO.of(['aaa', 'bbb', 'ccc'])
  .map(fp.reverse)
  .map(getFirst)
  .map(fp.toUpper)
console.log(f.join()) // 'CCC'

我们虽然传递了一个数组,但是它会在函子内部转成一个函数进行存储,当我们调用join时才能拿到返回的值,这样就保证了这个值是惰性求值的,等需要时才去调用。

Monad函子

上面的IO函子会有一些问题,比如下面这种情况:

// 同步读取文件,返回一个新IO函子
const readFile = filename => new IO(() => fs.readFileSync(filename, 'utf-8'))

// 打印内容,返回一个新IO函子
const print = x => new IO(() => {
  console.log(x);
  return x
})

// 函数组合
const cat = fp.flowRight(print, readFile)
const f = cat('../package.json')

console.log(f.join()); // 打印两次 IO {}

我们可以看到当调用join方法时,打印了两次IO函子。这是为啥呢?我们慢慢来分析,首先调用cat函数并返回了f,这一步主要是做了两件事:

  • 执行函数组合
  • 设置函子的值

执行readFile时,它返回了一个new IO(() => fs.readFileSync(filename, 'utf-8')),同时在这个函子内部把#value设置为() => fs.readFileSync(filename, 'utf-8'),接着把new IO(() => fs.readFileSync(filename, 'utf-8'))传递给printprint同理先设置它这个函子的值为:

this.#value = () => {
  console.log(new IO(() => fs.readFileSync(filename, 'utf-8')));
  return new IO(() => fs.readFileSync(filename, 'utf-8'))
}

然后返回一个嵌套的函子,此时的f为:

new IO(() => {
  console.log(new IO(() => fs.readFileSync(filename, 'utf-8')));
  return new IO(() => fs.readFileSync(filename, 'utf-8'))
})

当调用join方法时,它会执行下面这个函数

() => {
  console.log(new IO(() => fs.readFileSync(filename, 'utf-8')));
  return new IO(() => fs.readFileSync(filename, 'utf-8'))
}

此时进行了第一次打印,接着这个函数又返回了一个函数,所以我们使用console可以打印这个返回的函子。而我们其实真正要执行的函子还是没有执行,所以需要对这个返回的函子再次调用join方法,才可以去读取文件:

console.log(f.join().join()) // 文件内容

我们可以看到当函子嵌套函子时,代码将变得十分复杂,所以我们需要在函子内部实现一个flatMap方法来让函子“扁平化”。

flatMap (fn) {
 return this.map(fn).join()
}

改造后的代码逻辑:

const f = readFile('../package.json')
              .map(fp.toUpper)
              .flatMap(print)
              .join()


console.log(f);

如果函子内部实现了像上面的joinflatMap等方法,我们可以称之为Monad函子。当函子内部嵌套函子时,使用Monad可以很好地解决问题。

Task函子

上面的函子主要是针对FP中的异常处理以及如何控制副作用,实际开发中我们处理异步操作的场景也十分常见,我们可以借助folktale这个库的Task函子来帮我们处理异步任务。

folktalelodash一样也是一个标准的函数式编程库。不同的是,它没有提供很多功能函数,只提供了一些函数式处理的操作,例如:compose、curry 等,一些函子 Task、Either、MayBe 等。folktale官网

比如我们在Node.js中异步获取文件,并对文件进行操作,我们可以使用Task函子来帮助我们进行成功和失败处理。

const { task } = require('folktale/concurrency/task')
const fs = require('fs')
const fp = require('lodash/fp')

function readFile(filename) {
  return task((resolver) => {
    fs.readFile(filename, 'utf-8', (err, data) => {
      if (err) {
        resolver.reject(err)
      }
      resolver.resolve(data)
    })
  })
}

readFile('../package.json')
  .map(fp.split('\n'))
  .map(fp.find((x) => x.includes('version')))
  .map(fp.toUpper)
  .run()
  .listen({
    onRejected: (err) => {
      console.log(err)
    },
    onResolved: (value) => {
      console.log(value)
    }
  })

细心的朋友们会发现,它跟Promise的操作很像,而且比Promise更难理解和使用,但它是FP中处理异步操作中很重要的函子。个人认为,我们实际开发还是用Promise比较好,我们只需要了解在FP中可以借助Task函子来帮我们解决异步问题就可以了。

总结

本文从纯函数、柯里化、函数组合等几个主要概念出发,简单介绍了函数式编程中的一些特性和优点,以及如何使用函子来减少副作用、处理异常和异步操作。掌握一个编程范式并没有那么容易,但是学习FP有助于提高我们编程思维,也可以编写一些高质量的代码,控制副作用带来的缺陷。

参考