问题描述
我正在研究一种自定义语言,现在希望支持词法环境 (closures)。我正在寻求帮助,了解这在 JavaScript 中的外观如何使其更容易并更适用于其他人。
let a = 10
let b = 20
function doFoo() {
let x = 300
let y = 400
let z = dobar(x,y)
return a * z + b * z
function dobar(m,n) {
return m * a + n * b
}
}
保持简单(不考虑编译器如何优化或删除简单的表达式),词法环境(如 JSON)的对象是什么样的,以及词法环境通常如何在运行时使用?
>在我看来,你最终会是这样:
let a = {
vars: [
{
varname: 'a',value: 10
},{
varname: 'b',value: 20
}
],funcs: ['doFoo']
}
let b = {
parent: a,vars: [
{
varname: 'x',value: 300
},{
varname: 'y',value: 400
},{
varname: 'z',value: undefined
}
],funcs: ['dobar']
}
let c = {
parent: b,vars: [
{
varname: 'm',value: undefined
},{
varname: 'n',value: undefined
}
]
}
let environments = [a,b,c,/* and tons more */]
然后当您实际调用函数 doFoo
时,它会在那个时刻序列化数据或类似的东西。
run(script)
// 1. link a = 10,b = 20
// 2. call doFoo
// 3. create a new object,link it to the lexical environment.
// 4. repeat
这个“创建一个新对象” 部分是我迷失的地方。我们必须为每次调用函数创建一个新的“范围”或“上下文”正确吗?它与我创建的 environments
数组有什么关系?
似乎 scope
是一个环境的实例,其中环境的变量在使用之前就被填充了。然后环境只是说明有哪些变量。
但这似乎是一个性能问题,创建一个新的作用域,可能有几十个变量可以一次序列化。所以他们必须随着变量的变化更新范围。这通常看起来如何或如何工作?我们有哪些对象,它们如何链接在一起,以及它们何时/如何更新?
解决方法
首先我们想从理论上了解作用域和环境之间的区别。
Environments 是将变量与需要存储在某处的值相关联的绑定。你可以把它想象成一个映射,其中的键是变量名和值。更新值就像更改键指向的值一样简单。
范围 定义了代码中名称映射到值的区域。多个作用域使同一个名称在不同的上下文中可以指代不同的事物。
正如您所提到的,范围本质上是环境。但是当我们有以下情况时会发生什么:
function defineVariable() {
let a = "some variable";
}
defineVariable();
console.log(a); // undefined!
这里我们有一个函数,它包含它自己的作用域。这意味着在左大括号 {
之后定义的任何变量都在右大括号 }
之后被删除。
因此,在上面的代码片段中,我们将执行以下操作:
- 调用函数
defineVariable
- 创建范围
- 创建并分配变量
a
- 关闭范围
& 删除之前作用域中的所有变量:所以删除
a
- 将
a
记录到控制台(a 未定义,它已被删除,因此我们记录undefined
)
所以现在我们知道作用域将变量围在大括号 {}
之间。然而,我们也有阴影的情况。例如:
let shadowed = 0;
function printShadowed() {
let shadowed = 1;
console.log(shadowed); // 1
}
printShadowed(); // prints 1
console.log(shadowed); // 0
这里我们做了一些有趣的事情,我们声明了一个与现有变量同名的新变量。这称为阴影。它让我们创建与他们的育儿环境完全分开的新变量。为了更好地理解这一点,我们可以谈谈局部变量和全局变量之间的区别:
let global = 10;
function doMath() {
let local = 15;
console.log(global + local); // 25
}
doMath(); // prints 15
console.log(local); // undefined,`local` is local to the `doMath` scope
这个想法是一个作用域可以访问它的所有父作用域,但不能访问任何子作用域。
概括起来,环境是变量名称到其各自值的映射。程序是不同环境的树。范围是那些可访问环境的一个分支。当我们在其父环境中声明与变量同名的变量时,它被称为阴影。为了直观地描绘这一点,以下代码等效于:
let global = 10;
function doMath() {
let local = 15;
console.log(global + local); // 25
console.log(a); // undefined,a is not in scope
}
function other() {
let a = "...";
}
doMath(); // prints 15
现在在实践中,这可以通过多种方式实现。最简单的就是栈的思想。
let global = 10;
// environments = [ { "global": 10 } ]
function doMath() {
let local = 15;
// environments = [ { "global": 10 },{ "local": 15 } ]
console.log(global + local); // 25
// we walk the list from back to front until we find
// a map that contains the requested variable. So:
//
// for `local` we first check the last map,we find `local`
// in that map,so our value is 15
//
// for `global` we first check the last map,we don't find
// `global`,so we move up the list,we check the second last
// map (here it's the first) and we find `global` in that map,// our value is 10
}
// exiting function scope
// environments = [ { "global": 10 } ]
doMath();
编辑:我意识到我完全忘记了关闭部分。所以看下面的例子:
let x = "global";
function outer() {
let x = "inner";
function inner() {
console.log(x);
}
return inner;
}
outer()();
在基于堆栈的范围实现中,这将打印 "global"
,而理论上我们希望它打印 "local"
。这就是为什么我提到作用域创建一个树状结构的原因。但是对于闭包,我们需要以某种方式捕获该范围。我的意思是我们希望内部函数捕获它的作用域,因此从它的角度来看,x 的值为“local”应该优先。
让我们回顾一下我所说的捕获和存储范围的意思:因此,我们不是将环境表示为堆栈,而是将它们表示为树。然后我们可以通过指向那棵树来跟踪我们当前所在的分支。然后爬上树,我们获得了上面标准堆栈实现所需的所有信息。但是,如果我们可以看到用户定义了一个闭包,那么该闭包将包含两件事。组成闭包主体的代码,以及指向闭包作用域所在位置的指针。所以要在上面的例子中使用这个新逻辑:
let x = "global";
// { "x": "global",children: [] }
function outer() {
let x = "inner";
// { "x": "global",children: [ { "x": "local",children: [] } ] }
function inner() {
console.log(x);
}
// inner is a closure,it stores the code within it
// and a pointer to to the current scope that it was
// instantiated in
return inner;
}
// we are now pointing to the scope on the top of the tree,but notice how we
// don't delete the "local" x scope
// { "x": "global",children: [] } ] }
outer()();
// good thing we didn't,because this function returned a function that
// needed it about when that local x should be deleted is a headache for
// our garbage collector
现在你提到的另一种情况是,如果我们改变本地 x 的值会怎样?嗯,这就是为什么我们创建一个指向本地 x 范围的指针。所以对本地 x 的任何更改都会在我们调用它时反映出来。这还具有避免不必要复制的额外好处。
关于内存,这就是事情变得有点麻烦的地方。理想情况下,我们不希望我们的 GC 工作得比它需要的更努力。许多语言,比如 lua,只有在有闭包时才会存储作用域。这意味着它不会存储完美的环境树,而是仅存储被闭包捕获或当前正在使用的分支。
这太多了,查看其他资源可能更有帮助。 book that you linked 很棒,因为它总体上介绍了有关语言实现的大量细节。此外,@Bergi 在他对这个问题的评论中发布的链接也可能有所帮助。如果我自己遗漏了某些内容,或者此答案的任何部分有误,建设性 批评总是有帮助的 :)