突破桎梏(二):函数式编程

突破桎梏(二):函数式编程_第1张图片

PS:一万三千字长文,建议先 mark 后看,拿去吃灰也不错~

以下正文(首发于 2020年11月19日 02:34)

两年前,当我还在做后端的日子里,我在编程时经常使用的是面向对象编程范式,当时我也有接触函数式编程思想,但总感觉函数式没有面向对象好使。直到我转岗到前端,发现使用面向对象的机会很少,一直是函数来函数去的方法定义,普普通通平平无奇。我也一直以为函数式编程就是这样定义函数用来用去,但后来学习到事情的真相,函数式编程真没想象中那么简单。

在阮一峰大佬的函数式编程的教程日志下,清晰记录他的教程开始日期是2017年2月,距离我现在接触函数式编程相隔了四年时间。我看着这个时间特别感慨,大佬都是走在知识前沿,如果我在编程刚开始时候也紧跟大佬脚步,是不是就不会落后太多,一步晚步步晚。于是为了紧跟大佬脚步,我们还是要卷起来咳咳…就这样,我也不写更多的了,有疑问或者犹豫的随时评论 call 我,我是一个非常乐意帮助你成长的人。有时间我会开个专栏多聊聊自己聊聊生活,欢迎关注我。那么,下面开始正题。

原创不易,看在我每天凌晨两三点刻苦学习和更文的份上,请大家一键三连~

语雀的排版可能会好些:高级前端工程师

一、函数式编程是什么

  1. 是一种编程风格、亦或是一种思维模式,和面向对象编程是并列关系
  2. 抽象出细粒度的函数,可以组合为更强大的函数
  3. 函数指的不是程序中的方法,而是数学中的映射关系
  4. 相同的输入得到相同的输出
  5. 函数式编程是运算过程的抽象

二、为什么学习函数式编程

  1. 函数式编程方便测试和并行处理
  2. 使代码更简洁
  3. 更灵活

三、和函数式编程相关的概念

  1. 函数是一等公民(First-class Function
    注:该概念是高阶函数、柯里化等的基础概念
    ① 函数可以存储在变量中
    ② 函数作为参数
    // 模拟 forEach
    function forEach(array: any[], fn: Function): void {
        for (let i = 0; i < array.length; i++) {
            fn(array[i])
        }
    }
    
    forEach([1, 3, 5, 7], (item) => {
        console.log(item);
    })
    
    // 模拟 filter
    function filter(array: any[], fn): any[] {
    let result = []
    for (let i = 0; i < array.length; i++) {
        const item = array[i]
        const flag = fn(item)
        if (flag) { result.push(item) }
    }
    return result
    }
    
    const filterResult = filter([1, 3, 5, 7], (item) => { return item < 3 })
    console.log(filterResult);
    
    ③ 函数作为返回值
    // 函数作为返回值
    function add(a: number) {
        return function (b: number) { return a + b }
    }
    const addResult = add(1)(2)
    console.log(addResult);
    
    // 模拟用户多次点击但仅执行一次
    function once(fn: Function): Function {
        let done: boolean = false
        return function () {
            if (!done) {
                done = true
                fn.apply(this, arguments);
            }
        }
    }
    
    const onceExample = once((message) => {
        console.log(message);
    })
    onceExample('点击')
    onceExample('点击')
    
  2. 高阶函数(Higher-order Function
    高阶函数用来抽象通用的问题,让使用者不需要关心函数内的逻辑细节
    注:React中高阶组件其实就是高阶函数
    ① 可以把函数作为参数传递给另一个函数
    ② 可以把函数作为另一个函数的返回结果
  3. 闭包
    访问一个函数,该函数的内部函数访问该函数作用域内的成员,导致该函数不会被正常释放,这样即闭包。闭包延长了外部函数内部变量的作用范围。
    闭包的本质:函数在执行时会放到一个执行栈上,当函数执行完毕时从执行栈上移除,但由于堆上的作用域成员被内部函数引用而不能正常释放,因此形成闭包,使内部函数仍可访问外部函数作用域内的变量。
    闭包发生的位置:当内部函数调用时,才会发生闭包现象。(这个结论需要写一个闭包然后在调试界面打断点验证,调试过程中主要看 CallStack(执行栈、Scope(作用域、Closure(闭包
    // 闭包:调用函数的内部函数,且内部函数访问外部函数作用域中的成员
    const mkF = () => {
        let number = 1
        return (num: number) => {
            number = number + num
            return number
        }
    }
    const func = mkF()
    console.log(func(1));
    console.log(func(1));
    

函数式编程就是我们要可以在项目里不断的抽出一些低阶公共函数,如此我们可以在不同的页面使用到这些公共函数,而且还可以把各种低阶函数组合为高阶函数,通过柯里化等操作把低阶函数揉起来,这样在页面里也有高阶函数可以使用,代码的可维护性和逼格会大大提升。

其它代码:

  1. 模拟 map
    // 模拟 map
    const map = (array: any[], fn: Function) => {
        let result = []
        forEach(array, (item: any) => {
            result.push(fn(item))
        })
        return result
    }
    
    const mapResult = map([1, 3, 5, 7], (item: number) => {
        return item + 1
    })
    console.log(mapResult);
    
  2. 模拟 every
    注意空数组时是返回 true
    const every = (array: any[], fn: Function) => {
        let result = true
        forEach(array, (item: any) => {
            if (!fn(item)) {
                return result = false
            }
        })
        return result
    }
    const everyResult = every([1, 3, 5, 7], (item: number) => {
        return item > 0
    })
    console.log(everyResult);
    
  3. 模拟 some
    注意空数组时是返回 false
    const some = (array: any[], fn: Function) => {
        let result = false
        forEach(array, (item: any) => {
            if (fn(item)) {
                return result = true
            }
        })
        return result
    }
    const someResult = some([1, 3, 5, 7], (item: number) => {
        return item > 6
    })
    console.log(someResult);
    

四、纯函数

1. 纯函数的概念与示例
  • 纯函数根据相同的输入始终能够得到相同的输出,且没有任何可观察的副作用
  • 纯函数必须要有输入,同样也必须要有输出
  • 函数式编程不会保留计算中间结果
  • 纯函数简单示例:
    let array = [1,3,5,7,9]
    console.log(array.slice(0,3))
    console.log(array.slice(0,3))
    console.log(array.slice(0,3))
    
  • 不纯函数简单示例:
    let array = [1,3,5,7,9]
    console.log(array.splice(0,3))
    console.log(array.splice(0,3))
    console.log(array.splice(0,3))
    
2. 纯函数的好处
1. 可缓存

因为纯函数对相同的输入始终有相同的结果,所以可以把纯函数的结果缓存起来,Lodash里的memoize就有缓存纯函数的作用。示例如下:

// 缓存纯函数结果
function getArea(r) {
    console.log(r);  // 该行在输入参数相同时将仅执行一次
    return Math.PI * Math.pow(r, 2)
}
const getAreaMemory = _.memoize(getArea)
console.log(getAreaMemory(4));
console.log(getAreaMemory(4));
console.log(getAreaMemory(4));

那么memoize的原理是什么呢,我们模拟memoize的实现代码如下:

// 模拟 memoize
function memoizeMock(fn: Function): Function {
    let cache = {}
    return function () {
        let key = JSON.stringify(arguments)
        cache[key] = cache[key] || fn.apply(fn, arguments)
        return cache[key]
    }
}

const getAreaMemoryMock = memoizeMock(getArea)
console.log(getAreaMemoryMock(4));
console.log(getAreaMemoryMock(4));
console.log(getAreaMemoryMock(4));

根据上面结果,我们能够体会到纯函数的好处:可缓存以提升程序性能。

2 .因为纯函数始终存在输入和输出,而单元测试就是在断言函数的结果,所以纯函数都是可测试的函数。
3 .因为纯函数不需要访问共享的内存数据,所以并行环境下可以任意运行纯函数,且因不需要操作内存数据从而不会出现意外情况。

五、Lodash

Lodash中的FP模块里的函数都是纯函数,以下暂时简单示例下不是FP模块的简单使用。

1. Lodash的安装
安装 `lodash`:`npm install lodash`
2. Lodash中 非FP的函数使用
import * as _ from 'lodash'

const array = [1, 3, 5, 7, 9]
// 取数组首位
console.log(_.first(array));
// 取数组末尾
console.log(_.last(array));
// 取数组首位并转字符串
console.log(_.toString(_.first(array)));
// 反转数组
console.log(_.reverse(array));
// 遍历数组
const r = _.each(array, (item) => {
    console.log(item);
})
console.log(r);

六 、不纯函数的副作用

--- 0.0 ---

0.0 有副作用的函数是不纯函数
0.0 不纯函数一定存在副作用

1. 外部状态的依赖导致副作用

因为纯函数根据相同的输入返回相同的输出,而如果函数依赖有外部的状态,那么只要外部状态发生变化,函数就无法保证相同的输入得到的输出和之前相同,就会带来副作用,而该外部状态也是副作用的来源。
示例如下:

// 外部变量导致函数不纯
let mini = 18
function checkAge(age) {
    return age >= mini
}
2. 其它副作用

此外,副作用的来源还存在于如配置文件数据库获取用户的输入等。

3. 副作用的坏处

所有外部交互都有可能导致副作用,副作用也使得方法通用性下降不适合扩展可重用性变弱,同时副作用还会给程序带来安全隐患和不确定性
但是副作用不可能完全禁止(如用户名密码肯定还是要放数据库等等),只能尽可能控制它们在可控范围内发生。

七、Lodash FP 纯函数库的使用

/*
* Lodash 中的 FP 模块
* FP 提供了已被柯里化的实用的对函数式编程友好的方法
* FP 提供的方法使用时,参数都是函数优先,数据滞后
* 而 Lodash 中 非FP 模块的方法使用时,则是数据优先,函数滞后
* 数据要放在最后一个参数,这是为了方便柯里化。
*/
import * as _ from 'lodash'
import * as fp from 'lodash/fp'
// 1. FP 的简单使用与 非FP 方法对比
// map + toUpper 示例
const oneResult_1 = fp.map(fp.toUpper, ['a', 'b', 'c'])  // FP
const oneResult_2 = _.map(['a', 'b', 'c'], _.toUpper)  // 非 FP

// 2. NEVER SAY DIE --> never-say-die
const transferStr = fp.flow(fp.split(' '), fp.map(fp.toLower), fp.join('-'))
const twoResult = transferStr('NEVER SAY DIE')

// 3. lodash 不如 lodash/fp 的地方
// lodash 里的 map 会向指定的处理函数传递3个参数,会导致出现预期之外的结果
// 如下:
const threeResult_1 = _.map(['23', '8', '10'], parseInt)
// 而 FP 里的方法则因已柯里化,所以传入参数不会出现问题
const threeResult_2 = fp.map(parseInt, ['23', '8', '10'])
// lodash 模块 
const _ = require('lodash') 
_.map(['a', 'b', 'c'], _.toUpper)
// => ['A', 'B', 'C'] 
_.map(['a', 'b', 'c']) 
// => ['a', 'b', 'c'] 
_.split('Hello World', ' ')
// lodash/fp 模块 
const fp = require('lodash/fp') 
fp.map(fp.toUpper, ['a', 'b', 'c']) 
fp.map(fp.toUpper)(['a', 'b', 'c']) 
fp.split(' ', 'Hello World') 
fp.split(' ')('Hello World')
const fp = require('lodash/fp')
const f = fp.flowRight(fp.join('-'), fp.map(_.toLower), fp.split(' ')) 
console.log(f('NEVER SAY DIE'))

八、Point Free

import { LoDashFp } from 'lodash/fp';
const fp: LoDashFp = require('lodash/fp')
/*
* Point Free
* Pointfree 就是如何使用函数式编程的答案
* https://fr.umio.us/favoring-curry/
* http://lucasmreis.github.io/blog/pointfree-javascript/
* http://www.ruanyifeng.com/blog/2017/03/pointfree.html
*/
// 1. point free 概念
// 不使用所要处理的值,只合成运算过程。中文可以译作"无值"风格。
// var addOne = x => x + 1;
// var square = x => x * x;
// 上面是两个简单函数addOne和square。
// 把它们合成一个运算。
// var addOneThenSquare = R.pipe(addOne, square);
// addOneThenSquare(2) //  9
// 上面代码中,addOneThenSquare是一个合成函数。
// 定义它的时候,根本不需要提到要处理的值,这就是 Pointfree。
// Pointfree 的本质就是使用一些通用的函数,组合出各种复杂运算。
// 上层运算不要直接操作数据,而是通过底层函数去处理。这就要求,将一些常用的操作封装成函数。

// 2.过程式 和 point free 模式 示例
// 非 point free  Hello World -> hello_world
function noPointFree(word: string) {
    return word.toLowerCase().split(' ').join('_')
}
// point free 模式
const twoPointFree = fp.flow(fp.toLower, fp.split(' '), fp.join('_'))
const twoResult_1 = twoPointFree('Hello World')
const twoResult_2 = twoPointFree('Hello World')

// 3. Point free 复杂案例
// world wild web ==> W. W. W
const threePointFree = fp.flow(fp.upperCase, fp.split(' '), fp.map(fp.first), fp.join('. '))
const threeResult = threePointFree('world wild web')

九、函子

import { LoDashFp } from 'lodash/fp'
const fp: LoDashFp = require('lodash/fp')
const _ = require('lodash')
/*
* 函子
* 自己的理解:函子就是具有存储原始值、变换原始值、返回新函子功能的容器,表现形式通常以类Class形式抒写。
* 函子具有 map 方法来返回新的函子,还可通过函子控制函数式编程中的副作用
* 需要注意的是,函子传入的函数是纯函数
* http://www.ruanyifeng.com/blog/2017/02/fp-tutorial.html
* 函数不仅可以用于同一个范畴之中值的转换,还可以用于将一个范畴转成另一个范畴。这就涉及到了函子(Functor)。
* 它首先是一种范畴,也就是说,是一个容器,包含了值和变形关系。
* 比较特殊的是,它的变形关系可以依次作用于每一个值,将当前容器变形成另一个容器。
*/
// 1. 普通函子示例
class Functor {
    // 存储原始值/变换原始值
    val
    constructor(val) {
        this.val = val;
    }
    // 传入新的变换函数,返回新函子
    map(f) {
        return new Functor(f(this.val));
    }
}
const oneResult = new Functor(5).map(val => val + 1)
// 上面代码中,Functor是一个函子,它的map方法接受函数f作为参数,然后返回一个新的函子,里面包含的值是被f处理过的(f(this.val))。
// 一般约定,函子的标志就是容器具有map方法。该方法将容器里面的每一个值,映射到另一个容器。
// 上面生成新的函子的时候,用了new命令。这实在太不像函数式编程了,因为new命令是面向对象编程的标志。
// 函数式编程一般约定,函子有一个of方法,用来生成新的容器。
// 2. of方法替换掉new。
class FunctorTwo {
    _val
    constructor(val) {
        this._val = val
    }
    static of(value) {
        return new FunctorTwo(value)
    }
    map(fn) {
        return FunctorTwo.of(fn(this._val))
    }
}
const TwoResult = FunctorTwo.of(5).map(val => val + 1).map(val => Math.sqrt(val))


// 3. MayBe 控制副作用,如:null/undefined
// 如果使用链式调用 map 变换过程中值出现了空值情况程序就会崩掉,我们可以使用 MayBe 避免这个问题
class MayBe {
    _val
    constructor(val) {
        this._val = val
    }
    static of(val) {
        return new MayBe(val)
    }
    map(fn) {
        return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._val))
    }
    isNothing() {
        return this._val === null || this._val === undefined
    }
}
const threeResult = MayBe.of(5).map(val => val + 1).map(val => null).map(val => Math.sqrt(val))
console.log(threeResult);

// 4. either 函子:处理异常
// maybe 函子仅能处理空值异常,如果遇到其它异常,则可使用 either 函子来拦截错误信息并正常返回异常函子
// Left 用来拦截错误
class Left {
    _val
    static of(val) {
        return new Left(val)
    }
    constructor(val) {
        this._val = val
    }
    map(fn) {
        return this
    }
}
// Right 为正常函子
class Right {
    _val
    static of(val) {
        return new Right(val)
    }
    constructor(val) {
        this._val = val
    }
    map(fn) {
        return Right.of(fn(this._val))
    }
}

const jsonParse = (str: string) => {
    try {
        return Right.of(str).map(val => JSON.parse(val)).map(val => val.name)
    } catch (error) {
        return Left.of({ error: error.message })
    }
}
const fourResult = jsonParse('{name:"zs"}')

// 5. IO 函子
// 若我们希望在函子内使用非纯函数,那么既可以使用IO函子来传入自定义非纯函数
// 函子内仍为纯函数操作,保证非纯操作由使用者自己操作使用
class IO {
    _val
    static of(val) {
        return new IO(function () { return val })
    }
    constructor(fn) {
        this._val = fn
    }
    map(fn) {  // 这里是组合函数而不是调用函数
        return new IO(fp.flow(this._val, fn))
    }
}
// 此处返回值为Function
const fiveResult = IO.of(process).map(fn => fn.execPath)
console.log(fiveResult._val());

十、folktale 工具库

/*
* folktale
* folktale 是一个标准的函数式变成库
* 和 lodash、 ramda不同的是,他没有提供很多功能函数
* 只提供了一些函数式处理的操作,例如: compose、curry等,一些的子Task、 Either、 MayBe等
*/
// 1. folktale 简单使用
import { compose, curry } from 'folktale/core/lambda'
import * as fp from 'lodash/fp'
// - curry
const oneCurry = curry(2, (x, y) => x + y)  // arity 指明fn需要几个参数
const oneResult_1 = oneCurry(1)(2)
//  - compose
const oneCompose = compose(fp.toUpper, fp.first)
const oneResult_2 = oneCompose(['one', 'two'])


// 2. Task 函子处理异步任务
import { task } from 'folktale/concurrency/task'
import * as fs from 'fs'
import { isIP } from 'net'
function readFile(filename: string) {
    return task(resolver => {
        fs.readFile(filename, 'utf-8', (err, data) => {
            if (err) resolver.reject(err)
            resolver.resolve(data)
        })
    })
}
readFile('../package.json').map(fp.split('\n')).map(fp.find((val: string) => val.includes('version')))
    .run().listen({
        onRejected: err => {
            console.log(err);
        },
        onResolved: value => {
            console.log(value);
        }
    })
// 3. Pointed 函子 - 指实现了 of 方法的函子
// of 避免使用 new 来创建对象,但更深层含义是:把值放到容器中,使用 map 来处理值

// 4. monad 单子 ,单词意思为单细胞
// 我们使用组合函数的方式来解决函数嵌套问题
// 那么函子嵌套问题,IO(IO(x)),我们就会使用 monad 函子来解决
// monad 函子定义:一个函子如果具有 join 和 of 两个方法并遵守一些定律就是一个 Monad
// 当我们想要合并一个函数,这个函数返回一个值时,我们可以使用monad的map方法
// 当我们想要合并一个函数,这个函数返回一个函子时,我们可以使用monad的flatMap方法
class IO {
    _val
    static of(val) {
        return new IO(function () {
            return val
        })
    }
    constructor(fn) {
        this._val = fn
    }
    map(fn) {
        return new IO(fp.flow(this._val, fn))
    }
    join() {
        return this._val()
    }
    flatMap(fn) {
        return this.map(fn).join()
    }
}
const readFileMonad = function (filename) {
    return new IO(function () {
        return fs.readFileSync(filename, 'utf-8')
    })
}
const printMonad = function (x) {
    return new IO(function () {
        console.log(x);
        return x
    })
}
const fourResult = readFileMonad('../package.json').map(fp.upperCase).flatMap(printMonad).join()

总结

// 函数式编程总结
// 1. 认识函数式编程
// 2. 函数相关:函数是一等公民、高阶函数、闭包
// 3. 函数式编程基础:lodash、纯函数、柯里化、管道、函数组合
// 4. 函子

// 1.1 什么是函数式编程?
    // 是一种编程范式,和面向对象是一个级别。
    // 函数式编程的核心思想是把运算过程抽象成函数。
// 2.1 函数是一等公民的意思?
    // 函数也是对象,所以我们可以把函数当作值一样处理,如:把函数作为参数、把函数作为返回值。
// 2.2 高阶函数有哪些?
    // 其实就是把函数当作值一样处理,在使用柯里化和函数组合时就是基于高阶函数的。
// 2.3 什么是闭包?
// 3.1 什么是纯函数?
    // - 给一个函数相同的输入,总能得到相同的输出,且没有任何的副作用。
    // - 纯函数可以理解为就是数学中的函数,通过函数来把输入X和输出Y一一映射。
    // - 纯函数的好处有:可缓存、可测试、方便并行处理
// 3.2 柯里化的好处?
    // 可以对函数进行降维处理,我们可以把多元函数转化为一元函数,这样做的目的方便我们进行函数组合。
// 3.3 管道和函数组合的联系?
    // 我们可以把函数想象为处理数据的管道,我们输入一个数据就会得到一个结果,而函数组合可以把多个一元函数组合成一个功能强大的函数。
// 4.1 什么时函子?
    // 函子可以帮助我们控制副作用、进行异常处理或异步操作。
    // 函子可以理解为包含一个值和(处理值并返回结果的函数)的容器。
    // MayBe 处理空值、Either 处理异常,函子里存储的都是一个值。
    // IO 函子存储的是函数,且 of 和别的不一样,可以控制副作用,把不纯的函数处理让使用者传入进去。
    // Task 函子用于处理异步任务。
    // monad 函子用于处理函子嵌套问题,除了 of 和 map,还有 join 和 flatMap 方法。

你可能感兴趣的:(大前端之突破桎梏,函数式编程,大前端,前端,js,vue.js)