[js学习笔记] 词法分析和作用域

[前言] 在大三下学期我选了编译原理,在某一天的学习中发现它可以让我更深层地了解我所接触的编程语言,很多零碎的知识点好像开始串联成线。下面主要为学习笔记和自己的一些小理解。

词法作用域和动态作用域

程序设计语言的作用域可以分为静态作用域和动态作用域。而静态作用域在js中又称为词法作用域。
静态作用域,声明的作用域是在 定义 的时候确定的。如果在当前作用域找不到则会在 定义该函数的作用域 中查找,若没有则按此顺序往外查询。
动态作用域,声明的作用域是在 调用 的时候确定的。如果在当前作用域找不到则会在 调用该函数的作用域 中查找,若没有则按此顺序往外查询。
大多数现在程序设计语言都是采用静态作用域规则,而只有为数不多的几种语言采用动态作用域规则,包括APL、Snobol和Lisp的早期方言。
举个栗子

1
2
3
4
5
6
7
8
9
10
var x=1;
function cal(a){
console.log(a+x); // 3
}
function test(){
var x=2;
console.log(x); // 2
return cal(x);
}
test()

javascript是遵循词法作用域的,在函数test中调用函数cal,并传入参数x,此时词法作用域会现在当前函数test查找x(如果没有,则会在定义函数test的地方的作用域中寻找,也即是全局作用域),而函数test中定义了x=2,所以函数cal的参数为x=2。接着我们在函数cal中要计算a+x,a即为参数值2,而在该函数体内没有变量x的定义,所以我们在定义函数cal的地方查找,也即是全局作用域,所以x为全局变量x=1,a+x的结果就是3而不是4。

词法绑定和动态绑定

词法作用域和动态作用于分别的使用的是词法绑定和动态绑定。
动态绑定的特点是它内部的变量对于其调用的子程序都是 可见 的,也即是说在使用动态绑定的程序语言中,父函数定义的变量对于子函数来说类似全局变量,也可以直接使用的,例子如下

1
2
3
// lisp使用的是动态作用域
(defun foo1(x)(foo2)) // foo1中传入参数x,并调用foo2
(defun foo2()(+ x 5)) // foo2中可以直接使用x

而词法绑定,它内部的变量对于其调用的子程序都是 不可见 的,定义它的地方 所定义的变量才是全局变量,通常来说,我们接触的语言大多使用的都是词法绑定。
我自己的理解是,词法绑定可以避免变量逃出词法上下文,尽管动态绑定某种程度上看似容易理解,但父子函数间的数据耦合度过高,变量一不小心就会相互影响,在多人合作的大型项目中很容易会出错,而这错误可能还很难找到。而JavaScript中的es6标准中推荐使用let取代var某种程度考虑也有相同的地方,因为var会有变量提升的问题,在同一作用域中,就算在定义变量之前也可以使用该变量;而如果使用let定义变量,在该定义之前使用该变量会报错。其实这正正是让我们提前发现错误,减少犯错并以后找出错误的成本。所以我们更提倡显示说明,减少理所当然。
而在可拓展性语言中通常运用动态绑定规则,这里在动态绑定的角度分析它利弊的,有兴趣可以了解一下~

关于词法绑定和动态绑定的利弊,我接触不深所以可能还不能很透彻的分析,只能说一些浅显的理解,希望之后我还可以回来补充。

js中的语法分析

下列知识点整理自javascript 词法作用域和闭包分析说明
我们知道JavaScript是解析型语言,而编译型语言和解析型语言有什么不一样呢

  • 编译型语言:主要步骤为词法分析、语法分析、语义检查、代码优化和目标代码生成。
  • 解析型语言:通过词法分析和语法分析得到语法分析树后,就可以开始解释执行了。这里是一个简单原始的关于解析过程的原理,仅作为参考,详细的解析过程(各种JS引擎还有不同)还需要更深一步的研究
    当然上面的解析只是传统意义上的介绍,深入的研究可以参考这里虚拟机随谈(一):解释器,树遍历解释器,基于栈与基于寄存器,大杂烩

那以JavaScript为例,它的执行过程是怎样的呢

  • 步骤1. 读入第一个代码段(js执行引擎并非一行一行地执行程序,而是一段一段地分析执行的)
  • 步骤2. 做词法分析和语法分析,有错则报语法错误(比如括号不匹配等),并跳转到步骤5
  • 步骤3. 对【var】变量和【function】定义做“预解析”(永远不会报错的,因为只解析正确的声明)
  • 步骤4. 执行代码段,有错则报错
  • 步骤5. 如果还有下一个代码段,则读入下一个代码段,重复步骤2
  • 步骤6. 结束
    我们知道JavaScript都是一段段代码读入的,那什么是一个代码段呢?一个js文件、一个script标签等。

    语法分析树

    语法分析和预解析会共同构成语法分析树,其中
  • 变量集(variables)中,只有变量定义,没有变量值,这时候的变量值全部为undefined
  • 作用域(scope),根据词法作用域的特点,这个时候每个变量的作用域就已经明确了,而不会随执行时的环境而改变。【这就是我们上文所说的静态作用域。我们经常将一个方法 return 回去,然后在另外一个方法中去执行,执行时,方法中变量的作用域是按照方法定义时的作用域走。】
  • 语法分析完之后,我们调用每一个方法的时候,JS 引擎都会自动为其建立一个运行期上下文Execution Context和一个活动对象Activation Object,它们和方法实例的生命周期保持一致。

现在我们尝试构建一棵简易版语法分析树(伪代码)加深理解。
Espsrima这里可以构建抽象语法树。)

  • 代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    var x=1;
    function funcA(i,j){
    console.log(i)//undefined
    var i=100;
    function funcB(){
    var k=200;
    console.log(i)//100
    console.log(k);//200
    }
    funcB()
    }
    funcA(300,400)
  • 语法分析树

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    var SyntaxTree ={
    // 全局环境
    window:{
    variables:{
    x:{value:1}
    },
    functions:{
    funcA:this.funcA
    },
    //……
    },
    // 函数funcA
    funcA:{
    params:{
    i,
    j,
    },
    variables:{
    i:100
    },
    functions:{
    funcB:this.funcB
    },
    //……
    },
    funcB:{
    variables{
    k:200
    },
    //……
    }
    }
  • 运行期上下文

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    var ExecutionContext ={
    window:{
    type: "global",
    name: "global",
    body: ActiveObject.window
    },
    funcA:{
    type: "function",
    name: "funcA",
    body: ActiveObject.funcA,
    scopeChain: this.window.body
    },
    funcB:{
    type: "function",
    name: "funcB",
    body: ActiveObject.funcB,
    scopeChain: this.funcA.body
    },
    }
  • 活动对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    var ActiveObject ={
    window:{
    variables:{
    x:{value:1}
    },
    functions:{
    funcA:this.funcA
    },
    },
    funcA:{
    variables:{
    i:{value:100}
    },
    functions:{
    funcB: SyntaxTree.funcB
    },
    parameters:{
    i: {value: 300},
    j: this.variables.j
    },
    arguments:[this.parameters.i,this.parameters.j]
    },
    funcB:{
    variables:{
    k:{value:200}
    },
    functions:{},
    parameters:{},
    arguments:[]
    },
    }

现在来看一个例子

1
2
3
4
5
6
var i=2;
function test2(){
console.log(i)
var i=10;
console.log(i);
}

很多人可以第一反应输出的是2 10
然而正确答案应该是undefine 10
看到这里可能你也很快就反应过来了:因为 变量提升
可是JavaScript为什么会有变量声明提升呢?答案就是 JavaScript的预解析。

JavaScript的预解析 和 变量/函数声明提升

在上文中分析中,一段js代码段在进行词法分析和语法分析之后,都会首先处理var关键字和function定义式(函数定义式和函数表达式)再执行该段代码段。
所以在调用函数执行之前, 会首先创建一个活动对象, 然后搜寻这个函数中的局部变量定义和函数定义, 将变量名和函数名都做为这个活动对象的同名属性。对于局部变量定义,变量的值会在真正执行的时候才计算, 此时只是简单的赋为undefined。
而这就是我们常说的变量声明提升和函数声明提升的实现原理。而这也是函数定义式和函数表达式的不同, 对于函数定义式, 会将函数定义提前. 而函数表达式, 会在执行过程中才计算。

1
2
3
4
5
6
7
8
9
10
11
console.log(typeof eve); //结果:function
console.log(typeof walle); //结果:undefined
walle(); // 结果:报错,walle is not a function
eve(); // 结果:I am Laruence
function eve() { //函数定义式
console.log('I am Laruence');
};
var walle = function() { //函数表达式 ,walle仅作为变量声明提升,而不是函数声明提升
// 执行的时候才会知道它是函数
}
console.log(typeof walle); //结果:function

当我们对一个变量没有用var定义的时候,会跃升为全局变量。对该变量做标识符解析的时候, 因为是写操作, 所以当找到到全局的window活动对象的时候都没有找到这个标识符的时候, 会在window活动对象的基础上, 返回一个值为undefined的该变量同名属性.

1
2
var hi="hello";
age=18; // age为全局变量

参考链接

DynamicBinding Vs LexicalBinding
Dynamic Binding explained in emacs
ECMAScript® 2017 Language Specification
Javascript作用域原理
javascript 词法作用域和闭包分析说明