函数式编程
引言:函数式编程(Functional Programming),简称 FP,是一种编程范式。
它将电脑运算视为函数运算,并且避免使用程序状态以及易变对象。其中,lambda演算为该语言最重要的基础。而且,lambda演算的函数可以接受函数当作输入(引数)和输出(传出值)。
比起指令式编程,函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算,而不是设计一个复杂的执行过程。
纯函数
概念:即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。
所谓纯函数有两个重要概念:
例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
const baseNum = 10 const add = (num) => { return baseNum + num }
const add = (num) => { const baseNum = 10 return baseNum + num }
const arr = [1, 2, 3, 4, 5, 6]
arr.splice(0, 3)
arr.slice(0, 3)
|
好处
- 可预测性: 纯函数的输出只依赖于输入参数,因此对于相同的输入始终产生相同的输出。这种确定性使代码更易于测试和推理。
- 可缓存性: 纯函数对于相同的输入始终返回相同的结果,因此可以利用缓存来提高性能,避免重复计算。
- 可并行性: 由于纯函数不涉及共享状态,因此可以更容易地并行执行,无需担心竞争条件或锁的问题。
- 更容易推理: 纯函数不依赖于外部状态,使得代码更容易推理和理解,减少了出错的可能性
闭包
闭包是一类特殊的高阶函数,它是由函数和其周围状态的引用捆绑在一起形成的,它的表现就是可以在一个作用域中调用一个函数内部的函数并访问到该函数的作用域中的成员。
闭包形成的原因是,当一个函数执行完成以后,其内部的成员就会被释放掉。如果这个函数返回了一个函数,并且在这个返回的函数内部又访问了其外部函数中的成员变量,这就形成了闭包。即外部对内部成员有引用,就造成该成员不能被释放掉。
特点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const calcSalary = base => { return ( perf) => { return base + perf } }
const getSalaryOfDeveloper = calcSalary(12000) const getSalaryOfManager = calcSalary(15000)
const mikeSalary = getSalaryOfDeveloper(2000)
const amySalary = getSalaryOfDeveloper(3000)
const jackSalary = getSalaryOfManager(3000)
|
惰性调用
概念:延迟表达式的求值直到实际需要的时候。这意味着表达式不会在定义时立即被计算,而是在需要其值的时候才被求值和执行。
在函数式编程中,惰性计算经常用于创建无限序列或避免不必要的计算。这种方法允许程序在必要的时候才执行计算,以节省资源并提高效率。
例子
1 2 3 4 5 6 7 8 9 10
| const calcSalary = base => { return perf => base + perf }
const getSalaryOfDeveloper = calcSalary(12000)
const mikeSalary = getSalaryOfDeveloper(2000)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const createRegExpTemplate = (regStr) => { const reg = new RegExp(regStr) return (testValue) => reg.test(testValue) } const emailRegExp = createRegExpTemplate('^\\w+@[a-z0-9]+\\.[a-z]+$') const phoneRegExp = createRegExpTemplate('^1[34578]\\d{9}$')
console.log(emailRegExp('111.com'))
console.log(emailRegExp('helloword123@qq.com'))
console.log(phoneRegExp('17312341234'))
|
柯理化
柯里化是一种将使用多个参数的函数转换成一系列使用一个参数的函数的技术。它的核心思想是将一个接受多个参数的函数转换为一系列嵌套的单参数函数。
展现形式
柯理化函数是建立在闭包和惰性调用的基础之上的一个概念,有了这两个理念的支持才得以实现函数柯理化。
1 2 3 4 5 6 7 8 9 10 11 12 13
| const add = (a, b, c) => a + b + c
const add = a => b => c => a + b + c
console.log(add(1)(2)(3))
const addBase = add(100)(10) console.log(addBase(5))
console.log(addBase(8))
|
作用
多个参数的函数转换为一系列接受单个参数的函数。这种转换使得函数更加灵活、更易于复用,并且支持更为简洁的函数组合。
参数复用与部分应用
柯里化允许您先传递一部分参数,然后在后续调用中提供剩余参数。这对于创建更多抽象的函数非常有用。
1 2 3 4 5 6 7 8 9 10 11 12
| function calculateTax(taxRate) { return function(amount) { return amount * taxRate }; }
const apply5PercentTax = calculateTax(0.05)
const res = apply5PercentTax(100) const res2 = apply5PercentTax(200)
|
由于闭包和惰性执行的特性,柯理化函数把一个业务流程颗粒化成一系列的小粒度业务。可以随着流程的变化惰性的赋予其他参数,让这个业务可以随着代码的执行流程变得非常可控,是函数式编程中非常重要的组成。
柯理化工厂
1 2 3 4 5 6 7 8 9 10
| const curryFactory = (fn) => { const curried = (...args) => { if (args.length >= fn.length) { return fn(...args) } else { return (...moreArgs) => curried(...args, ...moreArgs) } }; return curried }
|
我们并不可能对多参数函数手动的实现它的柯理化,但是我们可以借助柯理化工厂来实现。
1 2 3 4 5 6 7 8 9 10 11
| const add = (a, b, c) => a + b + c;
const curriedAdd = curryFactory(add);
console.log(curriedAdd(2)(3)(4)) console.log(curriedAdd(2, 3)(4)) console.log(curriedAdd(2)(3, 4)) console.log(curriedAdd(2, 3, 4))
|
组合(Compose)
数组合是函数式编程中的一个重要概念,它涉及将多个函数结合在一起,形成一个新的函数。这种组合允许你以一种清晰、模块化的方式构建复杂的操作。
展现形式
在函数组合中,两个或多个函数按照一定的顺序合并在一起,使得每个函数的输出成为下一个函数的输入,从而形成一个新的函数。这种组合可以简化代码、提高可读性,并且可以减少中间变量的使用。一个简单的例子:
1 2 3 4 5 6 7 8 9 10 11
| const compose = (f, g) => x => f(g(x))
const add5 = x => x + 5 const multiply = (x) => x * 2
const res = compose(add5, multiply)(10) console.log(res)
|
从这个简单的例子可以发现,函数组合其实就是字面意思,将多个函数组合在一起形成一个新的函数,当组合多的时候,纯函数的特点就体现出来的。这就是为什么函数式编程提倡纯函数,提倡无作用函数的重要原因。
多函数组合
往往我们需要组合多个纯函数,那上面的简单组合工厂就不适用了,稍作调整:
1 2 3 4 5 6 7 8 9 10 11
| const compose = (...fns) => (x) => { return fns.reduceRight((acc, fn) => fn(acc), x); }
const add5 = x => x + 5 const multiply = x => x * 2 const desc10 = x => x - 10
const res = compose(add5, multiply, desc10)(10) console.log(res)
|
管道(Pipe)
管道(Pipe),它很类似于组合。但更强调数据的流动和函数的串联,不会创建新的函数。
展现形式
函数管道强调数据流向的方向,一个函数的输出成为下一个函数的输入,从而形成一个数据流水线。一个简单的例子:
1 2 3 4 5 6
| const add5 = (x) => x + 5 const multiply = (x) => x * 2
const res = multiply(add5(3)) console.log(res)
|
可以看到它和组合的展现形式有所不同,它是从前往后,而非从而往前。和组合比调换了执行顺序,也就是说:
1 2 3 4
| const compose = (f, g) => x => f(g(x))
const pipe = (f, g) => x => g(f(x))
|
管道和组合的区别
在广义上,管道和组合其实是同一个作用,它们都是把多个函数组合在一起来执行一系列的操作。它们的实现方式可能有些微小的差别,主要在于函数执行顺序上,组合通常从右到左执行函数,而管道通常从左到右执行函数。很多时候甚至都不对它们不做区分,只不过从语义上对它们的职责进行了分化,从而导致叫法不同。例如,通常组合不会组合很多的函数,当组合的函数多了,我们可以称之为管道。
函子(Functor)
在函数式编程中,函子(Functor)是一种特殊的对象或数据结构,它实现了 map 函数,并遵循一些特定规则。函子可被视为一种抽象,它提供了一种包装值的机制,并允许对这些值进行变换和操作,而无需直接暴露内部实现。
特点
函子的主要特点是它具有一个 map
方法,这个方法能够将函子中的值映射到另一个函子中。当对函子调用 map
方法时,它会返回一个新的函子,而不是直接操作原始值。
函子的实现可以是各种类型的数据结构,例如数组、对象、甚至 Promise。这种抽象允许我们对值进行变换、映射或其他操作,而无需直接访问或修改值。
规则
- 对值进行包装
- 提供
map
方法: 函子必须提供 map
方法,这个方法能够对函子内的值进行操作并返回一个新的函子。
- 保持对原值的不可变性:
map
方法对值进行操作时,不会直接修改原始值,而是返回一个新的函子包含了新值。
- 遵循同态性:
map
方法保持了函子对值的变换,并且可以链接多次 map
调用而不改变函子的结构。
具体形式
Pointed函子
实现了of
静态方法的函子被称为Pointed函子,未实现的则为普通函子。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| class Pointed { constructor(value) { this._value = value }
map(fn) { return Pointed.of(fn(this._value)) }
static of(value) { return new Pointed(value) } }
let num = Pointed.of(10).map(x => x + 1).map(x => x * 2)
|
Maybe函子
Maybe 会先检查自己的值是否为空,然后才调用传进来的函数,这样处理空值就不会出错了。Maybe 常用在那些可能无法成功返回结果的函数中;可以避免使用命令式的 if…else 语句,可以用 Maybe(null) 来表示失败,但却不能告诉我们太多有效信息,譬如:失败的原因是什么?是哪儿造成失败的?Either 函子能帮助我们解决这样的问题。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| class Maybe { static of (value) { return new Maybe(value) }
constructor (value) { this._value = value }
map (fn) { return this._value ? Maybe.of(fn(this._value)) : Maybe.of(null) } }
let toUpper = Maybe.of(null).map(x => x.toUpperCase())
console.log(toUpper)
function getFirst (arr) { return Maybe.of(arr[0]) } let firstElement = getFirst([]).map(x => x + 3)
console.log(firstElement)
|
Either函子
函数式编程里面,使用 Either 函子代替条件运算(if…else);另一个用途是代替 try…catch,使用左值表示错误。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| 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)) } }
|
上面所述就是Either函子:Left表示错误函子,Right表示正确函子。Either函子通常用于函数的返回值例如:
1 2 3 4 5 6 7 8 9
| const isEven = x => x % 2 === 0 ? Right.of(x) : Left.of('not even')
isEven(2).map(console.log)
isEven(3).map(console.log)
|
如上例子,将函数的返回值改为Either函子,函数满足您的逻辑正确,则执行Right函子的map反之则执行Left函子的map语句(直接返回当前实力,也就是说被截断。)
那么,又如何判断函数返回的函子是Right还是Left呢?两种方法:
标记函子
这个方法简单粗暴,给对应Either函子一个标识即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| class Left { isLeft() { return true } isRight() { return false } static of(value) { return new Left(value) } constructor (value) { this._value = value } map (fn) { return this } }
class Right { isLeft() { return false } isRight() { return true } static of (value) { return new Right(value) } constructor (value) { this._value = value } map (fn) { return Right.of(fn(this._value)) } }
const isEven = x => x % 2 === 0 ? Right.of(x) : Left.of('not even')
const resFunctor = isEven(2) if (resFunctor.isRight()) { resFunctor.map(console.log) } else { }
|
这种传统方式,不够灵活,可以试试使用fold方法来解决问题
fold方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| class Left { static of(value) { return new Left(value); }
constructor(value) { this._value = value }
map(fn) { return this }
fold(leftFn, rightFn) { return leftFn(this._value) } }
class Right { static of(value) { return new Right(value) }
constructor(value) { this._value = value }
map(fn) { return Right.of(fn(this._value)) }
fold(leftFn, rightFn) { return rightFn(this._value); } }
const isEven = x => x % 2 === 0 ? Right.of(x) : Left.of(`error: ${x} is not even`)
isEven(2).fold( console.log, x => { console.log(`${x} 为偶数,做一些额外处理`) } )
|
如果对错误事件想要进行额外的特殊处理,改怎么办?
无需变动上方代码,将特殊处理的逻辑传给leftFn
即可。
1 2 3 4 5 6 7 8 9 10 11 12 13
| isEven(3).fold( () => { console.log(`${x} 不为偶数,做一些额外特殊处理`) }, x => { console.log(`${x} 为偶数,做一些额外处理`) } )
|
这样就可以很优雅的处理分支和异常
函子的使用
通过函子实现链式编程,以及异常处理
另外,类似这样的函子还有一些,例如:Ap函子、IO函子、Monad函子。通过上面所述的函子,可以得出,函子它具有一个 map
方法,这个方法能够将函子中的值映射到另一个函子中。当对函子调用 map
方法时,它会返回一个新的函子,而不是直接操作原始值。
👋 保重;
技术分享 — 2023年12月15日