JavaScript的函数式编程简介

函数式编程的优点

  • React的推广、Vue3开始采用
  • 可以抛弃模拟面向对象编程的this
  • 打包时,利用sideEffects来过滤副作用代码
  • 方便测试,并行处理
  • lodash、underscore、ramda等库帮助开发
  • 与数据库查询语言的语法非常相似,擅长处理数据

函数式编程的概念

编程范式,对运算过程的抽象,将程序的本质(输入通过运算得到输出的过程)进行进一步的抽象,把运算过程中各种运算进行更加细粒度的划分为各种各样的函数,然后用函数的组合方式来抽象得到整个运算。这样有利于复用代码,灵活组合出不同的运算过程。
函数一词指的是数学中的函数定义,即输入和输出的映射关系

JavaScript中的函数

函数是一等公民

  • 用变量存储函数
  • 用函数作为参数
  • 用函数作为返回值

高阶函数

概念

高阶函数可以接收函数作为参数,可以返回函数作为返回值。
将通用的运算过程封装在高阶函数内部,通过传入其他函数作为参数来实现各种不同的处理结果。

常用高阶函数
  • forEach
  • map
  • filter
  • every
  • some
  • find/findIndex
  • reduce
  • sort

lambda表达式(即箭头函数)

(A) => B; // 形如该语法的就是lambda表达式,即ES6中的箭头函数,可以用相对简洁的语法来声明一个匿名函数 

因为函数的本质就是映射输入到输出,所以lambda表达式可以很清晰的显示这一点;

闭包Closure

概念

当函数调用之后,调用函数的外部环境依然有对函数内部成员的引用,此时内部成员就成为了闭包变量,被调用的函数不会释放内部成员的内存。一般都是从一个外部函数返回一个定义在其中的内部函数的场景。

纯函数

概念

相同的输入,相同的输出

优点
  • 返回结果可以缓存:只要输入不变,输出就不变,可以用缓存结果。Vue2.0中的计算属性就可以利用缓存。**lodash库中的memoize()**方法可以去缓存纯函数执行的结果。
  • 可测试
  • 并行处理:纯函数不访问共享数据,只依赖于传入的参数,因此可以在并行环境(web worker)下任意运行纯函数
副作用

函数的输出不仅依赖于内部环境,还依赖于外部环境,则此时函数无法保证输出只与输入相关,函数具有副作用,副作用代码就是依赖于外部环境的代码。

副作用来源

所有的外部交互都可能带来副作用

  • 配置文件
  • 数据库
  • 用户输入
副作用的问题
  • 降低函数的通用性
  • 给程序带来安全隐患
  • 给程序造成不易察觉的bug
副作用可控

副作用不可避免,要争取将副作用控制在一定范围内。

方法链式调用与函数管道化

链式调用方法

定义:可以对某一种数据类型进行连续的链式调用该类型的各种方法,从而对该数据进行很复杂的处理行为,并且使得函数式声明形式的代码更加易读。链式调用的前提是方法需要返回该数据的实例。
缺点:链式调用存在着紧耦合,也就是说链式调用的方法必须是该数据类型支持的方法,无法对该数据类型应用任意的函数来处理。

函数管道化

定义:将任意函数组合成有序的函数序列,前一个函数的输出作为后一个函数的输入,这样摆脱了数据类型与方法的耦合问题,使得对任意数据可以采用任意的函数来处理。这也是函数式编程重操作、轻数据的体现。
注意:为了实现管道化,被连接在一起的函数需要在参数数量(称为元数arity)和类型上互相兼容。

兼容条件
  • 类型:函数返回类型与接收函数的参数类型相匹配
  • 元数:接收函数必须声明至少一个参数才能处理上一个函数的返回值。元数被称为函数的length,函数的length属性可以拿到元数
单一参数的纯函数

函数的复杂性与函数的参数数量息息相关。 单一参数的纯函数简单,也满足函数的单一职责的编程原则。可以采用元组的数据结构(JavaScript没有元组的定义,需要自定义元组类型)来解决将多元参数变为一元参数的问题,或者采用函数的柯里化来将多元函数转换为一元函数。

柯里化(Haskell Brooks Curry)

概念

将多元函数转换为可以接收一元、二元等不定参数的函数,每次传入参数调用后返回一个新的函数,直到最终所接收的参数长度与原来函数长度一致时,才返回多元函数的调用结果。

  • 柯里化使得所有参数在被提供之前,可以挂起或延迟执行原函数
  • 柯里化使得原函数实现惰性求值,有效控制函数的执行时机

柯里化可以将函数参数进一步细粒化,由参数的值来定制不同的运算,提高函数的复用性。

lodash中的柯里化
_.curry(func)

将传入的func包装返回一个柯里化的函数curried。curried函数如果接收一部分参数(func函数的参数),则返回一个函数继续接收剩余参数;如果接收了全部参数,则返回调用结果。

模拟_.curry柯里化
function curry(func) {
  // 命名curried,方便后面进行递归调用;
  return function curried(...args) {
    // 对func的形参个数(函数的lenght属性)和柯里化后的函数传入的实参个数作比较
    if(args.length < func.length) {
	  return function() {
	    return curried(...args.concat(Array.from(arguments)));
      }
    } else {
      // 当实参个数不小于形参个数时,则调用func函数;
      return func(...args);
    }
  }
}
柯里化总结
  • 对函数参数进行了缓存
  • 函数颗粒度更小
  • 多元函数转换为一元函数,经过对这些一元函数的组合,可以构成更加丰富的函数

函数的组合compose

纯函数和柯里化容易形成洋葱代码,即函数之间层层嵌套h(g(f(x))),不易读懂。
函数组合让细粒度的函数重新组合生成新的函数,让代码更容易读懂。

函数组合的概念

将细粒度的函数组合起来成为一个新的函数,输入数据经由新的函数处理得到输出结果。如果把一个个小型函数比作小管道,那么新生成的函数即为拼接起来的大管道,输入经过大管道后输出。每个用于组合的函数都是一元函数。
函数组合一般默认从右向左依次执行。如fn = compose(f1, f2, f3),则fn的执行对应即为f3、f2、f1的顺序依次执行。这是为了和洋葱代码的嵌套顺序一致,比如前面的代码转换为洋葱代码即为f1(f2(f3())),也与赋值语句的顺序一致。

lodash中用于组合函数的方法
_.flowRight()
模拟_.flowRight()
function composeFromRight(...args) {
  // 接收value作为输入数据
  return function(value) {
    return args.reverse().reduce(function(accu, fn) {
      return fn(accu);
    }, value);
  }
}

使用箭头函数来改写整理一下

const composeFromRight = (...args) => value => args.reverse().reduce((accu, fn) => fn(accu), value);
函数组合的结合律

结合律指的是,可以先组合一部分函数生成fc,然后再将fc与其他函数组合直到没有剩余函数。最后的执行结果与组合的顺序无关。例如

// f1, f2, f3是等效的;
const f1 = compose(f, g, h);
const f2 = compose(compose(f, g), h);
const f3 = compose(f, compose(g, h));
函数组合的调试

经过组合生成的函数在执行中如果出现问题,则需要去定位问题出现在哪个一元函数上。通常在一元函数之间插入日志记录函数,用于输出一元函数的处理结果。

Lodash中的FP模块
  • 提供了函数式编程友好的方法
  • FP模块中的方法是不可变、auto-curried、 iteratee-first, data-last的方法,之所以数据最后传入,是因为数据是整个组合函数要处理的,数据从处理管道一直传递,所以需要接收数据为参数,返回数据,而处理函数则事先已经确定了,所以先传。
const _ = require('lodash');
// 传入参数时,先传数据,后传处理函数
_.map(['a', 'b', 'c'], _.toUpper); // ['A', 'B', 'C']

const fp = require('lodash/fp');
// 传入参数时,先传处理函数,再传数据
fp.map(fp.toUpper, ['a', 'b', 'c']);
// fp.map方法是自动被curried,可以参数都传入,也可以传入部分参数
fp.map(fp.toUpper)(['a', 'b', 'c']);
Lodash中的map与FP模块中的map
const _ = require('lodash');
_.map(['28', '6', '10'], parseInt); // [28, NaN, 2],原因在于每次遍历数组时,传递给parseInt的第二个参数是数组的索引

const fp = require('lodash/fp');
fp.map(parseInt, ['28', '6', '10']); // [28, 6, 10],原因在于传递给parseInt的参数只有一个,即数组元素。fp.map将传入的函数curried,此时parseInt就只接受第一个参数了。

PointFree风格

定义

PointFree风格即“无值风格”,也就是说,PointFree风格下的函数是对运算处理过程的抽象,本身与传入的数据无关。

  • 不需要指明需要处理的数据
  • 只需要合成运算
  • 需要定义一些辅助性质的基本运算函数
    通过PointFree模式可以提高函数的通用性。
参考

可以参考阮一峰老师的这篇文章Pointfree 编程风格指南

案例
// 目标:world wide web => W.W.W
const fp = require('lodash/fp');
const firstLetterToUpper = fp.flowRight(fp.join('.'), fp.map(fp.first), fp.map(fp.toUpper), fp.split(' ')); // 这里对数组做了两次遍历,性能会比较差;
firstLetterToUpper('world wide web'); // W.W.W
 // 两次遍历中的操作变为一次,这样只遍历一次数组即可。
const firstAndToUpper = fp.flowRight(fp.first, fp.toUpper);

函子functor

定义

函子是包裹值的容器,或者说是一种数据结构,其内部的值只能通过map方法来进行处理,然后返回一个新的同类型的函子。

意义

函子的出现是为了安全地操作数据,将操作数据的函数通过map方法传入,再返回一个新的函子,这样原来函子并未发生任何改变,对数据操作的函数如果抛出异常,则会被函子内部捕获,从而避免与外界交互产生副作用。

// 定义一个函子
class Container {
  constructor(value) {
    // _value私有属性
  	this._value = value;
  }
  map(fn) {
    // 通过fn处理内部值,然后传递给函子构造函数来返回一个新的函子,这类似于链式调用处理数据
    return new Container(fn(this._value));
  }
}
new Container(6).map(v => v + 2).map(v => v * v); // Container { _value: 64 }
// 不通过new来创建
class Container {
  // 静态方法of实际上是Monad函子的一种API规范
  static of(value) {
  	return new Container(value);
  }
  constructor(value) {
    // _value私有属性
  	this._value = value;
  }
  map(fn) {
    // 通过fn处理内部值,然后传递给函子构造函数来返回一个新的函子,这类似于链式调用处理数据
    return Container.of(fn(this._value));
  }
}
MayBe函子:处理传入空值的函子

外部在创建函子的时候,如果传入了空值,则会导致函子在处理数据时抛出异常。这种外部传入值的行为是一种副作用,我们需要对这种副作用控制在合理的范围内。

class MayBe {
  static of(value) {
    return new MayBe(value);
  }
  constructor(value) {
    this._value = value;
  }
  map(fn) {
    // 如果值为空值,则一直返回一个内部值为null的函子
    return this.isNothing()? MayBe.of(null): MayBe.fo(fn(this._value));
  }
  // 辅助函数,用于判断传入的值是否为空值
  isNothing() {
    return this._value === null || this._value === undefined;
  }
}

虽然MayBe函子可以处理内部值为空值的情况,使得不抛出异常,但如果数据处理中有一个环节出现了空值的情况,MayBe函子是无法得知哪个环节出现的问题,也无法记录出现的异常信息。

Either函子:

Either表示两者中的任意一个,因此需要定义两个函子。Either用来对传入数据进行异常记录,如果数据合法,则返回Either类的表示正常的子类Right,如果数据不合法,则返回Either类的表示异常的子类Left,该Left实例将一直携带异常数据传递下去。

// Either需要定义两个函子,一个函子用来记录异常,一个函子用来处理正常的情形,这两个函子都继承自Either函子
class Either {
  static of(value) {
    return value !== null? Right.of(value): Left.of(value);
  }
  // 实现传入数据的构造函数
  constructor(value) {
    this._value = value;
  }
}
// Left函子用来处理异常,如果给Left传入的数据出现非法值,则直接返回该Left实例
class Left {
  static of(value) {
  	return new Left(value);
  }
  map(fn) {
    // 注意,这里没有进行fn的调用;
    return this;
  }
}
// Right函子处理传入数据为合法值的情况
class Right {
  static of(value) {
  	return new Right(value);
  }
  map(fn) {
    return Right.of(fn(this._value));
  }
}

IO函子:用来处理不纯的IO操作
  • IO函子内部的_value属性是一个函数,把函数作为值来处理
  • IO函子把不纯的动作存储到_value中,延迟执行不纯操作(惰性执行)
  • 把不纯操作交给调用者来处理,定义run()方法来交给调用者执行。
const fp = require('lodash/fp');

class IO {
  static of(x) {
    return new IO(function() {
      return x;
    })
  }
  constructor(fn) {
    this._value = fn;
  }
  map(func) {
    // map返回的IO函子的_value为各种函数的组合,包括有传入的不纯操作函数;不纯操作的调用由外部来掌握
    return new IO(fp.flowRight(func, this._value));
  }
}
folktale函子

folktale是一个标准的函数式编程库,提供一些函数式的操作,如compose, curry等等,一些函子如Task, Either, MayBe等等

const { compose, curry } = require('folktale/core/lamda');
// curry()接收两个参数,第二个参数为函数,第一个参数为该函数需要接收的参数个数;
let fn = curry(2, (x, y) => x + y);
const { toUpper, first } = require('lodash/fp');
// func将传入的字符串数组的第一个元素转为大写;
let func = compose(toUpper, first);
folktale中的task函子处理异步任务

以folktale 2.0.3版本为例

const { task } = require('folktale/concurrency/task');
const { split, find } = require('lodash/fp');
const fs = require('fs'); // node的fs模块
// 读取文件的异步操作
function readFile(filename) {
  // task()方法返回一个task函子,给task()方法传入的参数为一个带有副作用的函数,
  // 在这里是一个读取文件的函数,在调用函子的run()方法时,才会执行副作用函数;
  // 这里的操作与Promise API非常相似
  return task(resolver => {
    fs.readFile(fileName, 'utf-8', (err, data) => {
      if(err) {
        resolver.reject(err)
      } 
      resolver.resolve(data);
    })
  });
}
// listen()方法监听异步操作的状态,并传入异常状态和完成状态的回调函数
// 与Promise的then()方法相似
readFile('package.json')
  .map(split('\n')) // 将split('\n')这个curried函数存储起来
  .map(find(v => v.includes('version'))) // 将find(v => v.includes('version'))这个curried函数存储起来
  .run() // 执行组合函数,按顺序先开始执行异步操作,然后执行上面存储的两个函数
  .listen({ // 监听执行的状态
  onRejected: (err) => {
    console.log(err);
  },
  onResolved: (data) => {
    console.log(data);
  }
})
Pionted函子:实现了of 静态方法的函子,of静态方法就是将值放入一个容器中,用来代替构造函数。

of方法用来把值放到上下文context中,即创建一个包含值的容器。

Monad函子

解决函子嵌套的情况,使得扁平化。函子嵌套指的是函数func接收函子a作为参数并返回a,然后将这个函数func作为函子b的参数,这样函子b._value为函数func, 调用func()返回函子a;数据的处理形式可能就会变为b._value()._value()这样的形式;
具有join和of两个方法且遵守一定规律的函子即为Monad函子

// join方法调用_value()来返回函子,从而解除了函子嵌套;
// 这里的join方法只解除了一层嵌套,可以使用递归的方式来解除多层嵌套,只留下嵌套最深的函子
join() {
  return this._value();
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章