一. 传统编译语言编译原理
这一节主要简单了解下①传统编译语言的编译流程/Javascript涉及的角色和流程,与变量声明并赋值时所涉及到的②LHS查询和③RHS查询。
Javascript引擎进行编译的步骤和传统的编译语言非常相似,在某些环节可能比预想的要复杂。在传统的编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”(下面介绍的是传统编译语言的流程)。
①分词/词法分析(Tokenizing/Lexing)
这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。例如,考虑程序var a = 2;。这段程序通常会被分解成为下面这些词法单元:var、a、=、2、;。空格是否会被当作词法单元,取决于空格在这门语言中是否具有意义。
提示:分词(tokenizing)和词法分析(Lexing)之间的区别是非常微妙、晦涩的,主要差异在于词法单元的识别是通过有状态还是无状态的方式进行的。简单来说,如果词法单元生成器在判断a是一个独立的词法单元还是其它词法单元的一部分时,调用的是有状态的解析规则,那么这个过程就被称为词法分析。
②解析/语法分析(Parsing)
这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。
例如:var a = 2;的抽象语法树中可能会有一个叫做VariableDeclaration(var)的顶级节点,接下来是一个叫做Identifier(a)的子节点,以及一个叫做AssignmentExpression(=)的子节点。AssignmentExpression节点由一个叫做NumericLiteral(它的值是2)的子节点。
③代码生成
将AST转换为可执行代码的过程被称为代码生成。这个过程与语言、目标平台等息息相关。
抛开具体细节,简单来说就是有某种方法可以将var a = 2;的AST(Abstract Syntax Tree)转换成一组机器指令,用来创建一个叫做a的变量(包括内存分配等),并将一个值存储在a中。
比起那些编译过程中只有三个步骤的语言的编译器,Javascript引擎要复杂的多。例如,在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化等。
二. 理解作用域
2.1 演员表
首先介绍将要参与到对程序var a = 2; 进行处理的过程中的演员们。
①引擎
从头到尾负责整个Javascript程序的编译及执行过程
②编译器
引擎的好朋友之一,负责分词/词法分析(将代码分解成有意义的代码块)、语法分析(将代码生成抽象语法树Abstract Syntax Tree)及代码生成(将AST转成可执行代码的过程)
③作用域
引擎的另外一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
对话
var a = 2;这段程序,引擎认为这里有两个完全不同的声明,一个由编译器在编译时处理,另一个则由引擎在运行时处理。
下面我们将var a = 2;分解,看看引擎和它的朋友们是如何协同工作的。
编译器首先会将这段程序分解成词法单元,然后将词法单元解析成一个抽象语法树(AST)。但是当编译器开始进行代码生成时,它对这段代码的处理方式会和预期有所不同。
可以合理的假设编译器所产生的代码能够用下面的伪代码进行概括:“为一个变量分配内存,将其命名为a,然后将值保存进这个变量”。然而, 这并不完全正确。
事实上编译器会进行如下处理:
1. 遇到var a,编译器会询问作用域中是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为a。
2. 接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理a = 2这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫做a的变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量。如果引擎最终找到了a变量,就会将2赋值给它,就会将2赋值给它。否则引擎就会举手示意并抛出一个异常!
总结:变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对其进行赋值。
2.2 进一步了解
编译器在编译过程的第二步生成了代码,引擎执行它时,会通过查找变量a来判断它是否已声明过。查找的过程由作用域进行协助。
在我们的例子中(var a = 2),引擎会为变量a进行LHS查询。另一个查找的类型叫做RHS。
LHS:当变量出现在赋值操作的左侧时进行LHS查询。
RHS:当变量出现在右侧进行RHS查询,更准确一点,RHS查询与简单地查找某个变量的值别无二致,而LHS查询则是找到容器的本身,从而可以对其进行赋值。从这个角度说,RHS并不是真正意义上的“赋值操作的右侧”,更准确地说是“非左侧”。可以将RHS理解成retrieve his source value(取到它的源值),这意味着“得到某某的值”。
如:console.log(a);,这里对a的引用是一个RHS引用,因为这里a并没有赋予任何值。相应地,需要查找并取出a的值,再将值传递给console.log(...)。
直接理解成:“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头(RHS)”。
再看一个例子
function foo(a) {
console.log(a);
}
foo(2);
最后一行foo(...)函数的调用需要对foo函数本身进行RHS引用,意味着去找到foo的值,并把它给我。然后执行console.log(a)之前还有一个隐式操作a=2,2会被分配给参数a。为了给参数a(隐式地)分配值,需要进行一个LHS查询。接着执行console.log(...)时,会先执行RHS查询到console对象,并且检查得到的值中是否有一个叫做log的方法。最后,在概念上可以理解成在LHS和RHS之间通过值2进行交互来将其传递进log(...)(通过变量a的RHS查询)。假设在log(...)(通过变量a的RHS查询)。假设在log(...)函数的原生实现中它可以接受参数,在将2值给其中第一个(也许叫做arg1)参数之前,这个参数需要进行LHS引用查询。
注意:你可能会倾向于将函数声明function foo(a) {...概念化为普通的变量声明和赋值,比如var foo、foo = function {...。如果这样理解的话,这个函数声明将需要进行LHS查询。
然而还有一个重要的细微差别,编译器可以在代码生成的同时处理声明和值的定义,比如引擎在处理代码时,并不会有线程专门用来将一个函数值“分配给”foo。因此,给函数声明理解成前面讨论的LHS查询和赋值的形式并不合适。
最后一个例子:
function foo(a) {
var b = a;
return a + b;
}
var c = foo(2);
1、找出所有的LHS查询:
c = ..; a = 2(隐式变量分配)、b = ..
2、找出所有的RHS查询
foo(2..、= a;、a..、..b
LHS 和 RHS 查询都会在当前执行作用域中开始,如果有需要(当前没找到),就会向上级作用域继续查找目标标识符(作用域链)。
不成功的 RHS 会导致抛出 ReferenceError 异常。不成功的 LHS 会自动隐式在全局作用域中创建一个全局变量(非严格模式下),该变量使用 LHS 引用的目标作为标识符。(如果是严格模式下也会抛出 ReferenceError 异常)。