静态作用域 vs 动态作用域

中英文对照表

本文目标

本文力求读者在阅读完本文后,能够充分理解静态作用域和动态作用域在实际应用中的区别。为了达到这个目标,我会以文末列出的参考资料为准,并结合自己的理解,尽可能使用通俗易懂但力求准确的语言来进行描述。如果有任何意见或建议,请到这里留言:https://github.com/ChrisZ-B612/ChrisZ-B612.github.io/issues/1

正文部分

什么是作用域?

如果我们把函数也视为一种变量的话,那么简单来讲,所谓的作用域(或者说是变量的作用域),其实就是变量的可见范围。说白了,在程序执行时的某个节点上,你能够访问到某个变量,那么你就处在这个变量的作用域范围内,否则你就在这个变量的作用域范围之外。

静态作用域 vs 动态作用域

在静态作用域中,变量的解析取决于变量在代码中的位置,也就是变量被定义的地方。而动态作用域的变量解析依赖于变量被访问时所处的执行上下文。在实际应用中,静态作用域的变量解析会先从它所处的代码块开始找,如果没找到就到外层的代码块中继续找。而动态作用域的变量解析会先从当前正在执行的函数中开始找,如果没找到就继续到调用当前执行函数的函数中去找。

两者的区别

静态作用域和动态作用域的根本区别在于对作用域的定义不同。静态作用域是由源代码决定,也就是说,你的代码写好了,那么代码中的变量的作用域也就确定下来了。动态作用域是在程序执行时确定下来的。比如说有一个函数myFunc,函数myFunc中会访问一个变量myVar(假设函数myFunc内部并没有定义变量myVar),那么在静态作用域环境下,函数myFunc访问变量myVar的结果一定是在函数执行之前就已经确定下来的,而且是可以直接通过阅读源代码就知道的(访问到函数外的某个myVar变量,或者什么也访问不到)。而在动态作用域环境下,当函数myFunc被其他不同的函数调用时,它访问变量myVar的结果也可能不同(可能访问到同一个myVar变量,也可能访问到不同的myVar变量,也可能什么也访问不到)。

一起来看下面这段代码:

function a() {
    var x = 1;    // x1

    function b() {
        x = 2;    // 修改的是哪一个x呢?
    }

    function c() {
        var x;    // x2
        b();
    }

    c();
    console.log(x);
}

a();

为了方便描述,我给不同位置的变量x分别命名了别名x1和x2。接下来我们分别讨论上面这段代码在静态作用域和动态作用域环境下执行的过程、原因以及结果。

在静态作用域环境下,变量的作用域是由源代码决定、并且是在编译阶段就确定下来的。所以当函数b试图修改变量x时,它改动的永远是x1,即使它是在函数c中被调用、并且函数c中也有一个变量x的声明。所以最后打印的结果是2。

在动态作用域环境下,变量的作用域是在程序执行时确定下来的。当程序执行时,编译器会为每一个变量名维护一个全局的作用域栈,任何时候访问某个变量时,都从该变量对应的全局作用域栈的栈顶读取当前访问变量的可见作用域。我们以上面的代码为例,来看看在动态作用域环境下程序执行时都发生了什么:

  1. 程序执行到x1处,编译器发现了变量x的声明,并且变量名x还没有对应的全局作用域栈,于是为变量x创建一个全局的作用域栈并将x => x1压入栈中,此时变量x的作用域栈看起来就像这样:[x => x1]
  2. 程序执行到x2处,编译器又发现了变量x的声明,于是将x => x2压入到变量x的作用域栈中,此时变量x的作用域栈就像这样:[x => x1, x => x2]
  3. 函数b开始执行并试图修改变量x,于是编译器从变量x的作用域栈的栈顶读取此时变量x的作用域,读取到的结果是x => x2,所以真正被修改的是x2,而x1的值保持不变。接下来函数b执行结束返回函数c;
  4. 紧接着函数c也执行结束,同时对变量x的作用域栈执行出栈操作,此时变量x的作用域栈就像这样:[x => x1]
  5. 最后打印变量x时,编译器通过查看变量x的作用域栈的栈顶得知此时访问的其实是x1,因为x1的值并没有发生改变(之前被修改的是x2),所以打印的结果为1。

再看看下面这段略微有所变化的代码:

function a() {
    var x = 1;    // x1

    function b() {
        x = 2;    // 修改的是哪一个x呢?
    }

    function c() {
        var x;    // x2
        b();
    }

    c();
    console.log(x);
    b();
    console.log(x);
}

a();

相比前面的示例,上面的代码主要多了一行对函数b的直接调用,所以当上面这段代码执行时,函数b被调用了两次,一次是调用函数c时对函数b的间接调用,另一次是对函数b的直接调用。

基于之前的分析我们可以得知,在静态作用域环境下,对函数b的两次调用修改的都是x2,所以两次打印的结果都是2。而在动态作用域环境下,第一次打印的结果和之前一样是1。但是接下来直接调用函数b时,因为变量x的全局作用域栈的栈顶为x => x1,所以这一次改动的就是x1了,所以第二次打印的结果是2。与静态作用域环境下的执行不同,这里对函数b的两次调用修改的是不同的x变量。

两者的应用

大多数现代的编程语言使用的都是静态作用域,包括:JavaScript、Java、Go等等。使用动态作用域的语言包括:一些Lisp的变种(例如:Emacs Lisp)、一些脚本语言(例如:Perl、Bash、PowerShell)以及一些模板语言等等。还有一些语言可以让用户在定义或者重新定义变量时选择使用静态作用域还是动态作用域(例如:Perl、Common Lisp)。

两者的优缺点

静态作用域的优点在于,在程序执行之前就可以确定变量解析的结果,这样更易于人阅读和分析代码,也便于代码分析工具对代码进行分析和处理,而动态作用域的执行结果依赖于执行时的上下文,相比静态作用域而言更加难以预测,导致程序执行的不确定性大大增加。不过静态作用域也有缺点,就是当一个嵌套很深的函数在外部被调用时,如果访问了很外层的变量,那么这个变量解析的过程也比较耗时。总而言之,从实际应用的结果来看,静态作用域是大势所趋。

动态作用域的实现方法

动态作用域的实现方法有很多,我们列举其中一种方法:编译器会为每一个变量名维护一个全局的作用域栈,名称相同的变量共享同一个作用域栈,名称不同的变量的作用域栈也不同。当执行程序进入一个新的执行上下文时(例如调用了一个函数),如果发现了变量声明x,那么就将当前执行上下文压入到变量x的作用域栈中(如果作用域栈不存在就创建一个),当执行程序离开当前执行上下文时,再对变量x的作用域栈执行出栈操作。任何时候访问变量x时,都从变量x的作用域栈的栈顶读取它当前所处的作用域。所以,跟静态作用域不同的是,变量的全局作用域栈是在执行阶段动态创建的,这也是动态作用域名称的由来。

参考资料

  1. Static (Lexical) Scoping vs Dynamic Scoping (Pseudocode)
  2. Scope (computer science)