[译] 玩转 JavaScript 面试:何为函数式编程?

原文地址 Medium - Master the JavaScript Interview: What is Functional Programming? javascript

函数式编程在 JavaScript 领域着实已经成为一个热门话题。就在几年前,不少 JavaScript 程序员甚至都不知道啥是函数式编程,可是就在近三年里我看到过的每个大型应用的代码库中都包含了函数式编程思想的大规模使用。前端

函数式编程(缩写为 FP)是一种经过组合纯函数来构建软件的过程,避免状态共享可变数据反作用的产生。函数式编程是一种声明式编程而不是指令式编程,应用的状态所有流经的是纯函数。与面向对象编程思想造成对比的是,其应用程序的状态一般都是与对象中的方法共享的。java

函数式编程是一种编程范式,意指它是一种基于一些基本的、限定原则的软件架构的思惟方式,其余编程范式的例子还包括面向对象编程和面向过程编程。git

相比指令式编程或面向对象,函数式编程的代码倾向于更为简洁、可预测且更容易测试。但若是你不熟悉这种方式或与其常见的几种相关模式的话,函数式编程的代码一样能够看起来很紧凑,相关文档对于新手来讲可能也较为难以理解。程序员

若是你开始去搜索函数式编程的相关术语,你可能很快就会碰壁,大量专业术语彻底能够唬住一个新手。单纯的讨论其学习曲线有点儿过于轻描淡写了,可是若是你已经从事 JavaScript 编程工做有一段时间了,那么你应该已经在你的项目中使用过不少函数式编程的思想或工具了。github

别让新词汇把你吓跑。它们会比听起来更容易。编程

这其中最难的部分能够说就是让一堆陌生词汇充斥你的脑壳了。各类术语一脸无辜,由于在掌握它们以前你还须要了解下面这些术语的含义:数组

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

一个纯函数定义以下:promise

  • 每次给定相同的输入,其输出结果老是相同的
  • 无任何反作用

纯函数中的不少特性在函数式编程中都很重要,包括引用透明度(若是表达式能够替换为其相应的值而不更改程序的行为,则该表达式称为引用透明)。前端框架

引用透明度说白了就是相同的输入老是获得相同的输出,也就是说函数中未使用任何外部状态:

function plusOne(x) {
    return x + 1;
}
复制代码

上面的例子即为引用透明度函数,咱们能够用 6 来代替 plusOne(5) 的函数调用。详细解释可参考 stack overflow - What is referential transparency?

函数组合是指将两个或多个函数进行组合以便产生一个新的函数或执行某些计算的过程。好比组合函数f.g(.的意识是指由...组成)在 JavaScript 中等价于 f(g(x))。理解函数组合对于理解使用函数式编程编写软件来讲是个十分重要的步骤。

状态共享

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

状态共享的问题在于为了了解一个函数的做用,你不得不去了解函数中使用的或影响的每个共享的变量的过往。

假定你有一个用户对象须要保存,你的saveUser()函数会向服务器上的接口发起请求。与此同时,用户又进行了更换头像的操做,调用updateAvatar()来更换头像的同时也会触发另外一次saveUser()请求。在保存时,服务器返回一个规范的用户对象,该对象应该替换内存中的任何内容以便与服务器上的更改或响应其余 API 调用同步。

可是问题来了,第二次响应比第一次返回要早。因此当第一个响应(已过时)返回时,新头像被从内存中抹去了,替换回了旧头像。这就是一个争用条件的例子 —— 是与状态共享有关的一个很常见的 bug。

另外一个跟状态共享有关的常见问题是更改调用函数的顺序可能会致使级联失败,由于做用于状态共享的函数与时序有关:

// 在状态共享的函数中,函数调用的顺序会致使函数调用的结果的变化
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的属性,而不是在原处改变x。该例中,不使用Object.assign()的话,它至关于简单的从头开始建立一个新对象,但这是 JavaScript 中建立现有状态副本而不是使用变换的常见模式,咱们在第一个示例中演示了这一点。

若是你仔细的看了本例中的console.log()语句,你应该会注意到我已经提到过的一些东西:函数组合。回忆一下以前说过的知识点,函数组合看起来像这样:f(g(x))。在本例中为x1(x2()),也即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}`);// Goodbye world!
复制代码

能够看到,一个被冻结的顶层的原始属性是不可变的。但若是属性值为对象的话,该对象依然可变(包括数组等)。除非你遍历整个对象树,将其层层冻结。

在不少函数式编程语言中都又比较特殊的不可变数据结构,称之为查找树数据结构,这种数据结构是能够有效的进行深度冻结的。

查找树经过结构共享来共享内存空间的引用,其在对象被复制后依然是不变的,从而节省了内存,使得某类操做的性能有显著的提高。

例如,你能够在一个对象树的根节点使用身份对照来进行比较。若是身份相同,若是身份相同,那你就不用去遍历整颗树来对比差别了。

在 JavaScript 中有一些比较优秀的利用树的类库,好比 Immutable.jsMori

这俩库我都用过,我更倾向于在须要不少不可变状态的大型项目中使用 Immutable.js

反作用

反作用就是指当调用函数时,除了返回函数值以外,还对主调用函数产生附加的影响。反作用的函数不只仅只是返回了一个值,并且还作了其余的事情:

  • 改变了外部对象或变量属性(全局变量或父函数做用域链中的变量)
  • 在控制台中有输出打印
  • 向屏幕中写了东西
  • 向文件中写了东西
  • 向网络中写了东西
  • 触发了外部过程
  • 调用了其余有反作用的函数

反作用在函数式编程中大多数时候都是要避免的,这样才能使得程序的做用一目了然,也更容易被测试。

Haskell 等其余编程语言老是从纯函数中使用 Monads 将反作用独立并封装。有关 Monads 内容太多了,你们能够去了解一下。

但你如今就须要了解的是,反作用行为须要从你的软件中独立出来,这样你的软件就更易扩展、重构、debug、测试和维护。

这也是大多数前端框架鼓励用户单独的管理状态和组件渲染、解耦模块。

经过高阶函数提升复用性

函数式编程倾向于复用一系列函数工具来处理数据。面向对象编程则倾向于将方法和数据放在对象中,这些合并起来的方法只能用来操做那些被设计好的数据,常常仍是包含在特定组件实例中的。

在函数式编程中,任何类型的数据都是同样的地位,同一个 map() 函数能够遍历对象、字符串、数字或任何类型的数据,由于它接收一个函数做为参数,而这个函数参数能够恰当的处理给定的数据类型。函数式编程经过高阶函数来实现这种特性。

JavaScript 秉承函数是一等公民的观点,容许咱们把函数当数据对待 —— 把函数赋值给变量、将函数传给其余函数、让函数返回函数等...

高阶函数就是指任何能够接收函数做为参数的函数、或者返回一个函数的函数,或者二者同时。高阶函数常常被用于:

  • 抽象或独立的动做、回调函数的异步流控制、promises,、monads 等等...
  • 建立能够处理各类数据类型的实用工具函数
  • 使用函数的部分参数或以复用目的或函数组合建立的柯里化函数
  • 接收一组函数做为参数而后返回其中的一些做为组合

容器、函子、列表、流

函子就是一种能够被映射的东西。换句话说,它就是一个有接口的容器,该接口能够被用来apply到函数内部的一个值(这句翻译太奇怪了,功力不够。原文 it’s a container which has an interface which can be used to apply a function to the values inside it.)。

前面咱们知道了相同的 map()函数能够在多种数据类型上执行。它经过提高映射操做以使用函子 API 来实现。关键的流控制操做能够经过 map() 函数利用该接口使用。若是是 Array.prototype.map() 的话,容器就是个数组,其余数据结构能够做为函子,只要它们提供了 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 ]
复制代码

使用如函子/高阶函数的概念来使用原生工具函数来操做不一样的数据类型在函数式编程中很重要。相似的概念被应用在 all sorts of different ways

列表在时间上的延续即为流。

你如今只须要知道数组和函数不是容器和值在容器中应用的惟一方式。好比说,一个数组就是一组数据。列表在时间上的延续即为流 -- 所以你可使用同类工具函数来处理进来的事件流 —— 在往后实践函数式编程中你会对此有所体会。

声明式编程 & 指令式编程

函数式编程是一种声明式编程范式,程序的逻辑在表达时没有明确的描述流控制。

指令式编程用一行行代码来描述特定步骤来达到预期结果。而根本不在意流控制是啥?

声明式编程抽象了流控制过程,用代码来描述数据流该怎么作,如何去得到抽象的方式。

下面的例子中给出了指令式编程映射数组中数字并返回将值乘 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)
复制代码

你会在代码中常常看见一个表达式被赋给一个变量、从函数中返回一个表达式或是被传入一个函数。

结论

本文要点:

  • 使用纯函数而不是共享状态或者有反作用的函数
  • 发扬不可变性而不是可变数据
  • 使用函数组合而不是指令式的流控制
  • 不少原生、可复用的工具函数能够经过高阶函数应用到不少数据类型上,而不是只能处理指定数据
  • 声明式编程而不是指令式编程(要知道作什么,而不是如何作)
  • 表达式和语句
  • 容器 & 高阶函数对比 特设多态

参考

什么是 Monad (Functional Programming)?函子究竟是什么?ApplicativeMonad

相关文章
相关标签/搜索