文章目录
1.3 JavaScript 是函数式编程语言吗?Is JavaScript functional?
差不多是时候弄明白另一个重要问题了:JavaScript
是函数式语言吗?通常,JavaScript
都不会出现在函数式编程语言的清单里。列入清单的都是些不怎么常见的语言,例如 Clojure
、Erlang
、Haskell
和 Scala
;但函数式编程语言并没有一个准确的定义,也没有该语言所特有的功能特性的精确描述。主流观点认为,如果一种语言支持与函数式编程相关的通用编程风格,就可以认为是函数式编程语言。让我们先来了解一下为什么要使用 JavaScript
,进而看看它是怎样发展演变到当前版本的,然后再来了解一下用 JavaScript
进行函数式编程的一些关键语法特性。
1.3.1 作为一种工具 JavaScript as a tool
JavaScript
是什么样的语言呢?就流行指数而言,正如在 TIOBE 指数 或 PYPL 上看到的一样,JavaScript
一直是最受欢迎的十大语言之一。从更偏学术的角度来看,JavaScript
则更像一个从各种语言借鉴了相关特性而构成的综合体。一些工具库通过提供不太容易获得的功能特性来助推该语言的发展,例如类(class
)和继承(inheritance
)(目前的版本的确支持类的语法,但不久前却并非如此),否则必须从 原型 上取巧实现。
【小知识】名字背后的掌故
当年选择
JavaScript
这个名字是为了蹭Java
语言的热度——出于营销的目的。其最早的名字叫Mocha
,然后又叫LiveScript
,最后才叫JavaScript
。
现如今 JavaScript
已经变得非常强大了。但是与所有强大的工具一样,在提供出色的解决方案的同时,也可能带来巨大的危害。函数式编程或许能够减少或避开该语言中最糟糕的某些部分,以一种更安全、更好的方式工作;然而,由于现有 JavaScript
的代码量过于庞大,根本不可能指望函数式编程来推动 JavaScript
代码的大规模重构 —— 这可能导致绝大多数网站停摆失效。但无论如何您都必须学会让好处与坏处并存,然后再扬长避短 1。
此外,JavaScript
还拥有大量现成的第三方库,以多种方式丰富并拓展了该语言的适用范围。本书将聚焦 JavaScript
的单独使用,但偶尔也会引用现成的代码来进行论述讲解。
至于 JavaScript
是否是实际意义上的函数式编程语言,答案仍旧是:可能是。因为 JavaScript
具备的几个特性,例如一等公民函数、匿名函数、递归和闭包(稍后会有论述),让其可被视为函数式编程语言;但另一方面,它也有一些非函数式编程语言的特点,例如副作用(非纯特质,impurity
)、可变对象(mutable objects)和递归的实际限制(practical limits to recursion)。因此,采用函数式方式编程时,既要利用好 JavaScript
所有相关的、恰当的语言特性,也要尽量减少由语言本身引入的问题。从这个意义上说,JavaScript
是不是函数式的语言,取决于所采用的编程风格。
要使用函数式编程,应该决定选用哪种语言;然而,一味选择纯函数式语言未必就是明智之举。如今写代码并不仅仅是使用一种编程语言那么简单,还得有框架、库和其他各种工具的支撑。如果能在利用好现有工具的同时,在代码中恰当地引入函数式的编程风格,这样双管齐下,JavaScript
是否是函数式编程语言其实已经不重要了。
1.3.2. 用 JavaScript 实现函数式编程 Going functional with JavaScript
JavaScript
一直在与时俱进。本书采用的版本为 JS13
(非正式名称),或者 ECMAScript 2022
(正式名称),通常缩写为 ES2022
或 ES13
;该版本于 2022 年 6 月发布,早期主要版本的历史沿革如下:
ECMAScript 1
,1997 年 6 月ECMAScript 2
,1998 年 6 月,基本和上一版相同;ECMAScript 3
,1999 年 12 月,加入一些新功能ECMAScript 5
,2009 年 12 月(没有ECMAScript 4
,因为被废弃了)ECMAScript 5.1
,2011 年 6 月ECMAScript 6
(或ES6
,后更名为ES2015
)2015 年 6 月;ECMAScript 7
(亦即ES7
,或ES2016
)2016 年 6 月;ECMAScript 8
(ES8
或ES2017
)2017 年 6 月ECMAScript 9
(ES9
或ES2018
),2018 年 6 月ECMAScript 10
(ES10
或ES2019
),2019 年 6 月ECMAScript 11
(ES11
或ES2020
),2020 年 6 月ECMAScript 12
(ES12
或ES2021
),2021 年 6 月ECMAScript 13
(ES13
或ES2022
),2022 年 6 月
【小知识】何谓 ECMA
ECMA
最初表示欧洲计算机制造协会(European Computer Manufacturers Association),现如今已不仅仅被视为首字母缩写了。该组织还负责除JavaScript
外的其他标准化工作,例如JSON
、C#
、Dart
等等。详见 www.ecma-international.org/。
您可以在 www.ecma-international.org/publications-and-standards/standards/ecma-262/ 查看 JavaScript
的语言标准规范。没有另行说明的情况下,本书提到的 JavaScript
皆为 ES13
(即 ES2022
)。至于书中涉及的语言特性,如果用的是 ES2015
,阅读本书时基本上也不会有什么问题。
目前还没有浏览器完全实现 ES13
标准(截止 2023 年 5 月);大多数都提供旧版的 JavaScript 5
(始于 2009 年),并夹带一些不断增长地、从 ES6
到 ES13
的少量新特性。这会带来一个问题,所幸是可以解决的问题,后文会有论述;本书将使用 ES13
。
关于恼人的版本差异……
其实
ES2016
与ES2015
之间的差别很小,比如方法Array.prototype.includes
,以及指数运算符**
。而ES2017
与ES2016
的差别较大——像async
与await
语法、一些字符串填充函数(string padding functions)等等——但均不影响本书代码。此外,本书还将在后续章节中考察更现代的替代方案,例如flatMap()
。
正式使用 JavaScript 前,先来考察一下与函数式编程目标相关的最重要的几个语言特性。
1.3.3 JavaScript 关键语言特性
JavaScript
并非纯粹的函数式编程语言,但也具备了我们需要的所有特性,使其能够像纯函数式语言一样工作。本书将用到的主要特性如下:
- 函数作为一等对象
- 递归
- 闭包
- 箭头函数
- 展开运算符
接下来对上述每个特性给出示例,看看它们对我们究竟有什么用。同时也要注意,JavaScript
的核心特性远不止这五个。下面介绍的内容旨在突出函数式编程最重要的语言特性。
1.3.3.1 函数作一等对象
将函数作为 一等对象(first-class objects)(也叫 一级实体(first-class entities) 或 一等公民(first-class citizens)),是指可以像处理其他对象那样处理函数。例如,将函数存入一个变量、将其传给另一个函数、或者打印出来等等。这也是函数式编程的关键所在:将函数作为参数传给其他函数,或是返回一个函数作为某函数调用的结果。
如果运行的是异步 Ajax
,那么您就已经体验过这一特性了:回调函数(callback
)是一个 Ajax
调用执行完毕时触发执行的函数。该函数以参数形式传入。利用 jQuery
,可以编写代码如下:
$.get("some/url", someData, function(result, status) {
// 检查 status 状态,并用 result 完成相关逻辑
});
其中,$.get()
函数接收一个回调函数作为参数,并在获取到结果(result
)时调用该函数。
【小贴士】回调的另一种写法
回调函数的问题,在更现代的解决方案中,通过使用
Promise
或async/await
语法可以得到更好的处理。鉴于仅供演示,走老路也无伤大雅。本书将在第 12 章“构建更好的容器——函数式数据类型”论述monad
相关概念时,重新讨论promise
的问题,详见 “UnexpectedMonads
-Promises
” 相关章节。
鉴于函数可以存到变量中,上述代码也可以改写如下。注意 $.get(...)
调用中变量 doSomething
的使用(第 6 行):
var doSomething = function(result, status) {
// 检查 status 状态,
// 并用 result 完成相关逻辑
};
$.get("some/url", someData, doSomething);
更多示例将在第 6 章产出函数 - 高阶函数中论述。
1.3.3.2 递归
递归 是开发算法最为行之有效的工具,也是解决大类问题的有力助手。其核心理念是函数可以在某个时刻调用自身,当 那一次 调用完成后,将得到的结果再作进一步处理。 该方法通常对某些特定问题或定义很有帮助。最经典的示例是定义非负整数 n
的阶乘函数(n
的阶乘写作 n!
):
- 若
n
为 0,则n!
= 1;- 若
n
大于 0,则n! = n * (n-1)!
【小贴士】关于排列问题
n!
的值是对n
个不同元素进行全排列的所有可能的排列方式的描述。例如将五本书排成一排,任选其一放入第一个位置,然后对剩余四本作全排列,则5! = 5 * 4!
。以此类推,将得到5! = 5 * 4 * 3 * 2 * 1 = 120
,因此n!
是 1 到n
的所有整数的乘积。
这可以快速转成代码描述:
// factorial.js
function fact(n) {
if (n === 0) {
return 1;
} else {
return n * fact(n - 1);
}
}
console.log(fact(5)); // 120
递归对算法设计大有帮助。递归可以等效替换所有 while
或 for
循环——并非刻意为之,关键是提供了一种可能性!本书将在 第 9 章 设计函数 - 递归 中全面论述以递归方式设计算法和编写函数的相关知识。
1.3.3.3 闭包
闭包是隐藏数据的一种方式(结合私有变量),并由此带出了模块及其他不错的语言特性。闭包的关键在于,定义函数时,既可以引用自身的局部变量,也可以引用函数上下文以外的所有内容。利用闭包可以创建一个统计自身运行次数的计数函数:
// closure.js
function newCounter() {
let count = 0;
return function () {
count++;
return count;
};
}
const nc = newCounter();
console.log(nc()); // 1
console.log(nc()); // 2
console.log(nc()); // 3
即使 newCount()
结束运行,其内部函数仍然可以访问变量 count
;而其他位置的代码则不行。
这是函数式编程的一个绝佳示例:一个函数(即 nc()
)在参数相同的情况下不应该返回不同的结果。
本书将考察闭包的几个实际应用,例如 memoization
(记忆功能)(详见 第四章 正确的行为 - 纯函数、第六章 产出函数 - 高阶函数),以及 模块化模式 (module pattern
)(详见 第三章 从函数入手 - 核心概念、第十一章 实践设计模式 - 以函数式的风格)。
1.3.3.4 箭头函数
箭头函数是一种更简短、更简洁地创建(匿名)函数的方法。除了不能用作构造函数外,箭头函数几乎可以在任何地方与传统函数互换。其语法如下:
(parameter, anotherparameter, ...etc) => { statements }
(parameter, anotherparameter, ...etc) => expression
前者可以编写任意数量的代码,后者是 { statements }
的缩写。重写先前的 Ajax
示例如下:
$.get("some/url", data, (result, status) => {
// 检查 status 状态,并用 result 完成相关逻辑
});
箭头函数版阶乘函数可以改写如下:
// factorial.js, continued...
const fact2 = (n) => {
if (n === 0) {
return 1;
} else {
return n * fact2(n – 1);
}
};
【小贴士】匿名函数
箭头函数通常也被称为匿名函数(
anonymous function
),因为它们没有名字。若要引用一个箭头函数,须将其赋给一个变量或对象属性,如上例所示;否则无法使用。本书将在 第三章 从函数入手 - 核心概念 的箭头函数小节作进一步探讨。
新版阶乘函数也可以写为单行形式(one-liner)—— 能看出来它与之前的代码是等效的吗?采用三目运算符来代替 if
语句是很常见的做法:
// continued...
const fact3 = (n) => (n === 0 ? 1 : n * fact3(n - 1));
有了这样的简写形式,关键字 return
也可以省略了。
【小贴士】函数在 lambda 算子演算下的写法
在
lambda
演算中,像x => 2x
这样的函数可以表示为λx.2x
。尽管存在语法差异,但定义是类似的。多参函数的表示稍微复杂一点:(x, y) => x + y
可以用λx.λy.x+y
来表示。本书将在第三章的 关于 Lambda 与函数 小节、以及第七章的 柯里化 小节作进一步介绍。
还有一点要记住:当箭头函数只有一个参数时,该参数两边的小括号可以省略不写。笔者更倾向于保留小括号,但书中代码已经过 JS
美化工具 Prettier
处理,从而删除了括号;是否保留括号完全取决于个人喜好(详见 github.com/prettier/prettier)。顺便说一下,笔者使用的格式化配置参数为 --print-width 75 --tab-width 2 --no-bracket-spacing
。
1.3.3.5 展开运算符
展开运算符 ...
(详见 developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Spread_operator)可以在需要传入多个参数、多个元素或多个变量的地方将一个表达式进行展开。例如在调用多参函数时替换传入的多个参数:
// sum3.js
function sum3(a, b, c) {
return a + b + c;
}
const x = [1, 2, 3];
const y = sum3(...x); // 等效于 sum3(1,2,3)
也可以像这样创建新数组,或者连结多个数组:
// continued...
const f = [1, 2, 3];
const g = [4, ...f, 5]; // [4,1,2,3,5]
const h = [...f, ...g]; // [1,2,3,4,1,2,3,5]
展开运算符也可以作用于对象:
// continued...
const p = { some: 3, data: 5 };
const q = { more: 8, ...p }; // { more:8, some:3, data:5 }
还可用于需要传入多个参数而非数组的函数中,常见的例子是 Math.min()
与 Math.max()
:
// continued...
const numbers = [2, 2, 9, 6, 0, 1, 2, 4, 5, 6];
const minA = Math.min(...numbers); // 0
const maxArray = (arr) => Math.max(...arr);
const maxA = maxArray(numbers); // 9
这样,函数 maxArray()
就可以接受一个数字数组作为参数了。
此外,利用 .apply()
方法接收一个 参数数组、.call()
方法接收 独立的参数 的特性,也可以结合展开运算符得到下面的恒等式:
someFn.apply(thisArg, someArray) === someFn.call(thisArg, ...someArray);
【小知识】关于函数参数的一个识记小窍门
如果在识记
.apply()
与.call()
方法各自所接收的参数格式上犯难,这个顺口溜或可助您一臂之力:A is for an array, and C is for a comma(A 对应Array
数组,C 对应逗号)。更多信息,详见以上方法的 MDN 线上文档:Function.prototype.apply() 以及 Function.prototype.call()。
利用展开运算符可以写出更简洁有力的代码。至此,我们回顾了即将用到的所有最为核心的 JavaScript
语言特性。作为本章的结尾,再来看看即将用到的一些辅助工具。
原文为:You must learn to live with the good and the bad, and simply avoid the latter part. ↩︎