JavaScript函数式编程-上

204 阅读8分钟

函数式编程上

基础理论

范畴论Category Theory

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

一句话总结: 成员都是一个集合,变形关系是一个函数

函数式编程基础理论

  • 数学中的函数书写如下形式f(X)=Y。一个函数F,已X作为参数,并返回输出Y。这很简单,但是包含几个关键点函数必须总是接受一个参数、函数必须返回一个值、函数应该依据接收到的参数(如X)而不是外部环境运行、对于给定的X只会输出唯一的Y。
  • 函数式编程不是用函数来编程,也不是传统的面向过程编程。主旨在于将复杂的函数符合成简单的函数(计算理论,或者递归论,或者拉姆达演算)。运算过程尽量写成一系列嵌套的函数调用
  • 通俗写法 function xx(){}区别开函数和方法。方法要与指定的对象绑定、函数可以直接调用。
  • 函数式编程(Functional Programming)其实相对于计算机的历史而言是一个非常古老的概念,甚至早于第一台计算机的诞生。函数式编程的基础模型来源于 λ (Lambda x=>x*2)演算,而 λ 演算并非设计于在计算机上执行,它是在 20 世纪三十年代引入的一套用于研究函数定义、函数应用和递归的形式系统。
  • JavaScript 是披着 C 外衣的 Lisp。
  • 真正的火热是随着React的高阶函数而逐步升温。

了解一下即可,只需要知道函数式编程由来以久。 在近几年随着 react 崛起而被带动渐渐火起来, 后来越来越多的框架和打包工具开始推崇。

函数式编程的原则

1. 函数是”第一等公民”

2. 只用”表达式",不用"语句"

3. 没有”副作用"

4. 不修改状态

5. 引用透明(函数运行只靠参数且相同的输入总是获得相同的输出)identity=(i)=>{return i} 调用identity(1)可以直接替换为1 该过程被称为替换模型

前端所接触到函数式编程

  • 纯函数
  • 偏应用函数、函数的柯里化
  • 惰性求值
  • 函数组合
  • Point Free

纯函数

相同的输出总是有相同的结果,无副作用,不依赖外部变量。
  • 举个例子
/* 纯函数 */
const add = (n, m) => n + m

/* 纯函数,每次返回相同的结果 */
Array.proptotype.slice

/* 不纯, 因为改变了原数组, 返回的结果不一样 */
Array.proptotype.splice
  • 纯函数有什么好处?
    • 打包优化,在webpack5引入的prepack理念

2.png - 热代码优化, 在V8中一段持续执行的代码会被直接转为字节码常驻于内存中 - 缓存优化, 基于一个输入对应一个输出的基础上. 理论上每个输入都只需要执行一次

    /*
        lodash.memoize 方法就是将传入纯函数的执行结果进行缓存
    */
    import { memoize } from 'lodash'
    const sqrt = memoize(x => Math.sqrt(x))
    // 第一次执行耗时
    console.time('首次执行')
    sqrt(9999)
    console.timeEnd('首次执行')


    // 第二次执行
    console.time('第二次执行')
    sqrt(9999)
    console.time('第二次执行')

3.png

纯但一定幂等

幂等性是指执行无数次后还具有相同的效果,同一的参数运行一次函数应该与连续两次结果一致。幂等性在函数式编程中与纯度相关,但也有不一致的情况。
  • Math.abs 求绝对值
  • 4%2 8%2 取模
  • ...

偏应用函数(partialapplication)

传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
偏函数之所以“偏”,在就在于其只能处理那些能与至少一个case语句匹配的输入,而不能处理所有可能的输入。
  • 示例
/* 接受一个name和msg参数,在控制台中打印 ${name}: ${msg} */
const say = (name, msg) => console.log(`${name}: ${msg}`)

/**
 * 接受一个函数并处理部分参数
 * partial就是一个偏应用函数, 把name偏应用到say函数
 */
const partial = (fn, ...args) => function (...newArgs) { return fn(...[...args, ...newArgs]) }

// 得到只接受单参数的偏函数
const XMSay = partial(say, '小明')
const XHSay = partial(say, '小红')

XMSay('hello') // 小明: hello
XHSay('hi') // 小红: hi

函数柯里化就是偏函数的实现

  • 柯里化(Curried)通过偏应用函数实现。它是把一个多参数函数转换为一个嵌套一元函数的过程。
  • 传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。

现实一个柯里化

const min = 18
/* 不纯,依赖了外部变量 */
const checkAge = age => age < min

/* 变成纯函数 */
const checkAge2 = (min, age) => age < min

/* 柯里化 */
const checkAge3 = checkAge2.bind(null, 18)

lodash的实现

loadsh对柯里化的实现

/* 抄录lodash的代码片段 */
var abc = function(a, b, c) {
   return [a, b, c];
};
var curried = lodash.curry(abc);

curried(1)(2)(3);
// => [1, 2, 3]

curried(1, 2)(3);
// => [1, 2, 3]

curried(1, 2, 3);
// => [1, 2, 3]

核心源码实现

/* 函数柯里化 */    
const curry = (fn, ...args) => function (...newArgs) {
    /* 收集参数合并 */
    const curArg = [...args, ...newArgs]
    /* 形参个数与实参个数相等 */
    if (curArg.length === fn.length) return fn(...curArg)
    /* 尾递归继续收集 */
    return curry(fn, ...curArg)
}

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

柯里化和偏函数的区别

柯里化和偏应用无非就是对参数的预处理的手段,目的就是使参数传递以及函数组合变得更灵活。
柯里化是收集完所有的参数进行统一处理, 偏函数是边收集边处理并返回一个新的处理函数。二者本质上都可以说是惰性的

惰性求值

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

当输入很大但只有一个小的子集有效时,避免不必要的函数调用就是所谓的惰性求值。
/* 伪代码 */

[1, 2, 3, 4, 5].map(处理函数).reverse(反转).slice(0, 3) // 只要取前3个

/* 使用惰性求值进行的优化 */
[1, 2, 3, 4, 5].map(处理函数).reverse(反转).slice(0, 3).value()
=>

/* 惰性求值会将一些操作合并减支,当调用 .values 操作才返回值 */ 
[1, 2, 3, 4, 5].slice(-3).map(处理函数)

lodash实战

import _ from 'lodash'

var users = [
    { 'user': 'barney',  'age': 36 },
    { 'user': 'fred',    'age': 40 },
    { 'user': 'pebbles', 'age': 1 }
];

/* _.chain创建了一个惰性链,会自动推断可以优化的点以及合并操作 */
var youngest = _.chain(users)
    .sortBy('age')
    .map(function(o) {
        return o.user + ' is ' + o.age;
    })
    .head()
    .value()

函数组合子

命令式与声明式代码

const arr = [1, 2, 3, 4, 5]
const con = new Array(arr.length)
/* 命令式代码 */
for (let i = 0, len = arr.length; i < len; i++) {
    con[i] = arr[i]
}

/* 声明式 */
const con2 = arr.map(x => x)

函数式编程的一个明显的好处就是这种声明式的代码,对于无副作用的纯函数,我们完全可以不考虑函数内部是如何实现的,专注于编写业务代码。优化代码时,目光只需要集中在这些稳定坚固的函数内部即可。

声明式代码方便我们随意组合

  • 简单示例
/*
    compose函数只能组合接受一个参数的函数,类似于filter、map 接受两个参数(投影函数:总是在应用转换操作,通过传入高
*/
const compose= (f,g) => ( x => f(g(x)));

/* 简单示例 */
const first = arr => arr[0];
const reverse = arr => arr.reverse();
const last = compose(first,reverse);

console.log(last([1,2,3,4,5])) // 5
  • 简单进阶
const data = [
    "life is a stream",
    "on which we strew", 
    "petal by petal the flower of our heart;",
    "the end lost in dream,",
    "they float past our view,",
    "we only watch their glad, early start."
]

/* 遇到多参数的,需要用偏函数处理成单参数 */
const changeMap = fn => function(arr) { return arr.map(fn) }
/* 首字母大写 */
const fristEmToUpper = changeMap(str => str.charAt(0).toLocaleUpperCase() + str.substring(1))
/* 全部转大写 */
const toUpper = changeMap(str => str.toLocaleUpperCase())
/* 数组转字符串 */
const join = arr => arr.join('\r\n')

// 首字母转大写
const fn1 = compose(join, fristEmToUpper)
/*
    Life is a stream
    On which we strew
    Petal by petal the flower of our heart;
    The end lost in dream,
    They float past our view,
    We only watch their glad, early start.
*/
console.log(fn1(data))

// 全部转大写
const fn2 = compose(join, toUpper)
/*
    LIFE IS A STREAM
    ON WHICH WE STREW
    PETAL BY PETAL THE FLOWER OF OUR HEART;
    THE END LOST IN DREAM,
    THEY FLOAT PAST OUR VIEW,
    WE ONLY WATCH THEIR GLAD, EARLY START.
*/
console.log(fn2(data))

函数组合的数据流是从右至左,因为最右边的函数首先执行,将数据传递给下一个函数以此类推,有人喜欢另一种方式最左侧的先执行,我们可以实现pipe(可称为管道、序列)来实现。它和compose所做的事情一样,只不过交换了数据方向。

其他组合子

  • 辅助组合子 无为(nothing)、照旧(identity)、默许(defaultTo)、恒定(always)

  • 函数组合子 收缩(gather)、展开(spread)、颠倒(reverse)、左偏(partial)、右偏(partialRight)、柯里化(curry)、弃离(tap)、交替(alt)、补救(tryCatch)、同时(seq)、聚集(converge)、映射(map)、分捡(useWith)、规约(reduce)、组合(compose)

  • 谓语组合子 过滤(filter)、分组(group)、排序(sort)

  • 其它 组合子变换juxt

PointFree(点自由?)

把一些对象自带的方法转化成纯函数,不要命名转瞬即逝的中间变量。 这种风格能够帮助我们减少不必要的命名,让代码保持简洁和通用。

进阶: 函子

待续...