说一说JavaScript中的函数式编程

333 阅读8分钟

一 . 什么是函数式编程

在开发或者面试中我们常常听到函数式编程,但什么是函数式编程呢?为什么我们要去学习函数式编程呢?别着急,我们先来看一段代码:

    // 非函数式编程
    let num1 = 2
    let num2 = 3
    let sum = num1 + num2
    console.log(sum)
    
    // 函数式编程
    function add (n1, n2) {
    return n1 + n2
    }
    let sum = add(2, 3)
    console.log(sum)

上面这段代码非常简单,就是求两数之和,在我们面向过程的思维方式里,是定义了两个变量来接收这两个值,最后的结果也是定义了变量来接收。乍一看,过程直观明了,但有一个问题就是我们目前是只求了2+3的和,如果我们要求5+8;7+9;或者其它更多可能呢?我们以面向过程的思维来看是不是就是得定义更多的变量,这个代码量就很大了,所以我们就得引入函数式编程了。

函数式编程(Functional Programming)它是一种编程范式,一种软件开发风格。再来看看我们用的函数式编程实现的两数之和,我们把求和的运算过程进行了抽离,封装成了一个函数。以后我们在调用的时候就不必在关心实现过程如何,只需要关注我们函数的调用,这可以说大大简化了我们的代码。这也是函数式编程的核心思维:对运算过程进行抽象,只关注运算结果本身,不必太多去关注运算过程的实现。

通过两种编程方式的比较而言,我们也可以看出函数式编程的优势在哪里,我大致列举几点:

  • 函数式编程是随着 React 的流行受到越来越多的关注。
  • Vue 3也开始拥抱函数式编程。
  • 函数式编程可以抛弃 this。
  • 打包过程中可以更好的利用 tree shaking 过滤无用代码。
  • 方便测试、方便并行处理。
  • 有很多库可以帮助我们进行函数式开发:lodash、underscore、ramda。

二 . 函数式编程中的常见概念

1.高阶函数

  • 可以把函数作为参数传递给另一个函数。
  • 可以把函数作为另一个函数的返回结果。 我们可以把满足以上两点的的函数称为高阶函数,JS数组常见的方法如map、filter、find、reduce、forEach等都是高阶函数,高阶函数是用来抽象通用的问题,而抽象可以帮我们屏蔽细节,只需要关注与我们的目标,我们常见的高阶函数有forEach,map,filter,every,some等。
// 面向过程的方式
let array = [1, 2, 3, 4]
for (let i = 0; i < array.length; i++) {
console.log(array[i])
}

// 高阶高阶函数
//可以看出高阶函数帮我们屏蔽细节
let array = [1, 2, 3, 4]
forEach(array, item => {
console.log(item)
})

2.闭包

  • 函数和其周围的状态(词法环境)的引用捆绑在一起形成闭包。
  • 可以在另一个作用域中调用一个函数的内部函数并访问到该函数的作用域中的成员。 闭包一定是函数对象,简单来说就是闭包就是能够读取其他函数内部变量的函数。闭包最大用处有两个:在函数外可以读取函数内部的变量;让这些变量的值始终保持在内存中。但同样的闭包会使得函数中的变量被保存在内存中,增加内存消耗,不能滥用闭包,否则会造成网页的性能问题,在低版本IE中还可能导致内存泄露。
function add(){
    var n = 5;
    //这里就是在函数内返回了另一个函数,并且访问了其内部成员变量n,形成了闭包
    return function fn2() {
        n++;
        return n;
    }
}
var fn = add();
    console.log( fn() );//6
    console.log( fn() );//7
    console.log( fn() ); //8

3.纯函数

  • 相同的输入永远会得到相同的输出,而且没有任何可观察的副作用。
  • 纯函数就类似数学中的函数(用来描述输入和输出之间的关系),y = f(x)。

我们先来看一段代码:

// 纯函数
function getArea (r) {
  console.log(r)//这里会被打印3次,记住这里我们等会来改造下,做个缓存,这也是纯函数的优势所在
  return Math.PI * r * r
}
console.log(getArea(4))//50.26548245743669
console.log(getArea(4))//50.26548245743669
console.log(getArea(4))//50.26548245743669
// 不纯的函数
let numbers = [1, 2, 3, 4, 5]
numbers.splice(0, 3)
// => [1, 2, 3]
numbers.splice(0, 3)
// => [4, 5]
numbers.splice(0, 3)
// => []

可以看出对于纯函数来说有着相同输入必有相同输出的特性,所以当调用多次纯函数时,可以缓存起来,提高性能。我们来改造下上边的getArea函数,做个缓存。看看纯函数是怎么做缓存,提高性能的。

    //首先实现一个缓存的方法
function memoize(f) {
  let cache = {}
  return function () {
    let key = JSON.stringify(arguments)
    cache[key] = cache[key] || f.apply(f, arguments)
    return cache[key]
  }
}

function getArea(r) {
  console.log(r) //这里现在只会被执行一次了
  return Math.PI * r * r
}

let getAreaWithMemory = memoize(getArea)
console.log(getAreaWithMemory(4))//50.26548245743669
console.log(getAreaWithMemory(4))//50.26548245743669
console.log(getAreaWithMemory(4))//50.26548245743669

好了,从上边的例子我们也能看出来,纯函数本身是不具备缓存功能了,只是因为我们知道了纯函数对相同的输入始终有相同的结果,所以可以把纯函数的结果缓存起来,下次对于相同的输入,直接给结果就好,不用去计算了。接下来我们看看纯函数的副作用指的是什么:

// 不纯的
let mini = 18
function checkAge1 (age) {
return age >= mini
}
// 纯的(有硬编码,后续可以通过柯里化解决)
function checkAge2 (age) {
let mini = 18
return age >= mini
}

可以看出上边的checkAge1即使保证了相同的输入,也不一定能保证相同的输出,它的输出还依赖其外部变量mini,这就是副作用。如果函数依赖于外部的状态就无法保证输出相同,就会带来副作用。我们常见的副作用来源大概有配置文件,数据库,获取用户的输入等。所有说副作用是不可能避免的,因为代码难免会依赖外部的配置文件、数据库等,只能最大程度上控制副作用在可控的范围内发生。

4.柯里化

  • 当一个函数有多个参数的时候先传递一部分参数调用它(这部分参数以后永远不变)。
  • 然后返回一个新的函数接收剩余的参数,返回结果。

接下来我们来解决上一节中的硬编码问题:

function checkAge (age) {
let min = 18
return age >= min
}
// 普通纯函数
function checkAge (min, age) {
return age >= min
}
checkAge(18, 24)
checkAge(18, 20)
checkAge(20, 30)
// 柯里化
function checkAge (min) {
return function (age) {
return age >= min
}
}
let checkAge18 = checkAge(18)
let checkAge20 = checkAge(20)
checkAge18(
checkAge18(20)

可以看出柯里化可以让我们给一个函数传递较少的参数得到一个已经记住了某些固定参数的新函数。这其实是一种对函数参数的'缓存',让函数变的更灵活,让函数的粒度更小。我们甚至可以把多元函数转换成一元函数,可以组合使用函数产生强大的功能。接下来我们来自己实现一个柯里化函数。

function curry (func) {
//柯里化函数就是入参一个函数,然后传出一个柯里化后的函数以供调用
  return function curriedFn(...args) {
    // 判断实参和形参的个数
    if (args.length < func.length) {
      return function () {
      //这里其实是递归,如果实参个数和形参个数不一致就递归调用,直到
      //实参和形参一一对应,以下面的打印来说curried(1)(2, 3)这样子的入参就 
    //是要把它扁平化处理,变成curried(1, 2, 3)这样的入参方式,然后调用func
        return curriedFn(...args.concat(Array.from(arguments)))
      }
    }
    return func(...args)
  }
}

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

const curried = curry(getSum)

console.log(curried(1, 2, 3))
console.log(curried(1)(2, 3))
console.log(curried(1, 2)(3))

5.函数组合

  • 如果一个函数要经过多个函数处理才能得到最终值,这个时候可以把中间过程的函数合并成一个函数。
  • 函数就像是数据的管道,函数组合就是把这些管道连接起来,让数据穿过多个管道形成最终结果。
  • 函数组合默认是从右到左执行。 纯函数和柯里化虽然各有优势,但纯函数和柯里化很容易写出洋葱代码 h(g(f(x))),比如我们要获取数组的最后一个元素再转换成大写字母,那我们的第一写法肯定会想到_.toUpper(.first(.reverse(array)))这样,代码一层套一层。这个例子还是简单的,容易理解,有容易看出他们的逻辑关系,但在实际开发中,场景无疑复杂多了,如果代码这写成这样,想象一样,维护起来多痛苦。所以我们来看看用函数组合的方式怎么更优雅的解决上边的问题。
//组合函数
function compose (...fns) {
return function (value) {
return fns.reverse().reduce(function (acc, fn) {
return fn(acc)
}, value)
}
}
function first (arr) {
return arr[0]
}
function reverse (arr) {
return arr.reverse()
}
// 从右到左运行
let last = compose(first, reverse)
console.log(last([1, 2, 3, 4]))

怎么样,代码是不是看上去简洁易读多了,别看我上边写了一堆代码,但在实际开发中,我们可能就这let last = compose(first, reverse)这一句,相比于洋葱代码更简洁,也更容易理解维护。

6.函子

  • 函子:是一个特殊的容器,通过一个普通的对象来实现,该对象具有 map 方法,map 方法可以运行一个函数对值进行处理(变形关系)。
  • 容器:包含值和值的变形关系(这个变形关系就是函数)。 到目前为止已经已经学习了函数式编程的一些基础,但是我们还没有演示在函数式编程中如何把副作用控制在可控的范围内、异常处理、异步操作等。而函子就可以处理这些问题,接下来我们来看看函子的实现:

Functor 函子

// 一个容器,包裹一个值
class Container {
// of 静态方法,可以省略 new 关键字创建对象
static of (value) {
return new Container(value)
}
constructor (value) {
this._value = value
}
// map 方法,传入变形关系,将容器里的每一个值映射到另一个容器
map (fn) {
return Container.of(fn(this._value))
}
}
// 测试
Container.of(3)
.map(x => x + 2)
.map(x => x * x)

// 但是如果值如果不小心传入了空值(副作用),这个时候就会出错,所以我们引入MayBe 函子
Container.of(null)
.map(x => x.toUpperCase())
// TypeError: Cannot read property 'toUpperCase' of null

MayBe 函子

  • 我们在编程的过程中可能会遇到很多错误,需要对这些错误做相应的处理。
  • MayBe 函子的作用就是可以对外部的空值情况做处理(控制副作用在允许的范围)。
// MayBe 函子
class MayBe {
  static of (value) {
    return new MayBe(value)
  }

  constructor (value) {
    this._value = value
  }

  map (fn) {
    return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value))
  }

  isNothing () {
    return this._value === null || this._value === undefined
  }
}
// 传入具体值
MayBe.of('Hello World')
.map(x => x.toUpperCase())
// 传入 null 的情况
MayBe.of(null)
.map(x => x.toUpperCase())
// => MayBe { _value: null }

Either 函子

  • Either 两者中的任何一个,类似于 if...else...的处理。
  • 异常会让函数变的不纯,Either 函子可以用来做异常处理。
// Either 函子
class Left {
  static of (value) {
    return new Left(value)
  }

  constructor (value) {
    this._value = value
  }

  map (fn) {
    return this
  }
}

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

  constructor (value) {
    this._value = value
  }

  map (fn) {
    return Right.of(fn(this._value))
  }
}
function parseJSON (str) {
  try {
    return Right.of(JSON.parse(str))
  } catch (e) {
    return Left.of({ error: e.message })
  }
}
//正常值
let r = parseJSON('{ "name": "zs" }')
          .map(x => x.name.toUpperCase())
console.log(r)
//不正常值
let r1 = parseJSON('{ name: zs }')
console.log(r1)

IO 函子

  • IO 函子中的 _value 是一个函数,这里是把函数作为值来处理。
  • IO 函子可以把不纯的动作存储到 _value 中,延迟执行这个不纯的操作(惰性执行),包装当前的操作。
  • 把不纯的操作交给调用者来处理。

Task 函子

  • 可以作用于异步操作 例如读取文件信息.
const fp = require('lodash/fp')
class IO {
static of (x) {
return new IO(function () {
return x
})
}
constructor (fn) {
this._value = fn
}
map (fn) {
// 把当前的 value 和 传入的 fn 组合成一个新的函数
return new IO(fp.flowRight(fn, this._value))
}
}
let io = IO.of(process).map(p => p.execPath)
console.log(io._value())
// Task 处理异步任务
const fs = require('fs')
const { task } = require('folktale/concurrency/task')
const { split, find } = 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(split('\n'))
  .map(find(x => x.includes('version')))
  .run()
  .listen({
    onRejected: err => {
      console.log(err)
    },
    onResolved: value => {
      console.log(value)
    }
  })

Monad 函子

  • Monad函子可以扁平化 以函子为返回值的函子。
  • Monad 函子内部封装的值是一个函数(这个函数返回函子),目的是通过 join 方法避免函子嵌套。
const fp = require('lodash/fp')
// IO Monad
class IO {
static of (x) {
return new IO(function () {
return x
})
}
constructor (fn) {
this._value = fn
}
map (fn) {
return new IO(fp.flowRight(fn, this._value))
}
join () {
return this._value()
}
flatMap (fn) {
return this.map(fn).join()
}
}
let r = readFile('package.json')
.map(fp.toUpper)
.flatMap(print)
.join()