函数式编程学习心得及教程

函数式编程


前言

如果你已经从事开发有一段时间了,肯定多少对'函数式编程'有一定的了解,很多人包括我自己在还没有深入了解函数式编程的时候,基本都会有一个误解,函数式编程就是用很多function函数去编程你的代码。最近正好我在学函数式编程,于是就想着写一篇文章去向一些像我一样的初学者去解释一下函数式编程。

什么是函数式编程

我们来看一下百度百科对函数式编程的定义:

简单说,"函数式编程"是一种"编程范式"(programming paradigm),也就是如何编写程序的方法论。
它属于"结构化编程"的一种,主要思想是把运算过程尽量写成一系列嵌套的函数调用。

我查了很多资料,于是我将他们归纳总结了一下:

  • 首先函数式编程(Functional programming,FP)是一种与面向对象编程(Object-oriented programming)和面向过程编程(Procedural programming)并列的编程范式。
  • 函数式编程中的函数不是程序中的函数(方法),而是数学中的函数即映射关系,例如:y=f(x)。
  • 函数式编程最主要的特征就是:函数是一等公民。
  • 对于一个函数,相同的输入始终要得到相同的输出,且没有副作用(纯函数的概念)。
  • 函数式编程旨在讲复杂的数学计算过程分解成可复用的函数。
  • 函数式编程起源于范畴学,我们可以把范畴想象成一个容器,里面包含两样东西:1.值;2.值的变形关系,也就是操作值的函数。

下面举一个对比非函数式编程和函数式编程的例子:

// 非函数式
let a = 1
let b = 2
const sum = a + b

// 函数式
function add(a, b) {
    return a + b
}
const sum = add(1, 2)

函数是一等公民

编程语言中一等公民的概念是由英国计算机学家Christopher Strachey提出来的,特征可以概括为以下几点:

  • 函数可以作为参数
  • 函数可以存储在变量中
  • 函数可以作为返回值
  1. 函数可以作为参数
    函数可以作为参数最典型的应用就是回调函数,例如异步编程中的setTimeout
setTimeout(function() {
    console.log('hello callback')
}, 0)
  1. 函数可以存储在变量中
    函数可以暂时存在一个变量中,等待我们需要执行的时候再执行
const print = function() {
    console.log('hello var')
}
print()
  1. 函数可以作为返回值
    函数可以作为返回值,最常见的应用就是闭包
function autoAdd() {
    let a = 0
    return () => a+=1
}
const add1 = autoAdd()
console.log(add1()) // 1
console.log(add1()) // 2

我们在说函数是'一等公民'的时候,其实就是在说函数在JavaScript中就是普通对象,只不过函数的一些特性使得函数相比于不同对象有那么一些特殊。

纯函数

纯函数的概念:纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。

const arr = [1,2,3,4,5,6]

// 纯函数
console.log(arr.slice(0,3)) // [1,2,3]
console.log(arr.slice(0,3)) // [1,2,3]
console.log(arr.slice(0,3)) // [1,2,3]

// 不是纯函数
console.log(arr.splice(0,3)) // [1,2,3]
console.log(arr.splice(0,3)) // [4, 5]
console.log(arr.splice(0,3)) // []

可以看出来slice是一个纯函数,因为每一次执行的结果都是一样的;而splice改变了原数组,导致下一个函数执行的结果变化了,所以splice不是一个纯函数
纯函数的好处:

  • 可缓存
    既然纯函数的每一次执行的返回结果都是相同的,那么我们是否可以将它执行的结果缓存起来,这样下次调用就可以直接返回上一次执行的结果,可以提升程序执行的效率
    lodash的函数库中为我们提供了memoize()记忆函数
const _ = require('lodash')
function add1(a) {
    console.log('输出', a)
    return a + 1
}
const addMomory = _.memoize(add1)
console.log(addMomory(10))
console.log(addMomory(10))
console.log(addMomory(10))


可以看到只输出了一次10,证明从第二个打印的11是从缓存中取出来的
下面我们来实现一个memoize函数

function memoize(fn) {
    const cache = {}

    return (...rest) => {
        const key = JSON.stringify(rest)
        console.log(cache)
        cache[key] = cache[key] || fn.apply(fn, rest)
        return cache[key]
    }
}
const addMomory = _.memoize(add1)
console.log(addMomory(10))
console.log(addMomory(10))
console.log(addMomory(10))


通过验证我们能看到和上面的输出是一样的

  • 可测试
    • 纯函数让测试更方便
  • 并行处理
    • 在多线程环境下并行操作共享的内存数据很可能会出现意外情况
    • 纯函数不需要访问共享的内存数据,所以在并行环境下可以任意运行纯函数 (Web Worker)

高阶函数

高阶函数是指至少要满足下列条件之一的函数:

  • 函数可以作为参数被传递
  • 函数可以作为返回值输出
  1. 函数可以作为参数被传递
let arr = [1,2,3]
const itemAdd = item => item += 2
arr = arr.map(itemAdd)
console.log(arr) // [3,4,5]
  1. 函数可以作为返回值输出
function once(fn) {
    let done = false
    return function() {
        if (!done) {
            done = true
            return fn.call(this, ...arguments)
        }
    }
}

函数柯里化(curry)

curry的概念:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
函数柯里化将 f(x) 和 g(x) 合成为 f(g(x)),所谓“柯里化”,就是将一个多参数的函数转化成多个单参数函数去执行

普通函数
function checkAge(min, age) {
    return age >= min
}
// 这样调用
console.log(checkAge(18, 25))

柯里化
function checkAge(min) {
    return function (age) {
        return age >= min
    }
}
// 上面代码可以用箭头函数简写为
const checkAge = min => age => age >= min
// 柯里化后的函数这样调用
const checkAge18 = checkAge(18)
console.log(checkAge18(25))

上面的代码我们手动实现了一个函数柯里化,那么有没有一个通用的方法,帮助我们来实现函数柯里化呢,lodash函数库中有一个curry函数自动实现了柯里化,我们来看看怎么使用

const _ = require('lodash')

function getSum(a, b, c) {
    return a + b + c
}

getSumCurry = _.curry(getSum) // curry函数返回一个柯里化后的函数

console.log(getSumCurry(1,2,3))
console.log(getSumCurry(1)(2,3))
console.log(getSumCurry(1,2)(3))
console.log(getSumCurry(1)(2)(3))

下面我们来实现一个curry

// polyfill
function curry(fn) {
    return function curriedFn() { // 返回一个新的函数
        const args = arguments // 保存新函数的参数
        if (args.length < fn.length) { // 如果新函数的参数小于fn的参数的长度,说明传进来的不是所有参数
            return function() {
                // 继续调用新函数
                return curriedFn(...Array.from(arguments).concat(Array.from(args)))
            }
        }
        return fn(...args) // 直到所有参数都入参,直接执行fn
    }
}
上面的代码还是比较绕的,可读性也没那么高,我们可以用箭头函数简化成一行代码
const curry = fn => curriedFn = (...args) => args.length < fn.length ? (...rest) => curriedFn(...args.concat(rest)) : fn(...args)
小结
  • 柯里化可以让我们给一个函数传递较少的参数得到一个已经记住了某些固定参数的新函数
  • 这是一种对函数参数的'缓存'
  • 函数柯里化让函数变的更灵活,让函数的粒度更小,我本人更愿意叫"函数颗粒化"
  • 可以把多元函数转换成一元函数,可以组合使用函数产生强大的功能

函数组合

管道运算

在日常开发中,我们经常遇到需要将多个函数组合成一个函数,然后去处理一些特定的数据的场景,于是我们会写出下面的代码

const arr = [1,6,3,4,5,2]

const addOne = (arr) => arr.map(item => item+1)
const filter = (arr) => arr.filter(item => item>2)
const sort = (arr) => arr.sort((a,b) => a-b)
const res = sort(filter(addOne(arr)))
console.log(res) // [3,4,5,6,7]

上面的代码就是典型的"洋葱代码",将上一个函数的返回结果作为下一个函数的参数,我们可以把这个过程想象成管道,下面这张图就是管道运算的过程

如果我们需要很多函数去处理一个值,那么上面的代码会显得很繁琐,ECMAScript的最新提案增加了一个管道运算符,可以让我们的代码显得更加清晰,有兴趣的可以看一看阮一峰的es6教程
通过管道运算符,我们可以将上面的代码简化成这样

const res = arr
            |> addOne
            |> filter
            |> sort
Compose组合函数

lodash函数库中为我们提供了类似compose的函数,下面我们来实现一个取出数组中最后一个元素,并将这个元素转换为大写的函数

const { flowRight } = require("lodash")
const reverse = arr => arr.reverse()
const first = arr => arr[0]
const toUpper = arr => arr.toUpperCase()

const fn = flowRight(toUpper, first, reverse) // 顺序为从右到左

const arr = ['aa', 'bb', 'cc', 'dd']

console.log(fn(arr)) // DD

我们可以自己实现一个flowRight函数,需要注意的是我们要满足结合律,下面我们用一行代码来实现

const _flowRight = (...args) => val => args.reverse().reduce((res, fn) => fn(res), val)

const my_fn = _flowRight(toUpper, first, reverse)
const my_fn1 = _flowRight(_flowRight(toUpper, first), reverse)

const arr = ['aa', 'bb', 'cc', 'dd']
console.log(my_fn(arr)) // DD
console.log(my_fn1(arr)) // DD

其实合成函数有一个问题,就是当我们的代码出现问题的时候,我们如何去debug我们的代码呢?
我们可以"管道运算"的特性:函数上一次的执行结果将作为参数传入下一个参数

const _ = require('lodash')
const { reduce } = require('lodash')

const split = _.curry((sep, str) => _.split(str, sep))
const join = _.curry((sep, arr) => _.join(arr, sep))

const log = _.curry((log, v) => {
    console.log(log, v)
    return v
})

const fn = _.flowRight(join('-') ,split(' ') , log('小写 之后'), _.toLower)

console.log(fn('NEVER SAY DIE'))


这样我们就可以打印出日志来调试我们的代码

Point Free

pointfree 模式指的是,永远不必说出你的数据
一等公民的函数、柯里化(curry)以及组合协作起来非常有助于实现这种模式。

// 非 Point Free
// NEVER SAY DIE => never-say-die
var fn = function (word) {
  return word.toLowerCase().replace(/\s+/g, '-') // 匹配空格
}

// Point Free
const fp = require('lodash/fp')
const fn = fp.flowRight(fp.join('-') ,fp.split(' '), fp.toLower)

函子

函子的概念:

  • 函子是函数式编程里面最重要的数据类型,也是基本的运算单位和功能单位
  • 函子是一个容器,包含值和值的变形关系(这个变形关系就是函数)
  • 通过一个普通的对象来实现,该对象具有 map 方法,map 方法可以运
    行一个函数对值进行处理(变形关系)
Functor函子
class Container {
    static of (val) {
        return new Container(val)
    }

    constructor(value) {
        this._value = value
    }

    map(fn) {
        return Container.of(fn(this._value))
    }
}

let r = Container.of(5)
    .map(a => a+5)
    .map(a => a*2)

console.log(r)
小结
  • 函数式编程的运算不直接操作值,而是由函子完成
  • 函子就是一个实现了 map 契约的对象
  • 函数式编程一般约定,函子有一个of方法,用来生成新的容器,实际上就是用of来代替new关键字
MayBe 函子

我们在编程的过程中可能会遇到很多错误,需要对这些错误做相应的处理,例如如果容器内部是一个null,Functor函子就会出错,于是就有了MayBe函子

class MayBe {
    static of (value) {
        return new MayBe(value)
    }

    constructor(value) {
        this._value = value
    }

    map(fn) {
        return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value))
    }

    isNothing() { // 判空处理
        return this._value === null || this._value === undefined
    }
}

const r = MayBe.of(null).map(x => x.toUpperCase())

console.log(r)

const s = MayBe.of('aaa')
            .map(x => x.toUpperCase())
            .map(x => null)
            .map(x => x.split('.'))

MayBe 函子的缺陷是当中间的 map 出现null,无法判断哪一个 map 出现的 null

Either 函子
  • Either函子类似于if...else运算符
  • Either 函子内部有两个值:左值(Left)和右值(Right),来处理异常代码
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 Right(value)
    }

    constructor(value) {
        this._value = value
    }

    map(fn) {
        return Right.of(fn(this._value))
    }
}

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

// const r1 = Left.of(12).map(x => x+2)
// const r2 = Right.of(12).map(x => x+2)
// console.log(r1,r2)
// const r = parseJSON('{name: aa}')
// console.log(r)

const r = parseJSON('{"name": "aa"}')
console.log(r)
IO 函子
  • IO 函子中的 _value 是一个函数,这里是把函数作为值来处理
  • IO 函子可以把不纯的动作存储到 _value 中,延迟执行这个不纯的操作(惰性执行)
  • 把不纯的操作交给调用者来处理
const fp = require('lodash/fp')

class IO {
    static of(value) {
        return new IO(function() {
            return value
        })
    }

    constructor(fn) {
        this._value = fn
    }

    map(fn) {
        return new IO(fp.flowRight(fn, this._value))
    }
}

const r = IO.of(process).map(p => p.execPath)
console.log(r._value())
Monad(单子)

函子是一个容器,我们也可以用函子来包含另一个函子,这样就会出现多层嵌套的函子IO.of(IO.of(IO.of({name: 'maoxiaoxing'}))),我们需要不断取 _value 的值,这样我们在取值的时候会特别的不方便

const fp = require('lodash/fp')
const fs = require('fs')
const { functionsIn } = require('lodash')

class IO {
    static of(value) {
        return new IO(function() {
            return value
        })
    }

    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()
    }
}

const readFile = function(filename) {
    return new IO(function() {
        return fs.readFileSync(filename, 'utf-8')
    })
}

const print = function(x) {
    return new IO(function() {
        return x
    })
}

// 通过函子来读取 package.json 文件
const r = readFile('package.json')
            .map(x => x.toUpperCase())
            .flatMap(print)
            .join()
console.log(r)

上面代码中如果 fn 是一个函子,那么 flatMap 中的 join 方法就保证了每次只返回一个单层的函子,这样就将嵌套的函子平铺了。

参考文章:

posted @ 2020-09-22 20:41  毛小星  阅读(340)  评论(0)    收藏  举报