zoukankan      html  css  js  c++  java
  • 再谈作用域

    前言:

    所有的编程语言都具备一个基本功能,储存、访问、修改变量的值,这种能力将状态带给了程序。

    那问题来了:变量储存在哪里?如何访问到它们?

    一,编译原理

    一段源代码在执行前会经历的三个步骤,统称为编译。

    1,传统编译语言(这里以执行“var a = 2;”为例)

    1️⃣分词/词法分析(Tokenizing/Lexing):

    词法单元生成器会将字符串分解成一个一个代码块,称为词法单元。如:var、a、=、2、;。一共5个,空格是否被当作词法单元取决于空格在这门语言中是否有意义。

    这里译为两个单词,说明这其实是两个步骤,但是在编译过程中两者放在了同一个步骤里,说明两者差异性并不是很大,并且两者的功能或者目的实际上是差不多的,那么分词和词法分析的异同是什么:这里我的理解是词法分析是为了更好的分词,两者其实是一个过程,唯一的区别是,分词只是简单地把字符串拆开来,拆成一个一个代码块,这个过程是无状态的,它不会去识别这些词法单元究竟是独立的还是和其它词法单元有关联(比如某个词法单元是另一个的一部分,它并非独立)。而词法分析就是调用有状态的解析规则,在分词的基础上再进行一次分析,两者实际上是做的同一块东西。

    贴一张图:

    2️⃣解析/语法分析(Parsing):

    上一步分析完的一个一个的词法单元,会组成一个词法单元流(数组),这一步会将这个流转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树,这个树称为“抽象语法树”(AST)。以var a = 2; 为例:

    这里再贴一张语法结构图,更清晰:

    3️⃣代码生成:

    将AST转换成可执行代码的过程被称为代码生成,简单来说,就是将var a = 2;的AST转化成一组机器指令,用来创建一个叫作a的变量(包括内存分配等),并将一个值储存在a中。

    普通编译器的编译过程一般就是以上三步,(上面的图片中的解析器链接:http://esprima.org/demo/parse.html#)而JavaScript引擎要复杂得多,在语法分析和代码生成阶段有特定步骤来对运行性能进行优化,对于JavaScript来说,编译发生在代码执行前的几微秒(甚至更短)

    二,引擎、编译器和作用域

    编译器:上面刚刚提到的三个步骤就是编译器做的事情。

    引擎:负责整个JavaScript程序的编译及执行过程。(主要是执行)

    作用域:负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。

    例如,执行var a = 2;的时候:

    1,编译器先进行编译(上面的三步),前两步一致(分词/词法分析、解析AST),但是当执行到第三步的时候(代码生成),有不同了:

    1️⃣遇到var a,编译器会先询问作用域是否已经有一个变量a在该作用域的集合中,如果是,则忽略该声明(因为作用域里已经有了一个a,不用再声明一个a,注意,此时没有分配内存,因为还在编译阶段,分配内存是引擎做的),如果不是,则在当前作用域下声明一个新的变量,命名为a。

    2️⃣接下来就是为引擎生成运行时所需的代码,也就是机器指令,它会告诉引擎“要给a这个变量赋值2”。

    2,上面就是编译器做的事情,而引擎在运行时会先询问作用域,在当前作用域的集合中是否存在一个叫作a的变量,如果是,引擎就会使用这个变量,如果不是,引擎会继续查找该变量(沿着作用域链)。引擎最终如果找到了a变量,就会将2赋值给它,如果没有找到,则会抛出一个异常。

    总结就是,编译器只负责编译部分,它不会去执行代码,而是把js语言编译成引擎能读懂的机器指令,而引擎负责执行这一系列指令。作用域的作用其实就比较大了,无论是在编译器编译的时候还是在引擎执行的时候,都起到了关键作用。

    这里其实有一个疑问:既然编译器已经做了一次a的作用域查找,并且如果当前作用域下没有,就新增一个a,那为什么引擎又要去找一遍,这不是重复工作了么?原因是两者运行的阶段时机不同,编译器是在执行代码前几微秒甚至更短的时候进行编译的,而引擎则是代码在执行的时候执行,所以需要进行再次判断。

    所以对于变量的赋值操作来说,会执行两个动作,第一是编译器先在当前作用域下声明一个变量(如果没有的话),第二是引擎会在运行时在作用域中查找该变量,如果能找到就对它进行赋值。

    三,LHS和RHS

    二者分别位于赋值操作的左侧和右侧

    1,LHS:查询一个变量的容器本身,就是LHS查询,这里强调的是查到这个变量的位置,并不是要得到它的值,例如a = 2;中,a就需要我们去进行LHS查询。

    2,RHS:查询一个变量的值,就是RHS查询,这里强调的是查到这个变量的值是什么,并不在意它在哪里,例如console.log(a),我们这里只要关心a的值是多少,不关心a放哪里了,就要对a进行RHS查询。

    举一些例子:

    function foo(a) {
        console.log(a);  
    }
    
    foo(2);

    1️⃣这里foo(...)就是一次RHS查询,我们在调用foo函数时,首先要知道foo这个函数是什么;

    2️⃣再把2赋值给这个函数的形参,那么要先进行一次LHS查询,查到这个函数的形参的容器是什么;

    3️⃣查到是a以后,对a进行赋值操作,a = 2;

    4️⃣然后执行下面的console.log(a);console.log(...)本身也是一次RHS查询,要查到这个console对象是什么,并且这个对象下面有没有log(...)这个方法;

    5️⃣查到了console.log方法后,这里对a进行一次RHS查询,要查到这个a是多少,查到a = 2,接着执行打印;

    这就是一套完整的流程。

    再来做一题:

    function foo(a) {
         var b = a;
         return a + b;  
    }
    
    var c = foo (2);

     1️⃣首先对c进行变量赋值操作,则先要对c进行LHS,不关心c是多少,因为要对这个容器重新赋值;

     2️⃣foo(2),首先对foo(...)函数进行RHS查询,然后对它的形参进行赋值操作,所以查找形参容器是一次LHS,查到为a容器,并赋值为2,a=2;

     3️⃣var b = a;对b赋值,因此先对b进行容器查询,即为LHS,然后赋值为a,那么对a进行RHS查询,因为我只关心a的值是多少;

     4️⃣return a + b;这里还是只关心a和b的值是多少,所以两次都是RHS;计算出值后return结果;

    综上,一共3次LHS,4次RHS。

    三,作用域

    关于作用域,前面有一篇随笔已经很详细的写过了,包括变量对象、活动对象、作用域链等概念。但是这里想要重点细说的是查找的过程。上面介绍了LHS和RHS,真正的目的就是为了说这个:

    1,LHS查找的时候,要找到变量的容器,这里要分两种情况,一种是var一个变量,还有一种是直接调用某个变量,如

    var a = 2和a = 2,两者的区别是,

    如果是var声明一个变量,那么只会查找当前作用域,如果有,就忽略它,不必重新声明,如果没有,就在当前作用域下重新声明一个变量a,(再说一次,这里不做内存分配,因为还处于编译阶段);

    如果是直接调用a变量,那么就会沿着作用域链查找,当前的找不到就去上一级,直到最外层的全局作用域下,在非严格模式下,如果直到全局作用域下都没有找到,那么就会在全局作用域下声明一个变量a,但是如果是严格模式下,就会报一个ReferenceError的异常。

    2,RHS查找的时候,会沿着作用域链一直查找,如果找不到该变量,就会报一个ReferenceError的异常;如果找到了,但是用法不对,比如foo(...)是一个函数,结果查找到的结果foo是一个普通对象或者基本类型的值,再比如引用null或undefined中的属性,那么引擎就会报一个TypeError的异常。

    四,词法作用域、函数作用域、块作用域

    1,词法作用域:

    在编译第一步时,词法单元生成器会将字符串分解成一个一个的词法单元,这个叫分词,如果是有状态的解析,还会给词法单元赋予语义。此时会进行作用域查找(这里不是LHS查找,也不是RHS,跟那个无关),这时候的作用域就是词法作用域。词法作用域是在分词和词法分析阶段(编译第一步)形成的,它由变量的位置来决定。一般情况下,词法作用域在编译时就确定了,不会再变。(除非遇到特殊的欺骗作用域)

    函数的词法作用域只取决于函数被声明时所处的位置决定。所有的标识符查找都是沿着词法作用域来查找,因为在编译时就已经确定了。

    欺骗词法:eval和with(这两个在严格模式下被禁止,后面也被废弃了,所以只需了解一下就行)

    1️⃣eval:接收一个字符串为参数,并将其内容视为在书写时就存在于程序中这个位置的代码。因为词法作用域是在编译的第一阶段就形成的,在分词的时候,eval里的字符串代码还没有执行,当前词法作用域下还没有eval里定义的某个变量,而执行eval()函数是引擎在编译之后才去执行,所以相当于只有在执行的时候,这个变量才会添加进去。通常情况下,词法作用域是在编译的时候就确定了,并且不会再变,而eval是在执行的时候强行修改了作用域(在作用域里添加一些东西)。

    举个例子

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

    在编译时,console.log(a, b)里面的b就是外层的var b = 2; ,因为根据词法作用域,在函数作用域中未找到b的定义,因此沿着作用域链继续找,在全局下找到了b的定义。但是执行的时候,eval强行在函数作用域里新增了一个b,因此遮蔽了外层的b,所以b的值为3。

    2️⃣with:先看下用法

    var obj = {
       a: 1,
       b: 2,
       c: 3  
    };
    
    // 修改obj的三个属性值
    
    obj.a = 2;
    obj.b = 3;
    obj.c = 4;
    
    //等同于
    
    with(obj) {
       a = 2;
       b = 3;
       c = 4;
    }

    这里是为了不重复引用obj自身。

    再看一个问题:

    function foo(obj) {
        with(obj) {
            a = 2
        }
    }
    
    var o1 = {
        a: 1
    };
    
    var o2 = {
        b: 1
    };
    
    foo(o1);
    
    o1.a;    // 2
    
    foo(o2);
    
    o2.a;    // undefined
    
    a;    // 2

    实际上with语句内部将对象处理为一个完全隔离的词法作用域,因此这个对象的属性就被处理为这个作用域中的词法标识符。所以说,在with语句中的a = 2在执行的时候,相当于进行了LHS引用,在o1这个“对象作用域”中进行a的LHS查找,发现有a这个“标识符”,所以这时候对a进行重新赋值,但是在o2中没有找到,因此继续到外层词法作用域查找,一直到全局下都没找到,因此在全局作用域下生成一个a,然而这并不是我们想要的。

    所以eval和with的区别就是,eval是在当前所在的词法作用域下进行修改,而with则是根据传递的对象新增了一个词法作用域。

    之所以不建议使用eval和with的原因是,前面提到了,JavaScript引擎会在编译阶段进行数项的性能优化,有些优化依赖于静态分析时所有的变量及函数定义的位置,才能在执行的过程中快速定义标识符。如果在执行的时候改变了词法作用域,那么之前做的优化等于无用功,反而会拖慢引擎,性能降低。所以这也是eval和with被禁止的原因。

    2,函数作用域

    在任意代码块外面添加包装函数,可以将部分变量变为私有,外部无法访问到包装函数内部的任何内容。但是带来的问题是,会在全局下定义一堆函数声明(函数声明简单来说就是function开头的代码块,注意:(function.... 这个不是函数声明,因为它不是function开头,而是(function开头 ),并且函数声明必须命名,所以也会污染到全局环境,而且必须手动调用才行。

    解决方案是,将函数声明改为函数表达式。

    (function foo() { var a = 3 })()

    这种函数表达式无法在全局下被访问到,不会污染全局,并且会立即执行,无需手调。(补充一点,后面的括号里可以传参)

    关于立即执行的函数,有两种写法:

    (function foo(){})();
    
    (function foo(){}());

    两者在功能上一致,使用哪个全凭个人喜好。第一种好像偏多,还有一个专业术语:IIFE

    函数表达式也可以是匿名的,比如setTimeout的回调函数,一般都是匿名的,但是匿名函数的缺点就是不方便进行调试,并且执行递归的过程中,无法使用自身,argument.callee已经被禁止了,因此解决方案是在匿名函数前面加一个函数名:

    setTimeout(function foo() {
        // ...
    }, 100);  
    

    3,块作用域

    ES6之前,JavaScript几乎没有块级作用域。(除了with语句和try/catch语句中的catch)。ES6之后就出现了块级作用域,尤其是let和const能对当前作用域进行挟持。

    end

  • 相关阅读:
    「UVA12293」 Box Game
    「CF803C」 Maximal GCD
    「CF525D」Arthur and Walls
    「CF442C」 Artem and Array
    LeetCode lcci 16.03 交点
    LeetCode 1305 两棵二叉搜索树中的所有元素
    LeetCode 1040 移动石子直到连续 II
    LeetCode 664 奇怪的打印机
    iOS UIPageViewController系统方法崩溃修复
    LeetCode 334 递增的三元子序列
  • 原文地址:https://www.cnblogs.com/yanchenyu/p/8940183.html
Copyright © 2011-2022 走看看