函数式编程核心

331 阅读11分钟

函数式编程核心

什么是函数式编程

  • 函数式编程是一种编程范式,将面向过程编程的过程进行封装(就是对运算的过程进行抽象)。
  • 根据输入通过一些运算的封装得到对应的输出,或者对数据的处理。
  • 函数式编程中的函数指的不是程序中的函数Function,而是数学中的函数即映射关系,例如:y=sin(x),是这种x和y的关系
  • 相同的输入时钟要得到相同的输出(纯函数)
  • 函数式编程用描述数据(函数)之间的映射

函数式编程和面向对象编程的不同

  • 从思维方式上来说:面向对象编程是对事物的抽象,而函数式编程是对运算过程的抽象。

函数式编程的优点

  • 函数式编程可以抛弃this
  • 打包过程中可以更好的利用tree shaking过滤无用代码
  • 方便测试、方便并行处理

函数式编程的前置知识

函数是一等公民

  • 在js 的世界中函数就是一个特别一点的普通对象,可以作为参数进行传递, 还可以作为返回值,还可以存储为某一个对象键的值。
// 一个示例
const BlogController = {
    index (posts) { return Views.index(posts) },
    show (post) { return Views.show(post) },
    create (attrs) { return Db.create(attrs) },
    update (post, attrs) { return Db.update(post, attrs) },
    destroy (post) { return Db.destroy(post) }
}

// 优化 赋值的是Views的index方法,不是方法的调用
const BlogController = {
    index: Views.index,
    show: Views.show,
    create: Db.create,
    update: Db.update,
    destroy: Db.destroy
}   

高阶函数

  • 如果一个函数的参数, 或者返回值是一个函数, 那么这个函数就是一个高阶函数。

高阶函数的作用

  • 将函数的部分功能封装起来形成新的函数,我们在使用的时候就可以屏蔽这一部分的实现细节。
常用的高阶函数
  1. 函数作为参数的高阶函数,数组的 forEach , filter, map 等方法。
  • 为什么要使用函数作为参数的高阶函数
  • 以forEach为例,forEach 本身只提供循环的方法,但是对循环内部的每一个元素的处理,就对应不同的业务的需求,不可能写死,所以就提供提供一个函数作为一个参数,来处理对应的逻辑。
  • 如果封装一个函数, 但是函数内部的一部分逻辑是变化的,就可以选择传递一个函数来处理。
  • 封装的函数只是对应的逻辑的公共部分。
let arr = [1, 2, 3, 4, 4, 5, 6, 6]
function forEach(arr, fn) {
    for (let index = 0; index < arr.length; index++) {
        const element = arr[index];
        fn(element)
    }
}
forEach(arr, function (item) {
    item = item * 2
    // console.log(item);
})
  1. 函数作为返回值
  • 为什么使用函数作为返回值。
  • 使用函数做返回值,实现函的柯里化。
  • 使用闭包对函数的返回值进行逻辑处理。

once 函数的实现

function once(fn){
    let done=false
    return function (){
        if (done) return   
        fn&& fn.apply(this,arguments)
        done=true
    }
}
let pay=function(num){
    console.log(num);
}
let payOnce=once(pay)
payOnce(5)
payOnce(5)
payOnce(5)
payOnce(5)
//多次调用 只打印一次

防抖

  • 无论多少次的触发 都是在最后的一次触发之后执行。
 let doc = window.document; 
        let debounce = function (fn, delay) {
            let timer = null
            return function () {
                if (timer) {
                    //多次触发如果 已经存在就重新开始
                    clearTimeout(timer)
                    timer = setTimeout(fn, delay)
                } else {
                    timer = setTimeout(fn, delay) 
                }
            } 
        } 
        doc.addEventListener('mousemove', debounce(function(){
            console.log('防抖');
            // 这样无论多少次的触发 都是在最后的一次触发的 3秒之后 执行。
        },3000))

节流

  • 将一个频率执行较高的函数,降低执行的频率。
 let doc = window.document;
        let throttle = function (fn, delay) {
            // 相当于 一个开关
            let valid = true
            return function () {
                if (!valid) {
                    //多次触发如果 已经存在就重新开始
                    return false
                } else {
                    setTimeout(() => {
                        fn(arguments)
                        valid = true
                    }, delay)
                }
                //每一次都把开关 关闭 只有在上一次 执行完成之后 才能打开开关
                valid = false
            }
        }
        doc.addEventListener('mousemove', throttle(function () {
            console.log('节流');
            // 这样无论多少次的触发 都是在最后的一次触发的 3秒之后 执行。
        }, 300))

闭包

  • 函数和其周围的状态(词法环境)的引用捆绑在一起形成闭包。
  • 可以在另一个作用域中调用一个函数的内部函数并访问到该函数作用域中的成员。
  • 闭包的核心:把函数内部成员的作用范围延长。

闭包的本质

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

纯函数

什么是纯函数

  • 相同的输入永远会得到相同的输出,而且没有任何可观察的副作用。(不依赖外部的环境,也不改变外部的环境)。

纯函数的优点

  • 安全性高
  • 可以缓存:因为对于相同的输入始终有相同的结果,那么可以把纯函数的结果缓存起来,可以提高性能。
  • 纯函数让测试更加的方便。
function getArea(r) {
    console.log(r);
    return Math.PI * r * r
}
function memoryFn(fn){
    // 缓存对象
    let cash={}
    return function(){
        let key=JSON.stringify(arguments)
         cash[key]=cash[key]|| fn.apply(this,arguments)
         return cash[key] 
    } 
}
 let getAreaM=memoryFn(getArea)
 // 相同的参数执行一次 因为 纯函数的结果 至于参数有关 因此 可以缓存起来

副作用

  • 副作用就是让一个函数变得不纯,纯函数的根据是相同的输入返回相同的输出,如果函数依赖于外部的状态就无法保证输出相同,就会带来副作用。

副作用来源:

  • 配置文件
  • 数据库
  • 获取用户的输入

所有的外部交互都有可能带来副作用,副作用也使得方法通用性下降不适合扩展和可重用性,同时副作用会给程序中带来安全隐患给程序带来不确定性,但是副作用不可能完全禁止,我们不能禁止用户输入用户名和密码,只能尽可能控制它们在可控范围内发生。

柯里化

  • 当函数有多个参数的时候,我们可以对函数进行改造。我们可以调用一个函数,只传递部分的参数(这部分参数以后永远不变),然后让这个函数返回一个新的函数。新的函数传递剩余的参数,并且返回相应的结果。

Lodash中的柯里化 —— curry()

  • 功能:创建一个函数,该函数接收一个或多个 func的参数,如果 func 所需要的参数都被提供则执行 func 并返回执行的结果。否则继续返回该函数并等待接收剩余的参数。
  • 参数:需要柯里化的函数
  • 返回值:柯里化后的函数
  • 柯里化相当于一个工厂函数一样对函数的部分参数进行封装 生成一个新的函数 重用函数的部分逻辑。
/ 例子  提取字符串中的符合 正则的字符
let reg=/\d+/g
let matchFn= function(reg,str){
        return str.match(reg) 
} 
// 柯里化 相当于 一个工厂 函数一样对 函数的部分参数进行封装 生成一个新的函数 重用函数的部分逻辑
let matchFnNum= _.curry(matchFn)(reg) 
console.log(matchFnNum('456445dd45688dsa'));

函数柯里化的实现

// 模拟柯里化函数
function curry (func) {
  // 取名字是为了下面实参小于形参的时候用的
  return function curriedFn(...args) {
    // 判断实参和形参的个数
    if(args.length < func.length) {
      return function() {
        // 等待传递的剩余参数,如果剩余函数的参数加上之前的参数等于形参,那么就返回func
        // 第一部分参数在args里面,第二部分参数在arguments里面,要将两个合并并且展开传递(使用...)
        // concat函数要合并两个数组,arguments为伪数组,所以用Array.from进行转换
        return curriedFn(...args.concat(Array.from(arguments)))
      }
    }
    // 如果实参大于等于形参的个数
    // args是剩余参数,是个数组形式,而返回的时候要展开(使用...)
    return func(...args)
  }
} 
// test
const curriedTest = curry(getSum) 
console.log(curriedTest(1, 2, 3))  // 6
console.log(curriedTest(1)(2, 3))  // 6
console.log(curriedTest(1, 2)(3))  // 6

柯里化总结

  • 柯里化可以让我们给一个函数传递较少的参数得到一个已经记住了某些固定参数的新函数(比如match函数新生成了haveSpace函数,里面使用了闭包,记住了我们给传递的正则表达式的参数)

  • 这是一种对函数参数的'缓存'(使用了闭包)

  • 让函数变的更灵活,让函数的粒度更小

  • 可以把多元函数转换成一元函数,可以组合使用函数产生强大的功能

    函数的柯里化是从一部分固定参数的角度 对函数进行复用, 函数的组合 是功能上就函数进行复用.

函数组合

每次把自己加的参数写前面,传入的值写后面

  • 纯函数和柯里化很容易写出洋葱代码 h(g(f(x)))
  • 函数组合可以让我们把细粒度的函数重新组合生成一个新的函数,避免写出洋葱代码。
  • 函数组合 (compose):如果一个函数要经过多个函数处理才能得到最终值,这个时候可以把中间 过程的函数合并成一个函数。
  • 函数组合默认是从右到左执行。 组合函数就像一个管道一样, 一节一节的往后流, 使用时我们并不关心, 管道的中间处理流程。

函数组合原理模拟

上面的例子我们来分析一下:

入参不固定,参数都是函数,出参是一个函数,这个函数要有一个初始的参数值

function compose (...args) {
  // 返回的函数,有一个传入的初始参数即value
  return function (value) {
    // ...args是执行的函数的数组,从右向左执行那么数组要进行reverse翻转
    // reduce: 对数组中的每一个元素,去执行我们提供的一个函数,并将其汇总成一个单个结果
    // reduce的第一个参数是一个回调函数,第二个参数是acc的初始值,这里acc的初始值就是value
      // 如果没有初始值就会省略第一步  直接执行第二步  将第一个元素作为初始值

    // reduce第一个参数的回调函数需要两个参数,第一个参数是汇总的一个结果,第二个参数是如果处理汇总的结果的函数并返回一个新的值
    // fn指的是数组中的每一个元素(即函数),来处理参数acc,完成之后下一个数组元素处理的是上一个数组的结果acc
    return args.reverse().reduce(function (acc, fn) {
      return fn(acc)
    }, value)
  }
}

//test
const fTest = compose(toUpper, first, reverse)
console.log(fTest(['one', 'two', 'three'])) // THREE 
// ES6的写法(函数都变成箭头函数)
const compose = (...args) => value => args.reverse().reduce((acc, fn) => fn(acc), value)

// 我们需要对中间值进行打印,并且知道其位置,用柯里化输出一下
const log = _.curry((tag, v) => {
  console.log(tag, v)
  return v
})

FP模块

函数组合的时候用到很多的函数需要柯里化处理,我们每次都处理那些函数有些麻烦,所以lodash中有一个FP模块

  • lodash 的 fp 模块提供了实用的对函数式编程友好的方法
  • 提供了不可变 auto-curried iteratee-first data-last (自动柯里化,函数置先,数据置后)的方法。

Point Free

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

  • 不需要指明处理的数据
  • 只需要合成运算过程
  • 需要定义一些辅助的基本运算函数
  • 可以把需要处理的数据定义成对象, 就可以传多个值。
//world wild 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(' '))
console.log(firstLetterToUpper('world wild web')) //W. W. W

// 上面的代码进行了两次的遍历,性能较低  直接在map里面进行函数的组合
// 优化
const firstLetterToUpper = fp.flowRight(fp.join('. '), fp.map(fp.flowRight(fp.first, fp.toUpper)), fp.split(' '))
console.log(firstLetterToUpper('world wild web')) //W. W. W

Functor 函子

  • 我们没有办法避免副作用,但是我们尽可能的将副作用控制在可控的范围内,我们可以通过函子去处理副作用,我们也可以通过函子去处理异常,异步操作等。

什么是Functor

  • 容器:包含值和值的变形关系(这个变形关系就是函数)。
  • 函子:是一个特殊的容器,通过一个普通的对象来实现,该对象具有 map 方法,map方法可以运行一个函数对值进行处理(变形关系)。
class Container {
  //使用类的静态方法,of替代了new Container的作用
  static of (value) {
    return new Container(value)
  }
  constructor (value) {
    this._value = value
  }
  // 对外暴露 map 方法对数据进行值的操作。
  map (fn) {
    return Container.of(fn(this._value))
  }
}

const r = Container.of(5)
            .map(x=>x+2) // 7
            .map(x=> x**2) // 49

console.log(r) // Container { _value: 49 }

总结

  • 函数式编程的运算不直接操作值,而是由函子完成
  • 函子就是一个实现了 map 契约的对象
  • 我们可以把函子想象成一个盒子,这个盒子里封装了一个值
  • 想要处理盒子中的值,我们需要给盒子的 map 方法传递一个处理值的函数(纯函数),由这个函数来对值进行处理
  • 最终 map 方法返回一个包含新值的盒子(函子)

MyBe函子

MayBe 函子的作用就是可以对外部的空值情况做处理(控制副作用在允许的范围) 只要函子在运行的过程中, 有null或者undefined 就返回数据 并且终止执行.

class MayBe {
  static of (value) {
    return new MayBe(value)
  }
  constructor (value) {
    this._value = value
  }

  map(fn) {
    // 判断一下value的值是不是null和undefined,如果是就返回一个value为null的函子,如果不是就执行函数
    return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value))
  }

 // 定义一个判断是不是null或者undefined的函数,返回true/false
  isNothing() {
    return this._value === null || this._value === undefined
  }
}

const r = MayBe.of('hello world')
  .map(x => x.toUpperCase())

console.log(r) //MayBe { _value: 'HELLO WORLD' }


// 如果输入的是null,是不会报错的
const rnull = MayBe.of(null)
  .map(x => x.toUpperCase())
console.log(rnull) //MayBe { _value: null }

Either函子

  • 解决 maybe 函子如果map中间有好几步,最后返回是null,并不知道是哪一个步骤返回的。解决这个问题,需要看下一个函子。
  • Either 两者中的任何一个,类似于 if...else...的处理。
  • 当出现问题的时候,Either函子会给出提示的有效信息。
  • 异常会让函数变的不纯,Either 函子可以用来做异常处理。
  • Right 函子用来处理数据, Left 函子用来显示错误。
  • 使用try catch 来判断 函子的运行。
// 因为是二选一,所以要定义left和right两个函子
// left 函子的作用就是显示错误
class Left {
  static of (value) {
    return new Left(value)
  }

  constructor (value) {
    this._value = value
  }

  map (fn) {
    return this
  }
}
// Right 函子用来做运算的。
class Right {
  static of (value) {
    return new Right(value)
  } 
  constructor (value) {
    this._value = value
  }

  map (fn) {
    return Right.of(fn(this._value))
  }
} 
// 那么这里如何处理异常呢?
// 我们定义一个字符串转换成对象的函数
function parseJSON(str) {
  // 对于可能出错的环节使用try-catch
  // 正常情况使用Right函子
  try{
    return Right.of(JSON.parse(str))
  }catch (e) {
  // 错误之后使用Left函子,并返回错误信息
    return Left.of({ error: e.message })
  }
}

let rE = parseJSON('{name:xm}')
console.log(rE) // Left { _value: { error: 'Unexpected token n in JSON at position 1' } }
let rR = parseJSON('{"name":"xm"}')
console.log(rR) // Right { _value: { name: 'xm' } }

console.log(rR.map(x => x.name.toUpperCase())) // Right { _value: 'XM' }

IO函子

  • IO就是输入输出,IO 函子中的 _value 是一个函数,这里是把函数作为值来处理
  • IO 函子可以把不纯的动作存储到 _value 中,延迟执行这个不纯的操作(惰性执行),包装当前的操 作
  • 把不纯的操作交给调用者来处理
const fp = require('lodash/fp')
class IO {
  // of方法快速创建IO,要一个值返回一个函数,将来需要值的时候再调用函数
  static of(value) {
    return new IO(() => value)
  }
  // 传入的是一个函数
  constructor (fn) {
    this._value = fn
  }

  map(fn) {
    // 这里用的是new一个新的构造函数,是为了把当前_value的函数和map传入的fn进行组合成新的函数
    return new IO(fp.flowRight(fn, this._value))
  }
}


// test
// node执行环境可以传一个process对象(进程)
// 调用of的时候把当前取值的过程包装到函数里面,再在需要的时候再获取process
const r = IO.of(process)
  // map需要传入一个函数,函数需要接收一个参数,这个参数就是of中传递的参数process
  // 返回一下process中的execPath属性即当前node进程的执行路径
  .map(p => p.execPath)
console.log(r) // IO { _value: [Function] }


// 上面只是组合函数,如果需要调用就执行下面
console.log(r._value()) // C:\Program Files\nodejs\node.exe