翻译连载 | 第 9 章:递归(下)-《JavaScript轻量级函数式编程》 |《你不知道的JS》姊妹篇

时间:2022-11-30 05:23:36

关于译者:这是一个流淌着沪江血液的纯粹工程:认真,是 HTML 最坚实的梁柱;分享,是 CSS 里最闪耀的一瞥;总结,是 JavaScript 中最严谨的逻辑。经过捶打磨练,成就了本书的中文版。本书包含了函数式编程之精髓,希望可以帮助大家在学习函数式编程的道路上走的更顺畅。比心。

译者团队(排名不分先后):阿希bluekenbrucechamcfanlifedailkyoko-dfl3velilinsLittlePineappleMatildaJin冬青pobusamaCherry萝卜vavd317vivaxy萌萌zhouyao

第 9 章:递归(下)

栈、堆

一起看下之前的两个递归函数 isOdd(..)isEven(..)

function isOdd(v) {
if (v === 0) return false;
return isEven( Math.abs( v ) - 1 );
} function isEven(v) {
if (v === 0) return true;
return isOdd( Math.abs( v ) - 1 );
}

如果你执行下面这行代码,在大多数浏览器里面都会报错:

isOdd( 33333 );			// RangeError: Maximum call stack size exceeded

这个错误是什么情况?引擎抛出这个错误,是因为它试图保护系统内存不会被你的程序耗尽。为了解释这个问题,我们需要先看看当函数调用时JS引擎中发生了什么。

每个函数调用都将开辟出一小块称为堆栈帧的内存。堆栈帧中包含了函数语句当前状态的某些重要信息,包括任意变量的值。之所以这样,是因为一个函数暂停去执行另外一个函数,而另外一个函数运行结束后,引擎需要返回到之前暂停时候的状态继续执行。

当第二个函数开始执行,堆栈帧增加到 2 个。如果第二个函数又调用了另外一个函数,堆栈帧将增加到 3 个,以此类推。“栈”的意思是,函数被它前一个函数调用时,这个函数帧会被“推”到最顶部。当这个函数调用结束后,它的帧会从堆栈中退出。

看下这段程序:

function foo() {
var z = "foo!";
} function bar() {
var y = "bar!";
foo();
} function baz() {
var x = "baz!";
bar();
} baz();

来一步步想象下这个程序的堆栈帧:

翻译连载 | 第 9 章:递归(下)-《JavaScript轻量级函数式编程》 |《你不知道的JS》姊妹篇

注意: 如果这些函数间没有相互调用,而只是依次执行 -- 比如前一个函数运行结束后才开始调用下一个函数 baz(); bar(); foo(); -- 则堆栈帧并没有产生;因为在下一个函数开始之前,上一个函数运行结束并把它的帧从堆栈里面移除了。

所以,每一个函数运行时候,都会占用一些内存。对多数程序来说,这没什么大不了的,不是吗?但是,一旦你引用了递归,问题就不一样了。

虽然你几乎肯定不会在一个调用栈中手动调用成千(或数百)次不同的函数,但你很容易看到产生数万个或更多递归调用的堆栈。

当引擎认为调用栈增加的太多并且应该停止增加时候,它会以主观的限制来阻止当前步骤,所以 isOdd(..)isEven(..) 函数抛出了 RangeError 未知错误。这不太可能是内存接近零时候产生的限制,而是引擎的预测,因为如果这种程序持续运行下去,内存会爆掉的。由于引擎无法判断一个程序最终是否会停止,所以它必须做出确定的猜测。

引擎的限制因情况而定。规范里面并没有任何说明,因此,它也不是 必需的。但如果没有限制的话,设备很容易遭到破坏或恶意代码攻击,故而几乎所有的JS引擎都有一个限制。不同的设备环境、不同的引擎,会有不同的限制,也就无法预测或保证函数调用栈能调用多少次。

在处理大数据量时候,这个限制对于开发人员来说,会对递归的性能有一定的要求。我认为,这种限制也可能是造成开发人员不喜欢使用递归编程的最大原因。

遗憾的是,递归编程是一种编程思想而不是主流的编程技术。

尾调用

递归编程和内存限制都要比 JS 技术出现的早。追溯到上世纪 60 年代,当时开发人员想使用递归编程并希望运行在他们强大的计算机的设备,而所谓强大计算机的内存,尚远不如我们今天在手表上的内存。

幸运的是,在那个希望的原野上,进行了一个有力的观测。该技术称为 尾调用

它的思路是如果一个回调从函数 baz() 转到函数 bar() 时候,而回调是在函数 baz() 的最底部执行 -- 也就是尾调用 -- 那么 baz() 的堆栈帧就不再需要了。也就意谓着,内存可以被回收,或只需简单的执行 bar() 函数。 如图所示:

翻译连载 | 第 9 章:递归(下)-《JavaScript轻量级函数式编程》 |《你不知道的JS》姊妹篇

尾调用并不是递归特有的;它适用于任何函数调用。但是,在大多数情况下,你的手动非递归调用栈不太可能超过 10 级,因此尾调用对你程序内存的影响可能相当低。

在递归的情况下,尾调用作用很明显,因为这意味着递归堆栈可以“永远”运行下去,唯一的性能问题就是计算,而不再是固定的内存限制。在固定的内存中尾递归可以运行 O(1) (常数阶时间复杂度计算)。

这些技术通常被称为尾调用优化(TCO),但重点在于从优化技术中,区分出在固定内存空间中检测尾调用运行的能力。从技术上讲,尾调用并不像大多数人所想的那样,它们的运行速度可能比普通回调还慢。TCO 是关于把尾调用更加高效运行的一些优化技术。

正确的尾调用 (PTC)

在 ES6 出来之前,JavaScript 对尾调用一直没明确规定(也没有禁用)。ES6 明确规定了 PTC 的特定形式,在 ES6 中,只要使用尾调用,就不会发生栈溢出。实际上这也就意味着,只要正确的使用 PTC,就不会抛出 RangeError 这样的异常错误。

首先,在 JavaScript 中应用 PTC,必须以严格模式书写代码。如果你以前没有用过严格模式,你得试着用用了。那么,您,应该已经在使用严格模式了吧!?

其次,正确 的尾调用就像这个样子:

return foo( .. );

换句话说,函数调用应该放在最后一步去执行,并且不管返回什么东东,都得有返回( return )。这样的话,JS 就不再需要当前的堆栈帧了。

下面这些 不能 称之为 PTC:

foo();
return; // 或 var x = foo( .. );
return x; // 或 return 1 + foo( .. );

注意: 一些 JS 引擎 能够var x = foo(); return x; 自动识别为 return foo();,这样也可以达到 PTC 的效果。但这毕竟不符合规范。

foo(..) 运行结束之后 1+ 这部分才开始执行,所以此时的堆栈帧依然存在。

不过,下面这个 PTC:

return x ? foo( .. ) : bar( .. );

x 进行条件判断之后,或执行 foo(..),或执行 bar(..),不论执行哪个,返回结果都会被 return 返回掉。这个例子符合 PTC 规范。

为了避免堆栈增加,PTC 要求所有的递归必须是在尾部调用,因此,二分法递归 —— 两次(或以上)递归调用 —— 是不能实现 PTC 的。我们曾在文章的前面部分展示过把二分法递归转变为相互递归的例子。也许我们可以试着化整为零,把多重递归拆分成符合 PTC 规范的单个函数回调。

重构递归

如果你想用递归来处理问题,却又超出了 JS 引擎的内存堆栈,这时候就需要重构下你的递归调用,使它能够符合 PTC 规范(或着避免嵌套调用)。这里有一些重构方法也许可以用到,但需要根据实际情况权衡。

可读性强的代码,是我们的终级目标 —— 谨记,谨记。如果使用递归后会造成代码难以阅读/理解,那就 不要使用递归;换个容易理解的方法吧。

更换堆栈

对递归来说,最主要的问题是它的内存使用情况。保持堆栈帧跟踪函数调用的状态,并将其分派给下一个递归调用迭。如果我们弄清楚了如何重新排列我们的递归,就可以用 PTC 实现递归,并利用 JS 引擎对尾调用的优化处理,那么我们就不用在内存中保留当前的堆栈帧了。

来回顾下之前用到的一个求和的例子:

function sum(num1,...nums) {
if (nums.length == 0) return num1;
return num1 + sum( ...nums );
}

这个例子并不符合 PTC 规范。sum(...nums) 运行结束之后,num1sum(...nums) 的运行结果进行了累加。这样的话,当其余参数 ...nums 再次进行递归调用时候,为了得到其与 num1 累加的结果,必须要保留上一次递归调用的堆栈帧。

重构策略的关键点在于,我们可以通过把 置后 处理累加改为 提前 处理,来消除对堆栈的依赖,然后将该部分结果作为参数传递到递归调用。换句话说,我们不用在当前运用函数的堆栈帧中保留 num1 + sum(...num1) 的总和,而是把它传递到下一个递归的堆栈帧中,这样就能释放当前递归的堆栈帧。

开始之前,我们做些改动:把部分结果作为一个新的第一个参数传入到函数 sum(..)

function sum(result,num1,...nums) {
// ..
}

这次我们先把 resultnum1 提前计算,然后把 result 作为参数一起传入:

"use strict";

function sum(result,num1,...nums) {
result = result + num1;
if (nums.length == 0) return result;
return sum( result, ...nums );
}

现在 sum(..) 已经符合 PTC 优化规范了!耶!

但是还有一个缺点,我们修改了函数的参数传递形式后,用法就跟以前不一样了。调用者不得不在需要求和的那些参数的前面,再传递一个 0 作为第一个参数。

sum( /*initialResult=*/0, 3, 1, 17, 94, 8 );		// 123

这就尴尬了。

通常,大家的处理方式是,把这个尴尬的递归函数重新命名,然后定义一个接口函数把问题隐藏起来:

"use strict";

function sumRec(result,num1,...nums) {
result = result + num1;
if (nums.length == 0) return result;
return sumRec( result, ...nums );
} function sum(...nums) {
return sumRec( /*initialResult=*/0, ...nums );
} sum( 3, 1, 17, 94, 8 ); // 123

情况好了些。但依然有问题:之前只需要一个函数就能解决的事,现在我们用了两个。有时候你会发现,在处理这类问题上,有些开发者用内部函数把递归 “藏了起来”:

"use strict";

function sum(...nums) {
return sumRec( /*initialResult=*/0, ...nums ); function sumRec(result,num1,...nums) {
result = result + num1;
if (nums.length == 0) return result;
return sumRec( result, ...nums );
}
} sum( 3, 1, 17, 94, 8 ); // 123

这个方法的缺点是,每次调用外部函数 sum(..),我们都得重新创建内部函数 sumRec(..)。我们可以把他们平级放置在立即执行的函数中,只暴露出我们想要的那个的函数:

"use strict";

var sum = (function IIFE(){

	return function sum(...nums) {
return sumRec( /*initialResult=*/0, ...nums );
} function sumRec(result,num1,...nums) {
result = result + num1;
if (nums.length == 0) return result;
return sumRec( result, ...nums );
} })(); sum( 3, 1, 17, 94, 8 ); // 123

好啦,现在即符合了 PTC 规范,又保证了 sum(..) 参数的整洁性,调用者不需要了解函数的内部实现细节。完美!

可是...天呐,本来是简单的递归函数,现在却出现了很多噪点。可读性已经明显降低。至少说,这是不成功的。有些时候,这只是我们能做的最好的。

幸运的事,在一些其它的例子中,比如上一个例子,有一个比较好的方式。一起重新看下:

"use strict";

function sum(result,num1,...nums) {
result = result + num1;
if (nums.length == 0) return result;
return sum( result, ...nums );
} sum( /*initialResult=*/0, 3, 1, 17, 94, 8 ); // 123

也许你会注意到,result 就像 num1 一样,也就是说,我们可以把列表中的第一个数字作为我们的运行总和;这甚至包括了第一次调用的情况。我们需要的是重新命名这些参数,使函数清晰可读:

"use strict";

function sum(num1,num2,...nums) {
num1 = num1 + num2;
if (nums.length == 0) return num1;
return sum( num1, ...nums );
} sum( 3, 1, 17, 94, 8 ); // 123

帅呆了。比之前好了很多,嗯?!我认为这种模式在声明/合理和执行之间达到了很好的平衡。

让我们试着重构下前面的 maxEven(..)(目前还不符合 PTC 规范)。就像之前我们把参数的和作为第一个参数一样,我们可以依次减少列表中的数字,同时一直把遇到的最大偶数作为第一个参数。

为了清楚起见,我们可能使用算法策略(类似于我们之前讨论过的):

  1. 首先对前两个参数 num1num2 进行对比。
  2. 如果 num1 是偶数,并且 num1 大于 num2num1 保持不变。
  3. 如果 num2 是偶数,把 num2 赋值给 num1
  4. 否则的话,num1 等于 undefined
  5. 如果除了这两个参数之外,还存在其它参数 nums,把它们与 num1 进行递归对比。
  6. 最后,不管是什么值,只需返回 num1

依照上面的步骤,代码如下:

"use strict";

function maxEven(num1,num2,...nums) {
num1 =
(num1 % 2 == 0 && !(maxEven( num2 ) > num1)) ?
num1 :
(num2 % 2 == 0 ? num2 : undefined); return nums.length == 0 ?
num1 :
maxEven( num1, ...nums )
}

注意: 函数第一次调用 maxEven(..) 并不是为了 PTC 优化,当它只传递 num2 时,只递归一级就返回了;它只是一个避免重复 逻辑的技巧。因此,只要该调用是完全不同的函数,就不会增加递归堆栈。第二次调用 maxEven(..) 是基于 PTC 优化角度的真正递归调用,因此不会随着递归的进行而造成堆栈的增加。

重申下,此示例仅用于说明将递归转化为符合 PTC 规范以优化堆栈(内存)使用的方法。求最大偶数值的更直接方法可能是,先对参数列表中的 nums 过滤,然后冒泡或排序处理。

基于 PTC 重构递归,固然对简单的声明形式有一些影响,但依然有理由去做这样的事。不幸的是,存在一些递归,即使我们使用了接口函数来扩展,也不会很好,因此,我们需要有不同的思路。

后继传递格式 (CPS)

在 JavaScript 中, continuation 一词通常用于表示在某个函数完成后指定需要执行的下一个步骤的回调函数。组织代码,使得每个函数在其结束时接收另一个执行函数,被称为后继传递格式(CPS)。

有些形式的递归,实际上是无法按照纯粹的 PTC 规范重构的,特别是相互递归。我们之前提到过的 fib(..) 函数,以及我们派生出来的相互递归形式。这两个情况,皆是存在多个递归调用,这些递归调用阻碍了 PTC 内存优化。

但是,你可以执行第一个递归调用,并将后续递归调用包含在后续函数中并传递到第一个调用。尽管这意味着最终需要在堆栈中执行更多的函数,但由于后继函数所包含的都是 PTC 形式的,所以堆栈内存的使用情况不会无限增长。

fib(..) 做如下修改:

"use strict";

function fib(n,cont = identity) {
if (n <= 1) return cont( n );
return fib(
n - 2,
n2 => fib(
n - 1,
n1 => cont( n2 + n1 )
)
);
}

仔细看下都做了哪些事情。首先,我们默认用了第三章中的 cont(..) 后继函数表示 identity(..);记住,它只简单的返回传递给它的任何东西。

更重要的是,这里面增加了不仅仅是一个而是两个后续函数。第一个后续函数接收 fib(n-2) 的运行结果作为参数 n2。第二个内部后续函数接收 fib(n-1)的运行结果作为参数 n1。当得到 n1n2 的值后,两者再相加 (n2 + n1),相加的运行结果会传入到下一个后续函数 cont(..)

也许这将有助于我们梳理下流程:就像我们之前讨论的,在递归堆栈之后,当我们传递部分结果而不是返回它们时,每一步都被包含在一个后续函数中,这拖慢了计算速度。这个技巧允许我们执行多个符合 PTC 规范的步骤。

在静态语言中,CPS通常为尾调用提供了编译器可以自动识别并重新排列递归代码以利用的机会。很可惜,不能用在原生 JS 上。

在 JavaScript 中,你得自己书写出符合 CPS 格式的代码。这并不是明智的做法;以命令符号声明的形式肯定会让内容有些不清楚。 但总的来说,这种形式仍然要比 for 循环更具有声明性。

警告: 我们需要注意的一个比较重要的事项是,在 CPS 中,创建额外的内部后续函数仍然消耗内存,但有些不同。并不是之前的堆栈帧累积,闭包只是消耗多余的内存空间(一般情况下,是堆栈里面的多余内存空间)。在这些情况下,引擎似乎没有启动 RangeError 限制,但这并不意味着你的内存使用量是按比例固定好的。

弹簧床

除了 CPS 后续传递格式之外,另外一种内存优化的技术称为弹簧床。在弹簧床格式的代码中,同样的创建了类似 CPS 的后续函数,不同的是,它们没有被传递,而是被简单的返回了。

不再是函数调用另外的函数,堆栈的深度也不会大于一层,因为每个函数只会返回下一个将调用的函数。循环只是继续运行每个返回的函数,直到再也没有函数可运行。

弹簧床的优点之一是在非 PTC 环境下你一样可以应用此技术。另一个优点是每个函数都是正常调用,而不是 PTC 优化,所以它可以运行得更快。

一起来试下 trampoline(..)

function trampoline(fn) {
return function trampolined(...args) {
var result = fn( ...args ); while (typeof result == "function") {
result = result();
} return result;
};
}

当返回一个函数时,循环继续,执行该函数并返回其运行结果,然后检查返回结果的类型。一旦返回的结果类型不是函数,弹簧床就认为函数调用完成了并返回结果值。

所以我们可能需要使用前面讲到的,将部分结果作为参数传递的技巧。以下是我们在之前的数组求和中使用此技巧的示例:

var sum = trampoline(
function sum(num1,num2,...nums) {
num1 = num1 + num2;
if (nums.length == 0) return num1;
return () => sum( num1, ...nums );
}
); var xs = [];
for (let i=0; i<20000; i++) {
xs.push( i );
} sum( ...xs ); // 199990000

缺点是你需要将递归函数包裹在执行弹簧床功能的函数中; 此外,就像 CPS 一样,需要为每个后续函数创建闭包。然而,与 CPS 不一样的地方是,每个返回的后续数数,运行并立即完成,所以,当调用堆栈的深度用尽时,引擎中不会累积越来越多的闭包。

除了执行和记忆性能之外,弹簧床技术优于CPS的优点是它们在声明递归形式上的侵入性更小,由于你不必为了接收后续函数的参数而更改函数参数,所以除了执行和内存性能之外,弹簧床技术优于 CPS 的地方还有,它们在声明递归形式上侵入性更小。虽然弹簧床技术并不是理想的,但它们可以有效地在命令循环代码和声明性递归之间达到平衡。

总结

递归,是指函数递归调用自身。呃,这就是递归的定义。明白了吧!?

直递归是指对自身至少调用一次,直到满足基本条件才能停止调用。多重递归(像二分递归)是指对自身进行多次调用。相互递归是当两个或以上函数循环递归 相互 调用。而递归的优点是它更具声明性,因此通常更易于阅读。

递归的优点是它更具声明性,因此通常更易于阅读。缺点通常是性能方面,但是相比执行速度,更多的限制在于内存方面。

尾调用是通过减少或释放堆栈帧来节约内存空间。要在 JavaScript 中实现尾调用 “优化”,需要基于严格模式和适当的尾调用( PTC )。我们也可以混合几种技术来将非 PTC 递归函数重构为 PTC 格式,或者至少能通过平铺堆栈来节约内存空间。

谨记:递归应该使代码更容易读懂。如果你误用或滥用递归,代码的可读性将会比命令形式更糟。千万不要这样做。

** 【上一章】翻译连载 | 第 9 章:递归(上)-《JavaScript轻量级函数式编程》 |《你不知道的JS》姊妹篇 **

翻译连载 | 第 9 章:递归(下)-《JavaScript轻量级函数式编程》 |《你不知道的JS》姊妹篇

翻译连载 | 第 9 章:递归(下)-《JavaScript轻量级函数式编程》 |《你不知道的JS》姊妹篇