函数式编程入门学习

220 阅读2分钟

函数式编程

函数式编程的最高要义就是函数组合,为了更方便的进行函数组合,需要函数具有纯函数、柯里化、pointfree等特征作为前提。

为什么要学习函数式编程

- React,vue3都在拥抱函数式编程,学习函数式编程,有助于我们更好的去学习流行框架
- 函数式编程可以不管面向对象编程中烦人的this指向问题
- 对打包过程中的tree shaking比较友好
- 方便测试
- 生态:有lodash, ramda,underscore等比较流行的函数式编程库可以直接使用

什么是函数是编程

- 面向对象编程:把现实世界中的事物和事物之间的联系,抽到到程序中的类和对象、通过封装,继承,多肽来描述事物之间的联系
  • 函数式编程:把现实世界中的事物和事物之间的联系,抽象到程序世界,对运算过程进行抽象
    • 程序的本质: 根据输入通过某种运算,得到特定的输出,是一种 y = f(x) 的运算关系
    • 相当的输入有相同的输出
    • 用函数来描述数据间的关系

前置知识

  • 函数是一等公民

    1. 可以赋值给一个变量

      const add = (a, b) => a + b
      
    2. 可以作为参数传递给另一个函数

      const map = (fn, array) => {
        const result = []
        for (let i = 0; i < array.length; i++) {
          result.push(fn(array[i], i))
        }
        return result
      }
      
      const result = map(v => v * v, [1, 2, 3, 4])
      console.log(result) // [ 1, 4, 9, 16 ]
      
    3. 可以作为另一个函数的返回值

      const once = function(fn) {
        let done = false
        return function() {
          if (!done) {
            done = true
            return fn.apply(this, arguments)
          }
        }
      }
      
      const pay = once((money) => {
        console.log(`支付了${money}元`)
      })
      pay(10) // 支付了10元
      pay(10) // 没有任何操作
      
  • 高阶函数

    一个函数接受一个函数作为参数,或者返回一个函数,那么这么函数就是一个高阶函数

    1. 接受一个函数作为参数
    function filter (fn, array) {
      const result = []
      for (let i = 0; i < array.length; i++) {
        if (fn(array[i], i)) {
          result.push(array[i])
        }
      }
      return result
    }
    
    const result1 = filter(v => v > 10, [1, 2, 11, 3])
    console.log(result1)
    
    1. 返回一个函数
    /**
     * 员工工资 = 基本工资(base) + 绩效工资(performance)
     * @param {基本工资} base 
     */
    function makeGetSalaryFn (base) {
      return function (performance) {
        return base + performance
      }
    }
    
    // 获取level1等级的员工工资
    const getLevel1Salary = makeGetSalaryFn(10000)
    
    // 获取level2等级的员工工资
    const getLevel2Salary = makeGetSalaryFn(15000)
    
    const salary1 = getLevel1Salary(2000)
    const salary2 = getLevel1Salary(1500)
    const salary3 = getLevel2Salary(1500)
    console.log(salary1, salary2, salary3)
    
  • 闭包

    函数a里面返回一个函数b, 函数b里面可以访问到函数a里面的成员变量

纯函数

纯函数的概念

1. 相同的输入永远会得到相同的输出
2. 不会有任何副作用(函数内部环境依赖外部变量)
3. 可以把一个函数得到的结果,传递给另外一个函数使用

纯函数的好处

  1. 可缓存
function getCircleArea (r) {
  console.log(r)
  return Math.PI * r * r
}

function memoize(fn) {
  const cache = {}
  return function() {
    const key = JSON.stringify(arguments)
    cache[key] = cache[key] ? cache[key] : fn(...arguments)
    return cache[key]
  }
}

const getMemoizeCircleArea = memoize(getCircleArea)
const area1 = getMemoizeCircleArea(4)
const area2 = getMemoizeCircleArea(4)
console.log(area1, area2)
// 4 只打印了一次
// 50.26548245743669 50.26548245743669
  1. 方便测试: 纯函数让测试更方方便
  2. 可并行处理: 在多线程下操作共享数据,很有可能出现意外的bug,纯函数应该相同的输入永远只会有相同的输出,所有各个线程可放心使用任何纯函数。

副作用

副作用让一个函数变的不纯,纯函数的基本上相同的输入永远对应相同的输出,如果函数依赖于外部的状态或修改了外部的状态,就无法保证输出相同,就会带来副作用。

  1. 依赖外部的变量

    const a = 1
    function add (number) { // 有副作用
      return a + 1
    }
    
  2. 修改外部或者传入的变量

    const arr = [1, 2, 3] 
    const config = { b: 1}
    function test (arr) {
      arr[1] = 0 // 副作用
      config.b = 2 // 副作用
    }
    

柯里化( Haskell Brooks Curry)

假设我们需要一个函数,来检查一个人的年龄是否超过18岁

function checkAge (age) {
	const mini = 18
  return age >= mini
}

以上代码会有一个硬编码的问题,就是mini已经写死在了checkAge 内部,那么我们改造一下checkAge

function checkAge (min, age) {
  return age >= mini
}
checkAge(18, 20) // true
checkAge(18, 17) // false

但是这样一来,我们每次掉用checkAge的时候,都需要传入传入18,有点麻烦,继续改造

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

const checkAge18 = checkAge(18) // 检查年龄是否大于18岁
const checkAge20 = checkAge(20) // 检查年龄是否大于20岁

console.log(checkAge18(20)) // true
console.log(checkAge20(17)) // false

这样,将checkAge需要的两个参数,分两次传入,第一次传入一个min返回一个函数,这个函数来接受剩下来的参数,代码的通用性和语义化都比较好。

lodash中的 curry

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

const fn = _.curry(getSum)
console.log(fn(1, 2, 3)) // 6
console.log(fn(1)(2)(3)) // 6
console.log(fn(1, 2)(3)) // 6
console.log(fn(1)(2, 3)) // 6

自己实现一个curry函数

// 1 接否一个被柯里化的函数fn
// 2 返回一个函数a
// 3 判断a被调用的时候传入的参数个数,如果参数个数跟fn的参数个数相同,则直接调用fn并返回对应的结果
// 4 如果不参数不同,则返回一个函数,因为这个函数记住了上一次传递的参数,所以,将上一次调用的参数和这一次的传入的参数合并,然就递归调用
function curry (fn) {
  return function curryFn(...args) {
    if (args.length < fn.length) {
      return function() {
        return curryFn(...args, ...arguments)
      }
    } else {
      return fn(...args)
    }
  }
}

总结:

1. 柯里化可以让我们给一个函数传递较少的参数得到一个已经记住了某些固定参数的新函数
2. 这是一种对函数参数的'缓存'
3. 让函数变的更灵活,让函数的粒度更小
4. 可以把多元函数转换成一元函数,一元函数可以更方便的进行函数组合

函数组合

管道

​ 函数的本质,就是数据的映射, y = f(x),有的时候处理一些比较复杂的逻辑的时候,f(x)里面的逻辑会比较多,这样不方便测试,也不方便代码的公用。但是,有个纯函数和柯里化的加持,我们可以将函数的粒度更加的细化,通过函数组合,将上一个函数的值,传递给下一个函数,然后,再有最后一个函数返回运算的结果。

函数组合

const fn = compose(f1, f2, f3), const result = fn(2),我们可以先将f1,f2,f3组合成一个新的函数fn,然后调用fn的时候再传入对应的数据。举个列子

lodash中的函数组合

// 题目:将字符串server side render,提取每个单词的首字母,并转换成大写,再合并在一起, 得到SSR
const fp = require('lodash/fp') 
const f = fp.flowRight(fp.join(''),fp.map(fp.first),fp.map(fp.toUpper) ,fp.split(/\s+/g))
console.log(f('server side render')) // SSR

以上是通过 lodash``fp模块中的相关函数来实现的,flowRight会从右向左执行,将上一个函数的值,传递给下一个函数,最后在返回最终的结果。我们可以看到我们调用了两次fp.map,就是循环了两次,于是,我们可以利用函数组合,将p.firstp.toUpper再一次组合在一起,这样就只有一次循环了。

const fp = require('lodash/fp')
const f = fp.flowRight(fp.join(''), fp.map(fp.flowRight(fp.first, fp.toUpper)) ,fp.split(/\s+/g))
console.log(f('server side render'))

自己实现一个compose函数

/**
 * 1 接受很多个函数
 * 2 返回一个函数
 * 3 从右向左执行这些函数,将上一个函数的执行结果,传递给下一个函数
 */
function compose(...args) {
  return function(value) {
    return args.reduce((acc, fn) => fn(acc), value)
  }
}

const f2 = compose(fp.join(''), fp.map(fp.flowRight(fp.first, fp.toUpper)), fp.split(/\s+/g))
console.log(f2('server side render')) // [ 'S,E,R,V,E,R,', ',S,I,D,E,', ',R,E,N,D,E,R' ]

完蛋了,我们希望得到的结果是 SSR,实际得到的结果却是[ 'S,E,R,V,E,R,', ',S,I,D,E,', ',R,E,N,D,E,R' ],因为中间经过了那么多个参数,我们怎么知道,在执行哪个函数的时候出了拐呢。这就引发了一个问题,组合函数如何调试?

调试

因为组合函数的特点,可以将上一个函数执行的结果,传递给下一个函数。那么我们可以写一个trace函数,来打印上一个函数式哪个函数,执行的结果是什么

const trace = (tag, value) => {
  console.log(`${tag}: ${value}`)
  return value
}

那么问题来了,我们函数组合的时候,只接受一个参数呀,怎么办?诶,这个时候,就可以用柯里化来改造我们的trace函数

const trace = _.curry((tag, value) => {
  console.log(`${tag}: ${value}`)
  return value
})
const f2 = compose(fp.join(''), trace('fp.map'), fp.map(fp.flowRight(fp.first, fp.toUpper)), trace('fp.split'), fp.split(/\s+/g))
console.log(f2('server side render'))
// fp.map: server side render
// fp.split: S,E,R,V,E,R, ,S,I,D,E, ,R,E,N,D,E,R
// [ 'S,E,R,V,E,R,', ',S,I,D,E,', ',R,E,N,D,E,R' ]

然后发现,居然先打印的fp.map,再打印的fp.split,原来是函数执行的顺序搞反了,然后我们修改compose函数

function compose(...args) {
  return function(value) {
    return args.reverse().reduce((acc, fn) => fn(acc), value)
  }
}

用es6的语法来改造一下,逼格更高一些,也就一行代码的事情。

const compose = (...args) => (value) =>  args.reverse().reduce((acc, fn) => fn(acc), value)

pointfree

我们可以把数据处理的过程定义成与数据无关的合成运算,不需要用到代表数据的那个参数,只要把简单的运算步骤合成到一起,在使用这种模式之前我们需要定义一些辅助的基本运算函数。

  • 不需要指明处理的数据
  • 只需要处理运算的过程

Functor 函子

Todo...

为什么要学习函子

什么是函子

Functor 函子

MayBe函子

Either函子

IO函子

Task异步任务

Pointed函子

Monad函子