作者:Eric Elliott
原文: 《Master the JavaScript Interview: What is Functional Programming?》
译者:the5fire
译者注:我翻译只是为了更好的理解函数式编程,也参考了其他人的翻译,推荐看月影大神的翻译征服 JavaScript 面试: 什么是函数式编程?
-----------开始-------------
“精通JavaScript面试”是一系列文章,专门给那些在为中高级JavaScript岗位的面试做准备的人设计的。这些都是我在真实面试中经常用到的问题。
在JavaScript的世界中函数式编程已然变成热门的话题了。仅仅在几年之前,极少数的JavaScript程序员听说过函数式编程是什么,但是在过去三年里我看到的每个大型应用的代码库中都使用了大量函数式编程的想法。
函数式编程(经常缩写为:FP)是指通过组合纯函数、避免共享状态、避免易变数据以及避免副作用的方式来构建软件的过程。函数式编程是声明式而不是命令式,并且程序的状态通过纯函数进行流转。这与面向对象编程中通常在对象方法中共享和组合应用状态形成对比。
函数式编程是一种编程范式,意味着一种基于一些基本原理和有限定的原则(上面已经列出来了)来构建软件的方式的思考。其它编程范式的案例包括面向对象编程和过程式编程。
函数式代码要比命令式或者面向对象方式的代码更简洁,更可预测以及更容易测试 - 但是如果你不熟悉它及跟它相关的常用模式,函数式代码看起来也是非常晦涩,并且对于初学者来说它的相关文献也很难理解。
如果你开始google搜索函数式编程术语,你将很快撞到一堵学术术语的墙上,对初学者来说这太吓人了。说它有学习曲线是严重低估的说法(the5fire注,意译:它的学习曲线相当高)。但是如果你已经用JavaScript编程一段时间了,很可能你已经在实际软件开发中使用大量函数式编程概念和工具。
不要让那些陌生的词汇吓到你。它比声称的容易多了。
最难的部分就是你脑袋周围的这些不熟悉的词汇。在你开始掌握函数式编程要领之前,你需要先理解那些看起来无关的定义所呈现的含义:
- 纯函数
- 函数合成
- 避免状态共享
- 避免修改状态
- 避免副作用
换句话说,如果你想要知道在实践中函数式编程意味着什么,你需要先理解这些核心概念。
纯函数是指:
- 给定相同的输入,始终返回相同的输出,并且
- 没有副作用
纯函数有许多在函数式编程中重要的特性,包括引用透明(你可以通过用函数的结果值替换函数调用而不影响整个程序的意思)。阅读《What is a Pure Function?》了解更多细节。
函数合成是指通过以一定顺序组合两个或者多个函数产生一个新函数或者执行某种计算的过程。比如说,f . g
(点号表示“同...组合”)的合成等价于JavaScript中的 f(g(x))
。理解函数合成是理解如何使用函数式编程构建软件的重要一步。阅读《What is Function Composition?》了解更多细节。
状态共享
状态共享是指任何变量,对象,或者内存空间存在于共享作用域内,或者是作为对象的属性在各作用域间传递。一个共享的作用域能够包含全局作用域和闭包作用域。通常,在面向对象编程中,对象在各作用域间共享是通过给其他对象增加属性的方式。
举例来说,一款电脑游戏可能有一个主要的游戏对象,角色和游戏物品以属性的方式被存储在这个对象上。函数式编程避免共享状态 - 反而依赖不可变数据结构和纯运算从已有的数据上派生出新数据。了解更多函数式编程如何处理应用程序状态的细节,看这里:《10 Tips for Better Redux Architecture》
共享状态的问题是为了理解函数的效果,你必须知道函数使用或者影响的所有共享变量的整个历史状态。
想象一下你有一个需要保存的用户对象,你的 saveUser()
函数发起一个请求到服务器的API上。当请求发生时,用户通过 updateAvatar()
修改了他的个人照片然后触发另外一个 saveUser()
的请求。在保存时,应该用服务器发送回来的规范的用户对象替换现在内存中的对象,以保持跟服务器端或者其他接口响应的变化同步。
不幸的是,第二个响应比第一个先返回,那么当第一个响应(现在已经过时了)返回时,新的个人照片被从内存中抹掉然后替换为旧的。这是竞态条件的例子 - 一个跟共享状态相关的常见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
// 代码地址: https://gist.github.com/ericelliott/c1aad9d5c13b0147630cb75e29a5b920/raw/a2d2cb70dc388a109ad2bedeeb244a3a5b317e01/timing-dependency.js
当你避免共享状态,函数调用的时序和顺序就不会修改函数调用的结果。使用纯函数,给定相同的输入,你将永远获得相同的输出。这使得函数的调用完全独立于其他函数的调用,这能从根本上简化变更和重构。修改一个函数或者函数调用的时间不会波及或者破坏程序的其它部分。
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
// the5fire注:
// 一开始觉得作者有点鸡贼,因为这个例子中函数调用的顺序没变化,
// 都是 ``x1(x2(y)).val`` 也就是先调用x2,然后调用x1。但是想一下上面作者的描述
// 函数的执行只依赖另外函数的执行(结果)
// 这一点确实跟共享状态不同,共享状态依赖的是状态的变化顺序
// 代码地址: https://gist.github.com/ericelliott/2b7f45b2ed3684440a3983bf3bf8cdab/raw/a48473ee5f43e141bdaeec9e4cc2408862315e02/no-timing-dependency.js
在上面的例子中,我们使用 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会蔓延在你的软件中。可以看这篇文章 《The Dao of Immutability》 了解更多不可变性的重要性。
在JavaScript中,能够区分常量和不可变性很重要的。常量创建的变量绑定在创建之后不可以被重新赋值。常量不会创建不可变对象。你不能修改绑定了的对象引用,但是你依然能修改对象上的属性,这意味着绑定创建对象到常量上是可变的,非不可变的。
不可变对象不能被做任何改变。你可以通过深度冻结(freeze)这个对象来制造一个真正不可变对象。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 use structural sharing to share reference memory locations for all the parts of the object which are unchanged after an object has been copied by an operator, which uses less memory, and enables significant performance improvements for some kinds of operations.
fix:前缀树使用结构共享来共享内存地址的引用给那些在对象被操作符复制之后没有发生变化的对象的所有部分上,这使用更少的内存,同时在一些操作上有显著的性能提升。
比如说,你可以在对象的根节点使用id来对比。如果id是相同的,你就不需要遍历整个树来查找不同点。
在JavaScript中有一些库利用了前缀树,包括 Immutable.js和Mori。
这两个我都用过,并且我打算在需要大量不可变状态的大项目中使用Immutable.js。想了解更多,看这里10 Tips for Better Redux Architecture。
副作用
副作用是指任何应用状态的显式变化在函数的调用之外而不是通过返回值。副作用包括:
- 修改任何外部的变量或者对象属性(比如:全局变量,或者父作用域中的变量)
- 打印日志到console
- 输出内容到屏幕上
- 写文件
- 发送网络请求
- 触发外部的进程
- 调用任何其他有副作用的方法
函数式编程中通常是避免副作用的,为了使程序结果更加容易理解,和更加容易测试。
Haskell和其他函数式编程语言经常使用monads把纯函数和副作用进行隔离和封装。关于monads的话题足以写一本书了,我们稍后再来说这个话题。
你现在所需要知道的是有副作用的操作需要从你软件的剩余部分隔离。如果你坚持让副作用同你程序逻辑的剩下的部分隔离,那么你的软件将会变得更容易扩展,重构,调试,测试和维护。
这就是大部分前端框架鼓励用户通过独立的、松耦合的模块去管理状态和组件渲染的原因。
高阶函数提升可复用性
函数式编程倾向于复用一组通用的函数工具集来处理数据。面向对象编程倾向于把方法和数据一起绑定到对象上。这些绑定的方法仅仅能操作他们设计好要操作的数据类型,并且常常只是包含在特殊对象实例中的数据。
在函数式编程中,任何数据类型都是平等的。同样的 map()
工具能够遍历映射对象、字符串、数字,或者任何其他数据类型,因为它采用函数作为参数,这个函数可以恰当的处理给定的数据类型。函数式编程通过高阶函数实现了通用工具的“诡计”。
JavaScript中函数是一等公民,允许我们把函数作为数据 —— 把他们赋值给变量,传递到其他函数中,作为函数的返回值,等等
高阶函数是指那些把函数作为参数,或者返回一个函数,或者都有(即把函数作为参数,又返回函数作为结果)。高阶函数经常用在:
- 抽象或者隔离操作、影响,或者使用回调函数控制异步流程,promise,monads等
- 创建一个能够处理宽泛数据类型的工具集
- 给一个函数部分参数赋值,或者创建一个柯里化的函数便于复用或者函数合成
- 传入一个函数列表然后返回这些输入函数的某种组合
容器,函子,列表和流
函子是指能够被映射遍历的东西。换一种说法就是,它是一个容器,有一个接口能够被用来迭代遍历内部数值。当你看到函子(functor)这个词时,你应该想到“可映射(mappable)”。
之前我们学到的同样的map()
工具可以作用于多种多样的数据类型。它通过把映射操做作用到函子(functor)上来做这件事。重要的流程控制操作通过map()
利用这个接口完成,拿Array.prototype.map()
来说,这个容器是一个数组,但是其他数据结构也可以是函子——只要他们支持映射接口。
让我们来一起看下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》
“随时间流逝的列表表达式是一个流”
现在你所需要理解的是数组和函子不是容器和容器中的值概念应用的唯一方式。比如说,数组只是一些东西的列表。随时间流逝的列表表达式是一个流——因此你能够运用同样类型的工具来处理输入事件的流 —— 当你开始使用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]
命令式代码经常使用声明语句。一个声明语句就是执行某种操作的一段代码。常用的声明语句例子包括for,if,switch,throw,等。
声明式代码更多的依赖表达式。一个表达式是一段求某种值的代码。表达式通常是一些函数调用,值,以及操作的组合,执行后能够产生最终结果。
这是表达式的所有例子:
2 * 2
doubleMap([2, 3, 4])
Math.max(4, 3, 2)
通常在代码中,你会看到表达式被赋值给一个标示符,从一个函数返回,或者传递给一个函数。赋值,返回或者传递之前,表达式会先执行,并且会使用返回的结果。
总结
函数式编程的偏好:
- 纯函数取代状态共享和副作用
- 不可变性优于可变数据
- 函数合成优于命令式流程控制
- 大量通用、可复用的使用可以处理多种数据类型的高阶函数工具集替代仅仅能处理它自身持有数据的方法
- 声明式胜于命令式(去做什么,胜于如何做)
- 表达式优于声明语句
- 容器和高阶函数优于即时多态
作业
学习并实践数组提供一组核心函数:
.map()
.filter()
.reduce()
使用map把下面数组中的值转换为每个条目的名称的数组:
// 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== */
// () => {}
item => item.name // answer by the5fire
/* ==^^^ 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== */
// () => {}
item => item.points >= 3 // answer by the5fire
/* ==^^^ 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== */
//() => {}
(prevPoints, curr) => prevPoints + curr.points, 0 // answer by the5fire
/* ==^^^ 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 ^^^
- from the5fire.com微信公众号:Python程序员杂谈