【玩转 JS 函数式编程_003】1.3 JavaScript 是函数式编程语言吗?

时间:2024-09-30 07:19:39

文章目录

    • 1.3. JavaScript 是函数式编程语言吗?Is JavaScript functional?
      • 1.3.1. 作为一种工具
      • 1.3.2. 用 JavaScript 实现函数式编程
      • 1.3.3. 关键语言特性
        • 1.3.3.1 函数作一等对象
        • 1.3.3.2 递归
        • 1.3.3.3 闭包
        • 1.3.3.4. 箭头函数
        • 1.3.3.5 展开运算符

1.3. JavaScript 是函数式编程语言吗?Is JavaScript functional?

差不多是时候弄明白另一个重要问题了:JavaScript 是函数式语言吗?通常,JavaScript 都不会出现在函数式编程语音的清单里。列入清单的都是些不怎么常见的语言,例如 ClojureErlangHaskellScala;但函数式编程语言并没有一个准确的定义,也没有该语言所特有的功能特性的精确描述。最主要的是,如果一种语言支持与函数式编程相关的通用编程风格,就可以认为是函数式编程语言。让我们首先来了解一下为什么要使用 JavaScript,看看该语言是如何发展到其当前版本的,然后再来了解用 JavaScript 进行函数式编程的一些关键特性。

1.3.1. 作为一种工具

JavaScript 是什么样的语言呢?就流行指数而言,正如在 www.tiobe.com/tiobe-index/http://pypl.github.io/PYPL.html 上看到的一样,JavaScript 一直是最受欢迎的十大语言之一。从更偏学术的角度来看,JavaScript 更像一个从多种不同语言借用了相关特性所构成的综合体。一些库通过提供不太容易获得的特性来帮助该语言发展,例如类(class)和继承(inheritance)(目前的版本的确支持类,不久前情况并非如此),否则必须从 原型 上取巧实现。

小知识

当年选择 JavaScript 这个名字是为了蹭 Java 语言的热度——出于营销的目的。其最早的名字叫 Mocha,然后又叫 LiveScript,最后才叫 JavaScript

现如今 JavaScript 已经变得非常强大了。但是与所有强大的工具一样,在提供出色的解决方案的同时,也可能带来巨大的危害。函数式编程或许能够减少或避开该语言中一些最糟糕的部分,以一种更安全、更好的方式工作;然而,由于现有的 JavaScript 代码量巨大,要是指望它来推动 JavaScript 代码 —— 这种可能导致绝大多数网站失效的语言 —— 进行大规模重构,最终也是痴人说梦。无论好坏都得去学习了解,然后再扬长避短。

此外,JavaScript 还拥有大量现成的第三方库,以多种方式丰富并拓展了该语言的适用范围。本书将围绕 JavaScript 本身,引用现成代码进行论述讲解。

至于 JavaScript 是否是实际意义上的函数式编程语言,答案仍旧是:可能是。因为 JavaScript 具备的几个特性,例如一等公民函数、匿名函数、递归和闭包(稍后会有论述),让其可被视为函数式编程语言;但另一方面,它也有一些非函数式风格的特点,例如副作用(不纯性(impurity))、可变对象和受限的递归。因此,采用函数式方式编程时,既要利用好 JavaScript 所有相关的、恰当的语言特性,也要尽量减少由语言本身引入的问题。从这个意义上说,JavaScript 是不是函数式的语言,取决于所采用的编程风格。

要使用函数式编程,应该决定选用哪种语言;然而,一味选择纯函数式语言未必就是明智之举。如今写代码并不仅仅是使用一种编程语言那么简单,还得有框架、库和其他各种工具的支撑。如果能在利用好现有工具的同时,在代码中恰当地引入函数式的编程风格,这样双管齐下,JavaScript 是否是函数式编程语言,已经不重要了。

1.3.2. 用 JavaScript 实现函数式编程

JavaScript 一直在与时俱进。本系列将采用的版本为 JS13(非正式),或者 ECMAScript 2022(正式),通常缩写为 ES2022ES13;该版本于 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 8ES8ES2017)2017 年 6 月
  • ECMAScript 9ES9ES2018),2018 年 6 月
  • ECMAScript 10ES10ES2019),2019 年 6 月
  • ECMAScript 11ES11ES2020),2020 年 6 月
  • ECMAScript 12ES12ES2021),2021 年 6 月
  • ECMAScript 13ES13ES2022),2022 年 6 月

小知识

ECMA 最初表示欧洲计算机制造协会(European Computer Manufacturers Association),现如今已不仅仅被视为首字母缩写了。该组织还负责除 JavaScript 外的其他标准化工作,例如 JSONC#Dart 等等。详见 www.ecma-international.org/

您可以在 www.ecma-international.org/ecma-262/7.0/ 查看语言标准规范。没有明确规定的情况下,本书中的 JavaScript 皆为 ES10ES2019)版。至于书中涉及的语言特性,如果用的是 ES2015,基本上也不会有什么问题。

目前还没有浏览器完全实现 ES10 标准;大多数都提供旧版的 JavaScript 5(始于 2009 年),并夹带一些不断增长地、从 ES6ES10 的少量新特性。这会带来一个问题,所幸是可以解决的问题,后文会有论述;本书将使用 ES10

小知识

事实上 ES2016ES2015 之间的差别很小,比如方法 Array.prototype.includes,以及取幂运算符 **。而 ES2017 与 ES2016 的差别较大——像 asyncawait 语法、字符串填充函数等等——但均不影响我们的代码。在后续章节中,本书将对甚至是最近新增的功能,flatMap,给出相应的替代方案。

正式使用 JavaScript 前,先来考察一下与函数式编程目标相关的最重要的几个语言特性。

1.3.3. 关键语言特性

JavaScript 并非纯粹的函数式编程语言,但也具备一旦视为函数式编程语言所应该具备的全部语言特性。本书将用到的主要特性如下:

  • 函数作一等公民对象
  • 递归
  • 箭头函数
  • 闭包
  • 展开运算符

接下来,就每个特性给出示例,考察它们有所助益的原因。同时也要注意,JavaScript 的特性远比我们即将用到的要多。下面介绍的内容旨在突出函数式编程最重要的语言特性。

1.3.3.1 函数作一等对象

函数作一等对象(也叫一级公民),表示可以像处理其他对象那样处理函数。例如,将函数存入一个变量、将其传给另一个函数,打印出来等等。这也是函数式编程的关键所在:将函数作为参数传给其他函数,或是将返回一个函数作为某函数调用的结果。

如果运行的是异步 Ajax,那么您已经使用过这个特性了:回调函数(callback)是一个 Ajax 调用执行完毕时触发执行的函数。该函数以参数形式传入。利用 jQuery,可以写出像下方那样的代码:

$.get("some/url", someData, function(result, status) {
    // 检查 status 状态,
    // 并用 result 完成相关逻辑
});

$.get() 函数接收一个回调函数作参数,并在获取到结果(result)时调用该函数。

小贴士

回调函数的问题,在更现代的解决方案中,通过使用 promiseasync/await 语法可以得到更好的处理。鉴于仅供演示,走老路也无伤大雅。本书将在第 12 章“构建更好的容器——函数式数据类型”论述 monad 相关概念时,重新讨论 promise 的问题,详见 “Unexpected Monads - Promises” 相关章节。

鉴于函数可以存到变量中,上述代码也可以改写如下。注意 $.get(...) 调用中变量 doSomething 的使用:

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 的所有整数的乘积。

这可以快速转成代码描述:

function fact(n) {
    if (n === 0) {
        return 1;
    } else {
        return n * fact(n - 1);
    }
}

console.log(fact(5)); // 120

递归对算法设计大有帮助。递归可以等效替换所有 whilefor 循环——并非刻意为之,关键是提供了一种可能性!本书将在 第 9 章 设计函数 - 递归 中全面论述以递归方式设计算法和编写函数的相关知识。

1.3.3.3 闭包

闭包是隐藏数据的一种方式(结合私有变量),由此引入模块及其他不错的语言特性。闭包的关键在于,定义函数时,既可以引用自身的局部变量,也可以引用函数上下文以外的所有内容。利用闭包可以创建一个统计自身运行次数的计数函数:

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 完成相关逻辑
});

箭头函数版阶乘函数可以改写如下:

const fact2 = n => {
    if (n === 0) {
        return 1;
    } else {
        return n * fact2(n - 1);
    }
};
console.log(fact2(5)); // also 120

小贴士

箭头函数通常被称作匿名函数(anonymous function),因为它们没有名字。若要引用一个箭头函数,须将其赋给一个变量或对象属性,如上例所示;否则无法使用。本书将在 第三章 从函数入手 - 核心概念 的箭头函数小节作进一步探讨。

新版阶乘函数也可以写为单行代码——您能看出与之前的代码是等效吗?采用三目运算符来代替 if 语句是很常见的做法:

const fact3 = n => (n === 0 ? 1 : n * fact3(n - 1));

console.log(fact3(5)); // again 120

有了这样的简写形式,保留字 return 也可以省略不写了。

lambda 演算中,诸如 x => 2x 之类的函数可以表示为 λx.2x。尽管存在语法差异,但定义是类似的。多参函数的表示稍微复杂一点:(x, y) => x + y 表示为 λx.λy.x+y。本书将在第三章的 Lambda and functions 小节、以及第七章的 Currying 小节作进一步介绍。

还有一点要记住:箭头函数在只有一个参数时,该参数两旁的括号可以省略不写。笔者更倾向于保留括号,但书中代码已经过 JS 美化工具 Prettier 处理,从而删除了括号;是否保留括号完全取决于个人喜好(详见 https://github.com/prettier/prettier)。顺便说一下,笔者使用的格式化配置参数是 --print-width 75 --tab-width 2 --no-bracket-spacing

1.3.3.5 展开运算符

展开运算符 可以将一个表达式扩展到需要传入多个参数、元素或变量的地方。例如替换函数的参数:

const x =  [1, 2, 3];

function sum3(a, b, c) {
    return a + b + c;
}

const y = sum3(...x); // equivalent to sum3(1,2,3)
console.log(y); // 6

也可以创建或连结数组如下:

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]

展开运算符也可以作用于对象:

const p = { some: 3, data: 5 };

const q = { more: 8, ...p }; // { more:8, some:3, data:5 }

还可用于需要传入多个参数而非数组的函数中,常见的例子是 Math.min()Math.max()

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

利用 .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 对应逗号),详见 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/apply 及 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/call。

利用展开运算符可以写出更简短、简洁的代码。

至此,我们回顾了即将用到的所有重要语言特性。作为本章的结尾,再来看看即将用到的一些工具。