函数式编程

204 阅读11分钟

概要

函数式编程思想是一种起源很早的编程思路,它在Javascript中的作用也随着Reac的兴起越来越受到重视

为什么要学习函数式编程

  • 函数式编程随着React的流行受到越来越多的关注
  • Vue3也开始拥抱函数式编程
  • 函数式编程可以抛弃this
  • 打包过程中可以更好的利用 tree sharking 过滤无用代码
  • 方便测试,方便并行处理
  • 有很多库可以帮助我们进行函数式开发:lodash、underscore、ramda

函数式编程的相关概念

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

函数是一等公民

  • 函数可以存储在变量中
  • 函数可以作为参数
  • 函数可以作为返回值

在 Javascript 中,函数就是一个普通的对象(可以通过new Function() 生成),我们可以把函数存储到变量、数组中,它还可以作为另一个函数的参数或返回值

什么是高阶函数

高阶函数也是函数,但是它具有以下特点:

1、可以把函数作为参数传递给另一个函数
// 在这个示例代码中,item => console.log(item)即作为参数被传递到forEach函数中
const foo = [1,2,4,6,7,9];

function forEach(arr, fn) {
    for (let i = 0; i < arr.length; i++) {
        fn(arr[i]);
    }
}

forEach(foo,item => console.log(item))
// 1 2 4 6 7 9
2、可以把函数作为另一个函数的返回结果
// 函数作为返回值最基础用法
function foo() {
    const bar = 100;
    
    return function() {
        console.log(bar);
    }
}

// 函数作为返回值-实现once函数
function once(fn) {
    let done = false;
    
    return function() {
        if (!done) {
            done = true;
            return fn.apply(this, arguments)
        }
    }
}
const fn = once((money) => console.log(money))

fn(100)
fn(100)
fn(100)
// 100
// 虽然执行了三次,但是传入once的函数只会被执行一次

高阶函数的意义

高阶函数可以抽象通用问题,对于某一类问题的解决方案,只关注具体细节而省去前面通用的解决方案(代码),简而言之,高阶函数有利于简洁、抽象公共代码

// 这里的forEach函数即帮开发者忽略了如何去循环数组这个问题,直接针对数组中每个元素做处理即可,即抽象了循环这一逻辑,开发者只需关注数据处理
const foo = [1,2,4,6,7,9];

function forEach(arr, fn) {
    for (let i = 0; i < arr.length; i++) {
        fn(arr[i]);
    }
}

forEach(foo,item => console.log(item))
// 1 2 4 6 7 9

常见的高阶函数

Javascript原生提供的高阶函数如map、forEach、filter、reduce方法等非常常见,这些方法的共同特点就是必定有一个参数为函数,并且这些方法都几乎会循环数组,但是其内部抽象了循环逻辑,使用户只需关注数据处理逻辑

// 模拟实现map函数
const map = (arr, fn) => {
    const result = [];
    
    for (let item of arr) {
        result.push(fn(item));
    }
    
   return result; 
}
console.log(map([1,2,3,4], item => item * item))
// [1, 4, 9, 16]

// 模拟实现every函数
const every = (arr, fn) => {
    let result = true
    
    for (let item of arr) {
        result = fn(item)
        if (!result) {
            break
        }
    }
    
    return result
}

console.log(every([11,12,13,14], v => v > 10));
// true

// 模拟实现some函数
const some = (arr, fn) => {
    let result = false
    
    for (let v of arr) {
        result = fn(v)
        if (result) {
            break
        }
    }
    
    return result
}

console.log(some([1,2,3,7], v => v % 2 === 0))
// true

纯函数

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

纯函数的好处 - 缓存

调用纯函数时,对于相同参数,无论调用几次,结果始终是相同的。那么针对于部分耗时的函数,便可以将第一次调用后的结果缓存起来,以后每次调用时只要返回第一次调用时的结果即可

// 模拟实现lodash的memorize函数
const memorize = fn => {
    const cache = {}
    
    return function() {
      let arg_str = JSON.stringify(arguments)
      
      cache[arg_str] = cache[arg_str] || fn.apply(this, arguments)
      
      return cache[arg_str]
    }
}

const size5 = (size) => {
    console.log('hh')
    return size * 5
}

const size5_mem = memorize(size5)

console.log(size5_mem(10))
console.log(size5_mem(10))
console.log(size5_mem(10))

// hh 
// 50
// 50
// 50

上面示例代码中,hh只会被打印一次,因为size5函数的执行结果被缓存了,第二次调用size5_mem函数时,缓存结果直接就被返回了

副作用

概念:副作用是让一个函数变得不纯(如上例),纯函数会根据相同的输入返回相同的输出,如果函数依赖于外部的状态,那么对于相同的输入就无法保证每次输出相同,就会带来副作用

副作用的来源:全局变量、配置文件、数据库、用户输入等

注意:所有的外部交互都有可能带来副作用,副作用也会使得方法通用性下降、不利于扩展和重用,同时副作用会给程序带来不确定性,但是副作用不可能完全禁止,我们只能尽可能控制它们在合理范围内发生。

// 不纯的(min来自外部环境,checkAge运行时它可能随时发生变化,因此调用checkAge函数时,即使传入相同参数,不能保证每次的输出结果都相同)
let min = 18

function checkAge(age) {
    return age >= min
}

// 纯的(min是写死的,这被称为硬编码,后面会通过柯里化解决)
function checkAge(age) {
    let min = 18
    return age >= min
}

柯里化

使用柯里化可以解决硬编码问题

上面示例代码中的checkAge函数虽然通过在函数内部写死min的方式来避免函数不纯,但是失去了灵活性

下面展示通过柯里化来解决这种硬编码问题,使得函数即纯又不失灵活

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

const checkAge18 = checkAge(18);
const checkAge20 = checkAge(20)

checkAge18(17) // false
checkAge18(20) // true
checkAge20(19) // false
checkAge20(22) // true

柯里化的概念:当函数有多个参数的时候,先传递一部分参数调用它并返回一个新的函数(在新的函数中,这部分参数就是固定的了),只需在新函数中接收剩余参数并调用,就完成了之前函数的全部功能

lodash中的柯里化函数

_.curry(func):创建一个函数,该函数接收一个或多个func的参数,如果 func 所需要的参数都被提供则执行 func 并返回执行的结果,否则会继续返回一个函数并等待接收剩余的参数,当参数全部被传入后,函数会返回执行结果。curry 函数的返回值是柯里化后的函数,参数是需要进行柯里化的函数(如func)。

function add(a,b,c) {
    return a + b + c;
}

const add_curr = _.curry(add)

add_curr(1)(2)(3) // 6
add_curr(1,2,3) // 6
add_curr(1,2)(3) // 6
add_curr(1)(2,3) // 6

模拟实现curry函数

function curry(fn) {
    return function curryFn(...arg) {
        if (fn.length > arg.length) {
            return function() {
                return curryFn(...arg.concat(Array.from(arguments)))
            }
        }
        
        return fn(...arg)
    }
}

function add(a,b,c) {
    return a + b + c;
}

const add_curr = curry(add)

add_curr(1)(2)(3) // 6
add_curr(1,2,3) // 6
add_curr(1,2)(3) // 6
add_curr(1)(2,3) // 6

柯里化函数的总结

  • 柯里化可以让我们给一个函数传递较少的参数得到一个已经记住了某些固定参数的新函数,达到反复利用的效果
  • 柯里化可以认为是一种对函数部分参数的‘缓存’
  • 可以把多元函数转为一元函数
  • 不恰当的使用柯里化很容易写出洋葱代码,和回调嵌套一样,非常不友好

函数组合

概念:如果一个函数要经过多个函数处理才能得到最终值,并且我们并不关心处理过程中的数据变化,这个时候可以将处理过程中的多个函数合并成一个函数

function compose(foo, bar) {
    return function(v) {
        return foo(bar(v))
    }
}

function reverse(arr) {
    return arr.reverse()
}

function first(arr) {
    return arr[0]
}

const last = compose(first, reverse)

console.log(last([1,2,3,4,5]))

上面示例代码,通过组合数组反转和获取数组第一个元素这两个功能,达到获取数组最后一个元素的目的。虽然本身这个函数无任何实际意义,但是它表达的是组合使用不同功能的函数生成新函数的方法

lodash 中的组合函数 flowRight

lodash提供了类似于上面compose函数的组合函数flowRight,不过它支持无限个参数,也就是说它可以将无数个函数按照从右到左的方向依次执行

import _ from 'lodash'

const reverse = arr => arr.reverse()
const first = arr => arr[0]
const upperCase = str => str.toUpperCase()

const foo = _.flowRight(upperCase, first, reverse)

console.log(foo(['hh', 'ff', 'ee']))
// EE

模拟实现 lodash 的 flowRight 函数

const reverse = arr => arr.reverse()
const first = arr => arr[0]
const upperCase = str => str.toUpperCase()

// ES5版本
function flowRight(...args) {
  return function (v) {
    return [...args].reverse().reduce(function (acc, cur) {
      return cur(acc)
    }, v)
  }
}
const foo = flowRight(upperCase, first, reverse)
foo(['dd','aa','ee'])
// EE

// 使用箭头函数简化代码
const reverse = arr => arr.reverse()
const first = arr => arr[0]
const upperCase = str => str.toUpperCase()

const flowRight = (...args) => v => [...args].reverse().reduce((acc, cur) => cur(acc), v)

const foo = flowRight(upperCase, first, reverse)
foo(['dd','aa','ee'])
// EE

lodash 的 fp 模块

由于组合函数的参数要求是只有单个参数的函数,而lodash默认提供的函数如map、split等都是多个参数,且前面的参数不是数据,这就不符合组合函数参数的要求

lodash 的 fp 模块提供了对函数式编程友好的方法(它所提供的方法都是柯里化后的,并且是函数优先,数据之后

fp 模块提供的方法都具有 iteratee-first data-last 的特征,也就是说,fp模块的方法的最后一个参数是数据,前面的参数则是方法,这样,在组合函数时,就可以按照把数据流依次在组合函数的多个参数之间依次流转并处理(如:flowRight是从右向左)

import fp from 'lodash/fp'

const foo = fp.flowRight(fp.join('-'), fp.map(fp.upperCase), fp.split('-'))

console.log(foo('aad-dsada-sdada-sdad'))

// AAD-DSADA-SDADA-SDAD

Note:flowRight 函数中的参数应为处理数据的方法,因此这些函数都对数据做直接处理,数据像是流水线上的产品,被流水线上的工具依次处理

import fp from 'lodash/fp'

const format = fp.flowRight(fp.replace(/\s+/g, '_'), fp.toUpper)

console.log(format('ss dd dsaa    sds'))
// SS_DD_DSAA_SDS

Functor(函子)

函子的作用:函数的不纯是无法彻底避免的,在函数式编程过程中如何把副作用控制在可控的范围内非常重要,而函子就是实现控制的最佳方法

函子的概念:它是一个特殊的容器,在代码中通过一个普通的对象来实现,该对象具有 map 方法以及它的私有属性值(私有性是约定),map 方法可以运行一个函数对私有属性值进行处理,简单来说,就是容器具有私有属性,该私有属性只能通过 map 方法传入的函数来进行处理

class Container {
    constructor(value) {
        this._value = value
    }
    map(fn) {
        return new Container(fn(this._value))
    }
}

console.log(new Container(5).map(v => v + 1).map(v => v * v))
// {_value: 36}

// Container类实际上实现了链式调用

MayBe函子

正常情况下使用函子是一个通过连续调用 map 函数对函子内部的值进行处理的过程,但是当这中间处理过程产生任意错误时,程序就会抛出异常,解决这种异常的思路之一就是提前避免异常

不过这种提前预防的思路虽然可以避免程序抛出异常,但是无法准确知道程序在哪一个环节出现了问题,导致返回值为 null 或 undefined

class MayBe {
    constructor(value){
        this._value = value
    }
    map(fn) {
        return this._value == null? new MayBe(null) : new MayBe(fn(this._value))
    }
}

console.log(new MayBe(5).map(v => null).map(v => v.toUpperCase()))
// {_value: null}

Either函子

如何使用函子时既能对正确的数据加以处理,对错误加以返回并不影响后续的操作呢

class Left {
    constructor(value) {
        this._value = value
    }
    map(){
        // 针对错误处理使用Left类,对所有的map处理都直接返回发生错误时生成的Left实例
        return this
    }
}

class Right{
    constructor(v) {
        this._value = v
    }
    map(fn) {
        return new Right(fn(this._value))
    }
}

function parseJson(str) {
    try {
        return new Right(JSON.parse(str))
    } catch(e) {
        return new Left({error: e.message})
    }
}

let l = parseJson('{ name: hh }')
console.log(l)
// {_value: {error: "Unexpected token n in JSON at position 2"}}

let r = parseJson('{ "name": "hh" }')
console.log(r)
// {_value: {name: "hh"}}

对函子的总结

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