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

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

差不多是时候弄明白另一个重要问题了:JavaScript 是函数式语言吗?通常,JavaScript 都不会出现在函数式编程语言的清单里。列入清单的都是些不怎么常见的语言,例如 ClojureErlangHaskellScala;但函数式编程语言并没有一个准确的定义,也没有该语言所特有的功能特性的精确描述。主流观点认为,如果一种语言支持与函数式编程相关的通用编程风格,就可以认为是函数式编程语言。让我们先来了解一下为什么要使用 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(正式名称),通常缩写为 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

ECMA 最初表示欧洲计算机制造协会(European Computer Manufacturers Association),现如今已不仅仅被视为首字母缩写了。该组织还负责除 JavaScript 外的其他标准化工作,例如 JSONC#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 年),并夹带一些不断增长地、从 ES6ES13 的少量新特性。这会带来一个问题,所幸是可以解决的问题,后文会有论述;本书将使用 ES13

关于恼人的版本差异……

其实 ES2016ES2015 之间的差别很小,比如方法 Array.prototype.includes,以及指数运算符 **。而 ES2017ES2016 的差别较大——像 asyncawait 语法、一些字符串填充函数(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)时调用该函数。

【小贴士】回调的另一种写法

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

递归对算法设计大有帮助。递归可以等效替换所有 whilefor 循环——并非刻意为之,关键是提供了一种可能性!本书将在 第 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 语言特性。作为本章的结尾,再来看看即将用到的一些辅助工具。


  1. 原文为:You must learn to live with the good and the bad, and simply avoid the latter part. ↩︎

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

安冬的码畜日常

您的鼓励是我持续优质内容的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值