函数式编程已经成为JavaScript世界中一个非常热门的话题。就在几年前,有JavaScript程序员甚至不知道什么是函数式编程,但是我在过去三年中看到的每个大型应用程序代码库都大量使用了函数式编程思想。

函数式编程(通常缩写为FP)是通过组合纯函数,避免共享状态, 可变数据和副作用来构建软件的过程。函数式编程是声明性的,而不是命令性的,并且应用程序状态通过纯函数流动。与面向对象的编程相反,在面向对象的编程中,应用程序状态通常与对象中的方法共享并共置。
函数式编程是一种编程范例,这意味着它是一种基于一些基本的定义原则来思考软件构造的方式。编程范例的其他示例包括面向对象的编程和过程编程。
与强制性或面向对象的代码相比,函数编程往往更简洁,更可预测且更易于测试-但是,如果您不熟悉它以及与之相关的通用模式,则函数编程也可能看起来更加密集,并且相关文献可能对新来者来说是不可渗透的。
如果您开始使用函数式编程术语进行搜索,那么您很快就会碰到一门学术用语的砖墙,这对初学者可能会非常吓人。说它具有学习曲线是一个严重的轻描淡写。但是,如果您使用JavaScript已有一段时间,那么很有可能在实际软件中使用了许多函数编程概念和实用程序。
不要让所有的新词吓到你。这比听起来容易得多。
最难的部分是将您的头缠在所有陌生的词汇上。天真的外观定义中有很多想法,在您开始理解函数式编程的含义之前,都需要理解所有这些想法:
- 纯函数
- 函数组成
- 避免共享状态
- 避免改变状态
- 避免副作用
换句话说,如果您想知道函数式编程在实践中意味着什么,则必须从对这些核心概念的理解开始。
纯函数是一个函数,其中:
- 给定相同的输入,总是返回相同的输出,并且
- 没有副作用
纯函数具有许多在函数编程中很重要的属性,包括引用透明性(您可以用其结果值替换函数调用,而无需更改程序的含义)。
函数组合是组合两个或多个函数以产生新函数或执行某些计算的过程。例如,组成f . g
(点表示“由...组成”)与f(g(x))
JavaScript中的等效。
共享状态
共享状态是共享作用域中存在的任何变量,对象或内存空间,或者是在作用域之间传递的对象的属性。共享范围可以包括全局范围或关闭范围。通常,在面向对象的编程中,通过将属性添加到其他对象来在范围之间共享对象。
例如,计算机游戏可能有一个主游戏对象,角色和游戏项目存储为该对象拥有的属性。函数式编程避免了共享状态-而是依靠不变的数据结构和纯计算从现有数据中获取新数据
共享状态的问题在于,为了了解函数的效果,您必须了解函数使用或影响的每个共享变量的全部历史记录。
假设您有一个需要保存的用户对象。您的saveUser()函数向服务器上的API发出请求。发生这种情况时,用户使用更改其个人资料图片updateAvatar()并触发另一个saveUser()请求。保存时,服务器将发回规范的用户对象,该对象应替换内存中的所有内容,以便与服务器上发生的更改或响应其他API调用同步。
不幸的是,第二个响应在第一个响应之前被接收,因此,当第一个(现在已过时)响应被返回时,新的配置文件图片将在内存中被擦除并替换为旧的。这是争用条件的一个示例-与共享状态相关的非常常见的错误。
与共享状态相关的另一个常见问题是,更改函数调用顺序可能会导致一系列故障,因为作用于共享状态的函数与时间相关:
// With shared state, the order in which function calls are made
// changes the result of the function calls.
const x = {
val: 2
};
const x1 = () => x.val += 1;
const x2 = () => x.val *= 2;
x1();
x2();
console.log(x.val); // 6
// This example is exactly equivalent to the above, except...
const y = {
val: 2
};
const y1 = () => y.val += 1;
const y2 = () => y.val *= 2;
// ...the order of the function calls is reversed...
y2();
y1();
// ... which changes the resulting value:
console.log(y.val); // 5
当您避免共享状态时,函数调用的时间和顺序不会改变调用函数的结果。使用纯函数,给定相同的输入,您将始终获得相同的输出。这使得函数调用完全独立于其他函数调用,从而可以从根本上简化更改和重构。一个函数的更改或函数调用的时间不会波动并破坏程序的其他部分。
const x = {
val: 2
};
const x1 = x => Object.assign({}, x, { val: x.val + 1});
const x2 = x => Object.assign({}, x, { val: x.val * 2});
console.log(x1(x2(x)).val); // 5
const y = {
val: 2
};
// Since there are no dependencies on outside variables,
// we don't need different functions to operate on different
// variables.
// this space intentionally left blank
// Because the functions don't mutate, you can call these
// functions as many times as you want, in any order,
// without changing the result of other function calls.
x2(y);
x1(y);
console.log(x1(x2(y)).val); // 5
在上面的示例中,我们使用Object.assign()并传入一个空对象作为第一个参数来复制的属性,x而不是对其进行适当的突变。在这种情况下,这相当于只从头开始创建一个新对象,而无需使用Object.assign(),但这是JavaScript中创建现有状态副本而不使用突变的常见模式,这在第一个示例中已得到证明。
如果仔细查看console.log()本示例中的语句,您应该注意到我已经提到的一些内容:函数组合。回想一下,函数组成如下所示:f(g(x))。在这种情况下,我们将f()和替换g()为x1()和x2(),以组成:x1 . x2。
当然,如果您更改合成的顺序,则输出也会更改。操作顺序仍然很重要。f(g(x))并不总是等于g(f(x)),但不再重要的是函数外部变量发生了什么,这很重要。使用不纯函数,除非您了解函数使用或影响的每个变量的全部历史记录,否则无法完全理解函数的作用。
删除函数调用计时相关性,然后消除一整类潜在的错误
不变性
一个不可变对象是它的创建后不能被修改的对象。相反,可变对象是创建后可以修改的任何对象。
不变性是函数式编程的核心概念,因为没有它,程序中的数据流就会丢失。状态历史记录被遗弃,奇怪的错误会潜入您的软件中。
在JavaScript中,不要将const
不变性与混淆是很重要的。const
创建一个变量名绑定,创建后不能重新分配。const
不会创建不可变的对象。您不能更改绑定引用的对象,但仍可以更改对象的属性,这意味着使用创建的绑定const
是可变的,而不是不变的。
不变的对象根本无法更改。通过深度冻结对象,可以使值真正不变。JavaScript有一种将对象冻结一级的方法:
const a = Object.freeze({
foo: 'Hello',
bar: 'world',
baz: '!'
});
a.foo = 'Goodbye';
// Error: Cannot assign to read only property 'foo' of object Object
但是冻结的对象只是表面上不可变的。例如,以下对象是可变的:
const a = Object.freeze({
foo: { greeting: 'Hello' },
bar: 'world',
baz: '!'
});
a.foo.greeting = 'Goodbye';
console.log(`${ a.foo.greeting }, ${ a.bar }${a.baz}`);
如您所见,冻结对象的顶级原始属性无法更改,但是仍然是对象(包括数组等)的任何属性仍可以进行更改-因此,即使您不走冻结对象,冻结对象也不会保持不变。整个对象树并冻结每个对象属性。
在许多函数式编程语言中,有一些特殊的不可变数据结构称为trie数据结构,这些结构被有效地深度冻结-意味着无论对象层次结构中的属性级别如何,任何属性都无法更改。
尝试使用结构共享为对象的所有部分共享参考内存位置,这些位置在复制对象后未更改,从而使用较少的内存,并可以显着提高某些操作的性能。
例如,您可以在对象树的根目录使用身份比较进行比较。如果身份相同,则不必遍历整个树来检查差异。
JavaScript中有几个利用trie
的库,包括Immutable.js和Mori。
我已经对两者进行了试验,并且倾向于在需要大量不可变状态的大型项目中使用Immutable.js。
副作用
副作用是在应用程序状态变化中,除了返回值之外,在调用的函数之外都可以观察到。副作用包括:
- 修改任何外部变量或对象属性(例如,全局变量或父函数作用域链中的变量)
- 打印console
- 写入屏幕
- 写入文件
- 写入网络
- 触发任何外部过程
- 调用其他有副作用的函数
在函数式编程中,通常会避免产生副作用,这使得程序的效果更易于理解和测试。
Haskell和其他函数语言经常使用monad将副作用与纯函数隔离并封装在一起。monad的主题足够深入,因此可以写一本书,因此我们将其保存以备后用。
您现在需要知道的是,副作用操作必须与软件的其余部分隔离开。如果您将副作用与程序逻辑的其余部分分开,则软件将易于扩展,重构,调试,测试和维护。
这就是大多数前端框架鼓励用户在单独的,松散耦合的模块中管理状态和组件渲染的原因。
通过高阶函数的可重用性
函数式编程倾向于重用一组通用的函数实用程序来处理数据。面向对象的编程倾向于将方法和数据并置在对象中。这些共置方法只能对设计用于操作的数据类型进行操作,并且通常只能对特定对象实例中包含的数据进行操作。
在函数式编程中,任何类型的数据都是公平的游戏。相同的map()实用程序可以映射对象,字符串,数字或任何其他数据类型,因为它将函数作为参数来适当地处理给定的数据类型。FP使用更高阶的函数启动了通用工具的骗术。
JavaScript具有一流的函数,它使我们可以将函数视为数据-将它们分配给变量,将它们传递给其他函数,从函数中返回它们,等等。
函数高阶函数是取函数作为参数,返回一个函数或二者的任何函数。高阶函数通常用于:
- 使用回调函数,promise,monad等来抽象或隔离动作,效果或异步流控制…
- 创建可以处理多种数据类型的实用程序
- 为重用或函数组合的目的,部分将函数应用于其参数或创建咖喱函数
- 取得函数列表并返回这些输入函数的一些组合
容器,函子,列表和流
函子是可以映射的东西。换句话说,它是一个具有接口的容器,该接口可用于将函数应用于其中的值。当您看到函子这个词时,您应该认为“可映射”。
早先我们了解到,同一map()实用程序可以对多种数据类型起作用。它通过解除映射操作以使用functor API来实现。所使用的重要流控制操作map()利用了该接口。对于Array.prototype.map(),容器是一个数组,但是其他数据结构也可以是函子,只要它们提供映射API。
让我们看一下如何Array.prototype.map()允许您从映射实用程序中提取数据类型以使其map()可用于任何数据类型。我们将创建一个简单的double()映射,该映射将传入的值乘以2:
const double = n => n * 2;
const doubleMap = numbers => numbers.map(double);
console.log(doubleMap([2, 3, 4])); // [ 4, 6, 8 ]
如果我们想对游戏中的目标进行操作以使其获得的分数翻倍,该怎么办?我们要做的就是对double()传递给的函数进行微妙的更改map(),一切仍然有效:
const double = n => n.points * 2;
const doubleMap = numbers => numbers.map(double);
console.log(doubleMap([
{ name: 'ball', points: 2 },
{ name: 'coin', points: 3 },
{ name: 'candy', points: 4}
])); // [ 4, 6, 8 ]
在函数式编程中,使用仿函数和高阶函数之类的抽象以便使用通用实用程序函数来操纵任意数量的不同数据类型的概念非常重要。您将看到类似的概念以各种不同的方式应用。
您现在只需要了解的是,数组和函子并不是应用容器和容器中的值的唯一方法。例如,数组只是事物的列表。随时间推移而表达的列表是一个流-因此您可以应用相同类型的实用程序来处理传入事件流-当您开始使用FP构建真实软件时,您会看到很多东西。
声明式与命令式
函数式编程是一种声明式范例,意味着在不明确描述流控制的情况下表示程序逻辑。
命令式程序花费了几行代码来描述用于实现预期结果的特定步骤-流程控制:如何做事。
声明性程序将流控制过程抽象化,而是使用描述数据流的代码行:做什么。该如何抽象。
例如,此命令映射采用一个数字数组,并返回一个新数组,其中每个数字均乘以2:
const doubleMap = numbers => {
const doubled = [];
for (let i = 0; i < numbers.length; i++) {
doubled.push(numbers[i] * 2);
}
return doubled;
};
console.log(doubleMap([2, 3, 4])); // [4, 6, 8]
该声明性映射执行相同的操作,但是使用函数Array.prototype.map()实用程序将流控制抽象化,这使您可以更清楚地表示数据流:
const doubleMap = numbers => numbers.map(n => n * 2);
console.log(doubleMap([2, 3, 4])); // [4, 6, 8]
命令式代码经常利用语句。函数语句是一段代码执行一些动作。常用语句的例子包括for,if,switch,throw,等...
声明性代码更多地依赖于表达式。一个表达式是一段代码,其评价为某个值。表达式通常是函数调用,值和运算符的某种组合,对它们进行评估以产生结果值。
这些都是表达式的示例:
2 * 2
doubleMap([2, 3, 4])
Math.max(4, 3, 2)
通常在代码中,您会看到表达式被分配给标识符,从函数返回或传递给函数。在赋值,返回或传递之前,首先对表达式求值,然后使用结果值。
结论
函数编程优点:
- 纯函数而不是共享状态和副作用
- 可变数据的不变性
- 函数组成超过命令式流量控制
- 许多通用的,可重用的实用程序,它们使用高阶函数来处理多种数据类型,而不是仅对它们的共置数据进行操作的方法
- 声明性而不是命令性代码(做什么,而不是怎么做)
- 语句之上的表达式
- 容器和高级多态性
Master the JavaScript Interview: What is Functional Programming?
网友评论