函数式编程-入门

824 阅读10分钟

前言

函数式编程在前端日常工作中,随处可见,也变得尤为重要,它本身也成为FE的基本技能之一,比如一些常用库: Redux、Koa等,同时掌握在开发中,使用函数式编程,可以最小度避免未知错误,降低系统复杂度。废话不多说,Let's hit the road!

编程类型介绍

  • 面向过程编程:想到哪写到哪
  • 面向对象编程:将所有的属性和方法封装到一个类中(class或对象原型)
  • 面向切面编程:IOC(装饰器模式),常用于BFF、SFF项目中
  • 函数式编程:FP - Functional Programming,提纯无关于业务的纯函数,函数套函数产生神奇的效果
    • 函数式编程不是单纯的用函数来编程,也不是传统的面向对象编程,主旨在于将复杂的函数合成简单函数,运算过程尽量写成一系列嵌套的函数调用
    • 真正火热起来是因为 React 高阶函数的推广,Redux最能体现FP的编程思想
    • 流行应用库:fp-ts重要的事情说3遍!!!这个库一定要掌握,它完全继承了FP的编程思想,不过在学习这个库前,一定要掌握FP高阶理论知识(容器、函子等)后,才可进行,否则,你可能【还没入门就放弃】我会在后面的文章介绍高阶理论和这个库

重新认识一下函数

函数是一等公民

函数与其它数据类型一样,出于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值

函数式编程中,函数的使用:

  • 只用表达式,不用语句
  • 函数不产生副作用
  • 不可变性,不可修改状态

不可变性: 在函数式编程中,变量被函数代替了,变量仅仅代表某个表达式,这里说的变量是不能被修改的。所有变量只能被赋一次初值

  • 引用透明

函数运行只依赖参数,相同的输入一定获得相同的输出

核心概念

纯函数(Pure)

前面介绍过,函数式编程,首先要做到的就是函数提纯

何为纯函数

  • 对于相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用,也不依赖外部环境的状态
var xs = [1, 2, 3, 4, 5];
// Array.slice是纯函数,因为它没有副作用,对于固定的输入,输出总是固定的
xs.slice(0, 3);
xs.slice(0, 3);

xs.splice(0, 3); //不是纯函数,因为结果变为[4,5]了,变脏了
xs.splice(0, 3); //不是纯函数

纯函数特性

  • 纯函数不仅可以有效降低系统复杂度,还有其他很棒的特性,如:可缓存性
    • 抽象代码更方便单元测试
  • 可缓存性
import _ from 'lodash';
var sin = _.memorize(x => Math.sin(x));
//第一次计算的时候会稍慢一点
var a = sin(1);
//第二次有了缓存,速度极快
var b = sin(2);
  • 缺点:扩展性比较差,硬编码会降低灵活度

看下面的例子

// 不纯的
var min = 18;
var checkAge = age => age > min;

// 纯的
var checkAge = age > 18;

在不纯的版本中: checkAge不仅取决age,还有外部依赖的变量min

在纯的版本中: checkAge把关键数字18硬编码在函数内部,扩展性比较差,函数柯里化可以解决,但在这之前,我们先了解一下偏应用函数

偏应用函数(Partial application)

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

// 声明一个函数
const add3 = (a, b, c) => a + b + c;

// 偏应⽤ `2` 和 `3` 到 `add3` 给你⼀个单参数的函数 fivePlus
const fivePlus = partial(add3, 2, 3); // 等同于 add3.bind(null, 2, 3)

// 最终会调用,本质上还是在调用:(a, b, c) => a + b + c
fivePlus(4); // 2 + 3 + 4

上面的例子中,partial是一个闭包,暂存了args的值,然后返回给使用者包含这写args的另一个函数,而使用者只需要调用这个值,传递剩余的值

  • bind的实现其实就是偏应用函数的应用之一
// const fn = add3.bind(null, 2, 3)
// fn(4);
Function.prototype.bind = function() {
  const args = Array.prototype.slice.call(argumnets, 1); // 获取除null外的参数
  const me = this;
  return function () {
   const _args = Array.prototype.slice.call(argumnets);
   return me.apply(me, args.concat(_args)); // 将全部参数传入
  }
}

函数柯⾥化(Curring)

  • 函数柯⾥化是偏应用的实现,它是把一个多参数函数转换为一个嵌套一元函数的过程(与偏应用函数的区别:柯⾥化一次只能传递一个参数
  • 它可以记住参数,相当于对参数的一种缓存,它就是偏应用函数极致应用
  • 通过函式编程解决纯函数的硬编码问题
  • ???柯里化的参数列表是从左向右的
// 柯⾥化之前
function add(x, y) { 
  return x + y; 
} 
add(1, 2) // 3 

// 柯⾥化之后
function addX(y) { 
  return function (x) { 
    return x + y; 
  }; 
} 
addX(2)(1) // 3
var checkAge = min => (age => age > min);
// 让纯函数内部的变量 更加灵活 这个参数不依赖于外部的任何变量
var checkAge18 = checkAge(18);
checkAge18(20);
  • 函数柯里化实现
funciton Currying(fn) {
  return function curried(...args) {
    if (fn.length === args.length) {
      return fn.apply(this, args);
    } else {
      return function (...args2) {
        return curried.apply(this, args.concat(args2));
      }
    }
  }
}

const curryTest = Currying((a,b,c,d) => a + b + c + d);
curryTest(1)(2)(4)(3); // 10

// 详细解释
const currying = function(fn, ...args) {
    // fn 参数个数, 比如 add(a, b),那么 add.length就为2
    const len = fn.length
    // 返回一个函数接收剩余参数
    return function (...params) {
        // 拼接已经接收和新接收的参数列表
        let _args = [...args, ...params]
        // 如果已经接收的参数个数还不够,继续返回一个新函数接收剩余参数
        if (_args.length < len) {
            return currying.call(this, fn, ..._args)
        }
        // 参数全部接收完调用原函数
        return fn.apply(this, _args)
    }
}
  • 函数柯里化应用
// 柯⾥化之前
function checkByRegExp(regExp, str) {
  return regExp.test(str)
}

// 校验手机号
checkByRegExp(/^1\d{10}$/, '15152525634'); 
checkByRegExp(/^1\d{10}$/, '13456574566'); 
checkByRegExp(/^1\d{10}$/, '18123787385'); 
// 校验邮箱
checkByRegExp(/^(\w)+(.\w+)*@(\w)+((.\w+)+)$/, 'fsds@163.com'); 
checkByRegExp(/^(\w)+(.\w+)*@(\w)+((.\w+)+)$/, 'fdsf@qq.com'); 
checkByRegExp(/^(\w)+(.\w+)*@(\w)+((.\w+)+)$/, 'fjks@qq.com');

// 柯⾥化之后
function checkByRegExp(regExp) {
  return function(str) {
     return regExp.test(str)
  }
}
// 于是我们传入不同的正则对象,就可以得到功能不同的函数:
// 校验手机
const checkPhone = checkByRegExp(/^1\d{10}$/)
// 校验邮箱
const checkEmail = checkByRegExp(/^(\w)+(.\w+)*@(\w)+((.\w+)+)$/)

// 校验手机号
checkPhone('15152525634'); 
checkPhone('13456574566'); 
checkPhone('18123787385'); 
// 校验邮箱
checkEmail('fsds@163.com'); 
checkEmail('fdsf@qq.com'); 
checkEmail('fjks@qq.com');
  • 柯⾥化的优缺点

优点:柯里化是一种"预加载"函数的方法,通过传递较少的参数得到一个已经记住了这些参数的新函数,某种意义上讲,这是一种对参数的"缓存",是一种非常高效的编写函数的方法

缺点:柯里化函数所最后生成的洋葱代码 h(g(f(x))),不易阅读

反柯⾥化

  • 函数柯⾥化,是固定部分参数,返回一个接受剩余参数的函数,也称部分计算函数,目的是为了缩小适用范围,创建一个针对性更强的函数
  • 而反柯⾥化,正好相反,为了接收更多参数,扩大适用范围,创建一个应用更广的函数,使原本只有特定对象才适用的函数,扩展到更多的对象
  • 比如我们希望得到类数组对象,我们可以借鉴Array原型的push方法

我们希望可以类似这样使用

const obj = {};
Array.prototype.push.call(obj, 'one', 'two');
// {0: 'first', 1: 'two', length: 2}

假如,我们构造一个反柯⾥化函数,扩大对象的适用范围,可以如下操作

const push = Array.prototype.push.unCurrying();
push(obj, "first", "two");// {0: 'first', 1: 'two', length: 2}

接下来,我们来实现unCurrying这个函数

Function.prototype.unCurrying = function() {
  const self = this;
  return function () {
    // 去掉arumengts的第一个参数,并返回第一个参数,因为我们要操作第一个参数,这就用到数组的shift 方法了
    const obj = Array.prototype.shift.call(arguments);
    return self.apply(obj, arguments);
  }
}
const push = Array.prototype.push.unCurrying();
const obj = {};
push(obj, "first", "two");

函数组合(compose)

  • 数组合是为了解决柯里化函数所最后生成的洋葱代码 h(g(f(x)))
  • 组合函数相当于把一页页的洋葱贴起来,像拼积木
  • 洋葱代码函数执行顺序是从里往外执行的,那么写成compse,那么执行顺序就为从右往左执行,看这洋葱代码,像不像Koa2的use执行
const compose = (f, g) => (x => f(g(x)));

const first = arr => arr[0];
const reverse = arr => arr.reverse();

const last = compose(first, reverse);
last([1, 2, 3, 4, 5]); // 5
  • 特点:

    • compose中只能组合接受一个参数的函数
    • compose的数据流和参数顺序相反,比如自左而右的函数参数,调用的时候是从右至左。不喜欢这一点可以用pipe,它实现的功能和compose相同
  • compose实现

function compose(...funcs) {
  let length = funcs.length;
  if (length === 0) {
    return (...args) => args;
  }
  
  if (length === 1) {
    return funcs[0];
  }
  
  return funcs.reduce((a, b) => {
    return (...args) => {
      return a(b(...args));
    }
  })
}
  • compose 满足结合律
let f = compose(f, g, h)
let associative = compose(compose(f, g), h) == compose(f, compose(g, h))

Point Free

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

例如:const f = str => str.toUpperCase.spit('');,使用了str做为中间变量,没有意义

  • 不使用所要处理的值,只合成运算过程,可称为 "无值" 风格
const toUpperCase = str => str.toUpperCase();
const split = x => str.split(x);

const f = compose(split(''), toUpperCase);
f('abc def');

const addOne = x => x + 1;
const square = x => x * x;
const t = compose(square, addOne);
t(2); // 9

命令式代码和声明式代码

  • 命令式代码的意思就是,我们通过编写一条又一条指令去让计算机执行一些动作,这其中一般都会涉及到很多繁杂的细节。
  • 而声明式就要优雅很多了,我们通过写表达式的方式来声明我们想干什么,而不是通过一步一步的指示。
//命令式
let CEOs = [];
for(var i = 0; i < companies.length; i++)
  CEOs.push(companies[i].CEO)
}

// 声明式
let CEOs = companies.map(c => c.CEO);
  • 优缺点:

    • 声明式代码,对于无副作用的纯函数,完全可以不考虑函数内部是如何实现的,专注于编写业务代码。
    • 优化代码时,目光只需要集中在这些稳定坚固的函数内部即可
    • 相反,不纯的函数代码会产生副作用或依赖外部环境,使用时,还要考虑这些不干净的副作用,容易脑抽

惰性求值、惰性函数

  • 惰性函数就是比较懒得函数,求一次值,下一次不想再求值了

在命令式编程中,代码会按顺序执行,由于每个函数都有可能改动或依赖于外部的状态,因此必须顺序执行

假如同一个函数,未来在程序中会被大量调用,并且这个函数内部又有许多判断来检测函数,这样对于一个调用会浪费时间和浏览器资源,所有当第一次判断完成后,直接把这个函数改写, 比如:createXHR和事件绑定

export const addEvent = (() => {
    if(document.addEventListener){
        return function(type, element, func){
            element.addEventListener(type, func, false);
        };
    }else if(document.attachEvent){
        return function(type, element, func){
            element.attachEvent('on'+type, func);
        };
    }else{
        return function(type, element, func){
            element['on'+type] = func;
        };
    }
})();
addEvent('click', divNode, handle);

高阶函数

  • 函数当作参数,把传入的函数做一个封装,然后返回这个封装的函数,达到更高程度的抽象
  • 高阶组件应用,HOC
//命令式
var add = function(a,b){return a + b;};
function math(fn, arr){return fn(arr[0],arr[1]);}
math(add, [1,2]);//3

闭包

  • 函数式编程会充盈着大量的闭包
  • 闭包是存在“堆”上的,因为当一个函数调用结束,栈上的调用帧被释放,但是堆上的作用域并不被释放
  • 虽然栈的引用丢了,但是真正的函数对象还在,函数对象在的话,内部的的变量就还在,这些变量可能还会用的到。我们需要用一些技术手段拿到这些变量。这就是闭包要做的事情
  • 闭包:就是用外层函数return出一个能访问到其变量的内层函数

尾声

函数式编程基础就先到这里,希望能先理解的基础上,再修习后面的进阶内容