函数式编程——JS

326 阅读7分钟

什么是函数式编程(functional programming)

函数式编程是一种编程范式,它将电脑运算视为函数运算,并且避免使用程序状态以及易变对象在函数式编程中,函数是第一类对象,意思是说一个函数,既可以作为其它函数的输入参数值,也可以从函数中返回值,被修改或者被分配给一个变量。

函数式编程的中的函数不是指程序中的函数(方法),而是数学中的映射关系,例如 y = sin(x),x和y的关系

高阶函数

首先我们来看一下维基百科对于高阶函数的定义:

在数学和计算机科学中,高阶函数是至少满足下列一个条件的函数:

  • 接受一个或多个函数作为输入
  • 输出一个函数

简单点来说,我们定义一个函数时,参数是函数或者返回值是函数,这个函数高阶函数。而对于JavaScript来说这两种情况都满足。

函数作为参数

    function callbackFn() {
        console.log('高阶函数')
    }
    function fnParam(fn) {
        console.log('普通函数')
        // fn 函数作为参数传递
        fn()
    }
    // 测试
    fnParam(callbackFn)
    

上述例子fn函数作为参数传递,这种函数被称作为回调函数

回调函数是异步的基石,上述例子中 callbackFn 作为参数传递到 fnParam 函数中,callbackFn 的执行 权由fnParam 决定。

在JavaScript语言中内置的很多这种函数作为参数的高阶函数,例如

  • Array.prototype.map
  • Array.prototype.filter
  • Array.prototype.find
  • ....

Array.prototype.map 为例子进行详细讲解

使用高阶函数

      // map 使用实例
     const arr = [1, 2, 3, 4]
     const result = arr.map(item => item * 2)
     console.log(arr)  // [1, 2, 3, 4]
     console.log(result)  // // [2, 4, 6, 8]

不使用高阶函数

     // 同步代码实现 map 功能
     const arr = [1, 2, 3, 4]
     const result = []
     for(let i = 0; i < arr.length; i++) {
         const item = arr[i]
         result.push(item * 2)
     }
     console.log(arr)  // [1, 2, 3, 4]
     console.log(result)  // // [2, 4, 6, 8]

模拟实现

    // 模拟 map
    const map = (arr,fn)=>{
        let res = [];
        for(let i = 0; i < arr.length; i++){
            res.push(fn(i))
        }
        return res;
    }
    // 测试
    const newAry = map([1,23,4,5,6],item=>item**2)
    console.log(newAry) // [1, 529, 16, 25, 36]
    // 模拟 filter
    function filter(arr,fn){
        let res = [];
        for(let i = 0; i < arr.length; i++){
            if(fn(arr[i])){
                res.push(arr[i])
            }
        }
        return res
    }
    // 测试
    const ary = filter([1,2,34,5,6],function(item){
        return item%2 === 0;
    })
    console.log(ary) // [2, 34, 6]

函数作为返回值

我们来看一个简单例子

    function getMsg() {
        const msg = 'hello msg'
        // 函数作为返回值
        return function() {
            console.log('msg')
        }
    }
    // 测试
    const firstAction = getMsg()
    firstAction() // hello msg

getMsg执行之后得到的是一个函数,这个函数赋值给了firstAction,这个firstAction就是函数作为返回值。这里会产生闭包

接下来我们利用高阶函数这两个特性来实现一个只能调用一次的函数

    function once(fn) {
        let done = false
        debugger
        return function() {
            if(!done) {
                done = true
                return fn.apply(this, arguments)
            }
        }
    }
    const pay = once(function(money) {
        console.log('支付了'+ money)
    })
    pay(5)
    pay(5)
    pay(5)

在浏览器环境中查看闭包

closure.png

闭包可以在另一个作用域中调用一个函数的内部函数并访问到该函数的作用域中的成员(也就是延长了作用域)

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

柯里化

调用一个函数只传递部分参数(这部分参数永远不变),然后返回一个新的函数并接收剩余的参数并返回结果

  // 柯里化简单例子
  function checkAge(min) {
    return function(age) {
      return age >= min
    }
  }
  // 只传部分参数
  const age18 = checkAge(18);
  //接收剩余的参数
  console.log(age18(22));
  console.log(age18(55));
  console.log(age18(17));

模拟lodash中的柯里化 (关键点:实参等于形参的个数时,才执行函数,否则缓存参数

 function curry(fn) {
   return function curriedFn(...args) {
     // 判断实参和形参的个数
     // Function.length length 属性指明函数的形参个数
     if (args.length < fn.length) {
       return function() {
         // 缓存参数
         curriedFn(...args.concat(Array.from(arguments)))
       }
     }
     return fn(...args)
   }
 }

 // 测试
 function getSum(a, b, c) {
   rerturn a + b + c
 }

 const curried = curry(getSum)
 console.log(curryFun(1,2,3));
 console.log(curryFun(1,2)(3));
 console.log(curryFun(1)(2,3));

函数组合

如果一个函数要经过对个函数处理才能得到最终的值,这个时候可以把中间过程的函数合并成一个函数

函数就是数据的管道,把多个管道连接起来,让数据穿过形成最终的结果

函数组合默认是从右到左执行,每一个函数接收一个参数并且返回相应的结果

  // 实现一个函数组合功能的函数
  function compose(...args) {
    return function(value) {
      // 先翻转数组,然后通过reduce依次执行函数
      return args.reverse().reduce(function(acc, fn) {
        return fn(acc)
      }, value)
    }
  }
  // 箭头函数写法
  const compose_arrow = (...args) => value => args.reverse().reduce((acc, fn) => fn(acc), value)

  // 测试
  const reverse = (arr)=>arr.reverse();
  const first = (arr)=>arr[0];
  const test1 = compose(first,reverse)
  const test2 = compose_arrow(first,reverse)
  console.log(test1([1,5,6,4,8,6,'dsadasdsd']));
  console.log(test2([1,5,6,4,8,6,'dsadasdsd']));

纯函数

相同的输入得到相同的输出,而且没有任何可观察的副作用

纯函数例子

  const array = [1, 2, 3, 4, 5]
  console.log(array.slice(0, 3))
  console.log(array.slice(0, 3))
  console.log(array.slice(0, 3))
  // 执行三次得到 相同 结果

不纯函数例子

  const array = [1, 2, 3, 4, 5]
  console.log(array.splice(0, 3))
  console.log(array.splice(0, 3))
  console.log(array.splice(0, 3))
  // 执行三次得到 不同 结果

纯函数好处

  • 可缓存:因为纯函数对相同的输入始终都有相同的输出,所以它的结果可以通过闭包被缓存下来(记忆函数) 实现一个记忆函数
  function getArea (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] || fn.apply(fn, arguments)
      return cache[key]
    }
  }
  // 测试
  let memoizeFun = memoize(getArea)
  console.log(memoizeFun(4));
  console.log(memoizeFun(4));
  • 可测试: 纯函数让测试更方便(因为纯函数始终有输入和输出,而单元测试就是在断言这个结果
  • 并行处理 (web worker):在多线程环境下并行操作共享的内存数据很可能会出现意外情况. 纯函数不需要访问共享的内存数据,所以在并行环境下可以任意运行纯函数

副作用

副作用例子

  // 不纯的
  let mini = 18
  function checkAge(age) {
    // 依赖于外部变量 mini,当mini发生变化时候,相同输入不一定会得到相同输出
    return age >= mini
  }

如果函数依赖于外部的状态就无法保证输出相同,就会带来副作用,副作用会让函数不纯

副作用来源:配置文件、数据库、获取用户输入。。。(所有与外部交互都有可能带来副作用

副作用不可能完全禁止,尽可能控制它们在可控范围内发生

函子(Functor)

容器:包含值和值得变形关系(这个变形关系就是函数)

函子:是一个特殊的容器,通过一个普通的对象来实现,该对象具有map方法,map方法可以运行一个函数对值进行处理(变形关系)

  // Functor 函子
  class Container {
    // 封装 实例化 Container
    static of(value) {
      return new Container(value);
    }
    constructor(value) {
      this._value = value;
    }
    // 契约对象
    map(fn) {
    /**
     * fn是个回调函数(纯函数)
     * 把fn处理的值,作为实例化 Container 的参数
     */
      return Container.of(fn(this._value));
    }
  }

  let result = Container.of(5).map((x) => x + 2).map((x) => x * x);
  console.log(result);

存在问题: 不能对非法的值输入值进行判断,而导致程序报错终止(用MapBe函子解决)

MayBe 函子

作用:对外部的空值情况做处理(空值副作用在允许范围内)

  // MayBe 函子
  class MayBe {
    static of(value) {
      return new MayBe(value);
    }
    constructor(value) {
      this._value = value;
    }
    map(fn) {
      return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value));
    }
    isNothing() {
      return this._value === null || this._value === undefined;
    }
  }

  let result = MayBe.of(null).map((x) => x.toUpperCase());
  console.log(result);

存在问题:当链式调用非常多的时候,其中一步的值置为nullMayBe函子无法准确定位被赋值为null的步数(用Either函子解决)

Either 函子

Either 两者中的任何一个,类似于if...else...的处理

异常会让函数变得不纯,Either函子可以用来做异常处理

 // Either 函子
  class Left {
    static of(value) {
      return new Left(value);
    }
    constructor(value) {
      this._value = value;
    }
    map(fn) {
      return this;
    }
  }

  class Right {
    static of(value) {
      return new Right(value);
    }
    constructor(value) {
      this._value = value;
    }
    map(fn) {
      return Right.of(fn(this._value));
    }
  }
  // test
  function parseJSON(str) {
    try {
      return Right.of(JSON.parse(str));
    } catch (e) {
      // 存错错误信息
      return Left.of({ error: e.message });
    }
  }

  let result3 = parseJSON('{name: zs}');
  let result4 = parseJSON('{ "name": "zs" }');
  console.log(result3);
  console.log(result4);

IO函子

IO函子中的_value 是一个函数,这里是把函数作为值来处理

IO 函子可以把不纯的动作存储到_value 中,延迟执行这个不纯的操作(惰性执行),包装当前操作纯

把不纯的操作交给调用者来处理

  // 函数组合
  function compose(...args) {
    return function(value) {
      // 先翻转数组,然后通过reduce依次执行函数
      return args.reverse().reduce(function(acc, fn) {
        return fn(acc);
      }, value);
    };
  }

  // IO 函子
  class IO {
    static of(value) {
      return new IO(function() {
        return value;
      });
    }
    constructor(fn) {
      this._value = fn;
    }
    map(fn) {
      // 把当前的 value 和传入的 fn 组合成一个新的函数
      return new IO(compose(fn, this._value));
    }
  }

  // test
  const result = IO.of(process).map((p) => p.execPath);
  console.log(result._value());

存在问题:当去调用嵌套函子执行结果时要.value().value(),这样书写很不友好(用Monad函子解决)

Monad函子

Monad函子是可以变扁的Pointed函子,Monad主要解决函子嵌套输出问题 IO(IO(x))

一个函子如果具有join和of两个方法并遵守一些定律就是一个Monad

如果说函数嵌套可以用函数组合,那么函子嵌套就可以用Monad

  // 函数组合
  function compose(...args) {
    return function(value) {
      // 先翻转数组,然后通过reduce依次执行函数
      return args.reverse().reduce(function(acc, fn) {
        return fn(acc);
      }, value);
    };
  }
  
class Monad {
    static of (value) { 
      	//of方法还是接收一个值,只不过这个值把这个值封装到了函数里 
        return new Monad(function(){ 
            return value
        })
    }
    constructor(fn){
      	//接受一个函数 缩小副作用
        this.value = fn 
    }
    map(fn){//map是处理值的操作
      	//所谓减少副作用既输入一定有输出,这里始终输出的是一个函数并保存到了value中
        return new Monad(compose(fn,this.value)) 
    }
    join(){
      	//是配合flatMap的操作
        return this.value();
    }
    flatMap(fn){
      	//flatMap是处理嵌套函子从而避免重复.value()的操作
        return this.map(fn).join();
    }
}

函数编程库

Folktale: folktale.origamitower.com/

folktale是一个标准的函数式编程库和lodash,ramada不同的是,他没有提供很多的功能函数只提供了一些函数式处理的操作,如:compose,curry等,以及一些函子:Task(异步操作),Either,MayBe等.

Folktale之Task异步操作

//task实现过于复杂,这边就不模拟了
//folktale 2.x与1.x 中的task区别比较大,1.0中更接近我们现在演示的函子
//这里以2.3.2来演示
const {task} = require('folktale/concurrency/task');
const _ = require('lodash/fp');
const fs = require('fs');
function readFile (fileName) {
    return task(resolver => {
        fs.readFile(fileName,'utf-8',(err,data)=>{
            if(err) resolver.reject(err)
            resolver.resolve(data)
        })
    })
}

readFile('package.json')
.map(_.split('\n'))
.map(_.find(x=>x.includes('version')))
.run()
.listen({
    onRejected(err){
        console.log(err);
    },
    onResolved(data){
        console.log('解析version',data);
    }
})