函数式编程
- 概念:函数式编程通过使用函数来将值转换成抽象单元,接着用于构建软件系统。
- 软件系统本质就是展示数据,函数式编程就是把一个数据通过一些方法,一步一步变成另外一些数据再展示在系统上。
- 具体是通过那些方法,就是看业务的需求了。
一、函数式编程和传统编程思路上的区别是什么?
- 函数式编程就是把一个数据通过一些方法,一步一步变成另外一些数。
- 函数式编程它更关注的是“谓词”,例如:求和函数,过滤函数,它关注的是“求和”,“过滤”。
函数式编程和命令式编程的区别
- 函数式编程和命令式编程都是编程规范。
- 命令式编程是面向过程编程的思想,强调的是步骤和程序的控制结构。这意味着你需要明确地告诉计算机每个步骤应该执行什么任务,以及这些步骤应该如何顺序执行。
- 函数式编程即不是面向过程也不是面向对象的编程思想,函数式编程的核心思想是,任何程序都可以被视为一系列函数的组合,这些函数通过接收和产生输出互相联系。此外,函数式编程还强调纯函数的运用,即不产生副作用并且在相同的输入下总是产生相同的输出的函数。
写一个函数传入 [0, 1, 2, 3, 4, 5, 6]
输出 [2, 4, 6, 8, 10, 12]
命令式编程示例:
const modifyArrCmd = (arr) => {
const result = []
for (let i = 0; i < arr.length; i++) {
if (arr[i] !== 0) {
result.push(arr[i] * 2)
}
}
return result
}
const list = [0, 1, 2, 3, 4, 5, 6]
console.log(modifyArrCmd(list))
函数编程示例
- 第一步:数组要经历一个筛选函数,把 0 筛除掉;
- 第二步:数组要经历一个乘法函数,每一个数据*2。
const modifyArrFP = (arr) => arr.filter(Boolean).map((item) => item * 2)
const list = [0, 1, 2, 3, 4, 5, 6]
console.log(modifyArrFP(list))
函数式编程和面向对象编程的区别
- 函数式强调的是,一切皆是函数。
- 面向对象强调的是,一切皆是对象。
- 面向对象强调的是主语(对象),函数式强调的是谓词。
例如我要做我要做一道菜-回锅肉
- 命令式编程:
- 从养一头猪开始,养大、喂养、宰杀、切肉、炸、炒、上桌。
- 面向对象编程:
- 谁来做,哪个实例对象来做出这道菜。
- 函数式编程:
- 喂养的 function; 宰杀的 function; 制作的 function,将这 function 组合起来做出这道菜。
二、函数式的特点
函数式编程具有以下几个特点:
- 纯函数性质(Pure Function):函数式编程强调使用纯函数,即函数的输出仅依赖于输入参数,没有任何副作用。纯函数不修改可变状态,也不对外部环境产生可观察的影响。这种特性使得纯函数具有可重用、可测试和易推导的优势。
- 不可变数据(Immutable Data):函数式编程倾向于使用不可变数据,即数据一旦创建就不能被修改。通过避免直接对数据进行修改,函数式编程可以减少复杂性、提高可读性和可维护性。不可变数据也为并发和并行编程提供了更好的支持。
- 函数作为一等公民(First-class Functions):函数式编程将函数视为一等公民,即函数可以作为变量存储、作为参数传递给其他函数,也可以作为函数的返回值。这使得函数的抽象和组合成为可能,可以更灵活地构建和组合功能。
- 高阶函数(Higher-order Functions):函数式编程支持高阶函数,即可以接受一个或多个函数作为参数,或者返回一个函数作为结果的函数。高阶函数可以用于构建抽象和复用代码,可以将函数作为参数传递、组合和构造新的函数。
- 函数组合(Function Composition):函数式编程鼓励函数的组合,即将多个函数按照特定的方式组合在一起,形成新的函数。函数组合可以简化代码,提高可读性,同时也支持将小的函数组合成更大的、复杂的功能。
- 延迟求值(Lazy Evaluation):函数式编程支持延迟求值,在需要的时候才计算表达式的值。这种延迟求值的特性可以提高性能和资源的利用率,同时也支持按需计算。
- 声明式编程(Declarative Programming):函数式编程更倾向于使用声明式的方式来描述程序的逻辑,即关注“做什么”而不是“如何做”。通过函数的组合和转换,可以以一种更抽象的方式描述程序的意图和逻辑。
这些特点使得函数式编程具有一些独特的优势,例如代码的可读性、可维护性、可测试性和易于推导行为。函数式编程适用于处理数据转换、并发、并行、函数组合等领域,并且在现代编程中越来越受到重视。
纯函数
纯函数具有以下几个主要特点:
- 输入决定输出/幂等(无论调用多少次,结果是相同的):纯函数的输出完全取决于输入参数,即同样的输入将始终产生相同的输出。纯函数没有隐藏的状态依赖,不会受到外部状态的影响,也不会改变外部状态。
- 无副作用:纯函数不存在副作用,即不会对外部环境产生可观察的影响。它们不修改传入的参数,并且不会对共享的变量或状态进行修改。纯函数的唯一目的是根据输入计算输出。
- 可重用性:由于纯函数的输出仅取决于输入,没有任何隐藏的依赖关系,因此它们具有高度的可重用性。纯函数可以在不同的上下文中被多次调用,而不会导致意外的行为。
- 可测试性/无状态/无依赖:由于纯函数的行为完全可预测,并且不依赖于外部状态,因此它们很容易进行单元测试。通过提供不同的输入,可以轻松检查纯函数的输出是否符合预期。
- 缓存优化:由于纯函数的输出只取决于输入,函数的结果可以被缓存,以避免重复的计算。这种缓存优化在函数的运行时效率方面可以提供更好的性能。
- 引用透明性:纯函数是引用透明的,即可以用函数的输出来替换函数的调用,而不会影响程序的行为。这种特性使得代码更易于理解和推导。
- 并行和并发:由于纯函数不依赖于外部状态,且输入输出之间没有依赖关系,因此它们天然地适合并行和并发执行。多个纯函数可以在不同的线程或进程中独立地执行,而不会发生竞态条件或死锁等问题。
纯函数是函数式编程的核心概念之一,它们的特点使得代码更具可读性、可维护性和可测试性,同时也有助于构建可靠的、高效的软件系统。在函数式编程中,鼓励尽可能使用纯函数,并通过组合和转换这些纯函数来构建复杂的功能和逻辑。
围绕着纯函数,取代面向过程式的代码,往往能够有以下收益:
- 可读性,因为“一切都是函数”,通过函数的合理命名,函数原子的拆分,我们能够一眼看出来程序在做什么,以及做的过程;
- 复用性,因为“一切都是函数”,函数本身具有天然的复用能力;
- 维护性,纯函数和幂等性保证同样的输入就有同样的输出,在维护或者调试代码时,能够更加专注,减少因为共享带来的潜在问题。
栗子
副作用很大
const minusCount = () => {
window.count--
}
没那么纯,但是没有副作用
const minusCount = (global) => {
global.count--
}
有副作用时,tree shaking 做性能优化时摇不掉 a
,因为 window.a--
引用过
export const a = window.a--;
import {a};
Vue 中的 methods
一般都不是纯函数,因为依赖了 this
methods: {
getDataList(query) {
get({ query })
.then(res => {
this.resData = res.data;
})
},
}
高阶函数(闭包的两个表现)
- 函数可以作为返回值,给函数一些组合、缓存的能力
- 函数可以作为参数传递,给函数提供了一个包装的能力
三、多人协作的开发过程中,如何去考虑副作用?
函数的编写
在我们编写一个函数时,比如,一个典型的接口请求,我们怎么写?
- 封装成纯函数,通过传入参数,然后返回结果的形式
methods: {
getDataList(query) {
get({ query })
.then(res => {
this.resData = res.data;
})
},
}
改成
methods: {
getDataList(query) {
get({ query })
.then(res => {
return res.data;
})
},
}
组件的封装时,我们应该考虑哪些问题和维度?
- 副作用
- css 隔离, 同时考虑 css 类重名问题。
- 组件角色定位
- 例如是纯 UI 的组件,把搜索、拉下框的信息,放在外面。
- 复用性
- props 越多复用性越强,但组件越难维护,在业务需求和复用性之间权衡。考虑把一个组件直接拷贝到第二个地方用方不方便
架构的设计
我们思考一下:模块化、微前端,究竟是解决什么问题?副作用究竟是什么?
解决熵增问题
- 熵增:代表系统的无序程度增加。
- 熵:代表着混乱程度。
四、函数的柯里化
柯里化
在计算机科学中,柯里化(currying),又译为卡瑞化或加里化,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。这个技术由克里斯托弗·斯特雷奇以逻辑学家哈斯凯尔·加里命名的。
栗子
// 原始的非柯里化函数
function add(a, b, c) {
return a + b + c
}
// 柯里化的函数
function curriedAdd(a) {
return function (b) {
return function (c) {
return a + b + c
}
}
}
// 调用非柯里化函数
console.log(add(1, 2, 3)) // 输出 6
// 调用柯里化函数
console.log(curriedAdd(1)(2)(3)) // 输出 6
加强版栗子
const add = (num1) => {
// 定义一个叫 nums 的数组,把所有的入参缓存起来(利用了闭包)
const nums = [num1]
const fn = (num2) => {
nums.push(num2)
return fn
}
// 改写 toString 方法,在进行逻辑判断的时候会触发 toString 方法
fn.toString = function () {
return nums.reduce((result, item) => result + item, 0)
}
return fn
}
console.log(add(1)(2)(3) == 6)
再把栗子炒一下
const add = (...num1) => {
let nums = [...num1]
const fn = (...num2) => {
nums = [...nums, ...num2]
if (nums.length === 3) {
return nums.reduce((result, item) => result + item, 0)
} else {
return fn
}
}
return fn
}
console.log(add(1)(2, 3) === 6)
console.log(add(1)(2)(3) === 6)
反柯里化
反柯里化(Uncurrying)是指将柯里化函数转换为接受多个参数的普通函数的过程。
在柯里化函数中,多个参数被分解为一系列单参数函数。而反柯里化则是相反的操作,将这些单参数函数重新合并为接受多个参数的函数。
通过反柯里化,我们可以将柯里化函数转换为普通函数,并使其更适合传统的多参数调用方式。这对于某些情况下使用柯里化函数不方便的场景,如使用其他需要多个参数的函数或算法时,特别有用。
下面是一个简单的示例来说明反柯里化的概念:
// 柯里化函数
function add(a) {
return function (b) {
return a + b
}
}
// 反柯里化函数
function uncurry(fn) {
return function (...args) {
let result = fn
for (let arg of args) {
result = result(arg)
}
return result
}
}
// 反柯里化后的函数
const uncurriedAdd = uncurry(add)
console.log(uncurriedAdd(2, 3)) // 输出:5
反柯里化是将柯里化函数转换为接受多个参数的普通函数的过程。它可以提供更传统的多参数调用方式,以适应某些情况下的需求。
五、函数式的应用
函数的缓存与闭包
- 假如有一个计算密集型的函数
- 我希望对这个函数进行包装,如何让这个函数如果之前计算过了我就不再计算了,直接返回值。
function add(a, b) {
return a + b
}
function memorize(fn) {
const map = {}
return function (...args) {
const key = args.join('_')
if (map[key]) {
return map[key]
} else {
return (map[key] = fn.apply(this || {}, args))
}
}
}
const madd = memorize(add)
console.log(madd(1, 2))
console.log(madd(1, 2))
console.log(madd(1, 2))
组合函数
示例
const compose =
(...functions) =>
(startNum) =>
functions.reduce((result, item) => item(result), startNum)
function addOne(num) {
return num + 1
}
function double(num) {
return num * 2
}
function square(num) {
return num ** 2
}
const composedFunction = compose(addOne, double, square)
// composedFunction --> (startNum) => functions.reduce((result, item) => item(result), startNum)
// result = startNum = 2
// result = item(2) --> addOne(2) --> 3
// result = item(3) --> double(3) --> 6
// result = item(6) --> square(6) --> 36
console.log(composedFunction(2)) // 输出 36,即 square(double(addOne(2))) -- > square(6)
functions
参数包中的每个函数都会被调用,并且传入前面所有函数的结果作为参数。- 初始值 startNum 会首先被传入第一个函数。
- 第一个函数的返回值会作为输入传给第二个函数,依此类推,直到所有的函数都执行完毕。
- 最后,返回所有函数执行完毕后的结果。
应用场景 - 设计模式
装饰器
- 实现一个
wrappedFn
包装我的fn
,让beforeFn
先执行
function fn() {
console.log('This is fn running ...')
}
function beforeFn() {
console.log('This is beforeFn running ...')
}
function wrappedBefore(fn, beforeFn) {
return function (...args) {
beforeFn()
return fn.apply(this, args)
}
}
const wrappedFn = wrappedBefore(fn, beforeFn)
wrappedFn()
改造一下 wrappedBefore
让 fn
可以有多个前置函数
function fn() {
console.log('This is fn running ...')
}
function beforeFn1() {
console.log('This is beforeFn1 running ...')
}
function beforeFn2() {
console.log('This is beforeFn2 running ...')
}
function beforeFn3() {
console.log('This is beforeFn3 running ...')
}
const compose =
(...functions) =>
(params) =>
functions.reduce((result, item) => item(result), params)
function wrappedBefore(fn, ...beforeFns) {
return function (...args) {
compose(...beforeFns)()
return fn.apply(this, args)
}
}
const wrappedFn = wrappedBefore(fn, beforeFn1, beforeFn2, beforeFn3)
wrappedFn()
我们再改造一下让 wrappedFn
可以传入参数,写一个更具体一线的例子
function fn(data) {
console.log('我是被处理过后的 data', data)
}
function beforeFn1(data) {
console.log('我是一个检验字符串的函数,如果数据中字符长度大于7,就截断它')
if (data.str.length > 7) {
data.str = data.str.slice(0, 7)
}
return data
}
function beforeFn2(data) {
console.log('我是一个检验数字的函数,如果数据中数字大于10,就将它除2')
if (data.num > 10) {
data.num = data.num / 2
}
return data
}
function beforeFn3(data) {
console.log('我是一个检验数组的函数,需要过滤数组中的0')
data.arr = data.arr.filter(Boolean)
return data
}
// 上面说到的组合函数
const compose =
(...functions) =>
(data) =>
functions.reduce((result, item) => item(result), data)
function wrappedBefore(fn, ...beforeFns) {
return function (data) { // wrappedFn(data)
const result = compose(...beforeFns)(data) // 将 data 传进去给组合函数处理,result 是被处理后的 data
return fn.call(this, result)
}
}
const wrappedFn = wrappedBefore(fn, beforeFn1, beforeFn2, beforeFn3)
const data = {
str: '我是一段字符串,我的长度最长只能是7',
num: 100,
arr: [0, 1, 2, 3, 4],
}
wrappedFn(data)
策略模式
写一个策略根据会员的等级计算商品的价格
const strategies = {
S: (price) => price * 0.7,
A: (price) => price * 0.8,
B: (price) => price * 0.9,
}
const calculateDiscount = function (level, price) {
return strategies[level](price) // 价格策略的函数,通过 return 返回
}
const discountedPrice = calculateDiscount('S', 1000)
console.log(discountedPrice)
六、写在最后
这个章节复习的内容比较抽象,主要是想表达函数编程的一个思想,我们活在金字塔底层前端业务猿用的函数式编程并不多 🤣,甚至纯函数用的都不多,可能在写一些公用工具函数的时候用得比较多,例如,日期、金额的格式化函数,防抖函数等。但是这并不妨碍我们学习它,它可能是我们从金字塔底层向上攀登的必要路径之一。