初识函数式编程

139 阅读9分钟

为什么要学习函数式编程

  • 函数式编程是随着 React 的流行受到越来越多的关注

  • Vue 3也开始拥抱函数式编程

  • 函数式编程可以抛弃 this

  • 打包过程中可以更好的利用 tree shaking 过滤无用代码

  • 方便测试、方便并行处理

有很多库可以帮助我们进行函数式开发:lodash、underscore、ramda

什么是函数式编程

函数式编程(Functional Programming)FP是编程范式之一,我们常听说的编程范式还有面向过程编程、面向对象编程。

  • 面向对象编程的思维方式:把现实世界中的事物抽象成程序世界中的类和对象,通过封装、继承和多态来演示事物事件之间的联系
  • 函数式编程的思维方式:把现实世界的事物和事物之间的联系抽象到程序世界(对运算过程进行抽象)
    • 程序的本质:根据输入通过某种运算获得相应的输出,程序开发过程中会涉及很多输入和输出的函数
    • 函数式编程中的函数指的不是程序中的函数(方法),而是数学中的函数即映射关系,例如:y = sin(x), x和y的关系
// 非函数式-面向过程编程
let num1 = 2
let num2 = 3
let sum = num1 + num2
console.log(sum)

// 函数式编程,一定要有输入、输出,且相同的输入会得到相同的输出
function add(n1, n2) {
    return n1 + n2
}
let sum = add(2, 3)
console.log(sum)

函数基础回顾

函数式一等公民

  • 函数可以存储在变量中
  • 函数作为参数
  • 函数作为返回值 在JavaScript中函数就是一个普通的对象,我们可以把函数存储到变量/数组中,它还可以作为另一个函数的参数和返回值,甚至我们可以在程序运行的时候通过new Function('alert(1)')来构造一个函数
    new Function语法
// 这里有一个没有参数的函数,只有函数体:
let sayHi = new Function('alert(1)')
sayHi()

高阶函数

  • 可以把函数作为参数传递给另一个函数
  • 可以把函数作为另一个函数的返回结果
// 函数作为参数-实现数组的forEach方法
function forEach(array, fn) {
    for(let i = 0; i < array.length; i++) {
        fn(array[i])
    }
}
// 函数作为返回值
function once(fn) {
    let done = false
    return function() {
        if(!done) {
            done = true
            return fn.apply(this, arguments)
        }
    }
}
// 测试
let pay = once(function(money){
    console.log(`支付:${money}`)
})
// 多次调用,只会有一次输出
pay(5)
pay(5)
pay(5)

高阶函数的意义:

  • 抽象可以帮我们屏蔽实现的细节,只需要我们关注于目标
  • 高阶函数用来抽象通用的问题 常用的高阶函数:
  • forEach
  • map
  • filter
  • every
  • some
  • find/findIndex
  • reduce
  • sort
  • ...

闭包

概念:可以在另一个作用域中调用一个函数的内部函数并访问该函数的作用域中的成员 闭包的本质:函数执行完毕后会从执行栈中移除,但是堆上的作用域成员因为被外部引用不能释放,因此内部函数依然可以访问外部函数的成员。

// 闭包案例
function sayHi(msg) { // msg 为外层函数作用域的变量
    return function (name) {
        console.log(`Hi ${name}, ${msg}`)
    }
}
const tomSayHi = sayHi('Good Morning') // Msg 值为 'Good Morning',被保存在内存中不会被销毁,即闭包
tomSayHi('Jane')

函数式编程相关的概念

纯函数

定义:相同的输入永远会得到相同的输出,而且没有任何可观察的副作用

let numbers = [1, 2, 3, 4, 5]
// 纯函数-不会改变原数组
numbers.slice(0, 3)
// => [1, 2, 3]
numbers.slice(0, 3)
// => [1, 2, 3]

// 不纯的函数-会改变原数组
numbers.splice(0, 3)
// => [1, 2, 3]
number.splice(0, 3)
// => [4, 5]

loadsh是一个纯函数的功能库,提供了对数组、数字、对象、字符串、函数等操作的一些方法

副作用:让一个函数变得不纯,如果函数依赖于外部的状态就无法保证输出相同,就会带来副作用。
副作用的来源:配置文件、数据库、获取用户输入...... 。
副作用使得方法通用性下降不适合扩展和可重用性,同时副作用会给程序中带来安全隐患给程序带来不确定性,但是副作用不可能完全禁止,尽可能控制它们在可控范围内发生。

柯里化

定义:

  • 当一个函数有多个参数的时候,先传递一部分参数调用它(这部分参数以后永远不变),
  • 然后返回一个新的函数接收剩余的参数,返回结果

示例:

function checkAge(age) {
    let min = 18 // 硬编码
    return age >= min
}

// 普通的纯函数
function checkAge(min, age) {
    return age >= min
}
checkAge(18, 24)
checkAge(18, 20)

// 柯里化
function checkAge(min) {
    return function (age) {
        return age >= min
    }
}

// ES6写法
let checkAge = min => (age => age >= min)
let checkAge18 = checkAge(18)

checkAge18(24)
checkAge18(20)

模拟loadsh中的柯里化函数的实现

_.curry(func)

  • 功能:创建一个函数,该函数接收一个或多个func的参数,如果func所需的参数都被提供则执行func并返回执行的结果,否则继续返回该函数并等待接收剩余的参数。
  • 参数:需要柯里化的函数
  • 返回值:柯里化后的函数
function curry(func) {
    return function curriedFn(...args) {
        // 判断实参和形参的个数
        if (args.length < func.length) {
            return function () {
                return curriedFn(...args.concat(Array.from(arguments)))
            }
        }
        // 实参和形参个数相同,调用 func,返回结果
        return func(...args)
    }
}

柯里化的作用:

  • 可以把多元函数转换成一元函数,可以组合使用函数产生强大的功能
  • 让函数变得更灵活,让函数的粒度更小

函数组合

纯函数和柯里化容易写出洋葱代码h(g(f(x))),函数组合可以让我们把细粒度的函数重新组合生成一个新的函数

image.png 上面这张图中可以想象成把fn这个管道拆分成了3个管道f1,f2,f3,数据a通过管道f3得到结果m,m再通过f2得到结果n,n通过管道f1得到最终结果b

fn = compose(f1, f2, f3)
b = fn(a)

函数就像是数据的管道,函数组合就是把这些管道链接起来,让数据穿过多个管道形成最终结果。

模拟实现loadsh的flowRight方法

// 多函数组合
function compose (...fns) {
    return function (value) {
        return fns.reverse().reduce(function (acc, fn) {
            return fn(acc)
        }, value)
    }
}

// ES6写法
const compose = (...fns) => value => fns.reverse().reduce((acc, fn) => fn(acc), value)

注意:

  • 函数组合默认是从右到左执行
  • 函数的组合要满足结合律,我们既可以把g和h组合,还可以把f和g组合,结果都是一样的
// 结合律
let f = compose(f, g, h)
let associative = compose(compose(f, g), h) == compose(f, compose(g, h))
  • loadsh/fp loadsh的fp模块提供了实用的对函数式编程友好的方法

Functor(函子)

为什么学习函子

学习函子是为了在函数式编程中借助各种各样的函子进行把副作用控制在可控的范围内、异常处理、异步操作等。

函子的定义:

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

函子示例

// 一个容器,包裹一个值
class Container {
    // of 静态方法,可以省略 new 关键字创建对象
    static of(value) {
        return new Container(value)
    }
    
    constructor (value) {
        this._value = value
    }
    
    // map方法,传入处理值的方法
    map (fn) {
        return Container.of(fn(this._value))
    }
}

// 测试
const r = Container.of(3)
    .map(x => x + 2)
    .map(x => x * x)
console.log(r)// Container {_value: 49}

总结:

  • 函数式编程的运算不直接操作值,而是由函子完成
  • 我们可以把函子想象成一个盒子,这个盒子里封装了一个值
  • 想要处理盒子中的值,我们需要给盒子的map方法传递一个处理值的函数,由这个函数来对值进行处理
  • 最终map方法返回一个包含新值的盒子(函子)

MayBe函子

MayBe函子的作用就是可以对外部的控制情况做处理(控制副作用在允许的范围)

class MayBe {
    static of(value) {
        return new MayBe(value)
    }
    
    constructor(value) {
        this._value = value
    }
    
    // 如果传入的为空值,则直接返回值为 null 的函子
    map(fn) {
        return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value))
    }
    
    isNothing () {
        return this._value === null || this._value === undefined
    }
    
}

// 传入具体值
MayBe.of('Hello world')
    .map(x => x.toUpperCase())
// => MayBe { _value: 'HELLO WORLD' }
// 传入 null 的情况
MayBe.of(null)
    .map(x => x.toUpperCase())
// => MayBe { _value: null }

在MayBe函子中,我们很难确认是哪一步产生的空值问题,如下例:

MayBe.of('hello world')
    .map(x => x.toUpperCase())
    .map(x => null)
    .map(x => x.split(' '))
// => MayBe { _value: null }

Either函子

Either两者中的任何一个,类似于if...else...的处理,异常会让函数变得不纯,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 Rgiht(value)
    }
    
    constructor (value) {
        this._value = value
    }
    
    map(fn) {
        return Right.of(fn(this._value))
    }
}

// Either 用来处理异常
function parseJSON(json) {
    try {
        return Right.of(JSON.parse(json))
    } catch (e) {
        return Left.of({error: e.messge})
    }
}

let r = parseJSON('{ name: zs }')
    .map(x => x.name.toUpperCase())
console.log(r)
// => Left {_value: { error: 'Unexpected token n in JSON at position 2 '}}

IO函子

IO函子中的_value是一个函数,IO函子可以把不纯的动作存储到_value中,延迟执行这个不纯的操作(惰性执行),包装当前的操作,把不纯的操作交给调用者来处理

const fp = require('lodash/fp')
class IO {
    // 接收一个值,并把它包裹在一个方法里,当需要这个值的时候,需要调用方法取值
    static of (value) { 
        return new IO(function () {
            return value
        })
    }
    
    // 接收一个函数,把它存在 _value 中
    constructor (fn) {
        this._value = fn
    }
    
    map(fn) {
        // 把当前的 value 和传入的 fn 组合成一个新的函数
        return new IO(fp.flowRight(fn, this._value))
    }
}

// 调用
let r = IO.of(process).map(p => p.execPath)
console.log(r._value()) 

Task 异步执行

folktale一个标准的函数式编程库,和lodash、ramda不同的是,他没有提供很多功能函数,只是提供了一些函数处理的操作,例如:compose、curry等,一些函子Task、Either、MayBe等

const fs = require('fs')
const { task } = require('folktale/concurrency/task')
const { split, find } = require('lodash/fp')

function readFile(filename) {
    return task(resolver => {
        fs.readFile(filename, 'utf-8', (err, data) => {
            if(err) resolver.reject(err)
            resolver.resolve(data)
        })
    })
}

// 调用 run 执行
readFile('package.json')
    .map(split('\n'))
    .map(find(x => x.includes('version')))
    .run().listen({
        onRejected: err => {
            console.log(err)
        },
        onResolved: value => {
            console.log(value)
        }
    })

Pointed 函子

  • Pointed函子是实现了of静态方法的函子
  • of方法是为了避免使用new来创建对象,更深层的含义是of方法用来把值放到上下文Context(把值放到容器中,使用map来处理值)

Monad(单子)

  • Monad函子是可以变扁的Pointed函子,IO(IO(x))
  • 一个函子如果具有join和of两个方法并遵守一些定律就是一个Monad
const fp = require('lodash/fp')
// IO Monad
class IO {
    static of (x) {
        return new IO(function () {
            return x
        })
    }
    
    constructor (fn) {
        this._value = fn
    }
    
    map (fn) {
        return new IO(fp.flowRight(fn, this._value))
    }
    
    join () {
        return this._value()
    }
    
    flatMap (fn) {
        return this.map(fn).join()
    }
}

let r = readFile('package.json')
            .map(fp.toUpper)
            .flatMap(print)
            .join()
  • 使用场景: 当一个函数返回一个函子时,可以使用Monad,Monad可以用来解决函子嵌套的问题。当我们想要合并一个函数,并且这个函数返回一个值,此时可以调用Map方法,当我们想要合并一个函数,这个函数返回一个函子,这个时候我们要用flatMap方法。

总结

函数式编程的优点

  • 代码简洁,开发快速: 函数式编程使用了大量的函数组合,函数的复用率很高,减少了代码的重复,因此程序比较短,开发速度较快。
  • 接近自然语言,易于理解: 函数式编程大量使用声明式代码,基本都接近自然语言,加上没有乱七八糟的循环,判断的嵌套,因此特别易于理解。
  • 易于"并发编程": 函数式编程没有副作用,所以函数式编程不需要考虑“死锁”,所以根本不存在“锁”线程的问题。
  • 更少的出错概率: 因为每个函数都很小,而且相同的输入永远可以得到相同的输出,因此测试很简单,没有副作用,因此也很少出现奇怪的Bug。

函数式编程的缺陷

  • 性能: 函数式编程往往多一个方法进行过度的包装,从而会产生上下文切换的性能开销。同时在 JS 这种非函数式语言中,函数式的方式会比直接写语句指令慢(引擎会针对很多指令做特别的优化)。
  • 资源占用: 在 JS 中为了实现对象状态的不可变,往往会创建新的对象,因此,它对垃圾回收所产生的压力远远超过其他编程方式。这在某些场合会产生十分严重的问题。
  • 递归陷阱:在函数式编程中,为了实现迭代,通常会采用递归操作,为了减少递归的性能开销,我们往往会把递归写成尾递归形式,以便让解析器进行优化。但是众所周知,JS 是不支持尾递归优化的。

学习函数式编程的意义在于:让你意识到在指令式编程,面向对象编程之外,还有一种全新的编程思路,一种用函数的角度抽象问题的思路。在日常工作中将函数式编程作为一种辅助手段,在条件允许的前提下,借鉴函数式编程中的思路,例如:

  • 多用纯函数减少副作用的影响
  • 使用柯里化增加函数适用率
  • 使用Pointfree编程风格,减少无意义的中间变量,让代码更简洁易读
  • ......

参考文章

简明 JavaScript 函数式编程——入门篇