函数式编程的充分介绍
介绍
纯函数
函数作为值
函数组合
不变性和不变方法
状态管理和副作用
结论
本文是系列文章的一部分,我们将从一般角度探讨函数式编程和反应式编程,并将其应用于 JavaScript。
在第一篇文章中,我们将以实用的方式讨论几个函数式核心概念,仅在绝对必要的情况下才会深入探讨理论部分。在第二篇文章中,我们将讨论函数式流;在第三篇和第四篇文章中,我们将从头开始实现我们自己的RxJS版本。
介绍
函数式编程将软件建模为一组纯函数,避免共享可变状态。目前,只需知道纯函数不会修改环境,并且对于相同的参数,其返回值相同即可。同时,共享状态的主要问题是它会降低可预测性,并使逻辑流程更难以遵循。
需要明确的是:不同的问题需要不同的工具,完美且通用的范式并不存在,但函数式编程在很多情况下都能带来优势。总结如下:
- 关注你想要实现什么(声明式),而不是如何实现(命令式)
- 更易读的代码,隐藏了无用的实现细节
- 逻辑流程清晰,状态分散性好,不易被隐式修改
- 功能/模块变得易于测试、重用和维护
- “更安全”的代码,没有副作用
为什么我们要关心命令式和声明式方法?让我们通过一个例子来讨论一下它们的区别。例子用两种方式执行相同的操作:从列表中过滤掉奇数,同时将较小的数递增到 5。
const numbers = [1,2,3,4,5,6,7,8,9,10]
// IMPERATIVE approach
let result = []
for (let i = 0; i < numbers.length; i++) {
if (numbers[i] % 2 === 0) {
if (numbers[i] < 5) {
result.push(5)
continue
}
result.push(numbers[i])
}
}
// DECLARATIVE approach
numbers
.filter(n => n % 2 === 0)
.map(n => n < 5 ? 5 : n)
同样的计算,同样的结果。但是,正如你所见,命令式代码冗长且不够清晰。另一方面,声明式方法可读性高且清晰,因为它专注于我们想要实现的目标。想象一下,将同样的差异扩展到应用程序的大部分内容,几个月后又回到同样的代码。未来的你(以及你的同事)一定会喜欢这种声明式风格!
再说一次,并不存在某些人声称的“最佳范式”,只有适合特定情况的正确工具。事实上,我也是使用组合(Go 的方式)实现的 OOP 的忠实粉丝。无论如何,函数式编程可以在您的应用程序的多个地方找到提高可读性和可预测性的地方。
让我们开始探索一些函数式编程的核心概念。我们将看看它们各自如何带来上面列出的一些优势。
纯函数
当满足以下条件时,函数就是纯函数:
- 它没有可观察到的副作用,例如 I/O、外部变量突变、文件系统更改、DOM 更改、HTTP 调用等,
- 具有引用透明性:函数可以用其执行结果替换,而不会改变整体计算的结果。
让我们通过一些基本的例子来阐明这个定义。
// impure, modifies external state
let counter = 0
const incrementCounter = (n) => {
counter = counter + n
return counter
}
// impure, I/O
const addAndSend = (x1, x2) => {
const sum = x1 + x2
return fetch(`SOME_API?sum=${sum}`)
}
// both pure, no side effects
const add = (x1, x2) => {
return x1 + x2
}
const formatUsers = users => {
if (!(users instanceof Array)) {
return []
}
return users.map(user => `
Name: ${user.first} ${user.last},
Age: ${user.age}
`)
}
纯函数是“安全的”,因为它们永远不会隐式地改变任何变量,而代码的其他部分现在或以后可能会依赖这些变量。
在这些限制下编写代码可能看起来不舒服,但想想这一点:纯函数是确定性的、“可抽象的”、可预测的和可组合的。
函数作为值
在支持 FP 的语言中,函数就是值,因此您可以将它们传递到其他函数并返回到其他函数,并将它们存储在变量中。
在 JS 中我们已经习惯了这种模式(可能不是有意识的),例如当我们为 DOM 事件监听器提供回调时,或者当我们使用像map
、reduce
或 这样的数组方法时filter
。
我们再看一下前面的例子:
const formatUsers = users => {
if (!(users instanceof Array)) {
return []
}
return users.map(user => `
Name: ${user.first} ${user.last},
Age: ${user.age}
`)
}
这里的map
参数是一个内联匿名函数(或lambda)。我们可以重写上面的代码片段,以更清楚地演示“函数即值”的思想,其中函数userF
被显式地传递给map
。
const userF = user => {
return `
Name: ${user.first} ${user.last},
Age: ${user.age}
`
}
const formatUsers = users => {
if (!(users instanceof Array)) {
return []
}
return users.map(userF)
}
JS 中的函数本身就是值,这使得我们可以使用高阶函数(HOF):函数接收其他函数作为参数,并/或返回新函数,这些新函数通常由接收的输入函数获得。HOF 的用途多种多样,例如函数的特化和组合。
让我们来看看get
HOF。此实用程序允许安全且无错误地获取对象/数组的内部节点值(提示:其语法...props
定义遵循 REST,用于收集参数列表,并将其作为数组保存在名为 props 的参数中)。
const get = (...props) => obj => {
return props.reduce(
(objNode, prop) => objNode && objNode[prop]
? objNode[prop]
: null,
obj
)
}
Get
接收一个键列表,用于查找所需的值,并返回一个期望对象深入挖掘的(专门的)函数。
这是一个实际的例子。我们想要从一个并非总是完整的对象(可能是从不受信任的 API 接收的)中提取description
数组第一个元素的节点monuments
。我们可以生成一个安全的 getter 来实现这一点。
const Milan = {
country: 'Italy',
coords: { lang: 45, lat: 9 },
monuments: [
{
name: 'Duomo di Milano',
rank: 23473,
infos: {
description: 'Beautiful gothic church build at the end of…',
type: 'Church'
}
},
{ /* ... */ },
{ /* ... */ },
{ /* ... */ }
]
}
无需进行多次(无聊的)检查:
const getBestMonumentDescription = get('monuments', 0, 'infos', 'description')
getBestMonumentDescription(Milan) // 'Beautiful gothic church…'
getBestMonumentDescription({}) // null (and no errors)
getBestMonumentDescription(undefined) // null (same for null, NaN, etc..)
getBestMonumentDescription() // null
函数组合
由于没有副作用,纯函数可以组合在一起,从而创建更安全、更复杂的逻辑。我所说的“安全”是指我们不会更改代码其他部分可能依赖的环境或外部变量(对于函数而言)。
当然,使用纯函数创建一个新函数并不能保证后者的纯度,除非我们仔细地避免其每个部分的副作用。举个例子,我们想计算所有满足给定条件的用户的金额总和。
const users = [
{id: 1, name: "Mark", registered: true, money: 46},
{id: 2, name: "Bill", registered: false, money: 22},
{id: 3, name: "Steve", registered: true, money: 71}
]
// simple pure functions
const isArray = v => v instanceof Array
const getUserMoney = get('money')
const add = (x1, x2) => x1 + x2
const isValidPayer = user =>
get('registered')(user) &&
get('money')(user) > 40
// desired logic
export const sumMoneyOfRegUsers = users => {
if (!isArray(users)) {
return 0
}
return users
.filter( isValidPayer )
.map( getUserMoney )
.reduce( add, 0 )
}
sumMoneyOfRegUsers(users) // 117
我们filter
创建了用户数组,并生成第二个包含金额的数组(map
),最后对reduce
所有值求和( )。我们以清晰、声明式且易读的方式编写了操作逻辑。同时,我们避免了副作用,因此函数调用前后的状态/环境是相同的。
// application state
const money = sumMoneyFromRegUsers(users)
// same application state
除了手动组合之外,还有一些实用程序可以帮助我们组合函数。其中两个特别有用:pipe
和compose
。其思路很简单:我们将连接n 个函数,并将前一个函数的输出作为参数调用每个函数。
// function composition with pipe
// pipe(f,g,h)(val) === h(g(f(val)))
const pipe = (...funcs) => {
return (firstVal) => {
return funcs.reduce((partial, func) => func(partial), firstVal)
}
}
// or more concisely
const pipe = (...fns) => x0 => fns.reduce((x, f) => f(x), x0)
Pipe
是一个 HOF,它接受一个函数列表。然后,返回的函数需要一个起始值,该起始值将通过输入输出链传递所有先前提供的函数。Compose
它非常相似,但操作方式是从右到左:
// compose(f,g,h)(val) === f(g(h(val)))
const compose = (...fns) => x0 => fns.reduceRight((x, f) => f(x), x0)
让我们用一个简单的例子来阐明这个想法:
// simple functions
const arrify = x => x instanceof Array ? x : [x]
const getUserMoney = get('money')
const getUserReg = get('registered')
const filterValidPayers = users => users.filter( user =>
getUserReg(user) &&
getUserMoney(user) > 40
)
const getUsersMoney = users => users.map(getUserMoney)
const sumUsersMoney = moneyArray => moneyArray.reduce((x, y) => x + y, 0)
// desired logic
export const sumMoneyOfRegUsers = pipe(
arrify,
filterValidPayers,
getUsersMoney,
sumUsersMoney
)
// get result
sumMoneyOfRegUsers(users) // 117
我们还可以使用该tap
实用程序检查每个中间结果。
// debug-only
const tap = thing => {
console.log(thing)
return thing
}
export const sumMoneyOfRegUsers = pipe(
arrify,
filterValidPayers,
tap,
getUsersMoney,
tap,
sumUsersMoney
)
// get result
sumMoneyOfRegUsers(users)
// [{...}, {...}] first tap
// [46, 71] second tap
// 117 final result
不变性和不变方法
不变性是函数式编程的核心概念。为了避免副作用并提高可预测性,数据结构应该被认为是不可变的。这一概念还带来了其他优势:突变跟踪和性能(在某些情况下)。
为了在 JS 中实现不可变性,我们必须按照惯例采用不可变的方法,即复制对象和数组,而不是“就地”修改。换句话说,我们始终希望在进行新的复制时保留原始数据。
在 JS 中,对象和数组是通过引用传递的,也就是说,如果被其他变量引用或作为参数传递,则对后者的更改也会影响原始对象。有时,仅以浅层方式(一层深度)复制对象是不够的,因为对象内部可能还有通过引用传递的对象。
如果我们想彻底摆脱与原版的束缚,就应该像我们常说的那样,深度克隆。看起来很复杂?或许吧,不过请耐心等待几分钟!😁
克隆和更新数据结构最有用的语言工具是:
- 对象和数组扩展运算符(“...”语法),
- 数组方法如 map、filter 和 reduce。它们都返回浅拷贝。
这里有一些编辑操作,采用不可变的方法执行:
// OBJECT SPREAD OPERATOR
const user = {
id: 1,
name: 'Mark',
money: 73,
registered: true
}
const updatedUser = { ...user, registered: false }
// ARRAY SPREAD OPERATOR
const cities = [ 'Rome', 'Milan', 'New York' ]
const newCities = [ ...cities, 'London' ]
在这两个例子中,数组的各个元素和对象的各个属性分别被复制到一个新数组和一个新对象中,它们与原始数组和对象无关。
要以不可变的方式编辑、添加或删除对象数组中的元素,我们可以结合使用扩展运算符和数组方法。每次我们根据具体任务创建一个具有一定变化的新集合。
// original
const subscribers = [
{id: 1, name: 'Tyler', registered: true, money: 36 },
{id: 2, name: 'Donald', registered: true, money: 26 },
{id: 3, name: 'William', registered: true, money: 61 }
]
// EDIT
const newSubscribers1 = subscribers
.map( sub => sub.name === 'Donald' ? {...sub, money: 89} : sub )
// DELETE
const newSubscribers2 = subscribers
.filter( sub => sub.name !== 'Tyler' )
// ADD
const newSubscribers3 = [
...subscribers,
{ id: 4, name: 'Bob', registered: false, money: 34 }
]
让我们从一些代码开始,快速讨论一下浅拷贝和深拷贝。
const subscribers = [
{ id: 1, name: 'Tyler', registered: true, money: 36 },
{ id: 2, name: 'Donald', registered: true, money: 26 },
{ id: 3, name: 'William', registered: true, money: 61 }
]
// SHALLOW copy
const newSubscribers1 = [ ...subscribers ]
// DEEP copy (specific case)
const newSubscribers2 = subscribers.map( sub => ({...sub}) )
两种复制类型的区别在于,如果我们在浅复制数组中更改对象的属性,该更改也会反映到原始数组中,而深复制则不会发生这种情况。在后一种情况下,发生这种情况是因为除了数组克隆操作之外,我们还克隆了其所包含的对象。
两种复制方式都可以,只要你始终克隆需要修改的部分即可。这样,我们就永远不会修改原始版本。
一个通用的“深度”解决方案是使用递归函数(为了方便和可靠,我们应该从库中获取)。如果我们想要完全自由地操作数据,或者我们不信任第三方代码,那么深度复制就很有用。
关于性能的说明
我们来简单谈谈性能。在某些情况下,不变性可以提升我们的应用程序。例如,克隆将被分配到与原始数据不同的内存位置,从而可以轻松快速地通过引用进行比较。相同的指针/引用(对象为 ===)?没有变化。不同的引用?检测到变化,因此需要做出适当的反应。无需内部比较,因为我们已经决定为每个变化创建单独的副本。
另一方面,每次创建新的副本可能会产生大量的内存消耗,从而导致性能损失。这是函数式编程中一个众所周知的固有问题,通过在克隆之间共享部分可操作的数据结构来解决。无论如何,这个复杂的主题超出了本文的讨论范围。
状态管理和副作用
在某些时候,我们需要使用状态来保存永久变量、执行某些 I/O 操作、修改文件系统等等。没有这些操作,应用程序就只是一个黑匣子。那么,如何以及在何处管理状态和副作用呢?
让我们从基础开始。为什么我们要尽量避免共享、可变和分散的状态?嗯,这个问题基本上可以归结为这样一个想法:为了理解共享状态函数的效果,你必须了解该函数使用或影响的每个共享变量的完整历史记录。换句话说,作用于共享状态的函数/操作/例程是时间和顺序相关的。
总之,共享可变状态降低了可预测性并且使得遵循逻辑流程变得更加困难。
纯函数式编程倾向于将状态和副作用推到应用程序的边界,以便在一个地方进行管理。实际上,解决这个问题的函数式解决方案是在应用程序“外部”的一个(大型)对象中处理状态,并使用不可变的方法进行更新(因此每次都需要克隆和更新)。
在前端开发领域,这种模式被所谓的状态管理器(例如 Redux 和 NgRx)所采用和实现。虽然代码量有所增加(但并不多),并且复杂性有所降低,但我们的应用程序将变得更加可预测、易于管理和易于维护。
状态管理器的工作原理如下,用一个极其简化的示意图来说明。事件触发操作,激活 Reducer,后者更新状态(存储)。最终,(大部分)无状态的 UI 将会得到正确的更新。这个论点比较复杂,但我简要地介绍了一下,以便让你了解其基本概念。
此外,副作用被容器化并在应用程序的一个或几个特定点执行(参见 NgRx 效果),始终以改善其管理为目的。
此外,此模式还支持突变跟踪。这是什么意思呢?如果我们仅使用不可变版本更新应用程序状态,则可以随时间推移收集它们(即使只是简单地存储在数组中)。因此,我们可以轻松跟踪更改,并从一个应用程序“状态”切换到另一个。此功能在类似 Redux 的状态管理器中称为时间旅行调试。
结论
在尝试广泛地讨论 FP 时,我们没有讨论现在必须提及的一些重要概念:柯里化和偏应用、记忆化和函数数据类型。
深入讨论 FP 需要几个月的时间,但我认为对于那些想要在应用程序的某些部分引入该范式的人来说,这个介绍已经是一个很好的起点。
下一篇文章,我们将探讨函数式流,开启响应式编程的新篇章。期待与你相见!😁
附言:英语不是我的母语,所以错误难免。欢迎大家评论指正!
文章来源:https://dev.to/mr_bertoli/an-adequate-introduction-to-functional-programming-1gcl