函数式编程理念

一、概述

函数式编程是一种方案简单、功能独立、对作用域外没有任何副作用的编程范式:INPUT -> PROCESS -> OUTPUT

函数式编程:

1)功能独立 —— 不依赖于程序的状态(比如可能发生变化的全局变量);

2)纯函数 —— 同一个输入永远能得到同一个输出;

3)有限的副作用 —— 可以严格地限制函数外部对状态的更改

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18

// 函数返回表示“一杯绿茶(green tea)”的字符串
const prepareTea = () => 'greenTea';

/*
有一个函数(代表茶的种类)和需要几杯茶,下面的函数返回一个数组,包含字符串(每个字符串表示一杯特别种类的茶)。
*/
const getTea = (numOfCups) => {
  const teaCups = [];

  for(let cups = 1; cups <= numOfCups; cups += 1) {
    const teaCup = prepareTea();
    teaCups.push(teaCup);
  }
  return teaCups;
};

const tea4TeamFCC = getTea(2); // [ 'greenTea', 'greenTea' ]

二、了解函数式编程术语

需求有变更,现在想要两种茶:绿茶(green tea)和红茶(black tea)。 事实证明,用户需求变更是很常见的。

基于以上信息,需要重构上例中的 getTea 函数来处理多种茶的请求。

可以修改 getTea 接受一个函数作为参数,使它能够修改茶的类型。 这让 getTea 更灵活,也使需求变更时为程序员提供更多控制权。

首先,介绍一些术语:

Callbacks 是被传递到另一个函数中调用的函数。

例如在 filter 中,回调函数告诉 JavaScript 以什么规则过滤数组。

函数就像其他正常值一样,可以赋值给变量、传递给另一个函数,或从其它函数返回,这种函数叫做头等 first class 函数

在 JavaScript 中,所有函数都是头等函数。

将函数作为参数返回值的函数叫做高阶 ( higher order) 函数

当函数被传递给另一个函数或从另一个函数返回时,那些传入或返回的函数可以叫做 lambda

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22

// 函数返回表示“一杯绿茶(green tea)”的字符串
const prepareGreenTea = () => 'greenTea';

// 函数返回表示“一杯红茶(black tea)”的字符串
const prepareBlackTea = () => 'blackTea';

/*
有一个函数(代表茶的种类)和需要几杯茶,下面的函数返回一个数组,包含字符串(每个字符串表示一杯特别种类的茶)。
*/
const getTea = (prepareTea, numOfCups) => {
  const teaCups = [];

  for(let cups = 1; cups <= numOfCups; cups += 1) {
    const teaCup = prepareTea();
    teaCups.push(teaCup);
  }
  return teaCups;
};

const tea4GreenTeamFCC = getTea(prepareGreenTea, 1); // [ 'greenTea' ]
const tea4BlackTeamFCC = getTea(prepareBlackTea, 2); // [ 'blackTea', 'blackTea' ]

三、了解使用命令式编程的危害

使用 函数式编程 是一个好的习惯。

它使代码易于管理,避免潜在的 bug。

但在开始之前,先看看 命令式编程 方法,以说明可能会遇到什么问题。

在英语 (以及许多其他语言) 中,命令式 时常用来发出指令。 同样,命令式编程 是向计算机提供一套执行任务的声明。

命令式编程 常常改变程序状态,例如更新全局变量。 一个典型的例子是编写 for 循环,它为一个数组的索引提供了准确的迭代方向。

相反,函数式编程是声明式编程的一种形式。 通过调用方法或函数来告诉计算机要做什么。

JavaScript 提供了许多处理常见任务的方法,所以无需写出让计算机应如何执行它们。
例如,可以用 map 函数替代上面提到的 for 循环来处理数组迭代。 这有助于避免语义错误,如调试章节介绍的 “Off By One Errors”。

考虑这样的场景:在浏览器中浏览网页,并想操作打开的标签。

下面试试用 面向对象 的思路来描述这种情景。

  • 窗口对象由选项卡组成,通常会打开多个窗口。
  • 窗口对象中每个打开网站的标题都保存在一个数组中。
  • 在对浏览器进行了如打开新标签、合并窗口、关闭标签之类的操作后,需要输出所有打开的标签。
  • 关掉的标签将从数组中删除,新打开的标签(为简单起见)则添加到数组的末尾。

代码编辑器中显示了此功能的实现,其中包含 tabOpen(),tabClose(),和 join() 函数。 tabs 数组是窗口对象的一部分用于储存打开页面的名称。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

// tabs 是在窗口中打开的每个站点的 title 的数组
const Window = function(tabs) {
  this.tabs = tabs; // 记录对象内部的数组
};

// 当将两个窗口合并为一个窗口时
Window.prototype.join = function(otherWindow) {
  this.tabs = this.tabs.concat(otherWindow.tabs);
  return this;
};

// 当在最后打开一个选项卡时
Window.prototype.tabOpen = function(tab) {
  this.tabs.push('new tab'); // 现在打开一个新的选项卡
  return this;
};

// 当关闭一个选项卡时
Window.prototype.tabClose = function(index) {

  // 使用 splice()。这会产生副作用(对原始数组的更改),在实践中应该避免。
  var tabsAfterIndex = this.tabs.splice(index);

  const tabsBeforeIndex = this.tabs.splice(0, index); // 点击之前获取 tabs
  const tabsAfterIndex = this.tabs.splice(1); // 点击之后获取 tabs

  this.tabs = tabsBeforeIndex.concat(tabsAfterIndex); // 将它们合并起来

  return this;
 };

// 创建三个浏览器窗口
const workWindow = new Window(['GMail', 'Inbox', 'Work mail', 'Docs', 'freeCodeCamp']); // 邮箱、Google Drive 和其他工作地点
const socialWindow = new Window(['FB', 'Gitter', 'Reddit', 'Twitter', 'Medium']); // 社交网站
const videoWindow = new Window(['Netflix', 'YouTube', 'Vimeo', 'Vine']); // 娱乐网站

// 现在执行打开选项卡,关闭选项卡和其他操作
const finalTabs = socialWindow
  .tabOpen() // 打开一个新的选项卡,显示猫的图片
  .join(videoWindow.tabClose(2)) // 关闭视频窗口的第三个选项卡,并合并
  .join(workWindow.tabClose(1).tabOpen());
console.log(finalTabs.tabs);

三、使用函数式编程避免变化和副作用

上例的问题出在 tabClose() 函数里的 splice

不幸的是,splice 修改了调用它的原始数组,所以第二次调用它时是基于修改后的数组,才给出了意料之外的结果。

这是一个小例子,还有更广义的定义 —— 在变量,数组或对象上调用一个函数,这个函数会改变对象中的变量或其他东西。

函数式编程 的核心原则之一是不改变任何东西。

变化会导致错误。

如果一个函数不改变传入的参数、全局变量等数据,那么它造成问题的可能性就会小很多。

前面的例子没有任何复杂的操作,但是 splice 方法改变了原始数组,导致 bug 产生。

在函数式编程中,改变或变更叫做 mutation,这种改变的结果叫做副作用(side effect)

理想情况下,函数应该是不会产生任何副作用的 纯函数(pure function)

尝试掌握这个原则:不要改变代码中的任何变量或对象。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

// 全局变量
let fixedValue = 4;

function incrementer() {

  return fixedValue + 1;
  
};

console.log(incrementer()); // 5
console.log(fixedValue); // 4

四、传递参数以避免函数中的外部依赖

上例更接近 函数式编程 原则,但是仍然缺少一些东西。

虽然没有改变全局变量值,但在没有全局变量 fixedValue 的情况下,incrementer 函数将不起作用。

函数式编程的另一个原则是:总是显式声明依赖关系
如果函数依赖于一个变量或对象,那么将该变量或对象作为参数直接传递到函数中。

这样做会有很多好处。

  • 其中一点是让函数更容易测试,因为确切地知道参数是什么,并且这个参数也不依赖于程序中的任何其他内容。

  • 其次,这样做可以更加自信地更改,删除或添加新代码。 因为很清楚哪些是可以改的,哪些是不可以改的,这样就知道哪里可能会有潜在的陷阱。

  • 最后,无论代码的哪一部分执行它,函数总是会为同一组输入生成相同的输出。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

// 全局变量
let fixedValue = 4;

function incrementer(value) {

  return value + 1

}

console.log(incrementer(fixedValue)); // 5
console.log(fixedValue); // 4

五、在函数中重构全局变量

目前为止,已经看到了函数式编程的两个原则:

  • 不要更改变量或对象 - 创建新变量和对象,并在需要时从函数返回它们。
    提示:使用类似 const newArr = arrVar 的东西,其中 arrVar 是一个数组,只会创建对现有变量的引用,而不是副本。 所以更改 newArr 中的值会同时更改 arrVar 中的值。

  • 声明函数参数 - 函数内的任何计算仅取决于参数,而不取决于任何全局对象或变量。

给数字增加 1 不够刺激,可以在处理数组或更复杂的对象时应用这些原则。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

// 全局变量
let bookList = ["西游记", "红楼梦", "金瓶梅", "来生不做中国人"];

/* 这个函数将一本书添加到列表中并返回列表 */
// 新参数应该在 bookName 之前

function add(list, bookName) {
  return [...list, bookName];
}

/* 这个函数从列表中删除一本书并返回列表 */
// 新参数应该出现在 bookName 一之前

function remove(list, bookName) {
  return list.filter(book => book !== bookName);
}

let newBookList = add(bookList, '寂静岭');
let newerBookList = remove(bookList, '红楼梦');
let newestBookList = remove(add(bookList, '寂静岭'), '红楼梦');

console.log(newBookList); // [ '西游记', '红楼梦', '金瓶梅', '来生不做中国人', '寂静岭' ]
console.log(newerBookList); // [ '西游记', '金瓶梅', '来生不做中国人' ]
console.log(newestBookList); // [ '西游记', '金瓶梅', '来生不做中国人', '寂静岭' ]
console.log(bookList); // [ '西游记', '红楼梦', '金瓶梅', '来生不做中国人' ]

知识点

  • 函数式编程的原则

1)功能独立 —— 不依赖于程序的状态(比如可能发生变化的全局变量);

2)纯函数 —— 同一个输入永远能得到同一个输出;

3)有限的副作用 —— 可以严格地限制函数外部对状态的更改