大厂面试官(3):讲讲你关于词法作用域的理解。

113 阅读6分钟

引言

我们在上一篇内容中将“作用域”定义为一套规则,这套规则用来管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找。不懂的可以看上一篇,这是上一篇文章的链接link。 作用域共有两种主要的工作模型。第一种叫作动态作用域,仍有一些编程语言在使用(比如Bash脚本、Perl中的一些模式等)。另一种是最为普遍的,被大多数编程语言所采用的词法作用域。我们会对这种作用域进行深入讨论。

定义

在上一篇我们讲了,大部分编译器的工作的第一步是词法化(也叫单词化)。回忆一下,词法化的过程会对代码中字符进行检查,如果在检查的过程中有对代码状态的解析,则会赋予单词语义

这个概念会有助于我们理解词法作用域及其名称的来历。

简单来说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域就是你在写代码时变量和块作用域的位置来决定的。因此在绝大部分情况下,当词法分析器处理代码时会保持作用域不变。

思考下面的代码:

function foo(a) {
    var b = a * 2;

function bar(c) { 
   console.log( a, b, c ); 
} 
    bar( b * 3 );
}
    foo( 2 ); // 2, 4, 12 

在这个例子中有三个逐级嵌套的作用域。为了帮助理解,可以将它们想象成几个逐级包含的气泡。

image.png 1包含着整个全局作用域,其中只有一个标识符:foo。

2包含着foo所创建的作用域,其中有三个标识符:a、bar和b。

3包含着bar所创建的作用域,其中只有一个标识符:c

作用域气泡由其代码的对应位置决定的,它们时逐级包含的。之后我们会讨论不同类型的作用域,现在我们先这样定义吧。

查找

作用域的气泡结构和互相的位置关系给引擎提供了足够的位置信息,引擎用这些信息来查找标识符的信息。

在上面的代码中,引擎执行了 console.log声明,用于查找a,b,c三个变量。

先从最内部的作用域bar(...)中开始找,首先我们找到了c,随后我们在上一级的嵌套函数foo(...)中找,在这里我们可以找到a。再找到b。

若a、c 都在bar(...),foo(...)的内部,则console.log(...)可以直接引用,就无需再到foo(...)中寻找

作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的 标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。抛开遮蔽效应,作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见 第一个匹配的标识符为止。

全局变量会自动成为全局对象(比如浏览器中的window对象)的属性,因此 可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引 用来对其进行访问。 window.a 通过这种技术可以访问那些被同名变量所遮蔽的全局变量。

但非全局的变量 如果被遮蔽了,无论如何都无法被访问到。

词法作用域查找只会查找一级标识符,比如a、b和c。如果代码中引用了foo.bar.baz, 词法作用域查找只会试图查找foo标识符,找到这个变量后,对象属性访问规则会分别接管对bar和baz属性的访问。

欺骗词法

如果词法作用域完全由写代码时函数声明位置来定义,那我们怎么样在运行时来"欺骗"(或修改)词法作用域呢?

在JS中常常有两种方法来实现这个目的,但并不是什么好主意,因为欺骗词法作用域会导致性能下降

在解释这个之前,我们来看看这两种机制是什么原理。

eval

JS 中的 eval(..) 函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。

换句话说,可以在你写的代码中用程序生成代码并运行,就好像代码原本就是写在那个位置的一样。 根据这个原理来理解eval(..),它是如何通过代码欺骗和假装成书写时(也就是词法期)代码就在那,来实现修改词法作用域环境的,这个原理就变得清晰易懂了。

在执行eval(..)之后的代码时,引擎并不“知道”或“在意”前面的代码是以动态形式插 入进来,并对词法作用域的环境进行修改的。引擎只会如往常地进行词法作用域查找。

就像以下代码:

 function foo(str, a){
 eval( str ); // 欺骗!
 console.log( a, b ); 
 } 
 var b = 2;
 foo( "var b = 3;", 1 ); // 1, 3
 

此时eval中调用的var b =3会被引擎认为本来就已经写在那里了,因为代码声明了一个新变量b,改变了foo()函数的词法作用域。在foo()中创建了一个变量b,所以遮蔽了全局作用域中的var b =2

因此在console.log(...)执行时,已经在foo()中找到了a和b。所以输出的是1,3而不是1,2.

with

JS中另一个用来欺骗词法作用域的功能是with关键字。

这里我们就不细谈这个的用法了,只需要知道它能够与被它影响的词法作用域进行交换就够了。因为它现在并不推荐使用。只知道有这个方法就够了。

总结

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段 基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它 们进行查找。

拓展

eval和with会在运行时创建新的词法作用域,以此来欺骗在书写时就定义的词法作用域。

你可能觉得,如果这样能实现更多功能,那不是很好的功能吗?

答案是否定的。

但如果引擎在代码中发现了eval(..)或with,它只能简单地假设关于标识符位置的判断 都是无效的,因为无法在词法分析阶段明确知道eval(..)会接收到什么代码,这些代码会 如何对作用域进行修改,也无法知道传递给with用来创建新词法作用域的对象的内容到底 是什么。

最悲观的情况是如果出现了eval(..)或with,所有的优化可能都是无意义的,因此最简 单的做法就是完全不做任何优化。

如果代码中大量使用eval(..)或with,那么运行起来一定会变得非常慢。无论引擎多聪明,试图将这些悲观情况的副作用限制在最小范围内,也无法避免如果没有这些优化,代 码会运行得更慢这个事实...