你还不懂函数式编程范式?

81 阅读13分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

一、为什么要学习函数式编程

函数式编程其实是一个古老的概念,它早于第一台计算机的诞生,函数式编程历史

ReactVue新的版本开始拥抱函数式编程。例如React:虽然说React并不是纯函数式的,但React的高阶组件使用了高阶函数来实现,高阶函数函数式编程的一个特性。另外React生态的Redux也使用了函数式编程的一些思想。

函数式编程可以抛弃this,不用像使用面向对象那样处理烦人的this

函数式编程开发的项目在打包时可以更好地利用tree shaking过滤无用的代码。

函数式代码方便测试和方便并行处理。

很多第三方库可以帮助我们进行函数式编程开发,比如:lodash underscore ramda等。

二、函数式编程的概念

函数式编程(Functional Programming,FP),是编程范式之一。

函数式编程的思维方式:把现实中的事物和事物之间的联系抽象成程序中来。(对运算过程的抽象)

  • 函数式编程中的函数指的并不是程序中的函数或方法,而是数学中的函数映射关系,例如:y=sin(x)y=sin(x)xxyy的关系,意思xx通过sinsin函数建立与yy联系或映射关系。

  • 函数相同的输入始终得到相同的输出纯函数

  • 函数式编程用来描述数据(函数)之间的映射

// 函数式
function add (a, b) {
    retrun a + b
}
const sum = add(1, 2)
console.log(sum) // 3

三、前置知识

1. 函数是一等公民

当一门编程语言的函数可以被当作变量一样用时,则称这门语言拥有头等函数函数是一等公民

Javascript拥有头等函数,函数可以作为参数传递给其他函数,可以作为另一函数的返回值,也可以被赋值给一个变量。

// 赋值给一个变量
const foo = function () {
    console.log('foo')
}
// 为参数传递给函数
function bar (callback) {
    callback()
}
// 作为另一函数的返回值
function baz () {
    return function () {
        console.log('baz')
    }
}

2. 高阶函数的意义

高阶函数是用来抽象通用的问题,使用高阶函数可以让我们的程序变得很灵活,因为抽象可以帮助我们屏蔽细节,只需要关注我们的目标

// 模拟实现filter,抽象过虑过程
function myFilter (arr, fn) {
    let res = []
    for (let i = 0; i < arr.length; i++) {
        const item = arr[i]
        if (fn(item)) res.push(item)
    }
    return res
}
// 测试
const arr = [1, 2, 3]
const res = myFilter(arr, function(item) {
    return item >= 2
})
console.log(res) // [2, 3]
// 实现创建只能调用一次函数的函数,抽象只能调用一次的过程
functon makeOnceFn (fn) {
    let flag = true
    return function () {
        if (flag) {
            flag = false
            return fn.apply(this, arguments)
        }
    }
}
// 使用makeOnceFn创建只能调用一次的函数
const pay = makeOnceFn(function (money) {
    console.log(`支付了${money}RMB`)
})
// 只能调一次
pay(50) // 支付了50RMB
pay(50)

3. 常用的高阶函数

为加深对高阶函数抽象通用的问题理解,我们来模拟几个常用的高阶函数。

// 模拟map
function myMap (arr, fn) {
    let res = []
    for (let i = 0; i < arr.length; i++) {
        const item = arr[i]
        res.push(fn(item))
    }
    return res
}
const arr = [1, 2, 3]
const res = myMap(arr, function (item) {
    return item ** item
})
console.log(res) // 1 4 9
// 模拟every
function myEvery (arr, fn) {
    let res = true
    for (let i = 0; i < arr.length; i++) {
        let item = arr[i]
        res = fn(item)
        if (!res) break
    }
    return res
}
const arr = [5, 10, 15, 20]
const res = myEvery(arr, function (item) {
    return item >= 10
})
console.log(res) // false
// 模拟some
function mySome (arr, fn) {
    let res = false
    for (let i = 0; i < arr.length; i++) {
        const item = arr[i]
        res = fn(item)
        if (res) break
    }
    return res
}
const arr = [1, 3, 5, 9]
const r = some(arr, v => v % 2 === 0)
console.log(r) // false

4. 闭包概念

闭包(Closure):函数和其周围的状态的引用捆绑在一起形成闭包。简单来讲就是可以在另一个作用域中调用一个函数的内部函数并访问到该函数的作用域中的成员

闭包的本质:函数在执行的时候会放到一个执行栈上,当函数执行完后会从执行栈(Call Stack)上移除,但堆上的作用域成员因为被外部引用不能释放,因此内部函数依然可以访问外部函数的成员。

function makeFn () {
    const msg = 'hello closure'
    return function () {
        console.log(msg)
    }
}
// makeFn函数返回一个函数给fn变量,
// 该返回函数对makeFn函数中的msg变量有引用,
// 所以makeFn函数中的msg变量不能释放,
// 因此返回函数(fn)依然可以访问其外部函数(makeFn)的成员msg变量。
const fn = makeFn()
fn() // hello closure

四、函数式编程概念

1. 纯函数的概念

纯函数:相同的输入始终会得到相同的输出,而且没有可观察的副作用。纯函数就类似数学中的函数,用来描述输入和输出之间的关系,y=f(x)y=f(x)

数组的slicesplice分别是:纯函数和不纯的函数。

  • slice返回数组中指定的部分,不会改变原数组。
  • splice对数组进行删除或替换现有元素或者原地添加新的元素来修改数组,会改变原数组。
// Array.prototype.slice
const arr = [1, 2, 3, 4, 5]
// 相同的输入始终有相同的输出,所以slice是纯函数
console.log(arr.slice(0, 3)) // 1 2 3
console.log(arr.slice(0, 3)) // 1 2 3

// Array.prototype.splice
// 不纯的函数
console.log(arr.splice(0, 3)) // 1 2 3
console.log(arr.splice(0, 3)) // 4 5

Lodash一个一致性、模块化、高性能的JavaScript 实用工具库,纯函数的代表。

// 体验Lodash
// npm init --yes
// npm install lodash --save
const _ = require('lodash')

const arr = ['jack', 'tom', 'kate']
console.log(_.first(arr)) // jack
console.log(_.last(arr)) // kate
console.log(_.toUpper(_.first(arr))) // JACK
console.log(_.reverse(arr)) // ['kate', 'tom', 'jack']
const res = _.each(arr, function (item, index) {
    console.log(item, index)
})
console.log(res)

副作用:让一个函数变的不纯,纯函数是相同的输入始终返回相同的输出,如果函数依赖于外部的状态无法保证输出相同,就会带来副作用。

副作用的来源一般是:配置文件、数据库、获取用户的输入等。

所有外部交互都有可能产生副作用,同时副作用会给程序带来不确定性安全引患,但副作用是不可能完全禁止的,只能控制它们在可控的范围发生

2. 柯里化的概念(Haskell Brooks Curry)

柯里化:当一个函数它有多个参数的时候可以先传递一部分参数调用它这部分参数以后不会变化。然后返回一个新的函数去接收剩余的参数,返回结果。

柯里化可以让我们给一个函数传递较少的参数得到一个已经记住了某些固定参数的新函数

是一种对函数参数的缓存

让函数变得更灵活,让函数的粒度更小

可以把多元函数转换成一元函数,可以组合使用函数产生强大的功能。

// 柯里化演示
function checkAge (min) {
    return function (age) {
        return age >= min
    }
}
// const checkAge = min => (age => age >= min) // ES6
const checkAge18 = checkAge(18)
const checkAge20 = checkAge(20)
console.log(checkAge18(27)) // true
console.log(checkAge18(19)) // true
console.log(checkAge20(19)) // false

Lodash中提供通用的柯里化方法(_.curry)。

  • 功能:创建一个函数,该函数接收一个或多个参数,如果所需参数都被提供则执行并返回结果。否则继续返回该函数并等待接收剩余参数。
  • 参数:需要柯里化的函数。
  • 返回值:柯里化后的函数
// lodash中curry基本使用
const _ = require('lodash')

function getSum (a, b, c) { // 三元函数
    return a + b + c
}
const curried = _.curry(getSum)
console.log(curried(1, 2, 3)) // 6
console.log(curried(1)(2, 3)) // 6
console.log(curried(1, 2)(3)) // 6
// 柯里化案例
function match (reg, str) {
    return str.match(reg)
}
const curriedMatch = _.curry(match)
const haveSpace = curriedMatch(/\s+/g)
const haveNumber = curriedMatch(/\d+/g)
console.log(haveSpace('hello world')) // [' ']
console.log(haveNumber('abc')) // null
console.log(haveNumber('ab2c')) // ['2']

// 过滤数组中所有具有空白字符的元素
const filter = _.curry(function (fn, arr) {
    return arr.filter(fn)
})
/*
const filter = _.curry((fn, arr) => arr.filter(fn))
*/
const findSpace = filter(haveSpace)
console.log(findSpace(['john connor', 'john_connor'])) // ['john connor']

柯里化原理模似。

// 柯里化原理
function myCurry (fn) {
    return function curriedFn (...args) {
        // 传递的参数个数小于fn的形参个数(函数名.length:获取函数形参的个数)
        if (args.length < fn.length) {
            // 返回一个存储了部分参数并等待接收剩余参数的函数
            return function (...rest) {
                // 已传递部分参数继续累计传递并递归调用curriedFn
                return curriedFn(...(args.concat(rest)))
            }
        }
        fn.apply(fn, args)
    }
}
// 测试
function getSum (a, b, c) {
    return a + b + c
}
const curried = myCurry(getSum)
console.log(curried(1, 2, 3)) // 6
console.log(curried(1)(2, 3)) // 6

3. 函数组合的概念

纯函数柯里化很容易写出洋葱代码,h(g(f(x)))。

例如:获取数组中最后的一个元素并转换为大写,.toUpper(.first(_.reserve(arr)))

函数组合可以避免这样的情况。

函数组合(compose):如果一个函数要经过多个函数处理才能得到最终结果,这时可能把中间过程函数合并成一个函数。

函数就像数据的管道函数组合就是把这些管道连接起来,让数据穿过多个管道形成最终的结果(函数组合一般默认是从右到左执行)。

const _ = require('lodash')
// 函数组合
function compose (f1, f2) {
    return function (val) {
        return f1(f2(val))
    }
}
const last = compose(_.first, _.reverse)
console.log(last([0, 1, 2, 3])) // 3

Lodash中提供的组合函数(_.flow和_.flowRight):

  • flowflowRight都可以组合多个函数。
  • flow是从左到右运行。
  • flowRight从右到左,一般使用多一些。
const _ = require('lodash')
// flowRight使用
const fn = _.flowRight(_.toUpper, _.first, _.reverse)
console.log(fn(['one', 'two', 'three'])) // THREE

flowRight原理模拟。

// flowRight原理
function compose (...args) {
    return function (val) {
        // Array.prototype.reduce方法是每个元素按顺序执行一个由你提交的函数,
        // 每次运行该函数会将前一个元素的计算结果作为参数传入,最后将其结果汇总为单个返回值。
        return args.reverse().reduce((preRes, fn) => {
            return fn(preRes)
        }, val)
    }
}
/*
const compose = (...args) => val => args.reverse().reduce((preRes, fn) => fn(preRes), val)
*/
// 测试
const fn = compose(_.toUpper, _.first, _.reverse)
console.log(fn(['one', 'two', 'three'])) // THREE

函数组合要满足结合律:我们既可以把g和h组合,还可以把f和g组合,结果都是一样的。

// 结合律(associativity)
const f = compose(f, g, h)
const associativity = compose(compose(f, g), h) == compose(f, compose(g, h))
console.log(associativity) // true

函数组合如何调试

const _ = require('lodash')
// 调试
const trace = _.curry((tag, v) => {
    console.log(tag, v)
    return v
})
const split = _.curry((sep, str) => _.split(str, sep))
const join = _.curry((sep, arr) => _.join(arr, sep))
const map = _.curry((fn, arr) => _.map(arr, fn))
const fn = _.flowRight(join('-'), trace('after map'), map(_.toLower), split(' '))
console.log(fn('NEVER SAY DIE')) // never say die

我们在使用函数组合解决问题的时候会使用Lodash的一些方法,如果这些方法有多个参数的时候我们要使用柯里化去处理处理并重新包装这些参数,这样会有些麻烦。

Lodashfp模块,fp模块提供了对函数式编程实用和友好的方法,这些方法是不可变已经柯里化函数优先数据之后的方法(auto-curried iterable-first data-last)。

// 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')
// 应用:lodash/fp
const fp = require('lodash/fp')
const fn = pf.flowRight(fp.join('-'), fp.map(fp.toLower), fp.split(' '))
console.log(fn('NEVER SAY DIE'))

Lodashmap方法的小问题。

const _ = require('parsetInt')
// lodash中map中的函数接收三个参数,第一个是要处理的每一个元素,第二个是索引,第三个是数组
console.log(_.map(['23', '8', '10'], parseInt)) // [23, NaN, 2]
// parseInt('23', 0, array) // parseInt第二个参数为0,表示十进制
// parseInt('8', 1, array) // parseInt第二个参数为1,不支持传1所以返回Nan
// parseInt('10', 2, array) parseInt第二个参数为0,表示二进制

const fp = require('lodash/fp')
// lodash/fp模块map中的函数接收的参数只有一个,那就是要处理的每一个元素
console.log(fp.map(parseInt, ['23', '8', '10']))

Point Free:把数据处理的过程定义为与数据无关的合成运算,不需要用到代表数据的那个参数,只要把简单的运算步骤合成到一起,在使用这种模式之前我们需要定义一些辅助的基本运算函数。

  • 无需指定要处理的数据。
  • 只需合成运算过程。
  • 需要定义一些基本的辅助函数。

之前我们在使用函数组合去处理问题的时候,其实就是Point Free模式。

const fn = fp.flowRight(fp.joint('-'), fp.map(fp.toUpper), fp.split(' '))
const fp = require('lodash/fp')
// Hello World => hello_world
const fn = fp.flowRight(fp.replace(/\s+/g, '_'), fp.toLower)
console.log(fn('Hello World')) // hello_world

五、函子(Functor)

到目前为止已经学习了函数式编程的一些基础,但我们还不知道在函数式编程中如何把副作用控制在可控的范围内异常处理异步操作等。

什么是Functor

  • 容器:包含值变形关系(这个变形关系就是函数)。
  • 函子:是一个特殊的容器,通过一个普通的对象来实现,该对象具有map方,map方法可以运行一个函数对值进行处理(变形关系)。

函子可以想像成一个盒子,里面放着要维护的数据,但我们不去直接操作里面数据,通过map方法去运行纯函数进行内部的数据的处理,内部的数据也永远放在函子中,副作用的处理全部放在函子中进行控制

因为假设一个纯函数它要接收一个字符串参数进行处理,但我们传入的是null或undefined(这数据可能接口请求回来),这时该纯函数就会报错(产生了副作用)。这时纯函数就没有了相同的输入始终有相同的输出这个特点了(有副作用),这个副作用的产生是不可控的,所以我们要使用函子将副作用控制在可控的范围内

// 函子Functor
class Container {
    constructor(val) {
        this._val = val // 函子中维护的值为私有,这里约_开始为私有成员
    }
    // 提供静态方法创建函子,尽量不使用new关键字来创建
    static of (val) {
        return new Container(val)
    }
    // map传入处理函数,将处理后的值放到新的函子中进行返回,这样map返回也是函子就可能链式调用
    map (fn) {
        return Container.of(fn(this._val))
    }
}
const res = Container.of(5)
    .map(x => x + 1)
    .map(x => x * x)
console.log(res) // Container {_val: 36}

1. Maybe函子

在编程的过程中可能会遇到很多错误,需要对这些错误做相应的处理。Maybe函子的作用就是可以对外部的空值情况做处理(控制副作用在允许的范围内)。

// Maybe函子
class Maybe {
    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
    }
}

let r = Maybe.of(null).map(x => x.toUpperCase())
console.log(r) // Maybe { _val: null }

2. Either函子

Either:两者中的任何一个,类似于if else的处理。异常会让函数变的不纯,Either函子可以用来做异常处理

// Either函子
class Left {
    constructor (val) {
        this._val = val
    }
    static of (val) {
        return new Left(val)
    }
    map (fn) {
        return this
    }
}
class Right {
    constructor (val) {
        this._val = val
    }
    static of (val) {
        return new Right(val)
    }
    map (fn) {
        return Right.of(fn(this._val))
    }
}

let r1 = Right.of(12).map(x => x + 2)
let r2 = Left.of(12).map(x => x + 2)
console.log(r1) // Right {_val: 14}
console.log(r2) // Left {_val: 12}

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

let r = parseJSON('{ name: ma }')
console.log(r) // Left { _val: { error: 'Unexpected token n in JSON at position 2' } }

let r = parseJSON('{ "name": "ma" }').map(x => x.name.toUpperCase())
console.log(r) // Right { _val: 'MA' }

3. IO函子

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

const fp = require('lodash/fp')

class IO {
    constructor (fn) {
        this._val = fn
    }
    static of (val) {
        return new IO(function () {
            return val
        })
    }
    map (fn) {
        // 这不是直接调用fn去处理数据,而是把fn与this._val拼接成一个函数
        return new IO(fp.flowRight(fn, this._val))
    }
}

let r = IO.of(process).map(p => p.execPath)
console.log(r) // IO { _val: [Function] }
console.log(r._val()) // C:\Program Files\nodejs\node.exe

4. Folktale中Task

异步任务的实现过于复杂,我们使用Folktale中的Task来演示。

Folktale一个标准函数式编程库

  • 和lodash、ramda不同的是,他没有提供很多功能函数。
  • 只提供一些函数式处理操作,例如:compose、curry等,一些函子Task、Either、MayBe等。
// folktale中curry、compose
const { compose, curry } = require('folktale/core/lambda')
const { toUpper, first } = require('lodash/fp')

let fn = curry(2, (x, y) => x + y)
console.log(fn(1, 2))
console.log(fn(1)(2))

let fn2 = compose(toUpper, first)
console.log(fn2(['one', 'two']))

Task函子,处理异步任务。Task的具体使请查看官方文档,这里就直接使用了。

// Task函子处理异步任务
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)
        })
    })
}

readFile('package.json')
    .map(split('\n'))
    .map(find(x => x.includes('version')))
    .run()
    .listen({
        onRejected: err => {
            console.log(err)
        },
        onResolved: val => {
            console.log(val) // "version": "1.0.0"
        }
    })

5. Pointed函子

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

之前所写的函子都是实现了of方法,所以都是Pointed函子。

6. Monad(单子)

IO函子的问题——函子嵌套

const fp = require('lodash/fp')
const fs = require('fs')

class IO {
    constructor (fn) {
        this._val = fn
    }
    static of (val) {
        return new IO(function () {
            return val
        })
    }
    map (fn) {
        return new IO(fp.flowRight(fn, this._val))
    }
}

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

function print (x) {
    return new IO(function () {
        console.log(x)
        return x
    })
}

let cat = fp.flowRight(print, readFile)
// IO(IO())
let r = cat('package.json')._val()._val() // 函子嵌套函子
console.log(r)

Monad函子可以变扁的Pointed函子( IO(IO(x)) )。一个函子如果具有joinof两个方法并遵守一些定律(这些都是数学中的一些定律这里就不赘述了)就是一个Monad函子。

// IO Monad
const fp = require('lodash/fp')
const fs = require('fs')

class IO {
    constructor (fn) {
        this._val = fn
    }
    static of (val) {
        return new IO(function () {
            return val
        })
    }
    map (fn) {
        return new IO(fp.flowRight(fn, this._val))
    }
    join () {
        return this._val()
    }
    flatMap (fn) { // map和join一起使用变扁函子
        this.map(fn).join()
    }
}

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

function print (x) {
    return new IO(function () {
        console.log(x)
        return x
    })
}

let r = readFile('package')
    .map(x => x.toUpperCase()) // 函数返回的值不是函子,使用map方法
    .flatMap(print) // 函数返回的值是函子,使用flatMap方法进行变扁
    .join()
console.log(r)

函数式编程的内容就到这里了,想要掌握一种新的编程范式是需要一个过程的(就像面向对象编程),在开发项目的过程中其实我们没必要一下子都使用函数式的方式去编写代码,因为这样是很难做到的,我们可以部分使用函数式来编写代码,进行慢慢撑握。

-EOF-