如何在 JavaScript 编译器中将词法环境编译为对象?

问题描述

我正在研究一种自定义语言,现在希望支持词法环境 (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!

这里我们有一个函数,它包含它自己的作用域。这意味着在左大括号 { 之后定义的任何变量都在右大括号 } 之后被删除。 因此,在上面的代码片段中,我们将执行以下操作:

  1. 调用函数defineVariable
  2. 创建范围
  3. 创建并分配变量 a
  4. 关闭范围 & 删除之前作用域中的所有变量:所以删除 a
  5. 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

Scopes visualized 红色圈出的环境是 doMath 函数的作用域。

现在在实践中,这可以通过多种方式实现。最简单的就是栈的思想。

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 在他对这个问题的评论中发布的链接也可能有所帮助。如果我自己遗漏了某些内容,或者此答案的任何部分有误,建设性 批评总是有帮助的 :)

相关问答

Selenium Web驱动程序和Java。元素在(x,y)点处不可单击。其...
Python-如何使用点“。” 访问字典成员?
Java 字符串是不可变的。到底是什么意思?
Java中的“ final”关键字如何工作?(我仍然可以修改对象。...
“loop:”在Java代码中。这是什么,为什么要编译?
java.lang.ClassNotFoundException:sun.jdbc.odbc.JdbcOdbc...