[译]掌握 JavaScript 面试:什么是函数式编程

掌握 JavaScript 面试:什么是函数式编程

Structure Synth — Orihaus (CC BY 2.0)

“掌握 JavaScript 面试” 是一系列的帖子,为了帮助求职者在面试中高级 JavaScript 职位时可能碰见的常见问题作准备。这些是我在真实面试场景中常常会问到的一些问题。javascript

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

函数式编程(一般缩写为 FP)是经过组合纯函数,避免状态共享可变数据反作用来构建软件的过程。函数式编程是声明式的,而不是命令式的,应用程序状态经过纯函数流动。与面向对象编程不一样,在面向对象编程中,应用程序状态一般与对象中的方法共享和协做。java

函数式编程是一种编程范式,这意味着它是一种基于一些基本的、定义性的原则(如上所列)来思考软件构建的方法。其余编程范式包括面向对象编程和面向过程编程。node

与命令式或面向对象的代码相比,函数式代码每每更简洁、更可预测、更易于测试 —— 但若是你不熟悉它以及与之相关的常见模式,函数式代码看起来也会密集得多,并且相关的文档对于新人来讲多是难以理解的。android

若是你开始在 Google 上搜索函数式编程术语,你很快就会遇到一堵学术术语的墙,这对初学者来讲是很是可怕的。说它有一个学习曲线是很是保守的说法。可是若是你已经使用 JavaScript 编程了一段时间,那么你极可能已经在实际的软件应用中使用了大量的函数式编程的概念和实用工具。ios

不要让全部生词吓跑你。它们比听起来容易多了。git

最困难的部分是吸取(或者理解)这些词汇。在你开始理解函数式编程的含义以前,上面这个看似简单的定义中有不少须要理解的概念:程序员

  • 纯函数
  • 函数组合
  • 避免状态共享
  • 避免可变数据
  • 避免反作用

换句话说,若是你想知道函数式编程在实践中意味着什么,你必须首先理解这些核心概念。github

纯函数指的是具备下列特征的函数:面试

  • 给定相同的输入,老是获得相同的输出
  • 没有反作用

纯函数有许多在函数式编程中很重要的属性,包括引用透明性(你可使用函数一次调用的结果值代替其他对该函数的调用操做,这样并不会对程序产生影响)。阅读“什么是纯函数?”了解更多。

组合函数是将两个或两个以上的函数组合起来以产生一个新函数或进行某种计算的过程。例如,f . g 组合(. 的意思是组合)在 JavaScript 中等价于 f(g(x))。理解组合函数是理解如何使用函数式编程构建软件的重要一步。阅读 “什么是组合函数?”了解更多。

状态共享

状态共享是指共享做用域中存在的任何变量、对象或内存空间,或者是在做用域之间传递的对象的属性。共享做用域能够包括全局做用域或闭包做用域。一般,在面向对象编程中,经过向其余对象添加属性,在做用域之间共享对象。

例如,计算机游戏可能有一个主游戏对象,其中的角色和游戏项存储为该对象所拥有的属性。函数式编程避免了状态共享,而是依赖不可变的数据结构和纯计算从现有数据中派生出新数据。有关函数式软件如何处理应用程序状态的更多详细信息,请参阅“10个更好的 Redux 架构提示”

共享状态的问题在于,为了了解函数的效果,你必需要了解函数使用或影响的每一个共享变量的所有历史记录。

假设你有一个须要保存的 user 对象。saveUser() 函数向服务器上的 API 发出请求。在此过程当中,用户使用 updateAvatar() 更改他们的我的头像,并触发另外一个 saveUser() 请求。在保存时,服务器发送回一个规范的 user 对象,为了同步服务端或者其余客户端 API 引发的更改,该对象应该替换掉内存中对应的对象。

不幸的是,第二个响应在第一个响应以前被接收,因此当第一个(如今已通过时了)响应被返回时,新的我的头像会在内存中被删除并替换为旧的我的头像。这就是一个竞争条件的例子 —— 与状态共享相关的很是常见的错误。

与共享状态相关的另外一个常见问题是,更改调用函数的顺序可能会致使一系列故障,由于做用于共享状态的函数依赖于时序:

// 在共享状态下,函数调用的顺序会更改函数调用的结果。

const x = {
  val: 2
};

const x1 = () => x.val += 1;

const x2 = () => x.val *= 2;

x1();
x2();

console.log(x.val); // 6

// 这个例子和上面彻底相同,除了对象名称
const y = {
  val: 2
};

const y1 = () => y.val += 1;

const y2 = () => y.val *= 2;

// 函数调用的顺序被颠倒了
y2();
y1();

// 从而改变告终果的值
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
};

// 因为不依赖于外部变量
// 咱们不须要不一样的函数来操做不一样的变量


// 此处故意留空


// 由于函数不会发生变化
// 因此能够按任意顺序屡次调用这些函数
// 而没必要更改其余函数调用的结果
x2(y);
x1(y);

console.log(x1(x2(y)).val); // 5
复制代码

在上面的例子中,咱们使用 Object.assign() 并传入一个空对象做为第一个参数来复制 x 的属性,而不是在原数据上进行修改。在以前的示例中,它至关于从零开始建立一个新对象,而不使用 object.assign(),但这是 JavaScript 中建立现有状态副本的常见模式,而不是使用突变的常见模式,咱们在第一个示例中证实了这一点。

若是仔细观察这个例子中的 console.log()语句,你应该会注意到我已经提到的一些东西:组合函数。回想一下前面,组合函数相似这样:f(g(x))。在本例中,咱们将组合 x1 . x2 中的 f()g() 替换为 x1()x2()

固然,若是你改变了组合的顺序,输出结果一样会改变。操做的顺序一样很重要。f(g(x)) 并不老是和 g(f(x)) 相同,但再也不重要的是函数外的变量发生了什么,这很重要。对于非纯函数,除非你知道函数使用或影响的每一个变量的整个历史记录,不然不可能彻底理解函数的做用。

移除函数调用计时依赖项,就消除了一整类的潜在 bug。

不变性

不可变对象是指建立后不能修改的对象。相反,可变对象是在建立后能够修改的对象。

不变性是函数式编程的核心概念,由于没有它,程序中的数据流是有损的。状态历史被抛弃,奇怪的 bug 可能会潜入你的软件。更多关于不变性的意义,请参阅“不变性之道”

在 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 数据结构(发音同“tree”),它其实是深度冻结的 —— 这意味着不管属性处于对象层次结构中的哪一个层级,都不能够改变。

Tries 使用了共享结构在不可变对象被复制以后为对象共享引用内存地址,该方法使用较少的内存,而且使得在一些操做下的性能获得提高。

例如,你能够在对象对的根节点进行一致性比较来比较两个对象是否一致。若是一致的话,你就不须要再遍历整个对象树查找差别了。

JavaScript 中有几个库使用到了 tries,包括 Immutable.jsMori

我尝试过这两种方法,而且倾向于在须要大量不可变状态的大型项目中使用 Immutable.js。有关更多信息,请参见“10个更好的Redux架构技巧”

反作用

反作用是指任何应用程序状态的改变都是能够在被调用函数以外观察到的,除了返回值。反作用包括:

  • 修改任何外部变量或对象属性(例如,全局变量或父函数做用域链中的变量)
  • 打印日志到控制台
  • 写入屏幕
  • 写入文件
  • 写入网络
  • 触发任何外部过程
  • 调用其它有反作用的函数

在函数式编程中,一般会避免产生反作用,这使得程序的做用更易于理解和测试。

Haskell 和其余函数语言常用 monad 从纯函数中分离和封装反作用。有关 monad 的话题的深度足以写一本书来讨论,因此咱们之后再谈。

你如今须要知道的是,反作用操做须要与软件的其余部分隔离开来。若是你将反作用与其余的程序逻辑隔离开,你的软件将更容易扩展、重构、调试、测试和维护。

这就是大多数前端框架鼓励用户在单独的、松散耦合的模块中管理状态和组件渲染的缘由。

经过高阶函数实现可重用性

函数式编程倾向于重用一组通用的函数式实用程序来处理数据。面向对象编程倾向于将方法和数据集中在对象中。这些协做方法只能对它们被设计用于操做的数据类型进行操做,并且一般只能对特定对象实例中包含的数据进行操做。

在函数式编程中,任何类型的数据都是平等的。同一个 map() api 能够映射对象、字符串、数字或任何其余数据类型,由于它以一个函数做为参数,该参数适当地处理给定的数据类型。FP 使用了高阶函数完成它的通用实用技巧。

JavaScript 中函数是头等公民,这些函数容许,它容许咱们将函数做为数据 —— 将其赋给变量、传递给其余函数、从函数返回等等。

高阶函数是那些函数做为参数、返回值为函数或二者兼有的函数。高阶函数一般用于:

  • 使用回调函数、promise、monad 等来抽象或隔离动做、效果或异步流控制。
  • 建立能够做用于多种数据类型的工具程序
  • 将一个函数部分地应用于它的参数,或者建立一个柯里化过的函数,以便重用或组合函数
  • 获取一个函数列表,并返回这些输入函数的一些组合

容器,函子,列表,和流

函子是能够映射的。换句话说,它是一个容器,它有一个接口,可用于将函数应用于其中的值。当你看到函子这个词时,你应该想到“可映射”。

前面咱们了解了 map() 工具程序能够做用于各类数据类型。它经过提高映射操做来使用函子 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 构建真正的软件时,你会看到不少这种状况。

声明式 vs 命令式

函数式编程是一种声明性的范式,这意味着程序逻辑的表达没有显式地描述流控制。

命令式程序花费几行代码来描述用于实现预期结果的特定步骤 —— 流控制:如何作事情。

声明性程序抽象了流控制过程,花费几行代码来描述数据流:应该作什么如何被抽象出来。

例如,这个命令式映射接受一个数字数组,并返回一个新数组,其中每一个数字都被乘以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]
复制代码

命令式代码常用语句。语句是执行某些操做的一段代码。经常使用的语句包括 forifswitchthrow 等。

声明式代码更多地依赖于表达式。表达式是计算某个值的一段代码。表达式一般是函数调用、值和运算符的组合,它们被用于计算出结果。

下面是表达式的一些例子:

2 * 2
doubleMap([2, 3, 4])
Math.max(4, 3, 2)
复制代码

一般在代码中,你会看到表达式被分配给标识符、从函数返回或传递到函数中。在被分配、返回或传递以前,表达式会先进行计算,实际使用的是其结果值。

总结

函数式编程倾向于:

  • 纯函数而不是状态共享或反作用
  • 不变性而不是可变的数据
  • 组合函数而不是命令式流控制
  • 大量通用的、可重用的实用程序,它们使用高阶函数来处理多种数据类型,而不是仅对位于同一位置的数据进行操做的方法
  • 声明式代码而不是命令式代码(作什么而不是怎么作)
  • 表达式而不是语句
  • 容器和高阶函数而不是多态

做业

学习和练习这些函数式数组的核心功能:

  • .map()
  • .filter()
  • .reduce()

探索《掌握 JavaScript 面试》系列文章

This post was included in the book “Composing Software”. Buy the Book | Index | < Previous | Next >


Eric Elliott 是一名分布式系统专家,而且是 《组合软件》《编写 JavaScript 程序》这两本书的做者。做为 EricElliottJS.comDevAnywhere.io 的联合创始人,他教开发人员远程工做和实现工做以及生活平衡所需的技能。他建立了加密项目的开发团队,并为他们提供建议。他还在软件体验上为 Adobe 系统、Zumba Fitness、华尔街日报、ESPN、BBC 以及包括 Usher、Frank Ocean、Metallica 等在内的顶级唱片艺术家作出了贡献。

他和世界上最漂亮的女人一块儿享受着远程(工做)的生活方式。

若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索