特别说明,为便于查阅,文章转自https://github.com/getify/You-Dont-Know-JS
代理黑入 [[Prototype]]
链
[[Get]]
操作是[[Prototype]]
机制被调用的主要渠道。当一个属性不能在直接对象上找到时,[[Get]]
会自动将操作交给[[Prototype]]
对象。
这意味着你可以使用一个代理的get(..)
机关来模拟或扩展这个[[Prototype]]
机制的概念。
我们将考虑的第一种黑科技是创建两个通过[[Prototype]]
循环链接的对象(或者说,至少看起来是这样!)。你不能实际创建一个真正循环的[[Prototype]]
链,因为引擎将会抛出一个错误。但是代理可以假冒它!
考虑如下代码:
var handlers = {
get(target,key,context) {
if (Reflect.has( target, key )) {
return Reflect.get(
target, key, context
);
}
// 假冒循环的 `[[Prototype]]`
else {
return Reflect.get(
target[
Symbol.for( "[[Prototype]]" )
],
key,
context
);
}
}
},
obj1 = new Proxy(
{
name: "obj-1",
foo() {
console.log( "foo:", this.name );
}
},
handlers
),
obj2 = Object.assign(
Object.create( obj1 ),
{
name: "obj-2",
bar() {
console.log( "bar:", this.name );
this.foo();
}
}
);
// 假冒循环的 `[[Prototype]]` 链
obj1[ Symbol.for( "[[Prototype]]" ) ] = obj2;
obj1.bar();
// bar: obj-1 <-- 通过代理假冒 [[Prototype]]
// foo: obj-1 <-- `this` 上下文环境依然被保留
obj2.foo();
// foo: obj-2 <-- 通过 [[Prototype]]
注意: 为了让事情简单一些,在这个例子中我们没有代理/转送[[Set]]
。要完整地模拟[[Prototype]]
兼容,你会想要实现一个set(..)
处理器,它在[[Prototype]]
链上检索一个匹配得属性并遵循它的描述符的行为(例如,set,可写性)。参见本系列的 this与对象原型。
在前面的代码段中,obj2
凭借Object.create(..)
语句[[Prototype]]
链接到obj1
。但是要创建反向(循环)的链接,我们在obj1
的symbol位置Symbol.for("[[Prototype]]")
(参见第二章的“Symbol”)上创建了一个属性。这个symbol可能看起来有些特别/魔幻,但它不是的。它只是允许我使用一个被方便地命名的属性,这个属性在语义上看来是与我进行的任务有关联的。
然后,代理的get(..)
处理器首先检查一个被请求的key
是否存在于代理上。如果每个有,操作就被手动地交给存储在target
的Symbol.for("[[Prototype]]")
位置中的对象引用。
这种模式的一个重要优点是,在obj1
和obj2
之间建立循环关系几乎没有入侵它们的定义。虽然前面的代码段为了简短而将所有的步骤交织在一起,但是如果你仔细观察,代理处理器的逻辑完全是范用的(不具体地知道obj1
或obj2
)。所以,这段逻辑可以抽出到一个简单的将它们连在一起的帮助函数中,例如setCircularPrototypeOf(..)
。我们将此作为一个练习留给读者。
现在我们看到了如何使用get(..)
来模拟一个[[Prototype]]
链接,但让我们将这种黑科技推动的远一些。与其制造一个循环[[Prototype]]
,搞一个多重[[Prototype]]
链接(也就是“多重继承”)怎么样?这看起来相当直白:
var obj1 = {
name: "obj-1",
foo() {
console.log( "obj1.foo:", this.name );
},
},
obj2 = {
name: "obj-2",
foo() {
console.log( "obj2.foo:", this.name );
},
bar() {
console.log( "obj2.bar:", this.name );
}
},
handlers = {
get(target,key,context) {
if (Reflect.has( target, key )) {
return Reflect.get(
target, key, context
);
}
// 假冒多重 `[[Prototype]]`
else {
for (var P of target[
Symbol.for( "[[Prototype]]" )
]) {
if (Reflect.has( P, key )) {
return Reflect.get(
P, key, context
);
}
}
}
}
},
obj3 = new Proxy(
{
name: "obj-3",
baz() {
this.foo();
this.bar();
}
},
handlers
);
// 假冒多重 `[[Prototype]]` 链接
obj3[ Symbol.for( "[[Prototype]]" ) ] = [
obj1, obj2
];
obj3.baz();
// obj1.foo: obj-3
// obj2.bar: obj-3
注意: 正如在前面的循环[[Prototype]]
例子后的注意中提到的,我们没有实现set(..)
处理器,但对于一个将[[Set]]
模拟为普通[[Prototype]]
行为的解决方案来说,它将是必要的。
obj3
被设置为多重委托到obj1
和obj2
。在obj2.baz()
中,this.foo()
调用最终成为从obj1
中抽出foo()
(先到先得,虽然还有一个在obj2
上的foo()
)。如果我们将连接重新排列为obj2, obj1
,那么obj2.foo()
将被找到并使用。
同理,this.bar()
调用没有在obj1
上找到bar()
,所以它退而检查obj2
,这里找到了一个匹配。
obj1
和obj2
代表obj3
的两个平行的[[Prototype]]
链。obj1
和/或obj2
自身可以拥有委托至其他对象的普通[[Prototype]]
,或者自身也可以是多重委托的代理(就像obj3
一样)。
正如先前的循环[[Prototype]]
的例子一样,obj1
,obj2
和obj3
的定义几乎完全与处理多重委托的范用代理逻辑相分离。定义一个setPrototypesOf(..)
(注意那个“s”!)这样的工具将是小菜一碟,它接收一个主对象和一组模拟多重[[Prototype]]
链接用的对象。同样,我们将此作为练习留给读者。
希望在这种种例子之后代理的力量现在变得明朗了。代理使得许多强大的元编程任务成为可能。
Reflect
API
Reflect
对象是一个普通对象(就像Math
),不是其他内建原生类型那样的函数/构造器。
它持有对应于你可以控制的各种元编程任务的静态函数。这些函数与代理可以定义的处理器方法(机关)一一对应。
这些函数中的一些看起来与在Object
上的同名函数很相似:
Reflect.getOwnPropertyDescriptor(..)
Reflect.defineProperty(..)
Reflect.getPrototypeOf(..)
Reflect.setPrototypeOf(..)
Reflect.preventExtensions(..)
Reflect.isExtensible(..)
这些工具一般与它们的Object.*
对等物的行为相同。但一个区别是,Object.*
对等物在它们的第一个参数值(目标对象)还不是对象的情况下,试图将它强制转换为一个对象。Reflect.*
方法在同样的情况下仅简单地抛出一个错误。
一个对象的键可以使用这些工具访问/检测:
-
Reflect.ownKeys(..)
:返回一个所有直属(不是“继承的”)键的列表,正如被Object.getOwnPropertyNames(..)
和Object.getOwnPropertySymbols(..)
返回的那样。关于键的顺序问题,参见“属性枚举顺序”一节。 -
Reflect.enumerate(..)
:返回一个产生所有(直属和“继承的”)非symbol、可枚举的键的迭代器(参见本系列的 this与对象原型)。 实质上,这组键与在for..in
循环中被处理的那一组键是相同的。关于键的顺序问题,参见“属性枚举顺序”一节。 -
Reflect.has(..)
:实质上与用于检查一个属性是否存在于一个对象或它的[[Prototype]]
链上的in
操作符相同。例如,Reflect.has(o,"foo")
实质上实施"foo" in o
。
函数调用和构造器调用可以使用这些工具手动地实施,与普通的语法(例如,(..)
和new
)分开:
-
Reflect.apply(..)
:例如,Reflect.apply(foo,thisObj,[42,"bar"])
使用thisObj
作为foo(..)
函数的this
来调用它,并传入参数值42
和"bar"
。 -
Reflect.construct(..)
:例如,Reflect.construct(foo,[42,"bar"])
实质上调用new foo(42,"bar")
。
对象属性访问,设置,和删除可以使用这些工具手动实施:
-
Reflect.get(..)
:例如,Reflect.get(o,"foo")
会取得o.foo
。 -
Reflect.set(..)
:例如,Reflect.set(o,"foo",42)
实质上实施o.foo = 42
。 -
Reflect.deleteProperty(..)
:例如,Reflect.deleteProperty(o,"foo")
实质上实施delete o.foo
。
Reflect
的元编程能力给了你可以模拟各种语法特性的程序化等价物,暴露以前隐藏着的抽象操作。例如,你可以使用这些能力来扩展 领域特定语言(DSL)的特性和API。
属性顺序
在ES6之前,罗列一个对象的键/属性的顺序没有在语言规范中定义,而是依赖于具体实现的。一般来说,大多数引擎会以创建的顺序来罗列它们,虽然开发者们已经被强烈建议永远不要依仗这种顺序。
在ES6中,罗列直属属性的属性是由[[OwnPropertyKeys]]
算法定义的(ES6语言规范,9.1.12部分),它产生所有直属属性(字符串或symbol),不论其可枚举性。这种顺序仅对Reflect.ownKeys(..)
有保证()。
这个顺序是:
- 首先,以数字上升的顺序,枚举所有数字索引的直属属性。
- 然后,以创建顺序枚举剩下的直属字符串属性名。
- 最后,以创建顺序枚举直属symbol属性。
考虑如下代码:
var o = {};
o[Symbol("c")] = "yay";
o[2] = true;
o[1] = true;
o.b = "awesome";
o.a = "cool";
Reflect.ownKeys( o ); // [1,2,"b","a",Symbol(c)]
Object.getOwnPropertyNames( o ); // [1,2,"b","a"]
Object.getOwnPropertySymbols( o ); // [Symbol(c)]
另一方面,[[Enumeration]]
算法(ES6语言规范,9.1.11部分)从目标对象和它的[[Prototype]]
链中仅产生可枚举属性。它被用于Reflect.enumerate(..)
和for..in
。可观察到的顺序是依赖于具体实现的,语言规范没有控制它。
相比之下,Object.keys(..)
调用[[OwnPropertyKeys]]
算法来得到一个所有直属属性的列表。但是,它过滤掉了不可枚举属性,然后特别为了JSON.stringify(..)
和for..in
而将这个列表重排,以匹配遗留的、依赖于具体实现的行为。所以通过扩展,这个顺序 也 与Reflect.enumerate(..)
的顺序像吻合。
换言之,所有四种机制(Reflect.enumerate(..)
,Object.keys(..)
,for..in
,和JSON.stringify(..)
)都同样将与依赖于具体实现的顺序像吻合,虽然技术上它们是以不同的方式达到的同样的效果。
具体实现可以将这四种机制与[[OwnPropertyKeys]]
的顺序相吻合,但不是必须的。无论如何,你将很可能从它们的行为中观察到以下的排序:
var o = { a: 1, b: 2 };
var p = Object.create( o );
p.c = 3;
p.d = 4;
for (var prop of Reflect.enumerate( p )) {
console.log( prop );
}
// c d a b
for (var prop in p) {
console.log( prop );
}
// c d a b
JSON.stringify( p );
// {"c":3,"d":4}
Object.keys( p );
// ["c","d"]
这一切可以归纳为:在ES6中,根据语言规范Reflect.ownKeys(..)
,Object.getOwnPropertyNames(..)
,和Object.getOwnPropertySymbols(..)
保证都有可预见和可靠的顺序。所以依赖于这种顺序来建造代码是安全的。
Reflect.enumerate(..)
,Object.keys(..)
,和for..in
(扩展一下的话还有JSON.stringify(..)
)继续互相共享一个可观察的顺序,就像它们往常一样。但这个顺序不一定与Reflect.ownKeys(..)
的相同。在使用它们依赖于具体实现的顺序时依然应当小心。
特性测试
什么是特性测试?它是一种由你运行来判定一个特性是否可用的测试。有些时候,这种测试不仅是为了判定存在性,还是为判定对特定行为的适应性 —— 特性可能存在但有bug。
这是一种元编程技术 —— 测试你程序将要运行的环境然后判定你的程序应当如何动作。
在JS中特性测试最常见的用法是检测一个API的存在性,而且如果它不存在,就定义一个填补(见第一章)。例如:
if (!Number.isNaN) {
Number.isNaN = function(x) {
return x !== x;
};
}
在这个代码段中的if
语句就是一个元编程:我们探测我们的程序和它的运行时环境,来判定我们是否和如何进行后续处理。
但是如何测试一个涉及新语法的特性呢?
你可能会尝试这样的东西:
try {
a = () => {};
ARROW_FUNCS_ENABLED = true;
}
catch (err) {
ARROW_FUNCS_ENABLED = false;
}
不幸的是,这不能工作,因为我们的JS程序是要被编译的。因此,如果引擎还没有支持ES6箭头函数的话,它就会在() => {}
语法的地方熄火。你程序中的语法错误会阻止它的运行,进而阻止你程序根据特性是否被支持而进行后续的不同相应。
为了围绕语法相关的特性进行特性测试的元编程,我们需要一个方法将测试与我们程序将要通过的初始编译步骤隔离开。举例来说,如果我们能够将进行测试的代码存储在一个字符串中,之后JS引擎默认地将不会尝试编译这个字符串中的内容,直到我们要求它这么做。
你的思路是不是跳到了使用eval(..)
?
别这么着急。看看本系列的 作用域与闭包 来了解一下为什么eval(..)
是一个坏主意。但是有另外一个缺陷较少的选项:Function(..)
构造器。
考虑如下代码:
try {
new Function( "( () => {} )" );
ARROW_FUNCS_ENABLED = true;
}
catch (err) {
ARROW_FUNCS_ENABLED = false;
}
好了,现在我们判定一个像箭头函数这样的特性是否 能 被当前的引擎所编译来进行元编程。你可能会想知道,我们要用这种信息做什么?
检查API的存在性,并定义后备的API填补,对于特性检测成功或失败来说都是一条明确的道路。但是对于从ARROW_FUNCS_ENABLED
是true
还是false
中得到的信息来说,我们能对它做什么呢?
因为如果引擎不支持一种特性,它的语法就不能出现在一个文件中,所以你不能在这个文件中定义使用这种语法的函数。
你所能做的是,使用测试来判定你应当加载哪一组JS文件。例如,如果在你的JS应用程序中的启动装置中有一组这样的特性测试,那么它就可以测试环境来判定你的ES6代码是否可以直接加载运行,或者你是否需要加载一个代码的转译版本(参见第一章)。
这种技术称为 分割投递。
事实表明,你使用ES6编写的JS程序有时可以在ES6+浏览器中完全“原生地”运行,但是另一些时候需要在前ES6浏览器中运行转译版本。如果你总是加载并使用转译代码,即便是在新的ES6兼容环境中,至少是有些情况下你运行的也是次优的代码。这并不理想。
分割投递更加复杂和精巧,但对于你编写的代码和你的程序所必须在其中运行的浏览器支持的特性之间,它代表一种更加成熟和健壮的桥接方式。
FeatureTests.io
为所有的ES6+语法以及语义行为定义特性测试,是一项你可能不想自己解决的艰巨任务。因为这些测试要求动态编译(new Function(..)
),这会产生不幸的性能损耗。
另外,在每次你的应用运行时都执行这些测试可能是一种浪费,因为平均来说一个用户的浏览器在几周之内至多只会更新一次,而即使是这样,新特性也不一定会在每次更新中都出现。
最终,管理一个对你特定代码库进行的特性测试列表 —— 你的程序将很少用到ES6的全部 —— 是很容易失控而且易错的。
“https://featuretests.io”的“特性测试服务”为这种挫折提供了解决方案。
你可以将这个服务的库加载到你的页面中,而它会加载最新的测试定义并运行所有的特性测试。在可能的情况下,它将使用Web Worker的后台处理中这样做,以降低性能上的开销。它还会使用LocalStorage持久化来缓存测试的结果 —— 以一种可以被所有你访问的使用这个服务的站点所共享的方式,这将及大地降低测试需要在每个浏览器实例上运行的频度。
你可以在每一个用户的浏览器上进行运行时特性测试,而且你可以使用这些测试结果动态地向用户传递最适合他们环境的代码(不多也不少)。
另外,这个服务还提供工具和API来扫描你的文件以判定你需要什么特性,这样你就能够完全自动化你的分割投递构建过程。
对ES6的所有以及未来的部分进行特性测试,以确保对于任何给定的环境都只有最佳的代码会被加载和运行 —— FeatureTests.io使这成为可能。
尾部调用优化(TCO)
通常来说,当从一个函数内部发起对另一个函数的调用时,就会分配一个 栈帧 来分离地管理这另一个函数调用的变量/状态。这种分配不仅花费一些处理时间,还会消耗一些额外的内存。
一个调用栈链从一个函数到另一个再到另一个,通常至多拥有10-15跳。在这些场景下,内存使用不太可能是某种实际问题。
然而,当你考虑递归编程(一个函数频繁地调用自己) —— 或者使用两个或更多的函数相互调用而构成相互递归 —— 调用栈就可能轻易地到达上百,上千,或更多层的深度。如果内存的使用无限制地增长下去,你可能看到了它将导致的问题。
JavaScript引擎不得不设置一个随意的限度来防止这样的编程技术耗尽浏览器或设备的内存。这就是为什么我们会在到达这个限度时得到令人沮丧的“RangeError: Maximum call stack size exceeded”。
警告: 调用栈深度的限制是不由语言规范控制的。它是依赖于具体实现的,而且将会根据浏览器和设备不同而不同。你绝不应该带着可精确观察到的限度的强烈臆想进行编码,因为它们还很可能在每个版本中变化。
一种称为 尾部调用 的特定函数调用模式,可以以一种避免额外的栈帧分配的方法进行优化。如果额外的分配可以被避免,那么就没有理由随意地限制调用栈的深度,这样引擎就可以让它们没有边界地运行下去。
一个尾部调用是一个带有函数调用的return
语句,除了返回它的值,函数调用之后没有任何事情需要发生。
这种优化只能在strict
模式下进行。又一个你总是应该用strict
编写所有代码的理由!
这个函数调用 不是 在尾部:
"use strict";
function foo(x) {
return x * 2;
}
function bar(x) {
// 不是一个尾部调用
return 1 + foo( x );
}
bar( 10 ); // 21
在foo(x)
调用完成后必须进行1 + ..
,所以那个bar(..)
调用的状态需要被保留。
但是下面的代码段中展示的foo(..)
和bar(..)
都是位于尾部,因为它们都是在自身代码路径上(除了return
以外)发生的最后一件事:
"use strict";
function foo(x) {
return x * 2;
}
function bar(x) {
x = x + 1;
if (x > 10) {
return foo( x );
}
else {
return bar( x + 1 );
}
}
bar( 5 ); // 24
bar( 15 ); // 32
在这个程序中,bar(..)
明显是递归,但foo(..)
只是一个普通的函数调用。这两个函数调用都位于 恰当的尾部位置。x + 1
在bar(..)
调用之前被求值,而且不论这个调用何时完成,所有将要放生的只有return
。
这些形式的恰当尾部调用(Proper Tail Calls —— PTC)是可以被优化的 —— 称为尾部调用优化(TCO)—— 于是额外的栈帧分配是不必要的。与为下一个函数调用创建新的栈帧不同,引擎会重用既存的栈帧。这能够工作是因为一个函数不需要保留任何当前状态 —— 在PTC之后的状态下不会发生任何事情。
TCO意味着调用栈可以有多深实际上是没有限度的。这种技巧稍稍改进了一般程序中的普通函数调用,但更重要的是它打开了一扇大门:可以使用递归表达程序,即使它的调用栈深度有成千上万层。
我们不再局限于单纯地在理论上考虑用递归解决问题了,而是可以在真实的JavaScript程序中使用它!
作为ES6,所有的PTC都应该是可以以这种方式优化的,不论递归与否。
重写尾部调用
然而,障碍是只有PTC是可以被优化的;非PTC理所当然地依然可以工作,但是将造成往常那样的栈帧分配。如果你希望优化机制启动,就必须小心地使用PTC构造你的函数。
如果你有一个没有用PTC编写的函数,你可能会发现你需要手动地重新安排你的代码,使它成为合法的TCO。
考虑如下代码:
"use strict";
function foo(x) {
if (x <= 1) return 1;
return (x / 2) + foo( x - 1 );
}
foo( 123456 ); // RangeError
对foo(x-1)
的调用不是一个PTC,因为在return
之前它的结果必须被加上(x / 2)
。
但是,要使这段代码在一个ES6引擎中是合法的TCO,我们可以像下面这样重写它:
"use strict";
var foo = (function(){
function _foo(acc,x) {
if (x <= 1) return acc;
return _foo( (x / 2) + acc, x - 1 );
}
return function(x) {
return _foo( 1, x );
};
})();
foo( 123456 ); // 3810376848.5
如果你在一个实现了TCO的ES6引擎中运行前面这个代码段,你将会如展示的那样得到答案3810376848.5
。然而,它仍然会在非TCO引擎中因为RangeError
而失败。
非TCO优化
有另一种技术可以重写代码,让调用栈不随每次调用增长。
一个这样的技术称为 蹦床,它相当于让每一部分结果表示为一个函数,这个函数要么返回另一个部分结果函数,要么返回最终结果。然后你就可以简单地循环直到你不再收到一个函数,这时你就得到了结果。考虑如下代码:
"use strict";
function trampoline( res ) {
while (typeof res == "function") {
res = res();
}
return res;
}
var foo = (function(){
function _foo(acc,x) {
if (x <= 1) return acc;
return function partial(){
return _foo( (x / 2) + acc, x - 1 );
};
}
return function(x) {
return trampoline( _foo( 1, x ) );
};
})();
foo( 123456 ); // 3810376848.5
这种返工需要一些最低限度的改变来将递归抽出到trampoline(..)
中的循环中:
- 首先,我们将
return _foo ..
这一行包装进函数表达式return partial() {..
。 - 然后我们将
_foo(1,x)
包装进trampoline(..)
调用。
这种技术之所以不受调用栈限制的影响,是因为每个内部的partial(..)
函数都只是返回到trampoline(..)
的while
循环中,这个循环运行它然后再一次循环迭代。换言之,partial(..)
并不递归地调用它自己,它只是返回另一个函数。栈的深度维持不变,所以它需要运行多久就可以运行多久。
蹦床表达的是,内部的partial()
函数使用在变量x
和acc
上的闭包来保持迭代与迭代之间的状态。它的优势是循环的逻辑可以被抽出到一个可重用的trampoline(..)
工具函数中,许多库都提供这个工具的各种版本。你可以使用不同的蹦床算法在你的程序中重用trampoline(..)
多次。
当然,如果你真的想要深度优化(于是可复用性不予考虑),你可以摒弃闭包状态,并将对acc
的状态追踪,与一个循环一起内联到一个函数的作用域内。这种技术通常称为 递归展开:
"use strict";
function foo(x) {
var acc = 1;
while (x > 1) {
acc = (x / 2) + acc;
x = x - 1;
}
return acc;
}
foo( 123456 ); // 3810376848.5
算法的这种表达形式很容易阅读,而且很可能是在我们探索过的各种形式中性能最好的(严格地说)一个。很明显它看起来是一个胜利者,而且你可能会想知道为什么你曾尝试其他的方式。
这些是为什么你可能不想总是手动地展开递归的原因:
与为了复用而将弹簧(循环)逻辑抽出去相比,我们内联了它。这在仅有一个这样的例子需要考虑时工作的很好,但只要你在程序中有五六个或更多这样的东西时,你将很可能想要一些可复用性来将让事情更简短、更易管理一些。
-
这里的例子为了展示不同的形式而被故意地搞得很简单。在现实中,递归算法有着更多的复杂性,比如相互递归(有多于一个的函数调用它自己)。
你在这条路上走得越远,展开 优化就变得越复杂和越依靠手动。你很快就会失去所有可读性的认知价值。递归,甚至是PTC形式的递归的主要优点是,它保留了算法的可读性,并将性能优化的任务交给引擎。
如果你使用PTC编写你的算法,ES6引擎将会实施TCO来使你的代码运行在一个定长深度的栈中(通过重用栈帧)。你将在得到递归的可读性的同时,也得到性能上的大部分好处与无限的运行长度。
元?
TCO与元编程有什么关系?
正如我们在早先的“特性测试”一节中讲过的,你可以在运行时判定一个引擎支持什么特性。这也包括TCO,虽然判定的过程相当粗暴。考虑如下代码:
"use strict";
try {
(function foo(x){
if (x < 5E5) return foo( x + 1 );
})( 1 );
TCO_ENABLED = true;
}
catch (err) {
TCO_ENABLED = false;
}
在一个非TCO引擎中,递归循环最终将会失败,抛出一个被try..catch
捕获的异常。否则循环将由TCO轻易地完成。
讨厌,对吧?
但是围绕着TCO特性进行的元编程(或者,没有它)如何给我们的代码带来好处?简单的答案是你可以使用这样的特性测试来决定加载一个你的应用程序的使用递归的版本,还是一个被转换/转译为不需要递归的版本。
自我调整的代码
但这里有另外一种看待这个问题的方式:
"use strict";
function foo(x) {
function _foo() {
if (x > 1) {
acc = acc + (x / 2);
x = x - 1;
return _foo();
}
}
var acc = 1;
while (x > 1) {
try {
_foo();
}
catch (err) { }
}
return acc;
}
foo( 123456 ); // 3810376848.5
这个算法试图尽可能多地使用递归来工作,但是通过作用域中的变量x
和acc
来跟踪这个进程。如果整个问题可以通过递归没有错误地解决,很好。如果引擎在某一点终止了递归,我们简单地使用try..catch
捕捉它,然后从我们离开的地方再试一次。
我认为这是一种形式的元编程,因为你在运行时期间探测着引擎是否能(递归地)完成任务的能力,并绕过了任何可能制约你的(非TCO的)引擎的限制。
一眼(或者是两眼!)看上去,我打赌这段代码要比以前的版本难看许多。它运行起来还相当地慢一些(在一个非TCO环境中长时间运行的情况下)。
它主要的优势是,除了在非TCO引擎中也能完成任意栈大小的任务外,这种对递归栈限制的“解法”要比前面展示的蹦床和手动展开技术灵活得多。
实质上,这种情况下的_foo()
实际上是任意递归任务,甚至是相互递归的某种替身。剩下的内容是应当对任何算法都可以工作的模板代码。
唯一的“技巧”是为了能够在达到递归限制的事件发生时继续运行,递归的状态必须保存在递归函数外部的作用域变量中。我们是通过将x
和acc
留在_foo()
函数外面这样做的,而不是像早先那样将它们作为参数值传递给_foo()
。
几乎所有的递归算法都可以采用这种方法工作。这意味着它是在你的程序中,进行最小的重写就能利用TCO递归的最广泛的可行方法。
这种方式仍然使用一个PTC,意味着这段代码将会 渐进增强:从在一个老版浏览器中使用许多次循环(递归批处理)来运行,到在一个ES6+环境中完全利用TCO递归。我觉得这相当酷!
复习
元编程是当你将程序的逻辑转向关注它自身(或者它的运行时环境)时进行的编程,要么为了调查它自己的结构,要么为了修改它。元编程的主要价值是扩展语言的普通机制来提供额外的能力。
在ES6以前,JavaScript已经有了相当的元编程能力,但是ES6使用了几个新特性及大地提高了它的地位。
从对匿名函数的函数名推断,到告诉你一个构造器是如何被调用的元属性,你可以前所未有地在程序运行期间来调查它的结构。通用Symbols允许你覆盖固有的行为,比如将一个对象转换为一个基本类型值的强制转换。代理可以拦截并自定义各种在对象上的底层操作,而且Reflect
提供了模拟它们的工具。
特性测试,即便是对尾部调用优化这样微妙的语法行为,将元编程的焦点从你的程序提升到JS引擎的能力本身。通过更多地了解环境可以做什么,你的程序可以在运行时将它们自己调整到最佳状态。
你应该进行元编程吗?我的建议是:先集中学习这门语言的核心机制是如何工作的。一旦你完全懂得了JS本身可以做什么,就是开始利用这些强大的元编程能力将这门语言向前推进的时候了!