五、函数式编程

学习目标

在本章结束时,你将能够做到以下几点:

  • 解释了函数式编程
  • 实现函数式编程的关键概念
  • 将函数式编程的概念应用到代码中
  • 以函数式编程风格构建新的代码库

本章解释了各种类型的编程,包括面向对象编程和函数式编程,以及如何使用不同类型的函数。

简介

在第一章中,我们介绍了 ES6 中发布的许多新的强大特性。 我们讨论了 JavaScript 的演变,并重点介绍了 ES6 中增加的关键功能。 我们讨论了作用域规则、变量声明、箭头函数、模板字面量、增强的对象属性、解构赋值、类和模块、编译以及迭代器和生成器。

在第二章中,我们介绍了 JavaScript 的异步编程范例。 我们讨论了 JavaScript 的事件循环、回调、承诺和 async/await 语法。

在第三章中,我们学习了文档对象模型(DOM)、JavaScript 事件对象和 jQuery 库。

在第四章中,我们讨论了测试 JavaScript 代码。 我们讨论了测试的原因以及在代码中添加测试的方法。 然后,我们讨论了不同类型的代码测试,以及如何将它们应用于代码库。 最后,我们讨论了各种 JavaScript 代码测试框架,以及如何在其中构建测试。

在本章中,我们将介绍函数式编程的编码原则。 在第一个主题中,我们将定义面向对象编程和函数式编程,讨论两者之间的区别,并概述我们使用函数式编程的原因。 然后,在接下来的部分中,我们将讨论函数式编程的每个关键概念。 对于每个关键概念,我们将概述其定义,并展示其在函数式编程中的应用。

函数式编程

有许多不同的方法来进行软件设计和构建。 两种最著名的设计理念或编程范式是:面向对象编程(OOP)函数式编程(FP)。 编程范式是思考软件设计和构建的一种方式。 编程范式基于几个定义原则,用于组织和描述软件应用的设计和构造。 函数式编程是一种编程范式,重点关注通过表达式和声明构建软件。 在本节中,我们将讨论面向对象编程和函数式编程的基础知识,并比较这两种编程范式。

面向对象编程

面向对象编程(OOP)是一种基于对象和语句的编程范式。 对象是用于组织应用片段的编程抽象。 在 OOP 中,对象通常在属性中包含和存储数据,具有可以在方法中运行的过程,并具有一些self的概念,这是对象引用自身的一种方式。 通常,对象以类的形式出现。 一个可以被认为是一个对象的定义,包括它的属性、方法和这个范围。 对象是类的实例化。 在 OOP 中,语句是指令驱动的代码。 这将在陈述句与祈使句主题中有更多的介绍。 许多编程语言在面向对象的软件开发中工作得很好。 最流行的 OOP 语言是 c++、Java 和 Python。

函数式编程

函数式编程(FP)是一种基于表达式和声明而非对象和语句的编程范式。 简而言之,这意味着 FP 依赖函数而不是对象来组织代码和构建应用。 函数式编程被认为起源于 20 世纪 30 年代创建的 lambda 演算。 函数式编程依赖于七个关键概念:声明函数,纯函数,高阶函数,【显示】共享状态,不变性,【病人】副作用,和功能组成。 每一个概念都将在本章的后续主题中介绍。

函数式编程被设计得更加简洁、可预测和可测试。 然而,这些好处可能导致 FP 代码比其他编码范式更密集。 一些最常见的函数式编程语言是 JavaScript、PHP 和 Python。

陈述句与祈使句

一般有两种编写代码的方法:说明性命令性。 用函数式编程范例编写的代码应该是声明性的。

声明性代码是表达计算逻辑而不描述其控制流的代码。 命令式代码是使用语句改变程序状态的代码。

如果您以前从未学习过声明式和命令式代码,则很难理解这些定义。 声明式代码通常与函数式编程一起使用,命令式代码通常与面向对象编程一起使用。 在决定使用哪种编码风格时,没有“正确答案”; 他们都有各自的权衡。 然而,声明式代码比命令式代码更适合函数式编程范例。

命令式功能

命式代码是 OOP 中最常见的代码。 技术上的定义很复杂,但我们可以简化它。 编写代码的命令式方法是关于如何解决问题。 考虑在餐馆找张桌子。 你走到主人/女主人面前说:“我看到角落里的那张桌子是空的。” 我妻子和我要走过去坐下。” 这是一个必要的方法,因为你准确地描述了你将如何从主人/女主人为你的聚会得到一张桌子。

声明性函数

声明式编程在 FP 中是最常见的。 编码的声明性方法可以简化为 WHAT we need to do。 考虑上一段中的餐馆例子。 获得一张桌子的声明性方法是走到主人/女主人面前说:“请给我一张两人桌。” 我们描述我们需要什么,而不是我们将采取的每一步来得到表。 声明性编程是符合开发人员的心理模型而不是机器的操作模型的行为。 从这些定义和隐喻中,我们可以得出结论,声明式编程是某些命令式实现的抽象。

现在,让我们从隐喻转向实际的代码。 考虑下面的代码片段:

function addImperative( arr ) {
 let result = 0;
 for ( let i = 0; i < arr.length; i++ ) {
   result += arr[ i ];
 }
 return result;
}
function addDeclarative( arr ) {
 return arr.reduce( ( red, val ) => red + val, 0 );
}
片段 5.1:声明式函数与命令式函数

在前面的代码片段中,我们创建了两个函数来在数组中添加值。 第一个函数addImperative是解决这个问题的必要方法。 代码准确地说明了如何一步一步地添加数组。 第二个函数addDeclarative是对相同问题的声明性方法。 代码说明了如何添加数组。 通过使用 JavaScript 数组 reduce 操作,它抽象出了许多命定解决方案(for循环)。

开始编写声明性代码而不是命令式代码的最简单方法是创建函数。 这些函数应该抽象出代码中命令式部分的逐步本质。 考虑数组操作,如findmapreduce。 这些函数都是声明性的数组成员函数。 它们抽象了数组迭代的分步性质。 使用它们将有助于在代码中引入声明性概念,并减少编写的命令式代码。

练习 28:构建命令式和声明式函数

您的研究团队从您最新的实验中获得了一份价值列表; 然而,由于校准误差,只能使用部分数据,任何可以使用的数据都需要进行缩放。 您必须构建一个实用函数,该函数接受一个数组,过滤掉任何小于或等于 0 的值,将剩余的值乘以2,并返回最终结果。 首先,构建一个命令式函数来完成这项工作,然后构建一个声明式函数来完成同样的工作。

要使用命令式和声明式编码实践创建函数,请执行以下步骤:

  1. Define a function called imperative that takes the following approach:

    接收一个名为arr的数组参数。 创建一个名为filtered的数组,用于保存过滤后的值。

    创建一个for循环来逐步遍历输入数组arr。 对于每个项,检查数组项的值。 如果大于0,则将该值推入过滤后的数组。

    创建一个for循环来遍历过滤的数组。 对于每一项,将其乘以2,并将其保存回相同索引的过滤数组中。

    返回过滤后的数组。

  2. Define a function called declarative that does the following:

    Array.filter()过滤输入数组。 在过滤器的callback功能中,检查该值是否大于0。 如果是,返回 true; 否则,返回 false。

    链接一个映射调用到filter输出。

    Array.map()映射过滤后的数组。

    在回调中,将每个值乘以2

    返回修改后的数组。

  3. 创建一个从-5+5的测试值数组。

  4. 使用值数组运行imperative并记录输出。
  5. 使用值数组运行declarative并记录输出。

编码

index.js
function imperative( arr ) {
 const filtered = [];
 for ( let i = 0; i < arr.length; i++ ) {
   if ( arr[ i ] > 0 ) {
     filtered.push( arr[ i ] );
   }
 }
 for ( let j = 0; j < filtered.length; j++ ) {
   filtered[ j ] = 2 * filtered[ j ];
 }
 return filtered;
}
function declarative( arr ) {
 return arr.filter( v => v > 0 ).map( v => 2 * v );
}
片段 5.2:命令式和声明式代码比较

https://bit.ly/2skAnic

结果

Figure 5.1: Test values output

图 5.1:测试值输出

Figure 5.2: The modified array output

图 5.2:修改后的数组输出

您已经成功地利用命令式和声明式编码实践来编写函数。

纯函数

纯函数是函数式编程的关键组成部分。 纯函数可以定义为对函数外的任何状态不产生任何影响或不使用任何状态的函数。 一个函数必须满足三个关键标准才能被认为是纯函数:

  • 当给定相同的输入时,函数必须始终返回相同的输出。
  • 功能必须没有副作用。
  • 函数必须具有引用透明性。

输入相同,输出相同

给定一组输入值,当提供这些输入值时,纯函数必须总是返回相同的值。 这听起来比实际情况复杂得多。 简单地说,纯函数的输出不能改变,除非输入值被改变。 这意味着函数的内部代码不能依赖于函数外部的任何程序状态。 纯函数不能使用函数外部的任何变量来进行计算或代码路径决策。 下面的代码片段显示了一个例子:

const state = { prop1: 5 };
function notPure () {
  return state.prop1 > 0 ? 'Valid': 'Invalid';
}
function pure( val ) {
  return val > 0 ? 'Valid': 'Invalid';
}
notPure(); // Expected output: 'Valid'
pure( state.prop ); // Expected output: 'Valid'
片段 5.3:依赖外部状态

在前面的代码片段中,我们创建了一个名为 state 的变量,将prop1属性设置为5。 然后定义两个函数,根据值比较返回字符串ValidInvalid。 在第一个函数notPure中,我们检查状态的prop1值,并根据该值返回一个值。 在第二个函数 pure 中,我们检查传入函数的值,以决定返回什么。 第一个函数不是纯函数。 它依赖于函数外部的状态来确定函数的返回值。 第二个函数是纯函数,因为它依赖于函数的输入值,而不是全局状态变量。

无副作用

纯函数必须没有副作用。 这意味着纯函数不能修改通过引用传入的任何对象或值。 副作用将在副作用主题中进行更详细的讨论。 在 JavaScript 中,只有对象和数组可以通过引用传递给函数。 纯函数不能以任何方式修改这些对象或数组。 如果你的函数需要在内部更新或修改数组或对象,我们必须首先创建数组/对象的副本。 需要注意的是,在 JavaScript 中,复制对象或数组只复制实体的第一级。 这意味着如果数组或对象中嵌套了数组或对象,这些嵌套引用将不会被复制。 当复制的对象是逐个引用时,嵌套的对象将不会被复制,而且也将被传递。 这意味着嵌套引用,如果没有显式地复制,可能会产生副作用。 要正确复制对象,必须创建深度复制。 对象的深层副本是复制所有嵌套引用的副本。 这可以通过递归或 Node.jsdeepcopy模块来完成。 下面的代码片段显示了一个副作用的例子:

function notPure( input ) {
  input.prop2 = 'test';
}
function pure( input ) {
  input = JSON.parse( JSON.stringify( input ) );
  input.prop2 = 'test';
  return input;
}
片段 5.4:避免副作用

在前面的代码片段中,我们定义了两个函数notPurepure。 这两个函数都向传入函数的input对象添加了一个属性。 函数(notPure())的非纯版本会就地修改input对象。 因为对象是通过引用传递的,所以该更新将在使用该对象的所有其他作用域中可见。 这是一个副作用。 函数的纯版本(pure())使用 JSON 操作创建对象的深层副本,然后向新对象添加一个新属性并返回新对象。 由于对象是克隆的,所以不会修改原始对象。 没有产生任何副作用。

参考透明度

参考透明度是纯函数的一个属性,它使函数行为更简单。 如果函数具有引用透明性,则对该函数的调用可以用函数调用的结果值(函数返回的值)替换,而不改变代码的含义。 简而言之,这意味着函数应该返回在它们所使用的代码上下文中有意义的值,并且它们不应该依赖或修改函数外部的状态。

编写纯函数给了我们两个关键的好处:

首先,纯函数非常容易进行单元测试。 纯函数不依赖于外部状态,因此在编写测试时不需要考虑其他上下文。 我们只需要考虑输入和输出值。

其次,纯函数使代码更简单、更灵活。 纯函数不依赖于外部状态,也不会产生副作用。 这意味着它们可以用于任何特殊的上下文中。 它们可以在更多的地方使用,因此更加灵活。

例练习 29:Building Pure Controllers

您被聘为开发人员,负责升级在线商店的购物车实现。 构建一个函数来将商品添加到购物车。 你的函数应该是纯函数。 您可以假设有一个全局数组,称为cart,它包含购物车。 该函数至少应该接受一个项(字符串)和一个数量(number)。 在提供的文件(exercise-test.js)中使用函数名addItem()创建函数。 该文件将有基本测试来测试纯度。

要用纯函数概念构建应用的一部分,执行以下步骤:

  1. exercises/exercise29/exercise-test.js处打开测试文件。
  2. 创建一个名为addItem的函数,该函数包含三个参数:cartitemquantity
  3. Duplicate the cart passed into the function and save the duplicated value into a variable called newCart. Duplicate the cart with one of the following methods:

    使用 JSON 操作进行最简单的复制:JSON.parse( JSON.stringify( cart ) )

    循环遍历原始购物车数组,并将每个项推入新数组。

    使用cart.map( () => {} ),因为数组中的所有项都是简单类型。

    使用rest/spread操作符,newCart= [ ...cart ],因为所有的项目都是简单类型。

  4. 将传入函数的项推入cart数组,quantity次数。

  5. 返回newCart数组。
  6. Run the code provided in exercise-test.js.

    如果抛出错误,修复代码中的错误并再次运行测试。

编码

exercise-solution.js
function addItem( cart, item, quantity ) {
 // Duplicate cart
 const newCart = JSON.parse( JSON.stringify( cart ) );
 newCart.push( ...Array( quantity ).fill( item ) );
 return newCart;
}
片段 5.5:功能纯度测试

https://bit.ly/2H2TXJG

输出

Figure 5.3: Returning the new cart array

图 5.3:返回新的购物车数组

您已经成功地应用了纯函数的概念来构建应用的一部分。

Higher Order Functions

正如我们在第一个主题中学到的,高阶函数是这样一种函数,它要么接受另一个函数作为输入参数,要么返回另一个函数作为返回值。 JavaScript 中几乎所有的异步代码都通过将回调函数作为输入参数传入来使用高阶函数。 除了在 JavaScript 中大量使用外,高阶函数也是函数式编程的关键部分,它的使用有三个主要好处:抽象、实用和降低复杂性。

高阶函数对于抽象非常重要。 抽象是一种隐藏过程内部工作或细节的方法。 例如,考虑根据食谱烹饪一顿饭的过程。 食谱可能要求你把食物切碎。 切是什么? 它是一种行为的抽象。 完成这个动作的步骤是拿把刀,放在食物上面,向下按压。 然后,把刀沿着食物移动一段距离,重复这个过程直到没有大块剩下。 斩波是这个动作的抽象形式。 用“chop the carrot”来代替冗长的描述更简单、更快。 与准备食物一样,代码使用抽象来包装复杂的过程,并隐藏代码的内部工作。

高阶函数对于创建函数实用程序非常有用。 作为程序员,我们经常创建用于对一组值执行操作的实用函数。 通常,我们希望最大限度地提高灵活性,并创建适用于各种潜在输入值或格式的函数。 创建接受一些参数并返回新函数的高阶实用函数是实现这一点的一种很好的方法。 这些函数在 JavaScript 中通常称为闭包。 考虑以下代码片段中显示的函数:

function sortObjField1( field ) {
 return function ( v1, v2 ) {
   return v1[ field ] > v2[ field ];
 }
}
function sortObjField2( field, v1, v2 ) {
 return v1[ field ] > v2[ field ];
}
代码片段 5.6:更高阶的实用程序

在前面的代码片段中,我们创建了两个实用函数,用于根据存储在指定字段中的值对对象数组进行排序。 实用程序函数都需要指定该字段。 它们不同的地方是返回值。 SortObjField1是一个高阶函数,它接受字段名并返回一个闭包函数。 闭包函数接受我们试图排序的两个对象并返回排序值。 第二个辅助函数sortObjField2同时接受字段和两个对象,并返回排序值。 高阶效用函数更强大,因为我们不需要同时知道所有的值。 我们可以将sortObjField( 'field' )作为参数传递给另一个函数,以便在程序的另一部分中使用。

高阶函数对于降低复杂性也非常有用。 代码越长越复杂,就越容易出现错误。 高阶函数抽象了代码复杂部分的内部工作,并可以使用实用函数来减少需要编写的代码行数。 这两种效果都将减少代码库的大小,从而降低复杂性。 简化代码将有助于减少您必须花在修复错误上的时间。

练习 30:编辑对象数组

目的是应用高阶函数的概念来编辑对象数组。 要使用必要的函数编辑数组,请执行以下步骤:

  1. 创建一个名为data的数组,其中包含以下数据:[ { f1: 6, f2: 3 }, { f1: 12, f2: 0 }, { f1: 9, f2: 1 }, { f1: 6, f2: 7 } ]
  2. 创建一个名为swap的函数,它有两个参数key1key2
  3. swap函数中添加return语句。 return语句应该返回一个函数。 这个函数应该有一个参数,obj
  4. Inside the returned function, using array destructuring, swap the values of key1 and key2 stored in obj.

    提示:使用[a, b] = [b, a]来交换数组解构值。

  5. 从函数中返回修改后的对象obj

  6. Edit the data array by calling the map function on data. Pass a call to swap, with the parameters f1 and f2, as the argument for the map function.

    提示:data.map( swap( 'f1', 'f2' ) );

  7. 将调用的输出记录到data.map()

编码

index.js
const data = [ { f1: 6, f2: 3 }, { f1: 12, f2: 0 }, { f1: 9, f2: 1 }, { f1: 6, f2: 7 } ];
function swap( key1, key2 ) {
 return obj => {
   [ obj[ key1 ], obj[ key2 ] ] = [ obj[ key2 ], obj[ key1 ] ];
   return obj;
 }
}
console.log( data.map( swap( 'f1', 'f2' ) ) );

https://bit.ly/2D0t70K

输出

Figure 5.4: Final Output

图 5.4:最终输出

您已经成功地应用了高阶函数的概念来编辑对象数组。

Shar【工人】和了

共享状态是存在于共享作用域中的任何变量、对象或内存空间。 被多个独立作用域(包括全局作用域和闭包作用域)使用的任何非常量变量都被认为处于共享状态。 在函数式编程中,应该避免共享状态。 共享状态防止函数是纯函数。 当共享状态规则被违反并且程序修改了一个变量时,就会产生一个副作用。 在 OOP 中,共享状态通常作为对象传递。 OOP 函数可以修改共享状态。 这非常违背函数式编程规则。 下面的代码片段显示了一个共享状态的示例:

const state = { age: 15 }
function doSomething( name ) {
  return state.age > 13 ? '${name} is old enough' : '${name} is not old enough';
}
片段 5.7:共享状态

在前面的示例中,我们在全局作用域中有一个变量,名为state。 在我们的函数doSomething中,我们引用变量状态来做出逻辑代码决策。 由于变量state是在doSomething函数的作用域之外定义的,并且不是不可变对象(创建后状态不能修改的对象),所以它被认为是共享状态。 这是函数式编程中应该避免的事情,因为它阻止了函数的纯粹性。

共享国家必须避免,原因有几个。 首先,共享状态会使理解函数变得困难。 为了真正理解函数是如何工作的以及输入的输出结果是什么,我们必须理解函数运行的整个状态。 如果我们的函数使用一个共享的状态,那么在正确理解函数之前,我们需要理解一个更复杂的状态。 要详细理解共享状态是非常困难的。 要正确地理解共享状态,必须了解状态是如何更新的,以及如何在与它共享的每个函数中使用它。

虽然一开始这听起来不像是一个主要的缺点,但不了解我们的功能是如何工作的将导致开发缓慢,更多的 bug,以及不充分的测试。 共享状态减缓了发展,因为我们必须花更多的时间来理解依赖它们的功能。 如果我们不花时间去理解共享状态和依赖于它们的函数,那么很可能我们就不会写出高效且无 bug 的代码。 这显然会增加调试和重构代码的时间。 未被完全理解的函数往往更容易出现 bug。 如果我们不完全理解函数在共享状态中定义的所有可能性和限制下需要如何操作,很可能我们会忘记在开发中处理边缘情况。 如果没有找到这些错误,则可能会发布错误代码。 最后,不理解函数几乎不可能完全测试函数。 为了全面测试任何函数,我们必须准确地理解它在所有条件下是如何运行的,或者换句话说,在它可以被调用的所有状态下。

练习 31:修复共享国家

其目的是重构代码以删除共享状态。 要正确地重构代码,请执行以下步骤:

  1. exercises/exercise31/exercise.js处打开文件。 您将更新此文件以解决该练习。
  2. 运行在步骤 1中打开的文件中的代码,并观察输出。
  3. 更新getOlder函数声明,以接受一个名为age的参数。
  4. 更新getOlder的主体,返回age+1++age,而不是修改全局变量。
  5. 更新formatName函数声明,以接受两个参数firstlast
  6. 更新formatName的主体,返回Mrs. ${first} ${last}字符串,其中 first 和 last 是存储在输入参数firstlast中的值。
  7. 更新对getOlder函数的调用,并传入person.age作为参数。 将返回值保存到person.age中。
  8. 将函数调用更新为formatName,并传入person.firstNameperson.lastName作为参数。 将返回值保存在person.name中。
  9. 运行代码并将输出与第 2 步的输出进行比较。 它们应该是一样的。

*编码

解决方案
const person = { age: 10, firstName: 'Sandra', lastName: 'Jeffereys' };
function getOlder( age ) {
 return ++age;
}
function formatName( first, last ) {
 return 'Mrs. ${first} ${last}';
}
console.log( person );
person.age = getOlder( person.age );
person.name = formatName( person.firstName, person.lastName );
console.log( person );

https://bit.ly/2CZwyoC

输出

Figure 5.5: Final Output

图 5.5:最终输出

您已经成功地重构了代码以删除共享状态。

输入能力

不变性是一个非常简单的概念,但对函数式编程非常重要。 教科书上对不可变性的定义就是“不可改变的东西”。 在编程中,我们用这个词来表示对象和变量,这些对象和变量的状态在创建之后就不能改变了。

在软件开发中,值可以通过引用传递到函数中。 当通过引用传递变量时,这意味着传递的是对内存位置(指针)的引用,而不是包含在内存中该位置的对象的序列化值。 由于通过引用传递的变量的所有指针都指向同一块内存,因此对引用传递的变量值的任何更新都将被指向该内存块的任何指针看到。 通过引用而不是通过值传递的任何变量都可以被视为共享状态,因为它可以被多个独立的作用域修改。 编写防止数据突变的函数是很重要的,因为对引用传递的值的任何更改都相当于对共享状态的更改。 修改通过引用传递的变量将违反函数式编程的原则,并将导致副作用。

在 JavaScript 中,不变性的概念通常适用于传递给函数和由函数返回的变量。 在 JavaScript 中,简单类型(字符串、数字、bool)通过值传递,复杂类型(对象、数组等)通过引用传递。 对这些复杂数据类型的任何更改都会影响所有的事件,因为它们本质上都只是指向相同内存块的指针。

JavaScript 对不变性的支持不是很完整。 JavaScript 没有内置的不可变数组或对象。 需要注意的是,变量创建关键字const不会创建不可变对象或数组。 正如第一章所讨论的,const 只是锁定了名称绑定,这样名称绑定就不能被重新赋值。 它不会阻止变量引用的对象被修改。 JavaScript 中的不可变对象可以通过两种方式创建:使用freeze函数和使用第三方库。

不可变对象可以用 freeze 函数创建。 freeze是对全局Object prototype ( Object.freeze()的一个函数。 它接受一个参数,即要冻结的对象,并返回相同的对象。 冻结可以防止向对象中添加、删除或修改任何内容。 如果一个数组被冻结,它将锁定元素值并阻止元素被添加到数组或从数组中移除。 重要的是要注意,freeze 函数只是一个浅冻结。 嵌套为属性(在对象中)或元素(在数组中)的对象和数组不会被freeze函数冻结。 如果想要完全冻结所有嵌套属性,必须编写一个 helper 函数来遍历对象或数组树,冻结每个嵌套级别,或者找到第三方库。 Object.freeze()的用法如下所示:

const data  = {
  prop1: 'value1',
  objectProp: { p1: 'v1', p2: 'v2' },
  arrayProp: [ 1, 'test' , { p1: 'v1' }, [ 1, 2, 3 ] ]
};
Object.freeze( data );
Object.freeze( data.objectProp );
Object.freeze( data.arrayProp );
Object.freeze( data.arrayProp[2] );
Object.freeze( data.arrayProp[3] );
片段 5.8:冻结一个对象

JavaScript 中的不变性

有几个第三方库为 JavaScript 添加了不可变的功能。 有两个库通常被认为是 JavaScript 中最好的不可变库。 它们分别是MoriImmutable。 Mori 是一个将 ClojurScript 的持久数据结构和不变性引入 JavaScript 的库。 Immutable是 Facebook 使用 JS API 实现的不可变库,为 JavaScript 带来了许多不可变的数据结构。 这两个库都被认为是非常高效的,在许多大型项目中都很常用。

请注意

有关 Mori 和 Immutable 的更多信息,以及完整的文档,请参阅库页面https://github.com/swannodette/morihttp://facebook.github.io/immutable-js/

在 JavaScript 中还有最后一种获取不变性的方法; 然而,它不是真正的不变性。 为了避免使用第三方库或冻结传递给函数的任何对象或数组,我们可以简单地创建通过引用传递的任何变量的副本,并修改副本而不是原始副本。 这将防止通过引用传递数据的共享状态问题,但它带来了内存效率和低效率的权衡。 简单地将引用赋值给一个新变量不会复制数据。 我们可以通过以下三种方式复制对象或数组:使用第三方库、遍历对象树或使用 JSON 操作。

存在用于创建对象深度副本的第三方库。 这通常是复制对象的最简单方法。 我们还可以遍历对象的树,并将每个值和属性复制到一个新对象中。 这通常需要我们编写和测试自己的函数。 最后,我们可以使用 JSON 操作 stringify 和 parse 来复制对象。 这是通过首先对对象进行字符串化,然后解析字符串(JSON.parse( JSON.stringify( obj ) ))来实现的。 JSON 操作通常是复制对象的最简单方法,但它们有最多的缺点和限制。 如果对象具有非 json 兼容的属性,例如函数或类,则此方法将不起作用。 将整个对象转换为字符串,然后再将整个字符串解析为对象,这也是非常低效的。 对于小对象,这可能不会影响性能,但如果必须复制大对象,则不建议使用此方法,因为它是一个阻塞操作。

效果

side effect副作用 副作用有好有坏,但通常都是意料之外的。 在函数式编程中,副作用是在函数调用之外可以看到的任何状态变化,但函数返回值除外。 根据函数编程的规则,函数不允许修改函数之外的任何状态。 如果函数有意或无意地修改了一个状态,这就被认为是一种副作用,因为它破坏了函数编程的原则。

副作用是不好的,因为它们使程序更复杂。 如前所述,共享状态增加了程序的复杂性。 函数的副作用会修改共享状态,从而增加复杂性。 有意或无意的副作用会使代码更难测试和调试。 下面的列表显示了 JavaScript 中最常见的副作用原因:

  • Modifying any external state (variable)

    这两种变量类型包括全局变量和父函数作用域中的变量。

从 FP 副作用的定义来看,这个列表中的第一点应该是不言自明的。 对任何外部状态(包括函数作用域之外的任何变量)的更改是一个副作用。 变量的作用域级别并不重要。 它可以在全局作用域中,也可以在父函数作用域树中的任何位置; 对函数中没有直接作用域的变量的任何更改都被认为是副作用。

  • Input/output

    该列表包括对控制台的日志记录、对屏幕或显示器的写入、文件 I/O 操作、网络操作、HTTP 请求、消息队列和数据库请求。

副作用列表中的第二个要点没有那么直观。 考虑一下 I/O 操作。 他们是做什么的? 他们修改一些外部资源。 这可以是控制台的内容、web 页面上显示的视图或显示、文件系统中的文件或仅通过网络访问的外部资源。 这些外部资源并不直接限定在修改它们的代码块中,可以从其他完全无关的应用中修改和查看它们。 根据定义,文件系统和控制台等资源是共享状态。 对这些资源的修改算作副作用。

  • 启动或结束外部进程

副作用列表中的第三个要点与第二个相似。 启动外部进程(如帮助线程)来卸载一些大的、同步量的工作,会产生副作用。 当我们开始一个新的过程时,我们直接改变了系统的状态。 一个新线程被创建,并且它超出了创建它的函数的作用域。 根据定义,这是一种副作用。

  • 调用任何其他有副作用的函数

副作用列表中的第 4 项也不那么直观。 任何调用有副作用的函数都被认为有副作用。 考虑一个程序设置,其中函数 a 调用函数 B,函数 B 导致全局状态的改变。 这种对全局状态的改变可以通过直接调用函数 B 或调用函数 a 来提示,后者最终调用函数 B 并改变状态。 由于对函数 a 的调用仍然会导致全局状态的改变,即使函数 a 中的代码没有直接修改全局状态,函数 a 仍然被认为具有副作用。

在编写 FP 代码时,我们必须考虑以下问题:

如果任何 I/O 操作导致副作用,我们如何应用 FP 原则来编写有用的代码而不产生副作用? 既然 I/O 操作会产生副作用,那么在我们的代码中使用的每个网络调用或文件系统操作不会产生副作用吗? 是的。 I/O 的副作用是不可避免的。 解决这个问题的方法是将副作用代码与软件的其余部分隔离开来。 任何有副作用的代码或依赖于有副作用的模块或操作(数据库操作等)的代码都必须与没有副作用的代码隔离。 这通常是通过模块实现的。 大多数前端和后端框架都鼓励我们通过使用模块将状态管理与代码的其余部分分开。 导致副作用的代码将被删除并放入其自己的模块中,以便可以在没有副作用的情况下测试和维护其余的代码库。

避免副作用

编写一个完全没有副作用的应用几乎是不可能的。 Web 应用/服务器必须处理/发出 HTTP 请求——根据定义,这是副作用。 为了实现这一点,你可以做以下事情:

  • 将有副作用的代码与其余代码库隔离。
  • 将状态管理代码和有副作用的代码与应用的其余部分分开。

这些方法使测试和调试更容易。

离子组成

函数组成是理解函数式编程的最后一个关键。 函数组合吸收了本章学到的许多概念,并将它们巧妙地包装在函数式编程的核心中。 函数组合的广泛使用的定义是,函数组合是一个数学概念,允许您组合多个函数来创建一个新函数。 这个定义告诉我们什么是函数组合,但并没有告诉我们如何组合函数或者为什么需要使用它。

从定义中我们知道,函数组合是将函数组合起来创建一个新函数的行为。 这到底意味着什么? 在数学中,我们经常看到这样组成函数:f(g(x))。 如果这不是熟悉你,表达式中 f (g (x)),我们通过变量 x 的函数 g,然后通过的结果 g 函数 f (x)。f (g (x))的表达式求值从内到外,从右到左的顺序 x, g、f。每个实例中使用的输入参数是函数 g, 我们可以代入 x 的值。函数 f 中使用输入参数的每个实例,我们都可以代入 g(x)的值。 现在,让我们考虑使用代码进行函数组合的方法。 考虑以下代码片段中的代码:

function multiplyBy2( c ) {
 return 2 * c;
}
function sumNumbers( a, b ) {
 return a + b;
}
const v1 = sumNumbers( 2, 4 ); // 2 + 4 = 6
const v2 = multiplyBy2( v2 ); // 2 * 6 = 12
const v3 = multiplyBy2( sumNumbers( 2, 4 ) ); // 2 * ( 2 + 4 ) = 12
代码片段 5.10:函数组成

在前面的代码片段中,我们创建了一个函数来将一个值乘以 2,并创建了一个函数来将两个数字相加。 我们可以用两种方法使用这些函数来计算值。 首先,我们单独使用这些函数,一次一个。 这需要我们创建一个变量并保存第一个函数的输出,用该值调用第二个函数,然后将第二个函数的结果保存到一个变量中。 这需要两行代码和两个变量。 计算值的第二个选择是使用函数组合。 我们只需要在第二个函数的输入参数中调用一个函数,并保存结果变量。 这需要一行代码和一个变量。 正如我们从代码片段中看到的,使用函数组合将有助于简化我们的代码,并减少我们需要编写的代码行数。

函数组合对于减少我们需要编写的代码行数以及降低代码的复杂性非常有用。 在使用函数式编程范式编写代码时,当我们可以利用函数组合时,识别实例是很重要的。

活动 5:递归不变性

您正在用 JavaScript 构建一个应用,您的团队被告知出于安全原因不能使用任何第三方库。 您现在必须为这个应用使用 FP 原则,并且需要一个算法来创建不可变对象和数组。 创建一个递归函数,在Object.freeze()嵌套的所有层次强制对象和数组的不变性。 为简单起见,可以假设对象中没有嵌套空值或类。 用'Lesson 5/topic f - immutability/activity-test.js'写函数。 该文件包含测试实现的代码。

要强制对象的不变性,请执行以下步骤:

  1. 创建一个名为immutable的函数,它只接受一个参数data
  2. 冻结data对象。
  3. 循环遍历对象值并递归地为每个对象调用不可变函数。

编码

结果

Figure 5.6: Returning the new cart array

图 5.6:返回新的购物车数组

您已经成功地演示了强制对象的不变性。

请注意

有关这项活动的说明可在 291 页找到。

小结

函数式编程是一种编程范式,专注于表达式和声明来设计应用和构建代码库。 函数式编程是一种热门的新编程风格,被认为是 JavaScript 编程的最佳风格。 函数式编程可以帮助我们的 JavaScript 更简洁、可预测和可测试。 函数式编程建立在七个关键概念之上:声明式函数、纯函数、高阶函数、共享状态、不变性、副作用和函数组合。

关注解决方案或目标是什么,而不是我们如何得到解决方案。 声明性函数的设计是为了抽象许多代码的命令式方法。 它们帮助开发人员编写的代码更符合开发人员的心理模型,而不是运行代码的机器的操作模型。

纯函数旨在使我们的代码更容易测试,更容易调试,更灵活和可重用。 我们用 JavaScript 编写的所有函数都应该力求纯粹。 当给定相同的输入值时,纯函数必须始终返回相同的输出值。 它们不能通过修改外部状态而引起任何副作用,并且必须具有引用透明性。

高阶函数是 JavaScript 异步编程中最常见的函数类型之一。 高阶函数是任何将函数作为输入并返回函数作为输出的函数。 高阶函数对于代码抽象、降低复杂性以及实用函数的创建和管理非常有用。 它们是闭包的关键,这使我们能够非常灵活地处理代码。

共享状态是函数式编程中要避免的最重要的事情之一。 共享状态是存在于共享作用域中的任何非常量变量或非不可变对象或内存空间。 共享作用域可以是全局作用域,也可以是父函数作用域树中的任何作用域。 共享状态会阻碍函数的纯粹性,并可能导致更多的 bug、不充分的测试和更慢的开发。

是指缺乏改变某事的能力。 在 JavaScript 中,所有通过引用传递的变量都应该是不可变的。 对通过引用传递的可变变量的更改可能会导致副作用,并无意中修改不应该共享的状态。 JavaScript 中的不变性可以通过Object.freeze()函数、第三方库、JSON 操作获得。

在 JavaScript 中,任何可以从函数调用之外看到的状态变化,不包括函数的返回值。 任何对共享状态变量的修改、任何 I/O 操作、任何外部进程执行或调用任何具有副作用的函数都可能导致副作用。 要完全消除 JavaScript 应用的副作用是非常困难的。 为了最小化副作用的影响,我们必须将任何有副作用的代码与代码库的其余部分隔离开来。 出于隔离目的,应将产生副作用的代码移到模块中。

函数组合是函数式编程的最后一个关键概念。 我们可以通过以新的方式组合更简单的函数来创建复杂而强大的函数。 函数组合的目的是帮助抽象和降低代码的复杂性。

在下一章,您将介绍服务器端 JavaScript 的基本概念,并构建 Node.js 和 Express 服务器。*