JavaScript 函数式编程简介

探索函数式编程以及如何使用它使您的程序更易于阅读和调试。
842 位读者喜欢这篇文章。
Rocket

Steve Jurvetson 通过 Flickr (CC-BY-2.0)

当 Brendan Eich 在 1995 年创建 JavaScript 时,他打算在浏览器中实现 Scheme。Scheme 是 Lisp 的一种方言,是一种函数式编程语言。当 Eich 被告知新语言应该是 Java 的脚本语言伴侣时,情况发生了变化。Eich 最终确定了一种具有 C 风格语法(与 Java 相同)但具有头等函数的语言。Java 在版本 8 之前在技术上没有头等函数,但是你可以使用匿名类来模拟头等函数。这些头等函数使得在 JavaScript 中进行函数式编程成为可能。

JavaScript 是一种多范式语言,允许你自由混合和匹配面向对象、过程式和函数式范式。最近,函数式编程的趋势日益增长。在诸如 AngularReact 等框架中,通过使用不可变数据结构,你实际上会获得性能提升。不可变性是函数式编程的核心原则。它与纯函数一起,使程序更容易推理和调试。用函数替换过程式循环可以提高程序的可读性,并使其更优雅。总的来说,函数式编程有很多优点。

函数式编程不是什么

在我们讨论函数式编程是什么之前,让我们先讨论一下它不是什么。实际上,让我们讨论一下你应该抛弃的所有语言结构(再见,老朋友们)

  • 循环
    • while
    • do...while
    • for
    • for...of
    • for...in
  • 使用 varlet 的变量声明
  • Void 函数
  • 对象突变(例如:o.x = 5;
  • 数组修改器方法
    • copyWithin
    • fill
    • pop
    • push
    • reverse
    • shift
    • sort
    • splice
    • unshift
  • Map 修改器方法
    • clear
    • delete
    • set
  • Set 修改器方法
    • add
    • clear
    • delete

没有这些功能,你如何进行编程? 这正是我们将在接下来的几节中探讨的内容。

纯函数

仅仅因为你的程序包含函数并不一定意味着你在进行函数式编程。函数式编程区分纯函数和非纯函数。它鼓励你编写纯函数。一个纯函数必须满足以下两个属性:

  • 引用透明性:对于相同的参数,函数总是给出相同的返回值。这意味着该函数不能依赖任何可变状态。
  • 无副作用:函数不能引起任何副作用。副作用可能包括 I/O(例如,写入控制台或日志文件)、修改可变对象、重新分配变量等。

让我们用几个例子来说明。首先,multiply 函数是纯函数的一个例子。对于相同的输入,它总是返回相同的输出,并且不会引起任何副作用。

以下是非纯函数的示例。canRide 函数依赖于捕获的 heightRequirement 变量。捕获的变量不一定使函数成为非纯函数,但可变(或可重新赋值)的变量会使其成为非纯函数。在本例中,它是使用 let 声明的,这意味着它可以被重新赋值。multiply 函数是非纯函数,因为它通过记录到控制台而引起副作用。

以下列表包含 JavaScript 中几个内置的非纯函数。你能说出每个函数不满足这两个属性中的哪一个吗?

  • console.log
  • element.addEventListener
  • Math.random
  • Date.now
  • $.ajax (其中 $ == 你选择的 Ajax 库)

生活在一个所有函数都是纯函数的完美世界中会很好,但正如你从上面的列表中看到的那样,任何有意义的程序都将包含非纯函数。大多数时候,我们需要进行 Ajax 调用、检查当前日期或获取随机数。一个好的经验法则是遵循 80/20 规则:你的函数中 80% 应该是纯函数,剩下的 20% 由于必要性将是非纯函数。

纯函数有几个优点:

  • 它们更容易推理和调试,因为它们不依赖于可变状态。
  • 返回值可以被缓存或“记忆化”,以避免将来重新计算。
  • 它们更容易测试,因为没有需要模拟的依赖项(例如日志记录、Ajax、数据库等)。

如果你正在编写或使用的函数是 void 类型(即,它没有返回值),这是一个它是非纯函数的线索。如果函数没有返回值,那么它要么是空操作,要么是引起一些副作用。同样,如果你调用一个函数但不使用它的返回值,那么你可能依赖它来执行一些副作用,并且它是一个非纯函数。

不可变性

让我们回到捕获变量的概念。上面,我们查看了 canRide 函数。我们认为它是一个非纯函数,因为 heightRequirement 可以被重新赋值。这是一个人为的例子,说明它是如何被重新赋值并产生不可预测的结果的:

让我再次强调,捕获的变量不一定使函数成为非纯函数。我们可以通过简单地更改声明 heightRequirement 变量的方式来重写 canRide 函数,使其成为纯函数。

使用 const 声明变量意味着它不可能被重新赋值。如果尝试重新赋值,运行时引擎将抛出错误;但是,如果我们的“常量”不是一个简单的数字,而是一个存储所有“常量”的对象呢?

我们使用了 const,因此变量不能被重新赋值,但仍然存在问题。对象可以被修改。正如以下代码所示,要获得真正的不可变性,你需要防止变量被重新赋值,并且还需要不可变的数据结构。JavaScript 语言为我们提供了 Object.freeze 方法来防止对象被修改。

不可变性适用于所有数据结构,包括数组、map 和 set。这意味着我们不能调用诸如 array.prototype.push 之类的修改器方法,因为它会修改现有数组。我们可以创建一个新数组,其中包含与原始数组相同的所有项,以及一个额外的项,而不是将项推入现有数组。实际上,每个修改器方法都可以用一个返回具有所需更改的新数组的函数来代替。

使用 MapSet 时也是如此。我们可以通过返回具有所需更改的新 MapSet 来避免修改器方法。

我想补充一点,如果你正在使用 TypeScript(我是 TypeScript 的忠实粉丝),那么你可以使用 Readonly<T>ReadonlyArray<T>ReadonlyMap<K, V>ReadonlySet<T> 接口,如果你尝试修改任何这些对象,你将收到编译时错误。如果你在对象字面量或数组上调用 Object.freeze,那么编译器将自动推断它是只读的。由于 Map 和 Set 在内部的表示方式,在这些数据结构上调用 Object.freeze 的效果不同。但是很容易告诉编译器你希望它们是只读的。

TypeScript Readonly Interfaces

opensource.com

好的,所以我们可以创建新对象而不是修改现有对象,但这不会对性能产生不利影响吗?是的,可能会。请务必在自己的应用程序中进行性能测试。如果你需要性能提升,那么请考虑使用 Immutable.js。Immutable.js 使用持久数据结构实现了 ListsStacksMapsSets 和其他数据结构。这与 Clojure 和 Scala 等函数式编程语言内部使用的技术相同。

函数组合

还记得高中时你学到的看起来像 (f ∘ g)(x) 的东西吗?还记得当时想,“我什么时候会用到这个?”吗?好吧,现在你要用到了。准备好了吗?f ∘ g 读作“f 与 g 的组合”。有两种等效的思考方式,如以下恒等式所示:(f ∘ g)(x) = f(g(x))。你可以将 f ∘ g 视为单个函数,或者视为调用函数 g,然后获取其输出并将其传递给 f 的结果。请注意,函数从右到左应用——也就是说,我们先执行 g,然后执行 f

关于函数组合的几个要点:

  1. 我们可以组合任意数量的函数(我们不限于两个)。
  2. 组合函数的一种方法是简单地获取一个函数的输出并将其传递给下一个函数(即 f(g(x)))。

诸如 Ramdalodash 等库提供了更优雅的函数组合方式。我们不仅仅是将一个函数的返回值传递给下一个函数,而是可以在更数学的意义上对待函数组合。我们可以创建一个由其他函数组成的单个复合函数(即 (f ∘ g)(x))。

好的,所以我们可以在 JavaScript 中进行函数组合。这有什么大不了的?嗯,如果你真的赞同函数式编程,那么理想情况下,你的整个程序都将只是函数组合。你的代码中将没有循环(forfor...offor...inwhiledo)。一个也没有(句号)。但是那是不可能的,你说!不尽然。这引出了接下来的两个主题:递归和高阶函数。

递归

假设你想实现一个计算数字阶乘的函数。让我们回顾一下数学中阶乘的定义:

n! = n * (n-1) * (n-2) * ... * 1.

也就是说,n! 是从 n 到 1 的所有整数的乘积。我们可以很容易地编写一个循环来为我们计算它。

请注意,在循环内部,producti 都在重复地被重新赋值。这是解决问题的标准过程式方法。我们如何使用函数式方法来解决它?我们需要消除循环并确保没有变量被重新赋值。递归是函数式程序员工具箱中最强大的工具之一。递归要求我们将总体问题分解为类似于总体问题的子问题。

计算阶乘是一个完美的例子。要计算 n!,我们只需要取 n 并将其乘以所有较小的整数。这与说:

n! = n * (n-1)! 

啊哈!我们找到了一个子问题来解决 (n-1)!,它类似于总体问题 n!。还有一件事需要处理:基本情况。基本情况告诉我们何时停止递归。如果我们没有基本情况,那么递归将永远持续下去。在实践中,如果递归调用过多,你会得到堆栈溢出错误。

阶乘函数的基本情况是什么?起初你可能会认为当 n == 1 时是基本情况,但由于一些复杂的数学知识,当 n == 0 时是基本情况。0! 被定义为 1。考虑到这些信息,让我们编写一个递归阶乘函数。

好的,让我们计算 recursiveFactorial(20000),因为...嗯,为什么不呢!当我们这样做时,我们得到这个:

Stack overflow error

opensource.com

这里发生了什么?我们得到了堆栈溢出错误!这不是因为无限递归。我们知道我们处理了基本情况(即,n === 0)。这是因为浏览器有一个有限的堆栈,而我们已经超过了它。每次调用 recursiveFactorial 都会导致一个新的帧被放入堆栈中。我们可以将堆栈可视化为一组堆叠在一起的盒子。每次调用 recursiveFactorial 时,都会在顶部添加一个新盒子。下图显示了在计算 recursiveFactorial(3) 时堆栈可能看起来的程式化版本。请注意,在真正的堆栈中,顶部的帧将存储执行后应返回到的内存地址,但我选择使用变量 r 来描述返回值。我这样做是因为 JavaScript 开发人员通常不需要考虑内存地址。

The stack for recursively calculating 3! (three factorial)

opensource.com

你可以想象 n = 20000 的堆栈会更高。我们能对此做些什么吗?事实证明,是的,我们可以做些什么。作为 ES2015(又名 ES6)规范的一部分,添加了一项优化来解决这个问题。它被称为尾调用优化 (PTC)。如果递归函数做的最后一件事是调用自身并返回结果,它允许浏览器省略或忽略堆栈帧。实际上,优化也适用于相互递归函数,但为了简单起见,我们只关注单个递归函数。

你会在上面的堆栈中注意到,在递归函数调用之后,仍然有额外的计算要进行(即,n * r)。这意味着浏览器无法使用 PTC 对其进行优化;但是,我们可以重写函数,使最后一步是递归调用。这样做的一个技巧是将中间结果(在本例中为 product)作为参数传递给函数。

现在,当计算 factorial(3) 时,让我们可视化优化的堆栈。如下图所示,在这种情况下,堆栈永远不会增长到超过两个帧。原因是我们将所有必要的信息(即 product)传递给了递归函数。因此,在 product 更新后,浏览器可以丢弃该堆栈帧。你会在图中注意到,每次顶部帧掉落并成为底部帧时,之前的底部帧都会被丢弃。它不再需要了。

The optimized stack for recursively calculating 3! (three factorial) using PTC

opensource.com

现在在您选择的浏览器中运行它,假设你在 Safari 中运行它,那么你将得到答案,即 Infinity(它是一个高于 JavaScript 中最大可表示数字的数字)。但是我们没有得到堆栈溢出错误,这很好!现在其他浏览器呢?事实证明,Safari 是唯一实现 PTC 的浏览器,并且它可能是唯一一个会实现它的浏览器。请参阅以下兼容性表:

PTC compatibility

opensource.com

其他浏览器提出了一个竞争标准,称为 语法尾调用 (STC)。“语法”意味着你将必须通过新语法指定你希望该函数参与尾调用优化。即使目前还没有广泛的浏览器支持,但编写递归函数使其为尾调用优化做好准备仍然是一个好主意,无论何时(以及如何)它到来。

高阶函数

我们已经知道 JavaScript 具有头等函数,可以像任何其他值一样传递。因此,我们可以将函数传递给另一个函数,这应该不足为奇。我们也可以从函数中返回一个函数。瞧!我们有了高阶函数。你可能已经熟悉 Array.prototype 上存在的几个高阶函数。例如,filtermapreduce 等。思考高阶函数的一种方式是:它是一个接受(通常称为)回调函数的函数。让我们看一个使用内置高阶函数的例子:

请注意,我们正在调用数组对象上的方法,这是面向对象编程的特征。如果我们想使其更具函数式编程的代表性,我们可以使用 Ramda 或 lodash/fp 提供的函数来代替。我们也可以使用我们在上一节中探讨的函数组合。请注意,如果我们使用 R.compose,我们需要反转函数的顺序,因为它从右到左(即,从下到上)应用函数;但是,如果我们想从左到右(即,从上到下)应用它们,如上面的示例所示,那么我们可以使用 R.pipe。下面给出了使用 Ramda 的两个示例。请注意,Ramda 有一个 mean 函数可以代替 reduce 使用。

函数式编程方法的优势在于它清楚地将数据(即 vehicles)与逻辑(即函数 filtermapreduce)分离。将此与以对象和方法的形式混合数据和函数的面向对象代码进行对比。

柯里化

非正式地,柯里化是将一个接受 n 个参数的函数转换为 n 个每个接受单个参数的函数的过程。函数的元数是它接受的参数的数量。接受单个参数的函数是 unary(一元函数),两个参数是 binary(二元函数),三个参数是 ternary(三元函数),n 个参数是 n-ary(n 元函数)。因此,我们可以将柯里化定义为将 n 元函数转换为 n 个一元函数的过程。让我们从一个简单的例子开始,一个计算两个向量的点积的函数。回顾线性代数,两个向量 [a, b, c][x, y, z] 的点积等于 ax + by + cz

dot 函数是二元函数,因为它接受两个参数;但是,我们可以手动将其转换为两个一元函数,如以下代码示例所示。请注意 curriedDot 如何成为一个一元函数,它接受一个向量并返回另一个一元函数,然后该函数接受第二个向量。

幸运的是,我们不必手动将我们的每个函数转换为柯里化形式。包括 Ramdalodash 在内的库都有可以为我们执行此操作的函数。实际上,它们执行一种混合类型的柯里化,你可以一次调用该函数一个参数,也可以继续一次传入所有参数,就像原始函数一样。

Ramda 和 lodash 也允许你“跳过”一个参数并在以后指定它。他们使用占位符来实现这一点。由于取点积是可交换的,因此将向量传递给函数的顺序无关紧要。让我们使用一个不同的例子来说明如何使用占位符。Ramda 使用双下划线作为其占位符。

在完成柯里化主题之前,最后一个要点是部分应用。部分应用和柯里化通常是齐头并进的,尽管它们实际上是单独的概念。即使柯里化函数没有被赋予任何参数,它仍然是一个柯里化函数。另一方面,部分应用是指函数已被赋予一些但不是全部参数的情况。柯里化通常用于进行部分应用,但这不是唯一的方法。

JavaScript 语言具有内置机制,可以在不进行柯里化的情况下进行部分应用。这是使用 function.prototype.bind 方法完成的。此方法的一个特性是它要求你传入 this 的值作为第一个参数。如果你不进行面向对象编程,那么你可以通过传入 null 来有效地忽略 this

总结

我希望你喜欢和我一起探索 JavaScript 中的函数式编程!对于某些人来说,这可能是一个全新的编程范式,但我希望你能给它一个机会。我想你会发现你的程序更容易阅读和调试。不可变性还将使你能够利用 Angular 和 React 中的性能优化。

本文基于 Matt 在 OpenWest 的演讲 JavaScript the Good-er PartsOpenWest 将于 2017 年 7 月 12 日至 15 日在犹他州盐湖城举行。

User profile image.
Matt 于 2008 年 5 月毕业于犹他大学,获得数学学位。一个月后,他找到了一份网络开发人员的工作,并且从那时起就一直热爱这份工作!2013 年,他获得了北卡罗来纳州立大学的计算机科学硕士学位。他曾在 LDS Business College 和 Davis School District Community Education program 教授网络开发课程。

2 条评论

哇,谢谢。信息量真大。(31 个屏幕深。29 个没有图片,17 个没有图片也没有 gist)我想我需要将此添加到书签并重新阅读几次才能理解它。

在第一次略读之后,我有一些问题要问。如果可变性和状态是如此糟糕的想法。为什么这么多人使用它们?

我知道在某些情况下可变性是不好的。例如,意外转换用户的电子商务购物车的值可能会很糟糕。我只是不确定硬性严格的纯洁性是否适用于更广泛的系统。我很乐意听到我的最初感觉是不正确的,但会感谢一些链接。

很容易看到这种方法的基础知识,以及它如何轻松地简化许多算法和数据处理。但是,如何在常见的 JS 应用程序中实际使用它并不那么明显。如果你考虑 JS 的一些主要用途,它们似乎都集中在非纯函数上,因为 Web 页面更新中状态必须改变。

如果能看到一些简单但常见的应用程序,并采用函数式方法,那就太好了。

Creative Commons License本作品根据知识共享署名-相同方式共享 4.0 国际许可协议获得许可。
© . All rights reserved.