针要学前端 | JavaScript深度挖掘之函数式编程

296 阅读11分钟

大家好,我是指针。冬天到了,人也变懒了,为了让自己动起来,我报名参加了拉勾教育的大前端高薪训练营。学习需要总结,需要分享,需要鞭策,于是便有了《针爱学前端》这一系列,希望大家看完能够有收获。如果文章中有不对的地方,希望能批评指正,不吝赐教!!!

函数式编程是甚么

今天突然有人问我,指老师发生甚麽事啦,我说怎么回事,给我发了一段视频,我一看!嗷!源赖氏函数式编程。我啪的一下就解释了起来,很快啊!

其实说来也简单,函数式编程是一种编程的思想,注重于对运算过程的抽象。类似于数学函数之间的映射:y = f(x),我只知道我输入x,要始终得到y,其中的计算过程抽象成f函数。

这么说还是有点绕啊,通俗的说,你需要计算a+b的和,c+d的和,你发现自己需要写两遍一模一样的相加逻辑,于是你选择将相加的逻辑写成了一个方法叫add。这样以后就算叫你计算e+f的和你也可以直接调用add方法,这其实就是函数式编程的思想,我们有时在工作中早就无形的使用到了它,只是不知道如何去描述它而已。

// 非函数式
let num1 = 1
let num2 = 2
let sum = num1 + num2
console.log(sum)

// 函数式
// 我们将计算过程封装在了一个方法里,add(x)的每次结果必然是相同的
// 但是我们却不必每次写x=>y的逻辑,因为我们已经抽象成立方法add
function add (a, b) {
    return a + b
}
let sum = add(1, 2)
cosole.log(sum)

为什么要学习函数式编程

  • 用的越来越多,reactvue3都在用,用了都说好!
  • 打包过程可以更好的利用tree shaking过滤无用代码(深了啊)。
  • 生态也不错,有很多现成的库使用,如lodashuderscoreramda
  • 代码重用率高,开发快速。
  • 函数式编程不依赖、也不会改变外界的状态,只要给定输入参数,返回的结果必定相同。因此,每一个函数都可以被看做独立单元,很有利于进行单元测试和除错,以及模块化组合。
  • 函数式编程不修改变量,所以多个方法可以同时执行,而不必担心一个方法的数据被另一个方法修改,也就是方便并行处理。

函数式编程的知识点

  • 函数是一等公民

    如果经常刷题的小伙伴应该下面的题目不陌生,这道题考的就是JavaScript中函数是一等公民的知识。

    // 问打印什么
    var a = 1
    console.log(a)
    var a = function () {
        
    }
    

    函数是一等公民的三个特性

    • 函数可以储存在变量中

    • 函数可以作为参数

    • 函数可以作为返回值

      // 在JavaScript中,函数就是一个普通对象(可以通过new Function()生成)
      // 既然是变量,那便可以赋值给变量,作为参数,作为返回值
      
      // 把函数赋值给变量
      let print = function (x) {
          console.log(x)
      }
      print("print")
      
      // 把函数作为参数(模拟foreach)
      function myForEach (array,fn) {
          for(let item of array) {
              fn(item)
          }
      }
      let arr = ["my", "name", "is", "zhizhen"]
      myForEach(arr, print)
      
      // 把函数作为返回值
      function returnFn () {
          let msg = "hello lagou"
          return print(msg)
      }
      
  • 高阶函数(Higher-order function)

    什么是高阶函数
    • 参数可以是函数
    • 返回值可以是函数
    高阶函数的意义

    抽象的说就是:帮助我们抽象函数实现的细节,只需要我们关注输入与输出

    通俗的说就是:你需要遍历1个数组,然后打印,你只需要将打印的方法作为参数传到一个新的方法里,那么那个新的方法里就不需要再关注打印的方法了,只需要关注传入的数组就行。

    // 面向过程编程
    let array = [1, 2, 3, 4]
    for(let item of array) {
        console.log(item)
    }
    
    // 高阶函数
    function print (item) {
        cosole.log(print)
    }
    function map (arr, fn) {
        for(let item of arr) {
            fn(item)
        }
    }
    map(array, print)
               
    

    es6中有许多高阶函数

    • forEach
    • map
    • filter
    • ...
  • 闭包(Closure)

    闭包,作为一个JavaScript的特性,是面试官最爱问的问题之一,关于它的总结网上有好多,我也说说我的总结,不一定百分百准确。

    A函数返回了B函数,在B函数中访问了A函数作用域中的参数,导致A函数一直无法被销毁。这样的一种情况就是闭包。

    // 借用闭包,我们可以实现一个一次性函数,不需要使用外部变量的那种
    function once (fn) {
        let done = false
        return function () {
            if(!done) {
                done = true
                // apply的第二个参数为数组,fn.apply()可以使fn使用匿名函数传递的参数
                // 当然,如果使用es6的语法就不用这么麻烦了
                return fn.apply(this, arguments)
            }
        }
        
        // es6语法返回
        // return (...args) => {
            // if(!done) {
                // done = true
                // return fn(...args)
            // }
        // }
    }
    
    let oncePrint = once(print)
    // 只会打印一次
    // 因为once中的done在第一次过后便被置为true,且因为返回的匿名函数一直在使用done
    // 导致once无法被回收,done一直是true
    oncePrint("hello lagou")
    oncePrint("hello lagou")
    

    上一个例子,done是once里定义的,我们也可以活用它,看下面的例子

    // 现在过年了,你需要群发拜年短信,现在有两种人,一种是上司,一种是下属
    // 两者发的短信内容是不一样的
    // 让我们用闭包去模拟一下
    function sayHi (type) {
        let msg = ""
        if(type == 1) {
            msg = "您忠诚的下属指针祝您新的一年万事大吉!"
        }else {
            msg = "还不快来给我拜年,小兔崽子"
        }
        return function (name) {
            return name + msg
        }
    }
    let toBoss = sayHi(1)
    let toSmallRabbitBaby = say(2)
    toBoss("张三")
    toBoss("李四")
    toSmallRabbitBaby。。。我忘了我没有下属了,再见:(
    

函数式编程的实现

  • 纯函数

    • 纯函数的概念

      相同的输入永远会得到相同的输出,而且没有任何可观察的副作用,这样的函数就是纯函数。

      let arr = [1, 2, 3, 4]
      
      // slice函数就是纯函数
      // 它执行多少次都会返回一样的数组
      arr.slice(0, 3)
      
      // splice不是纯函数
      // 它有一个可观察的副作用:改变了原数组。
      // 即使[1, 2, 3, 4].splice(0, 3)每次返回的数组都一样
      arr.splice(0, 3)
      
    • 纯函数的好处

      相同的输入,永远会得到相同的输出,所以我们可以将输出结果缓存起来,当遇到相同的请求时,直接返回缓存中的结果即可,节省资源。

      函数式编程的好处里提到的方便测试以及并行处理,就是因为纯函数不依赖于外部参数,也不改变外部参数。

      // 手写一个缓存函数
      function memoize (f) {
          let caches = {}
          return function () {
              let argString = JSON.stringify(arguments)
              console.time("memoize")
              caches[argString] = caches[argString] || f.apply(this, arguments)
              console.timeEnd("memoize")
              return caches[argString]
          }
      }
      memoize(5)
      memoize(5)
      
    • 副作用

      副作用就是会导致纯函数不纯的作用,一个是依赖外部参数,一个是改变外部参数

      // 依赖外部参数
      // 如果外部参数num发生改变,checkAge的输出也会发生改变
      let num = 18
      function checkAge (age) {
          return age > num
      } 
      
      // 改变外部参数
      // 改变了外部参数arr,如果此时另一个线程在使用或者改变arr,就会发生冲突
      arr.splice(0, 3)
      

      副作用无法完全禁止,我们将在函子中学会如何尽可能控制副作用在可控范围内发生

  • 柯里化(Curry)

    当一个函数有多个参数时,可以将它转化成先接受一部分参数,并且固化,以后永远不变,再返回一个新的函数用于接收剩下的参数。

    例子:add(1, 2, 3) => curryadd(1)(2)(3)

    让我们使用柯里化来控制一些副作用吧

    // 上例中的checkAge方法可以先优化成一般的纯函数
    // 当然存在硬编码问题(num初始就赋值)
    function checkAge (age) {
        let num = 18
        return age > num
    }
    
    // 使用柯里化
    function curryCheckAge (num, age) {
        return function (age) {
            return age > num
        }
    }
    // es6的写法
    let curryCheckAge = num => (age => age > num)
    // 先传入num,并且以后保持不变,再返回一个新的函数,接受剩下的参数
    let checkAge18 = curryCheckAge(18)
    checkAge18(20)
    

    看上去似乎很简单,但是现在的问题是,函数的参数数量不一定是两个,有可能是3个,4个或者更多,这时候就需要一个真正的柯里化方法,当然,你可以使用lodash等库里现成的方法,这里我们自己实现一个

    function myCurry (fn) {
        let paramsList = []
        return function curryFn(...args1) {
            // 比较传入的参数个数和fn应该传入的参数个数比较
            // 如果相等,直接执行fn(...args)
            // 如果小于,需要再返回一个新的函数,合并参数,比较参数个数
            if(args1.length < fn.length) {
                return function (...args2) {
                    return curryFn(...args1.concat(...args2))
                }
            }
            return fn(...args1)
        }
    }
    
    function add (a, b, c) {
        return a + b + c
    }
    let curryAdd = myCurry(add)
    console.log(curryAdd(1)(2)(3))
    console.log(curryAdd(1, 2)(3))
    console.log(curryAdd(1)(2, 3))
    

    总结

    • 柯里化实际是对函数参数的一种缓存
    • 可以使函数颗粒度更小
    • 可以把多元函数转换成一元函数,用于下面的函数组合
  • 函数组合

    函数组合可以让我们把细粒度的函数组合成一个新的函数,每个函数的返回作为下一个函数的参数传入

    例子:h(g((x)))=> compose(h, g, f)(x)

    函数的组合还要符合结合律:compose(h,g,f) === compose(h, compose(g, f))

    这个函数组合呢,可以使用已有库里的,当然也可以自己撸一个,本着自己动手丰衣足食的理念,开整

    // 利用了es6的reduce语法
    // fns反转是为了从右往左执行,更符合我们的习惯
    function myCompose (...fns) {
        return function (value) {
            return fns.reserve().reduce(function(acc, fn) {
                return fn(acc)
            }, value)
        }
    }
    
    // 如果不用reduce
    function myCompose (...fns) {
        return function (value) {
            let r = value
            fns = fns.reverse()
            while (fns.length) {
                r = fns[0](r)
                fns.shift()
            }
            return r
        }
    }
    
  • Point Free

    无值风格:不需要多个运算的中间值,只合成运算过程

    将多个函数组成一个复杂的运算,中间的数据不需要知道

    // 将一个数先加一,再取平方,再减去100
    // 我们不需要知道x+1为多少,也不需要x+1的平方是多少
    // 直接将运算合在一起
    let addAndSquare = myCompose(minus, squre, add)
    addAndSquare(5)
    
  • 函子(Functor)

    之前纯函数时提到了副作用,函子就是控制副作用的

    • 什么是函子
      • 函子是函数式编程里面最重要的数据类型,也是基本的运算单位和功能单位。

      • 函子是一个特殊的容器,通过一个普通的对象实现,这个对象里拥有一个map方法来处理值,理论上拥有map方法的对象都可以是函子。

      • 函子里的值我们不直接操作,而是通过map方法传递一个处理值的函数

      • map最终返回一个新的函子,包含新的值

    • Functor函子
      class Container {
          constructor (value) {
              this._value = value
          }
          map (fn) {
              return new Container(fn(this._value))
          }
      }
      
    • Pointed函子

      Pointed函子实现了of静态方法,用于初始化对象,避免使用new来创建对象,因为new是面向对象编程思想,与函数式编程思想不符。

      class Container {
          static of (value) {
              return new Container(value)
          }
          constructor (value) {
              this._value = value
          }
          map (fn) {
              return Container.of(fn(this._value))
          }
      }
      
    • MayBe函子

      MayBe函子就是处理空值的情况,控制副作用在允许的范围内

      class MayBe {
          static of (value) {
              return new MayBe(value)
          }
          constructor (value) {
              this._value = value
          }
          map (fn) {
              return this.isNull() ? MayBe.of(null) : MayBe.of(fn(this._value))
          }
          isNull () {
              return this._value === null || this._value === undefined
          }
      }
      
    • IO函子
      • IO函子中的_value是一个函数,这里将函数作为值来处理
      • IO函子将不纯的操作存储到_value中,延迟执行,最后调用的时候再执行
      • 如果IO初始包裹的是一个对象,则使用()=> x ,使得IO包裹的为一个函数
      class IO {
          constructor (fn) {
              this._value = fn
          }
          static of (x) {
              return new IO(x)
          }
          map (fn) {
              return new IO(myCompose(fn, this))
          }
      }
      
      let r = IO.of(() => process).map(p => p.execPath)
      let r2 = IO.of(readFile())
      
    • Monad单子

      Monda用于处理函子嵌套,它可以使函子扁平化

      它的重要作用就是实现IO操作

      class Monda extends IO {
          join () {
              return this._value()
          }
          chain (fn) {
              // 如果产生函子嵌套,就执行一次,取出之前函子的值,保证始终是个单层的函子
              return this.map(fn).join()
          }
      }
      
      // 读取文件操作和打印文件操作都是不纯的,因为他们依赖于读取的文件本身的内容
      // 所以我们将这两个方法包裹在函子中,使它们变纯
      function readFile (filename) {
          return Monda.of(function () {
              return fs.readFileSync(filename, 'utf-8')
          })
      }
      function print (x) {
          return Monda.of(function () {
              return x
          })
      }
      let r = readFile('package.json')
      			.chain(print)
      			.join()
      

导航

针要学前端 | JavaScript深度挖掘之函数式编程

针要学前端 | JavaScript深度挖掘之异步编程

针要学前端 | JavaScript深度挖掘之手写Promise

针要学前端 | JavaScript深度挖掘之ECMAScript

参考

以上皆由拉勾教育大前端训练营提供材料😀