[译]攻克前端javascript面试:什么是函数式编程?

原文地址:medium.com/javascript-…javascript

函数式编程已然变成了一个javascript语言中一个很是热门的话题。仅在几年之前,仅有少数的js程序员知道函数式编程是什么。可是在过去三年中,我所见过的每一个大型应用代码库里都使用了函数式编程概念。前端

函数式编程(常常缩写为FP)是经过组合纯函数,避免共享状态可变数据、和反作用来构建软件的过程。函数式编程是声明性的而不是命令式的,应用状态流经纯函数中。相比于面向对象编程,其中的应用状态常常是共享的,而且和方法一块儿定义在一些对象中。java

函数式编程是一种编程模式。意味着它是一种基于一些基本原理和定义原则(如上所列)来思考软件构造的方式。其它的编程模式还包括面向对象编程和过程式编程。node

相比于命令式的和面向对象式的代码,函数式的代码趋向于更简洁、更加可预言的、更容易测试。但若是你还不熟悉函数式编程以及它相关联的一些基本模式,函数式的代码看起来会更加紧凑,与之相关的文献对于初学者来讲也会比较费解。git

若是你开始谷歌搜索函数式编程时,你将很快会遇到大量的很是专业的学术性术语,这对初学者来讲是很是吓人的。说它有学习曲线就过轻描淡写了。但若是你已经写过js代码经验,颇有可能你已经在真实的软件中使用了大量的函数式编程概念和工具。程序员

不要让全部的新词汇吓走你。它一般比听起来更简单。

最难的部分是理解全部不熟悉的词汇。上面那些看似可有可无的定义包含许多概念,这些概念须要在你掌握函数式编程的含义以前理解:github

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

换句话说,若是你想知道函数式编程在实践中表明着什么含义,那么你不得不从理解这些核心概念开始。编程

纯函数redux

  1. 给定相同的输入,老是返回相同的输出
  2. 没有反作用 在函数式编程中,纯函数有不少重要的特性。包括引用透明性(你能够将一个函数调用替换成它的结果值而不会改变程序的意义)。可阅读什么是纯函数了解更多。

函数组合: 函数组合是将两个或更多的函数组合成一个新函数或者执行一些计算的过程。例如,在javascript中,组成 f . g (.点表明组成)等价于f(g(x))。在理解软件是如何使用函数式编程构建时,理解函数组合是很是重要的一步。 可阅读什么是函数组合了解更多。数组

共享状态: 共享状态是任意变量、对象或者是内存空间其存在于共享的做用域中,或者是做为一个对象的属性在各个做用域中传递。共享做用域包含全局做用域或者是闭包。常常,在面向对象编程中,在做用域中共享对象是经过将其添加为其余对象的属性。

例如,一个计算机游戏可能有一个主要的游戏对象,该对象包含一些任务角色和游戏项目做为它拥有的属性。函数式编程避免共享的状态—相反它依赖不可变的数据结构和纯计算从已有的数据中获取新数据。 更多关于函数式的软件是如何处理应用状态的,可参考10个关于得到更好的redux 架构的技巧

共享状态的问题在于,为了理解一个函数的效果,你必须知道每一个共享变量在函数中怎么使用和产生影响的整个历史。

想象一下你有一个用户对象须要保存。你的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)),可是在函数以外的变量发生了什么变得再也不重要了,这才是重要的事。若是使用非纯函数,那么久不可能彻底理解一个函数作了什么,除非你了解函数使用和影响的每一个变量的整个历史。

移除掉函数调用的时间依赖性,你会消除掉一整类的潜在的bug。

不变性: 不可变的对象是指一个对象一旦建立后不能对其修改。相反,可变的对象是指对象建立后可对其进行修改。

不可变性是函数式编程的核心概念。由于若是缺乏它,程序中的数据流将会有损耗。状态历史被遗弃的话,奇怪的bug将会蔓延到软件中。关于更多不可变性的意义,可参考The Dao of Immutability

在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 data structures(读做‘tree’)。它们是有效的深度冻结,意味着任何属性都不能被更改,不管它位于对象的那一层级上。

针对对象的全部部分,Tries 使用共享结构来共享引用内存位置。在对象被一个操做拷贝以后,它们仍然是未被改变的。Tries使用了更少的内存,使得一些操做在性能上有很大提高。

例如,你能够在对象树上的根部使用身份对照用于对比。若是身份相同,那就无需遍历整棵树来检查差别性。

在JavaScript中还有一些库利用了tries的有点,包括immutable-jsmori

我已经尝试过上面两种,并趋向于在须要大量不可变状态的大项目中使用Imuutable.js。更多相关内容请详见10个关于得到更好的redux 架构的技巧

反作用: 反作用是指任意的应用状态变化在程序调用的外面都是可见的而不是做为他的返回值。反作用包括:

  • 更改任意的外部变量和对象属性(如全局变量,或位于父函数做用域链中的变量)

  • 输出日志到console

  • 在屏幕上写

  • 写文件

  • 写数据到网络

  • 触发任意外部处理

  • 调用任何包含反作用的其它函数

反作用在函数式编程中大多被避免可以使得程序的效果更容易被理解和测试。

Haskell 和其它函数式语言常用**monads**从纯函数中隔离和封装反作用。monads主题的内容足够写一本书,因此咱们将它放在后面。

你如今只须要知道的是反作用须要在你软件剩下的部分中隔离出来。若是你保持反作用从剩下的程序逻辑中隔离出,那你的软件将会变得更加容易扩展、重构、调试和维护。

这就是为何大多数前端框架为何鼓励用户分开管理状态和组件渲染,弱耦合模块。

利用高阶函数达到可重用性 函数式编程趋向于重用一套通用的函数式的实用工具来处理数据。面向对象编程趋向于将方法和数据都放在对象中。这些同地协做的方法仅仅操做它们被设计好的指望操做的数据类型。并且常常是一些仅包含在特定对象实例中的数据。

在函数式编程中,任意数据类型都是场公平竞争的游戏。相同的map工具可映射在对象、字符串、数字、或者任何其它类型数据上。由于它接受一个函数做为参数并适当地处理给定的数据类型。FP使用高阶函数实现了它的通用工具诡计。

JavaScript具备一级函数,这容许咱们将函数做为数据赋值给变量,传递给其它函数,从函数中返回,等等。。。

高阶函数是采用一个函数做为参数,返回一个函数,或者二者兼具的一个函数。高阶函数经常使用于:

  • 抽取或者隔离动做,影响或者使用回调函数,promise, monads等的异步流控制
  • 建立可做用于各类各样数据类型的实用工具
  • 部分应用一个函数到它的参数或者建立一个柯里化函数达到重用或者函数组合的目的。
  • 接受一系列函数并返回这些输入函数的一些组合 Containers, Functors, Lists, and Streams functor是指可用于映射的东西。换句话说,它是一个容器,包含一个可应用一个函数到它内部数据的接口。当你看见functor这个词时,你应该想到可映射的(mappable)。

前面咱们学习了相同的map工具可做用于各类类型的数据类型。它经过映射操做和一个functor API一块儿工做完成目的。map()使用的重要流控制操做利用了接口的优势。从Array.prototype.map()状况来看,数组是container,可是其它数据结构也能够是functors,只要它们提供映射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 ]
复制代码

若是咱们但愿操做游戏中的数据,将游戏所得到的点数翻倍该怎么办呢?全部咱们须要作的是对传递给map()的double函数作一点微小的变更,而后全部的东西都会正常工做:

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 ]
复制代码

使用抽象(像functors和高阶函数这样为了使用通用的实用工具函数来操做任意数量的不一样数据类型)的概念对函数式编程是十分重要的,你将会看到一个相似的概念应用在各类不一样途径

*随着时间表示的列表是流*
复制代码

全部如今你须要理解就是数组和functors不是惟一的方式,让容器这个概念和容器中的值来使用。好比,一个数组仅仅是一列东西。随着时间表示的列表是流—因此你能够应用相同类型的工具来处理到来的事件流—这是一些当你利用FP开始构建真实的软件时常常看见的东西。

声明式 VS 命令式 函数式编程是声明式模式,意味着程序的逻辑的表达无需明确的流控制的描述。

命令式程序花费大量的代码描述具体的步骤以获取指望的结果—流控制:如何作。

声明式程序抽象出流控制过程而不是花费大量的代码描述数据流:作什么?怎么作(how)被抽象出来了。

举个栗子,命令式的映射传入一个数字数组并返回一个每一个数字都乘以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)
复制代码

在代码中,你会看到一些表达式赋值给一些标识符,从函数中返回出来或者传递给函数。在赋值、返回或者传递以前,表达式先被求值,而后结果值被使用。

结论 函数式编程主张:

  • 纯函数而不是共享状态和反作用
  • 基于可变数据的不可变性
  • 基于命令式流控制的函数组合
  • 大量的通用的,可重用的工具使用高阶函数做用于多种数据类型而不是只能在它们共同协做的数据上操做。
  • 声明式的代码而不是命令式的代码(作什么而非怎么作)
  • 表达式而不是陈述
  • 基于即时多态的容器和高阶函数

ps:欢迎指正翻译不正之处。

相关文章
相关标签/搜索