JavaScript 面试: 什么是函数式编程?| Eric Elliott

3,074 阅读16分钟
原文链接: zcfy.cc

函数式编程在 JavaScript 界已经成为了一个非常热门的话题。而仅在几年之前,还几乎没有 JavaScript 程序员了解函数式编程是什么,但在最近三年里,我看到非常多的应用程序代码库里大量使用着函数式编程思想。

函数式编程 (通常简称为 FP)是指通过复合纯函数来构建软件的过程,它避免了共享的状态(share state)易变的数据(mutable data)、以及副作用(side-effects)。函数式编程是声明式而不是命令式,并且应用程序状态通过纯函数流转。对比面向对象编程,后者的应用程序状态通常是共享并共用于对象方法。

函数式编程是一种编程范式意味着它是一种软件构建的思维方式,有着自己的理论基础和界定法则。其他编程范式的例子包括面向对象编程和过程式编程。

与命令式或面向对象代码相比,函数式代码倾向于更简洁、更可预测以及更易于测试 —— 但是如果你对它以及与它相关的常见模式不熟悉,读函数式代码会让你觉得信息量太大,而且相关文献对于初学者来说往往难以理解。

如果你开始 google 函数式编程的术语,你很可能一下子碰壁,那些学术术语对新人来说着实有点吓人。它有一个非常陡峭的学习曲线。但是如果你已经用 JavaScript 写了一段时间的代码,你很可能不知不觉中在你的软件里已经使用了很多函数式编程原理和功能。

不要让那些新名词把你吓跑。实际上它比你所听说的要简单很多。

最难的部分是记住那些以前不熟悉的词汇。在这些名词定义中蕴含了许多思想,你只有理解了它们,才能够开始掌握函数式编程真正的意义:

  • 纯函数(Pure functions)
  • 函数复合(Function composition)
  • 避免共享状态(Avoid shared state)
  • 避免改变状态(Avoid mutating state)
  • 避免副作用(Avoid side effects)

换句话说,如果你想要了解函数式编程在实际中的意义,你需要从理解那些核心概念开始。

一个纯函数是这样的一个函数:

  • 给它同样的输入,总是返回同样的结果,并且
  • 没有副作用

纯函数有着许多对函数式编程而言非常重要的属性,包括引用透明(你可以将一个函数调用替换成它的结果值,而不会对程序的运行造成影响)。获取更多细节,可以阅读“什么是纯函数”

函数复合是结合两个或多个函数,从而产生一个新函数或进行某些计算的过程。例如,复合操作 f·g(点号意思是对两者执行复合运算)在 JavaScript 中相当于执行 f(g(x))。理解函数复合是理解软件如何用函数式编程模型来构建的很重要的一步。获取更多细节,可以阅读 “什么是函数组合”

共享状态

共享状态 的意思是任意变量、对象或者内存空间存在于共享作用域下,或者作为对象的属性在各个作用域之间被传递。共享作用域包括全局作用域和闭包作用域。通常,在面向对象编程中,对象以添加属性到其他对象上的方式在作用域之间共享。

举个例子,一个电脑游戏可能会控制一个游戏对象(game object),它上面有角色(characters)和游戏道具(items),这些数据作为属性存储在游戏对象之上。而函数式编程避免共享状态 —— 与前者不同地,它依赖于不可变数据结构和纯粹的计算过程来从已存在的数据中派生出新的数据。要获取更多关于软件开发如何使用函数式编程处理应用程序状态的详细内容,可以阅读“10 Tips for Better Redux Architecture”

共享状态的问题是为了理解函数的作用,你需要了解那个函数所用到的全部共享变量的变化历史。

想象你有一个 user 对象需要保存。你的 saveUser() 函数向服务器 API 发起一个请求。此时,用户改变了他们的头像,通过 updateAvatar() 并触发了另一次 saveUser() 请求。在保存动作执行后,服务器返回一个更新的 user 对象,客户端要将这个对象替换内存中的对象,以保持与服务器同步。

不幸地是,第二次请求有可能比第一次请求更早返回,所以当第一次请求(现在已经过时了)返回时,新的头像又从内存中丢失了,被替换回旧的头像。这是一个同步竞争的例子,是一个非常常见的共享状态 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() 并传入一个空的 object 作为第一个参数来拷贝 x 的属性,以防止 x 在函数内部被改变。在这个例子里,它等价由于重新创建一个对象,而这是一种 JavaScript 里的通用模式, 用来拷贝已存在状态而不是使用引用,从而避免像我们第一个例子里产生的问题。

如果你仔细看例子里的 console.log() 语句,你会发现我前面已经提到过的概念:函数复合。回顾一下,函数复合看起来像是这样: f(g(x))。在这个例子里,我们的 f()g()x1()x2(),所以复合是 x1·x2

当然,如果你改变复合的顺序,输出将改变。操作的顺序仍然很重要。f(g(x)) 并不总是等价于 g(f(x)),但是,有一件事情发生了改变,那就是函数外部的变量不会被修改 —— 原本函数修改外部变量是一个大问题。要是函数不纯,我们如果不了解函数使用或操作的每个变量的完整历史,就不可能完全理解它做了什么。

移除函数时序依赖,你就完全消除了一大类潜在的 bug。

不可变性

一个不可变的(immutable)对象是指一个对象不会在它创建之后被改变。对应地,一个可变的(mutable)对象是指任何在创建之后可以被改变的对象。

不可变性是函数式编程的一个核心概念,因为没有它,你的程序中的数据流是有损的。状态历史被抛弃而奇怪的 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}`);

如你所见,被冻结的 object 的顶层基本属性不能被改变,但是如果有一个属性本身也是 object(包括数组等),它依然可以被改变 —— 因此甚至被冻结的对象也不是不可变的,除非你遍历整个对象树并冻结每一个对象属性。

在许多函数式编程语言中,有特殊的不可变数据结构,被称为 trie 数据结构(trie 的发音为 tree),这一结构有效地深冻结 —— 意味任何属性无论它的对象层级如何都不能被改变。

当一个对象被拷贝给一个操作符时,tries 使用结构共享来共用不可变对象的引用内存地址,这减少内存占用,而且能够显著地改善一些类型的操作的性能。

例如,你可以使用 ID 来比较对象,如果两个对象的根 ID 相同,你不需要继续遍历比较整个对象树来寻找差异。

有一些 JavaScript 的库使用了 tries,包括 Immutable.jsMori

我体验了这两个库,最终决定在需要大量不可变状态大的型项目中使用 Immutable.js。想要了解这一部分的更多内容,请移步 “10 Tips for Better Redux Architecture”

副作用(Side Effects)

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

  • 改变了任何外部变量或对象属性(例如,全局变量,或者一个在父级函数作用域链上的变量)
  • 写日志
  • 在屏幕输出
  • 写文件
  • 发网络请求
  • 触发任何外部进程
  • 调用另一个有副作用的函数

在函数式编程中,副作用被尽可能避免,这使得程序的作用更容易理解,也使得程序更容易被测试。

Haskell 以及其他函数式编程语言经常从纯函数中隔离和封装副作用,使用 monads 技巧。Mondas 这个话题要深入下去可以写一本书,所以我们先放一放。

你现在需要做的是要从你的软件中隔离副作用行为。如果你让副作用与你的程序逻辑分离,你的软件将会变得更易于扩展、重构、调试、测试和维护。

这也是为什么大部分前端框架鼓励我们分开管理状态和组件渲染,采用松耦合的模型。

通过高阶函数提升可重用性

函数式编程倾向于复用一组通用的函数功能来处理数据。面向对象编程倾向于把方法和数据集中到对象上。那些被集中的方法只能用来操作设计好的数据类型,通常是那些包含在特定对象实例上的数据。

在函数式编程里,对任何类型的数据一视同仁。同样的 map() 操作可以 map 对象、字符串、数字或任何别的类型,因为它接受一个函数参数,来适当地操作给定类型。函数式编程通过使用高阶函数来实现这一技巧。

在 JavaScript 里,函数是一等公民,JavaScript 允许使用者将函数作为数据 —— 可以将它们赋值给变量、作为参数传递给其他函数、将它们作为返回值返回,等等……

高阶函数指的是一个函数以函数为参数,或以函数为返回值,或者既以函数为参数又以函数为返回值。高阶函数经常用于:

  • 抽象或隔离行为、作用,异步控制流程作为回调函数,promises,monads,等等……
  • 创建可以泛用于各种数据类型的功能
  • 部分应用于函数参数(偏函数应用)或创建一个柯里化的函数,用于复用或函数复合。
  • 接受一个函数列表并返回一些由这个列表中的函数组成的复合函数。

容器、函子(Functor)、列表和流

Functor 是可以被用来执行具体 map 操作的数据。换句话说,它是一个有接口的容器,能够遍历其中的值。当你看到“functor”这个词,你在脑海里应该想到“mappable”。

之前我们说同样的 map() 函数能够操作各种数据类型。它是通过将 map 操作抽象出来,提供给 functor API 使用。map() 利用该接口执行重要的流程控制操作。在 Array.prototype.map() 的场景里,容器是一个数组,但是其他数据接口也可以作为 functor,同样它也提供了 mapping 操作的 API。

让我们看一下 Array.prototype.map() 是如何让你从 mapping 功能里抽象数据类型,让 map() 可以适用于任何数据类型的。我们创建一个简单的 double() mapping,它简单地将传给它的值乘以 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 以及使用高阶函数抽象来创建通用功能函数,以处理任意数值或不同类型的数据,这是函数式编程中很重要的概念。你还能看到类似的概念以各种不同的方式被应用。

“流即是随着时间推移而变化的列表。”

现在你所需要知道的是容器和容器的值所能应用的形式不仅仅只有数组和 functor。一个数组只是一些内容的列表。如果这个列表随着时间推移而变化则成为一个流 —— 所以你可以应用同样的功能来处理时间流 —— 如果你用函数式编程实际开始构建一个真正的软件时,你就会看到很多这种用法。

对比声明式与命令式

函数式编程是一个声明式范式,意思是说程序逻辑不需要通过明确描述控制流程来表达。

命令式 程序花费大量代码来描述用来达成期望结果的特定步骤 —— 控制流:即如何做。

声明式 程序抽象了控制流过程,花费大量代码描述的是数据流:即做什么。

举个例子,下面是一个用 命令式 方式实现的 mapping 过程,接收一个数值数组,并返回一个新的数组,新数组将原数组的每个值乘以 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]

而实现同样功能的 声明式 mapping 用函数 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()

使用 map 来转换如下数组的值为 item 名字:

// vvv Don't change vvv
const items = [
  { name: 'ball', points: 2 },
  { name: 'coin', points: 3 },
  { name: 'candy', points: 4}
];
// ^^^ Don't change ^^^

const result = items.map(
  /* ==vvv Replace this code vvv== */
  () => {}
  /* ==^^^ Replace this code ^^^== */
);


// vvv Don't change vvv
test('Map', assert => {
  const msg = 'Should extract names from objects';
  const expected = [
    'ball', 'coin', 'candy'
  ];

  assert.same(result, expected, msg);
  assert.end();
});
// ^^^ Don't change ^^^

使用 filter 来选择出 points 大于等于 3 的元素:

// vvv Don't change vvv
const items = [
  { name: 'ball', points: 2 },
  { name: 'coin', points: 3 },
  { name: 'candy', points: 4 }
];
// ^^^ Don't change ^^^

const result = items.filter(
  /* ==vvv Replace this code vvv== */
  () => {}
  /* ==^^^ Replace this code ^^^== */
);


// vvv Don't change vvv
test('Filter', assert => {
  const msg = 'Should select items where points >= 3';
  const expected = [
    { name: 'coin', points: 3 },
    { name: 'candy', points: 4 }
  ];

  assert.same(result, expected, msg);
  assert.end();
});
// ^^^ Don't change ^^^

使用 reduce 来求出 points 的和:

// vvv Don't change vvv
const items = [
  { name: 'ball', points: 2 },
  { name: 'coin', points: 3 },
  { name: 'candy', points: 4 }
];
// ^^^ Don't change ^^^

const result = items.reduce(
  /* ==vvv Replace this code vvv== */
  () => {}
  /* ==^^^ Replace this code ^^^== */
);


// vvv Don't change vvv
test('Learn reduce', assert => {
  const msg = 'should sum all the points';
  const expected = 9;

  assert.same(result, expected, msg);
  assert.end();
});
// ^^^ Don't change ^^^

更进一步

准备好更深入学习了吗?阅读 Jafar Husain 的 Learn Rx exercises 来学习一些最重要的函数式编程工具。

想要了解更详细的关于函数式编程的细节以及如何使用函数式编程结合实际每天使用的 JavaScript 来构建真正的应用程序?

与 Eric Elliott 一同学习 JavaScript 吧。这么好的机会,别错过。


Eric Elliott is the author of “Programming JavaScript Applications” (O’Reilly), and “Learn JavaScript with Eric Elliott”. He has contributed to software experiences for Adobe Systems, Zumba Fitness, The Wall Street Journal, ESPN, BBC, and top recording artists including Usher, Frank Ocean, Metallica, and many more.

He spends most of his time in the San Francisco Bay Area with the most beautiful woman in the world.

感谢 JS_Cheerleader.