大厂面试官(2):请你细说作用域的底层运算逻辑。

165 阅读9分钟

前言

现在的面试简直卷疯了!如果你对知识没有更深层的理解,那你在面试阶段会吃大亏。本系列将让你在面试阶段、在面试官提出问题时,用简洁,专业的语言将面试官给征服。文章适用于大厂面试,我的文章理念就是面向大厂的学习。

定义

要想知道作用域的底层运算逻辑,我们先要知道什么是作用域

作用域:指的是程序中一个标识符(函数名,变量等)的作用有效范围或生命周期。简单来说,就是作用域决定了一个程序的哪些部分可以访问某个特定的标识符。

一般来说,最常用的有这几种作用域类型:

  1. 全局作用域(Global Scope):在整个程序中都可以访问的变量或函数被称为具有全局作用域。这意味着它们可以在程序的任何地方被引用。
  2. 局部作用域(Local Scope):局部作用域通常指的是在函数或代码块内部定义的变量或函数。这些变量或函数只能在它们被定义的那个函数或代码块内被访问。一旦执行流离开这个区域,这些变量就不再可用。
  3. 块作用域(Block Scope):在某些语言中,如JavaScript中的letconst关键字声明的变量,或者C++中的变量,它们的作用域限于它们所在的代码块(例如,if语句或for循环内的大括号 {} 内部)。这种作用域比函数级作用域更细粒度,提供了更好的封装性和避免命名冲突的能力。
  4. 函数作用域(Function Scope):与块作用域类似,但是它特指那些只在函数内部有效的作用域。例如,在JavaScript中使用var关键字声明的变量就具有函数作用域。

分析

首先,我们先来分析一句看起来最简单的代码。

 var a = 1;

乍一看 ,这不就是声明一个变量a等于1吗。但如果让你回答它的更深层的意义,你还知道吗?

让我们来对这行代码进行一个深挖。

其实每行代码在运行的过程中都要经历许多块件,验证等等。

我们可以将这行代码分为两个部分:var aa =1

var a是代码的编译阶段,编译阶段会经过以下处理才能进行下一个阶段:

  1. 预处理(Preprocessing)

    • 预处理器处理源代码中的预处理指令,如宏定义(#define)、条件编译(#ifdef#endif)和文件包含(#include)等。
    • 例如,#include <stdio.h> 会被替换为 stdio.h 文件的内容。
  2. 词法分析(Lexical Analysis)

    • 这个阶段也称为扫描(Scanning),编译器将源代码转换成一系列的记号(Token)。记号是语言的基本单位,如关键字、标识符、常量、运算符等。
    • 例如,int main() 可能会被分解为 intmain 和 () 等记号。
  3. 语法分析(Syntax Analysis)

    • 编译器根据语言的语法规则(通常是上下文无关文法)解析记号序列,构建一个抽象语法树(Abstract Syntax Tree, AST)。
    • AST 是源代码的中间表示形式,反映了程序的结构和逻辑。
    • 例如,int x = 10; 可能会被解析为一个赋值表达式的节点,其中包含一个变量声明和一个常量。
  4. 语义分析(Semantic Analysis)

    • 在这个阶段,编译器检查 AST 中的语义错误,确保程序符合语言的语义规则。
    • 这包括类型检查、符号表管理、作用域分析等。
    • 例如,编译器会检查变量是否已经声明、类型是否匹配等。

经过以上的几个步骤,我们才来到了下一个步骤

a =1是代码的执行阶段,也要经历许多过程才能执行:

  1. 加载:程序被加载到内存中,包括 main 函数和 greet 函数。

  2. 初始化:全局变量和静态变量被初始化(如果有的话)。

  3. 主函数调用:操作系统调用 main 函数。

  4. 指令执行:CPU 执行 main 函数中的第一条指令,即调用 greet 函数。

  5. 函数调用和返回

    • CPU 保存当前状态,跳转到 greet 函数的入口点。
    • greet 函数执行 printf 函数,输出 "Hello, World!"。
    • greet 函数返回,CPU 恢复之前保存的状态,继续执行 main 函数。
  6. 指令执行main 函数执行 return 0;,返回值 0 表示程序正常结束。

  7. 终止:程序释放资源,控制权返回给操作系统,程序终止。

总结就是:

程序把一部分内存分给变量a。

把数值1 载入。

将数值1载入a中

进一步分析

以上所有的步骤,都必须有一个前提就是,在有效的作用域中。在执行var a =1 时,有这些东西会为它而工作

引擎:负责整个程序的编译与执行。可以把它比作公司中的CEO职位。
编译器:负责代码生成和分析语句。可以把它比作公司中的CTO职位。
作用域:负责代码的可读性和可维护性,收集所有声明的标志符。可以比作公司中的COO职位。

当我们在分析var a =1时,我们可能仅认为它是一个声明语句而已,但我们的引擎却不会这样认为。和上面一样,他会认为这行代码有两个部分,var a 是由编译器在编译阶段处理,后面的 a =1 才是我引擎在运行时该处理的。

在编译器处理var a 时候,它会先询问作用域中是否已经声明了一个变量a。若没有声明,则声明一个变量a。 若已经声明过变量a,则我们会忽略之前声明的变量a,以当前声明的变量a,继续向下编译。

接下来编译器将会编译引擎所需要的代码去处理a = 1部分,引擎会问作用域,a是否在当前执行的作用域中,若存在,引擎则会拿走a的值。若不存在,引擎则会沿着作用域链(下文会讲)一直查找变量a,直到找到为止。若一直没找到,则会输出 undefined

重点

我们在对a的处理及判断是否声明,是要通过什么方法查询呢?

这就是我们这篇文章要讲的重点, LHS(Left-Hand Search)和RHS(Right-Hand Search)查询。让我们通过两个简单的例子来解释LHS 和 RHS。

 var a = 1

这个就是经典的LHS查询,它对变量a进行了赋值,LHS是找到变量的容器这样一个操作,即locate the variable's container

console.log(a);

这是一个经典的RHS查询,它对变量a进行了取值,RHS理解为查询某个变量的值,即retrieve his source value得到他的源值。

最简单的说法,LHS是赋值操作,RHS是取值操作

让我们再通过一道较为复杂的题目来了解一下LHS和RHS查询

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

这里分别用了几次LHS和RHS呢?

LHS引用(共三处):var b = , var c =a=2,在foo(2)中隐藏的将2赋值给a

RHS引用(共四处):foo(2),获取foo()中的值...=a 获取a的值给breturn areturn b

LHS 引用用于找到变量的容器并对其赋值,而 RHS 引用用于获取变量的值。 在分析代码时,区分 LHS 和 RHS 引用可以帮助我们更好地理解代码的执行过程以及作用域的查找规则。

image.png

作用域嵌套

定义

作用域嵌套是指在一个作用域内部存在另一个作用域的情况。我们在一个内部作用域没有找到目标变量时,我们会继续向与之嵌套作用域内查找目标变量,直到查完所有作用域或找到目标变量为止。

作用域链

在上文中我们提到了一句作用域链,那么作用域链到底是什么呢?

作用域链确保了当一个变量被引用时,解释器或编译器能够找到正确的变量值。这个过程涉及到从当前作用域开始向上查找,直到找到目标变量或到达最外层作用域为止。从当前作用域父级作用域全局作用域未找到。按顺序查找

举个例子

再狗血爱情电视剧中,总有女主角被困在数不清的房屋中的其中一个,此时我们的男主角一头雾水,不知道从哪找起。随意乱找,容易漏掉某个女主角可能在的屋子,或搜索已经找过的屋子,所以按顺序找是最稳妥得一种方法,这也符合我们作用域链的工作原理

image.png

当男主角在最下方的当前作用域开始寻找时,他会沿着楼层一层层的向上找,直到在某一层,某一间找到女主角他才会停下来,然后抱得美人归。如果搜索完整栋楼都没有找到女主角,那么他会意识到被反派大BOSS给欺骗了,气急攻心,先女主角一步走了。

再用代码加强记忆

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

image.png 这行代码会爆出错误//ReferenceError:b is not defined,这是因为在用RHS查找b失败。因为b没有被声明。

可下面这代码结果是什么呢?

 function foo() {
  b = 2
 }
foo()
console.log(b)
}

是不是认为这也会爆出错误呢?

image.png 为什么?他也没有声明变量b啊。为什么他可以输出2?

但你仔细看会发现,这是LHS查询,上面却是RHS查询,

当RHS查询失败时,会抛出ReferenceError:b is not defined异常。

当LHS查询失败时,会默认声明为全局变量。

     "use strict";
  // 严格模式时, 
     function foo(){
   b =2 ;
    }
  foo();
  

此时,他又抛出了同样的错误。ReferenceError: b is not defined 这是因为我们使用了严格模式,在使用严格模式时,会为了防止以外的全局变量,保护程序,禁止默认声明全局变量。

      var a 
     a =2; //LHS 赋值 number
     a(); //RHS a number 没有 执行操作, TypeError 

这是会抛出另一个错误:TypeError: a is not a function

这是因为a并不是一个函数,没有a();方法可以调用。

a是一个数字类型,因此会抛出TypeError类型错误

总结

在理解了这些底层原理后,我们在大厂面试官面前就可以自信,完美的回答出答案。大厂和小厂的区别就在这里,更细致和更基础,把底层原理吃透,面试官对你的兴趣会更高。祝大家都能进大厂。