JavaScript函数式编程
@ 姜波 | 星期三,八月 14 日,2019 年 | 7 分钟阅读 | 更新于 星期三,八月 14 日,2019 年

函数式编程思维

范畴论

  • 函数式编程是范畴论的数学分支是一门很复杂的数学,认为世界上所有概念体系都可以抽象出一个个范畴
  • 彼此之间存在某种关系概念、事物、对象等等,都构成范畴。任何事物只要找出他们之间的关系,就能定义
  • 箭头表示范畴成员之间的关系,正式的名称叫做"态射" (morphism)。范畴论认为,同一个范畴的所有成员,就是不同状态的"变形"(transformation)。通过"态射", 一个成员可以变形成另一个成员。

函数式编程基础理论

  • 函数式编程(Functional Programming)其实相对于计算机的历史而言是一个非常古老的概念,甚至早于第一台计算机的诞生。函数式编程的基础模型来源于λ(Lambda x=>x*2)演算,而λ演算并非设计于在计算机上执行,它是在20世纪三十年代引入的一套用于研究函数定义、函数应用和递归的形式系统。
  • 函数式编程不是用函数来编程,也不是传统的面向过程编程。主旨在于将复杂的函数符合成简单的函数(计算理论,或者递归论,或者拉姆达演算)。运算过程尽量写成一系列嵌套的函数调用
  • JavaScript是披着C外衣的Lisp。
  • 真正的火热是随着React的高阶函数而逐步升温。
  • 特点
    • 函数是”第一等公民”
    • 只用”表达式",不用"语句"
    • 没有”副作用"
    • 不修改状态
    • 引用透明(函数运行只靠参数)

函数式编程常用核心概念

纯函数

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

  • 纯函数不仅可以有效降低系统的复杂度,还有很多很棒的特性,比如可缓存性

  • 纯度和幂等性

    幂等性是指执行无数次后还具有相同的效果,同一的参数运行一次函数应该与连续两次结果一致。幂等性在函数式编程中与纯度相关,但有不一致。

偏应用函数

  • 传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数
  • 函数之所以“偏”,在就在于其只能处理那些能与至少一个case语句匹配的输入,而不能处理所有可能的输入。
// 带一个函数参数 和 该函数的部分参数
const partial = (f, ...args) =>
	(...moreArgs) => 
		f(...args, ...moreArgs)

const add3 = (a, b, c) => a + b + c
// 偏应用 `2` 和 `3` 到 `add3` 给你一个单参数的函数
const fivePlus = partial(add3, 2, 3)
fivePlus(4)
//bind实现
const add1More = add3.bind(null,2,3) // (c) => 2 + 3 + c

函数的柯里化

  • 柯里化(Curried) 通过偏应用函数实现

  • 传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数

  • // 柯里化之前 
    function add(x, y) {
      return x + y;
    }
    add(1, 2) // 3
    // 柯里化之后 
    function addX(y) {
      return function (x) { 
        return x + y;
      };
    }
    addX(2)(1) // 3
    
  • function foo(p1, p2) {
      this.val = p1 + p2; 
    }
    var bar = foo.bind(null, p1); 
    var baz = new bar("p2"); 
    console.log(baz.val);
    
  • 事实上柯里化是一种“预加载”函数的方法,通过传递较少的参数, 得到一个已经记住了这些参数的新函数,某种意义上讲,这是一种 对参数的“缓存”,是一种非常高效的编写函数的方法

函数组合

  • 纯函数以及如何把它柯里化写出的洋葱代码 h(g(f(x))),为了解决函数嵌套的问题,我们需要用到“函数组合”

  • const compose = (f, g) => (x => f(g(x)));
    var first = arr => arr[0];
    var reverse = arr => arr.reverse();
    var last = compose(first, reverse);
    last([1,2,3,4,5]);
    

Point Free

  • 把一些对象自带的方法转化成纯函数,不要命名转瞬即逝的中间变量

声明式与命令式代码

  • 命令式代码的意思就是,我们通过编写一条又一条指令去让计算机执行一些动作,这其中一般都会涉及到很多繁杂的细节。而声明式就要优雅很多了,我们通过写表达式的方式来声明我们想干什么,而不是通过一步一步的指示。

  • //命令式
    let CEOs = [];
    for(var i = 0; i < companies.length; i++)
      CEOs.push(companies[i].CEO)
    }
    //声明式
    let CEOs = companies.map(c => c.CEO);
    

惰性求值、惰性函数、惰性链

  • 在指令式语言中以下代码会按顺序执行,由于每个函数都有可能改动或者依赖于其外部的状态,因此必须顺序执行。

高阶函数

  • 函数当参数,把传入的函数做一个封装,然后返回这个封装函数,达到更高程度的抽象

  • //命令式
    var add = function(a,b){
      return a + b;
    };
    function math(func,array){
      return func(array[0],array[1]);
    }
    math(add,[1,2]); // 3
    
  • 一等公民、一个函数作为参数、一个函数作为返回结果

尾调用优化PTC

  • 指函数内部的最后一个动作是函数调用。该调用的返回值,直接返回给函数。。 函数调用自身,称为递归。如果尾调用自身,就称为尾递归。递归需要保存大 量的调用记录,很容易发生栈溢出错误,如果使用尾递归优化,将递归变为循 环,那么只需要保存一个调用记录,这样就不会发生栈溢出错误了

  • 普通递归时,内存需要记录调用的堆栈所出 的深度和位置信息。在最底层计算返回值, 再根据记录的信息,跳回上一层级计算, 然后再跳回更高一层,依次运行,直到最 外层的调用函数。在cpu计算和内存会消耗 很多,而且当深度过大时,会出现堆栈溢出

    functionsum(n){
      if (n === 1) return 1;
      return n + sum(n - 1);
    }
      
    sum(5)
    //(5 + sum(4))
    //(5 + (4 + sum(3)))
    //(5 + (4 + (3 + sum(2))))
    //(5 + (4 + (3 + (2 + sum(1))))) (5 + (4 + (3 + (2 + 1))))
    //(5 + (4 + (3 + 3)))
    //(5 + (4 + 6))
    //(5 + 10)
    //15
    
  • 细数尾递归整个计算过程是线性的,调用一次sum(x, total)后,会进入下一个栈,相关的数据信息和 跟随进入,不再放在堆栈上保存。当计算完最后的值之后,直接返回到最上层的 sum(5,0)。这能有效的防止堆栈溢出。

    function sum(x, total) { 
      if (x === 1) {
       return x + total; 
      }
      return sum(x - 1, x + total);
    }
    sum(5, 0)
    //sum(4, 5)
    //sum(3, 9) 
    //sum(2, 12) 
    //sum(1, 14) 
    //15
    
  • function foo(n) { 
      return bar(n*2);
    }
    function bar() {
      //查看调用帧
      console.trace(); 
    }
    foo(1);
    //只有一个执行栈
    //foo@ VM65:2 (anonymous) @ VM65:10
    //强制指定只留下bar
    //return continue
    //!return 
    //#function()
    //遗憾的是浏览器并未支持
    
  • 尾递归的判断标准是函数运行【最后一步】是否调用自身, 而不是 是否在函数的【最后一行】调用自身, 最后一行调用其他函数 并返回叫尾调用

  • 按道理尾递归调用调用栈永远都是更新当前的栈帧而已,这 样就完全避免了爆栈的危险。但是现如今的浏览器并未完全支持原因有二 1、在引擎层面消除递归是一个隐式的行为,程序员意识不到。2、堆栈信息丢失了,开发者难已调试

  • 既然浏览器不支持我们可以把这些递归写成while

闭包

  • 如下例子,虽然外层的 makePowerFn 函数执行完毕,栈上的调用 帧被释放,但是堆上的作用域并不被释放,因此 power 依旧可以 被 powerFn 函数访问,这样就形成了闭包

    function makePowerFn(power) { 
      function powerFn(base) {
        return Math.pow(base, power);
      }
      return powerFn;
    }
    var square = makePowerFn(2); 
    square(3); // 9
    

容器、Functor

  • 函数不仅可以用于同一个范畴之中值的转换,还可以用于将一个范畴转成另一个范畴。这就涉及到了函子(Functor)
  • 函子是函数式编程里面最重要的数据类型,也是基本的运算 单位和功能单位。它首先是一种范畴,也就是说,是一个容 器,包含了值和变形关系。比较特殊的是,它的变形关系可 以依次作用于每一个值,将当前容器变形成另一个容器
  • Functor 是一个对于函数调用的抽象,我们赋予容器自己去调用 函数的能力。把东西装进一个容器,只留出一个接口 map 给容 器外的函数,map一个函数时,我们让容器自己来运行这个函数, 这样容器就可以自由地选择何时何地如何操作这个函数,以致于拥有惰性求值、错误处理、异步调用等等非常牛掰的特性

Maybe、Either、IO

当下函数式编程最热的库

RxJS

Cycle.js

Underscore.js

Lodash.js

Ramdajs

函数式编程的实际应用场景

易调试、热部署、并发

  • 函数式编程中的每个符号都是 const 的,于是没有什么函数会有副作用。 谁也不能在运行时修改任何东西,也没有函数可以修改在它的作用域之外修 改什么值给其他函数继续使用。这意味着决定函数执行结果的唯一因素就是 它的返回值,而影响其返回值的唯一因素就是它的参数
  • 函数式编程不需要考虑”死锁"(deadlock),因为它不修改变量,所以根本 不存在"锁"线程的问题。不必担心一个线程的数据,被另一个线程修改,所 以可以很放心地把工作分摊到多个线程,部署"并发编程"(concurrency)
  • 函数式编程中所有状态就是传给函数的参数,而参数都是储存在栈上的。 这一特性让软件的热部署变得十分简单。只要比较一下正在运行的代码以及 新的代码获得一个diff,然后用这个diff更新现有的代码,新代码的热部署就 完成了

单元测试

  • 严格函数式编程的每一个符号都是对直接量或者表达式结果的引用, 没有函数产生副作用。因为从未在某个地方修改过值,也没有函数修 改过在其作用域之外的量并被其他函数使用(如类成员或全局变量)。 这意味着函数求值的结果只是其返回值,而惟一影响其返回值的就是 函数的参数
  • 这是单元测试者的梦中仙境(wet dream)。对被测试程序中的每个函数, 你只需在意其参数,而不必考虑函数调用顺序,不用谨慎地设置外部 状态。所有要做的就是传递代表了边际情况的参数。如果程序中的每 个函数都通过了单元测试,你就对这个软件的质量有了相当的自信。 而命令式编程就不能这样乐观了,在 Java 或 C++ 中只检查函数的返 回值还不够——我们还必须验证这个函数可能修改了的外部状态
js
保存为图片

公众号

Image text

QQ

Image text

微信

Image text

微信打赏

Image text

社交链接