函数式编程及其特性

2,109 阅读9分钟

介绍

函数式编程(Function Programming, FP)是编程范式(编程范式是指计算机中编程的典范模式或方法)之一,我们常听说的编程范式还有面向对象编程,面向过程编程.

那么什么是函数式编程呢? 我们对比一下OO(面向对象)编程与FP(函数式编程)

  • OO思维方式: 把现实中的事物抽象成程序世界中的 类和对象,并且通过封装、继承多态来演示事物之间的联系.
  • FP思维方式: 把现实世界中事物与事物之间的联系抽象到程序世界中,也就是对过程进行封装.
    • FP是一个纯函数,相同的输入始终要得到相同的输出
    • FP用来描述数据(函数)之间的映射
    • FP中的函数指的不是程序中的函数(方法),而是数学中的函数即映射关系,例如:y= sin(x),x和y的关系 我们看一段简单的代码:
// 非函数式
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)

上面的代码中我们可以很清晰的看到 非函数式 就是一个编程代码的过程,而FP便是将其进行封装.
了解其编程思想时候接下来我们来看一下函数式编程的一些特性,包括纯函数,柯里化等

纯函数

介绍纯函数之前我们先来了解一下什么是函数: 我们都知道函数是一等公民,为什么呢? 因为函数可以作为值保存到变量或数组中,还可以作为另外一个函数的参数和返回值.
纯函数概念
相同的输入永远得到相同的输出,不会有任何副作用(什么是副作用会在下面介绍) 类似于数学中的映射关系 y = sin(X) image.png

  1. js 中的 slice 就是一个纯函数, slice就是截取当前数组中某些值并返回,但是不会改变原数组
  2. js 中的 splice 就不是一个纯函数, 首先它改变了原数组的值,如果你多次对同一个数组调用splice得到的是不同的,这就是不纯的函数带来的副作用 那么纯函数有什么作用(好处)呢?
  3. 可缓存 因为相同的输入始终有相同的输出,所以可以将函数返回的值保存起来
  4. 可测试 纯函数让测试更方便
  5. 并行处理 若不是js语言,像java,多线程并行处理共享数据很容易导致数据出错, 纯函数永远不会改变输入的值,也就不会出现问题.

副作用

看一段代码

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

像第一个函数内部有引入外部变量,会导致每次执行同一个checkAge可能会得到不同的值. 如果一个函数依赖外部的状态就无法保证输出,就会带来副作用.
并且副作用使得函数的延展性减弱,但是副作用不可能完全禁止, 我们可以控制它在一定的范围内.

柯里化

我们来看一个场景

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

这里有一个checkAge方法来检查某个人的年龄是否大于某个年龄 例如这里有5个人, 其中3个人都是 18岁, 检查其中一个是否大于20,一个是否大于22,还有一个是否大于33 那么我们就是调用

checkAge(18, 20)
checkAge(18, 22)
checkAge(18, 33)

可以看到的是18我们每次都在重复的写,柯里化的作用便是简化这一操作

function checkAge(age) {
  return function (min) {
    return age >= min
  }
}
const check18 = checkAge(18)
check18(22)
check18(30)
check18(33)

使用也比较方便,通过保存一个18岁的人,每次检查min就行.
柯里化 当一个函数有多个参数的时候先传递一部分参数调用它(这部分参数以后永远不变),然后返回一个新的函数接收剩余的参数,也可以再返回部分参数作为新的函数,直到参数全部传递完成,返回结果.

const _ = require('lodash')
// 要柯里化的函数
function getSum (a, b, c) {
return a + b + c
}
// 柯里化后的函数
let curried = _.curry(getSum)
// 测试
curried(1, 2, 3)
curried(1)(2)(3)
curried(1, 2)(3)

这里我们拿 lodash中的柯里化举例子,如下是模拟实现柯里化

function myCurried(fn) {
  // 声明数组保存参数
  let arr = []
  // 通过 fn.length可以获取fn需要接受参数的个数
  let len = fn.length
  return function a(...args1) {
    // 每次接受到参数之后push到 arr
    arr.push(...args1)
    // 当数组中参数的个数小于len,那么再次返回该函数(递归)
    if (arr.length < len) {
      return a
    }
    // 当参数个数与len相等调用fn并返回结果
    return fn(...arr.splice(0))
  }
}

总结:

  1. 柯里化能够传递较少参数并返回一个‘记住’了部分参数的函数
  2. 柯里化是对参数的一种 ‘缓存’
  3. 柯里化让函数更细粒化,更灵活

高阶函数

  • 可以把函数作为参数传递给另一个函数
  • 可以把函数作为另一个函数的返回结果 注意: 以上两者满足其一就是高阶函数
    函数作为参数
// forEach
function forEach(array, fn) {
  for (let i = 0; i < array.length; i++) {
    fn(array[i])
  }
}

foreach内部简单实现,将 fn作为参数传递进去,在foreach内部执行了,这就是高阶函数,之前憨憨的我以为只有返回函数的才是高阶函数
返回值作为参数

// once
function once(fn) {
  let done = false
  return function () {
    if (!done) {
      done = true
      return fn.apply(this, arguments)
    }
  }
}

let pay = once(function (money) {
  console.log(`支付:${money} RMB`)
})
// 只会支付一次
pay(5)
pay(5)
pay(5)

使用高阶函数的好处:

  1. 屏蔽函数内部处理的细节,只需关注我们要关注的目标
  2. 高阶函数通常用抽象通用的问题.
  3. 常用的高阶函数forEach、map、filter、every、some、find/findIndex、reduce、sort、……

闭包

闭包在很久之前的文章有介绍过,这里再简单说明下
闭包 (Closure):函数和其周围的状态(词法环境)的引用捆绑在一起形成闭包。 可以在另一个作用域中调用一个函数的内部函数并访问到该函数的作用域中的成员

// 函数作为返回值
function makeFn () {
let msg = 'Hello function'
return function () {
console.log(msg)
}
}
const fn = makeFn()
// 这里调用fn()的时候其实就会有闭包,内部函数访问到了外部函数的变量, msg由于fn的调用不能被释放
fn()

闭包的本质:函数在执行的时候会放到一个执行栈上当函数执行完毕之后会从执行栈上移除,但是 堆上的作用域成员因为被外部引用不能释放,因此内部函数依然可以访问外部函数的成员

函子(Factor)

为啥要知道函子,我们目前了解了一些函数式编程的基础,但是还不知道如何把副作用控制在可控范围内的、异步操作、异常处理等. 这个部分选读,本人了解也不深,,,

什么是函子

函子可以说是一个容器,包含值和对值的处理(这个处理就是函数) 函子是一个特殊的容器,通过一个对象实现,该对象具有map方法,map方法可以运行一个函数,改变传递的值.

factor 函子

// 一个容器,包裹一个值
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)
  • 函数式编程的运算不直接操作值,而是由函子完成
  • 函子就是一个实现了 map 契约的对象
  • 我们可以把函子想象成一个盒子,这个盒子里封装了一个值
  • 想要处理盒子中的值,我们需要给盒子的 map 方法传递一个处理值的函数(纯函数),由这 个函数来对值进行处理
  • 最终 map 方法返回一个包含新值的盒子(函子) factor有个缺陷就是当传入的值为null,然后再通过map操作就会报错,例如
// 值如果不小心传入了空值(副作用)
Container.of(null)
.map(x => x.toUpperCase())
// TypeError: Cannot read property 'toUpperCase' of null

MayBe 函子

我们在编程的过程中可能会遇到很多错误,需要对这些错误做相应的处理
MayBe 函子的作用就是可以对外部的空值情况做处理(控制副作用在允许的范围) 处理起来也比较简单,只需要加一个判断就行,如果为null或者undefined则返回空

// MayBe 函子
class MayBe {
  static of (value) {
    return new MayBe(value)
  }

  constructor (value) {
    this._value = value
  }
// 如果对空值变形的话直接返回 值为 null 的函子
  map (fn) {
    return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value))
  }

  isNothing () {
    return this._value === null || this._value === undefined
  }
}

在MayBe函子中很难确认是哪一步出现了错误,例如

MayBe.of('hello world')
.map(x => x.toUpperCase())
.map(x => null)
.map(x => x.split(' '))
// => MayBe { _value: null }

Either 函子

  • Either 两者中的任何一个,类似于 if...else...的处理
  • 异常会让函数变的不纯,Either 函子可以用来做异常处理
// 返回原来的函子,用来捕获异常
class Left {
  static of (value) {
    return new Left(value)
  }
  constructor (value) {
    this._value = value
  }
  map (fn) {
    return this
  }
}
// 用来在没有异常的时候执行输入的fn并返回一个函子
class Right {
  static of (value) {
    return new Right(value)
  }
  constructor (value) {
    this._value = value
  }
  map(fn) {
    return Right.of(fn(this._value))
  }
}

解决maybe函子不能确定null出现位置的函子。不仅处理什么地方出现的null,也可以用来获取异常出现的位置和异常的信息。例如


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

let r = parseJSON('{ name: zs }')
// r是异常会输出异常,r正常则输出正常的值
console.log(r)

IO 函子

  • IO 函子中的 _value 是一个函数,这里是把函数作为值来处理
  • IO 函子可以把不纯的动作存储到 _value 中,延迟执行这个不纯的操作(惰性执行),保证当前的操 作纯
  • 把不纯的操作交给调用者来处理
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())

总结:

函数式编程是随着 React 的流行受到越来越多的关注, vue3也开始拥抱函数式编程,函数式编程可以抛弃this,更好的 tree shaking,有很多 库也可以去了解一下,像 loadsh中的 'lodash/fp' 全部都是函数式编程.