函数式编程
函数式编程(Functional Programming,FP)是一种古老的概念,甚至早于第一台计算机的诞生,随着 react 的流行和 Vue3 的发布,函数式编程越来越受到关注。
现在的需求就是输出在网页上输出 “Hello World”。
可能初学者会这么写。
document.querySelector('#msg').innerHTML = '<h1>Hello World</h1>'
这个程序很简单,但是所有代码都是死的,不能重用,如果想改变消息的格式、内容等就需要重写整个表达式,所以可能有经验的前端开发者会这么写。
function printMessage(elementId, format, message) {
document.querySelector(elementId).innerHTML = `<${format}>${message}</${format}>`
}
printMessage('msg', 'h1', 'Hello World')
这样确实有所改进,但是仍然不是一段可重用的代码,如果是要将文本写入文件,不是非 HTML,或者我想重复的显示 Hello World。 那么作为一个函数式开发者会怎么写这段代码呢?
const printMessage = compose(addToDom('msg'), h1, echo)
printMessage('Hello World')
解释一下这段代码,其中的 h1 和 echo 都是函数,addToDom 很明显也能看出它是函数,那么我们为什么要写成这样呢?看起来多了很多函数一样。 其实我们是讲程序分解为一些更可重用、更可靠且更易于理解的部分,然后再将他们组合起来,形成一个更易推理的程序整体,这是我们前面谈到的基本原则。
可以看到我们是将一个任务拆分成多个最小颗粒的函数,然后通过组合的方式来完成我们的任务,这跟我们组件化的思想很类似,将整个页面拆分成若干个组件,然后拼装起来完成我们的整个页面。在函数式编程里面,组合是一个非常非常非常重要的思想。 好,我们现在再改变一下需求,现在我们需要将文本重复三遍,打印到控制台。
var printMessaage = compose(console.log, repeat(3), echo)
printMessage(‘Hello World’)
可以看到我们更改了需求并没有去修改内部逻辑,只是重组了一下函数而已。
为什么要学习函数式编程?
而当我们在进行应用程序开发时,应该遵循以下原则
- 可拓展性 —— 是否需要不断的重构代码来添加新的功能
- 可重用性 —— 是否写了很多重复的代码
- 易模块化 —— 当更改了一个文件,其他文件是否会受到影响
- 易于测试 —— 函数是否是便于测试的
- 易推理性 —— 代码是否晦涩难懂 而函数式编程的出现,恰好也解决了这些开发中的痛点。
函数式编程还有诸多好处,例如
- 函数式编程可以抛弃this
- 打包过程中可以更好的利用tree shaking 过滤掉无用代码
- 方便测试,方便并行处理
- 有很多库可以帮助我们进行函数式编程开发:比如lodash、underscore、ramda
什么是函数式编程?
函数式编程指对运算过程进行抽象,是一种强调以函数为主的编程方式,其中函数是一等公民,函数可像变量一样作为属性,作为参数,作为返回值。我们常听说的编程范式还有面向对象编程
-
面向对象编程——把现实世界中的事物抽象成类和对象,通过封装继承和多态来演示事物事件间的联系
-
函数式编程思维方式——把现实世界中事物间的联系抽象成函数(对运算过程进行抽象)
- 指的不是程序中的函数,而是数学中的映射关系
- 相同的输入总是得到相同的输出(纯函数)
- 用来描述数据之间的映射
- 细粒度,可以重用,可以组合 例如
// 非函数式编程
let num1 = 2
let num2 = 3
let sum = num1 + num2
console.log(sum)
// 函数式编程
function add (n1, n2) {
return n1 + n2
}
console.log(add(2,3))
在函数式编程中,函数是一等公民,可以作为参数,可以作为返回值。
函数作为参数
函数可以作为参数,例如下面的例子中,模拟foreach
// foreach
function foreach(array, fn) {
for(let i = 0; i < array.length; i++){
fn(array[i])
}
}
foreach(arr, (item) => { console.log(item) })
// filter
function filter (array, fn) {
let res = []
for(let i = 0; i < array.length; i++) {
if (fn(array[i])) {
res.push(array[i])
}
}
return res
}
const even = function (item) {
return item > 5
}
console.log(filter(array, even))
函数作为返回值
函数可以作为返回值 在支付系统中,不管点多少次按钮,函数都只执行一次。这里模拟lodash中的once函数,
// 函数作为返回值
// 模拟Once函数
function once(fn) {
let done = false
return function() {
if(!done){
done = true
return fn.apply(null, arguments)
}
}
}
const pay = once(function(money){
console.log(`支付了${money}元`)
})
pay(1)
pay(2)
pay(3)
// result: 支付了1元
在es6的高阶函数中,并不会展现函数实现的细节,这就是抽象通用的问题,比如数组的foreach函数就是对循环的抽象,filter函数是对数组筛选元素的抽象,函数式编程是声明的编程范式,对一系列操作进行描述而不会暴露操作本身。
// 非函数式编程
var array = [0, 1, 2, 3]
for(let i = 0; i < array.length; i++) {
array[i] = Math.pow(array[i], 2)
}
array; // [0, 1, 4, 9]
// 函数式编程
[0, 1, 2, 3].map(num => Math.pow(num, 2))
为什么我们要去掉代码循环呢?循环是一种重要的命令控制结构,但很难重用,并且很难插入其他操作中。而函数式编程旨在尽可能的提高代码的无状态性和不变性。要做到这一点,就要学会使用无副作用的函数--也称纯函数
纯函数
纯函数指没有副作用的函数,相同的输出总是有相同的结果。副作用指会使函数输出的结果不一致的情况。
lodash就是一个纯函数的功能库。
常常这些情况会产生副作用。
- 配置文件
- 数据库
- 改变一个全局的变量、属性或数据结构
- 改变一个函数参数的原始值
- 处理用户输入
- 抛出一个异常
- 屏幕打印或记录日志
- 查询 HTML 文档,浏览器的 Cookie 或访问数据库
所有外部的交互都有可能产生副作用,副作用也会使方法的通用性下降,不适合扩展和可重用性,同时副作用给程序中带来安全隐患,比如获取用户输入的时候可能带来跨站脚本攻击。但是副作用不可能完全禁止,我们要尽量让他在可控范围内发生
闭包
函数式编程中用到的一个重要概念就是闭包,一般函数在执行后内部变量会被释放,而闭包会将函数和其周围引用捆绑在一起,从而将变量保存在闭包中,延长了变量的作用范围。
简单来说就是当外部对内部成员有引用时就会形成闭包。
例如在函数作为返回值的once函数示例中的done参数,因为定义了变量pay引用了once返回的函数,从而导致once中的done属性没有被释放,延长了done的作用范围
闭包的本质
函数在执行的时候会被放到执行栈上,当函数执行完毕后会从执行栈上移除,而堆上的作用域成员由于外部变量对其有引用而导致不能被释放,因此内部函数依然可以访问外部函数的成员。
当once函数执行完毕后,once函数会从执行栈中移除,但是pay对外部函数的成员done有引用,导致done不会从堆内存中释放,从而可以访问done的值。
我们可以利用闭包写一个缓存的函数
function memoize(fn){
let cache = {}
return function() {
let args = JSON.stringify(arguments)
cache[args] = cache[args] || fn.apply(null,arguments)
return cache[args]
}
}
函数柯里化
柯里化是函数式编程的重点
比如我们有一个计算工资的函数,工程师有lv1 lv2两个等级,每个等级的基本工资是一样的,他们拿到的绩效是不同的,我们可以写一个函数来实现它
function computeSalary(wages, achievements) {
return wages + achievements;
}
const payJon = computeSalary(12000 + 2000)
const payJacky = computeSalary(12000 + 3000)
const payBob = computeSalary(15000 + 4000)
而正常情况下lv1 lv2的基本工资是一样的,会有大量重复的基本工资的参数,我们可以用上文讲到的闭包来改造它
const makeSalary = wages => (achievements => wages + achievements)
const payLV1 = makeSalary(12000)
const payLV2 = makeSalary(15000)
const payJon = payLV1(2000)
const payJacky = payLV1(3000)
const payBob = payLV1(4000)
当我们调用一个函数存在多个参数,那么可以调用这个函数传入部分参数,同时返回一个函数来接受剩余的参数并返回结果,这个过程称为函数的柯里化。
lodash中提供了一个柯里化的方法curry()
curry(func)
- 功能: 创建一个函数,接受一个或多个function的参数,如果function所需要的参数都被提供则执行function并返回执行的结果。否则继续返回该函数并等待接受剩余的参数。
- 参数: 需要柯里化的函数
- 返回值: 柯里化后的函数
const _ = require('lodash')
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
我们可以模拟一下柯里化函数实现的过程
const curry = (func) => {
return function curriedFn(...args){
if(args.length < func.length){
return function () {
// 继续调用curriedFn去接受剩余的参数
return curriedFn(...args.concat(Array.from(arguments)))
}
}
return func(...args)
}
}
当柯里化后函数的参数等于原函数接受的参数时,直接返回调用原参数的函数。
当柯里化后函数的参数比原函数参数少时,返回一个等待接受其他参数的柯里化函数,其中arguments是伪数组,把它转换成数组和之前的参数组合调用。
- 柯里化可以让我们给一个函数传递较少的参数得到一个已经记住了固定参数的函数
- 可以帮助我们缓存参数。
- 柯里化可以让函数变得更灵活,让函数的粒度更小
- 柯里化可以把多元函数变成一元函数,方便函数组合成更强大的函数
函数组合
使用细粒度的纯函数嵌套调用时更容易写出洋葱代码(类似h(g(f(x)))嵌套形式的代码),使用函数组合可以避免这种代码。函数组合要求函数为只接受一个参数的纯函数。
下面这张图表示程序中使用函数处理数据的过程,给fn输入参数a,返回结果b,可以想象a数据通过一个管道得到了b数据。
当fn比较复杂的时候,如果运算结果和预期不同,排查错误就比较麻烦,这个时候我们可以吧fn拆分成细粒度的多个fn,更方便的定位问题。
我们可以使用lodash中的组合函数flow()和flowRight(),使用比较多的是flowRight()
需要注意的是flowRight的调用顺序是从右向左执行,例如我们组合一个获取数组最后一个元素并转为大写的函数
const _ = require('lodash')
const reverse = array => array.reverse()
const first = array => array[0]
const toUpper = str => str.toUpperCase()
const fun = _.flowRight(toUpper,first, reverse)
flowRight方法会从右向左依次执行函数,然后将函数的返回值作为参数传递给下一个函数。
我们可以模拟一下flowRight的实现过程
function compose(...args) {
return function(value) {
return args.reverse().reduce(function(acc, fn){
return fn(acc)
}), value
}
}
// es6写法
const compose = (...args) => (value => args.reverse().reduce((acc,fn) => fn(acc), value))
先将组合函数的参数反转一下,然后使用reduce递归调用args中的方法,在reduce中将初始值设置为组合后函数传入的value,累加依次执行args中的方法。返回最终执行后的结果。
函数组合的结合律
函数组合是满足结合律的,就像数学中的结合律一样
let fn = compose(f, g, h)
let associative = compose(compose(fn, g), h) == compose(fn, compose(g, h))
// true
函数组合中的debug
之前说到拆分了函数的粒度更易于测试,那么我们如何调试我们的代码呢?
假设我们有一个需求是将'CHOO CHOO' 转换成 'choo-choo'我们可以利用函数组合实现
// CHOO CHOO => choo-choo
const _ = require('lodash')
const str = 'CHOO CHOO'
const split = _.curry((sep, str) => _.split(str, sep))
const join = _.curry((sep, array) => _.join(array, sep))
const compose = _.flowRight(join('-'), toLower, split(' '))
console.log(compose(str)) // c-h-o-o-,c-h-o-o
我们会发现得到的结果和我们预期的不一致,我们可以写一个调试函数,在我们想要调试的步骤插入调试函数来监控中间结果。
// 调试组合函数
const log = _.curry((tag, v) => {
console.log(tag, v)
return v
})
const compose = _.flowRight(join('-'), log('toLower'), _.toLower, log('split'), split(' '))
这样我们就可以发现错误在toLower的部分,toLower将数组转成了字符串,所以我们要将函数改成map函数,把数组每个元素toLower就可以了。
const toLower = _.curry((fn, array) => _.map(array, fn))
const compose = _.flowRight(join('-'), log('toLower'), toLower((item) => _.toLower(item)), log('split'), split(' '))
Lodash中的FP模块
我们开发过程中可能会用到Lodash中的方法,在进行函数式编程时,Lodash方法中有多个参数的情况,我们需要对其柯里化,这样就比较麻烦。Lodash中提供了FP模块,FP模块的函数都是被柯里化的,对函数式编程更加的友好。lodash的FP模块要求函数优先数据滞后。
我们可以使用lodash的FP模块重写上面的代码
// NEVER SAY DIE => never-say-die
const fp = require('lodash/fp')
const str = 'NEVER SAY DIE'
const compose = fp.flowRight(fp.join('-'), fp.map((item) => fp.toLower(item)), fp.split(' '))
Point Free
Point Free是一种编程风格,它的具体实现是函数的组合
我们可以把数据处理的过程定义成与数据无关的合成运算,不需要用到代表数据的那个参数,只要把简单的运算步骤合成到一起,在使用这种模式之前我们需要定义一些辅助函数。 比如把world wild web 转换成 W. W. W. 就可以使用PointFree风格
// world wild web => W. W. W.
const fp = require('lodash/fp')
const firstLitterToUpper = fp.flowRight(fp.join('. ') ,fp.map(fp.flowRight(fp.first, fp.toUpper)), fp.split(' '));