3

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

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

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

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

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

不要让所有的新词汇吓走你。它通常比听起来更简单。

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

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

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

纯函数

  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:欢迎指正翻译不正之处。


ylzsmallsun
92 声望2 粉丝

你就是太阳 蒸发了彷徨