<audio id="audio" title="08 | 作用域和生存期：实现块作用域和函数" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2c/31/2ccd0efea9df02bb7b7008fba5c9a931.mp3"></audio>

目前，我们已经用Antlr重构了脚本解释器，有了工具的帮助，我们可以实现更高级的功能，比如函数功能、面向对象功能。当然了，在这个过程中，我们还要克服一些挑战，比如：

- 如果要实现函数功能，要升级变量管理机制；
- 引入作用域机制，来保证变量的引用指向正确的变量定义；
- 提升变量存储机制，不能只把变量和它的值简单地扔到一个HashMap里，要管理它的生存期，减少对内存的占用。

本节课，我将借实现块作用域和函数功能，带你探讨作用域和生存期及其实现机制，并升级变量管理机制。那么什么是作用域和生存期，它们的重要性又体现在哪儿呢？

**“作用域”和“生存期”**是计算机语言中更加基础的概念，它们可以帮你深入地理解函数、块、闭包、面向对象、静态成员、本地变量和全局变量等概念。

而且一旦你深入理解，了解作用域与生存期在编译期和运行期的机制之后，就能解决在学习过程中可能遇到的一些问题，比如：

- 闭包的机理到底是什么？
- 为什么需要栈和堆两种机制来管理内存？它们的区别又是什么？
- 一个静态的内部类和普通的内部类有什么区别？

了解上面这些内容之后，接下来，我们来具体看看什么是作用域。

## 作用域（Scope）

作用域是指计算机语言中变量、函数、类等起作用的范围，我们来看一个具体的例子。

下面这段代码是用C语言写的，我们在全局以及函数fun中分别声明了a和b两个变量，然后在代码里对这些变量做了赋值操作：

```
/*
scope.c
测试作用域。
 */
#include &lt;stdio.h&gt;

int a = 1;

void fun()
{
    a = 2;
    //b = 3;   //出错，不知道b是谁
    int a = 3; //允许声明一个同名的变量吗？
    int b = a; //这里的a是哪个？
    printf(&quot;in fun: a=%d b=%d \n&quot;, a, b);
}

int b = 4; //b的作用域从这里开始

int main(int argc, char **argv){
    printf(&quot;main--1: a=%d b=%d \n&quot;, a, b);

    fun();
    printf(&quot;main--2: a=%d b=%d \n&quot;, a, b);

    //用本地变量覆盖全局变量
    int a = 5;
    int b = 5;
    printf(&quot;main--3: a=%d b=%d \n&quot;, a, b);

    //测试块作用域
    if (a &gt; 0){
        int b = 3; //允许在块里覆盖外面的变量
        printf(&quot;main--4: a=%d b=%d \n&quot;, a, b);
    }
    else{
        int b = 4; //跟if块里的b是两个不同的变量
        printf(&quot;main--5: a=%d b=%d \n&quot;, a, b);
    }

    printf(&quot;main--6: a=%d b=%d \n&quot;, a, b);
}

```

这段代码编译后运行，结果是：

```
main--1: a=1 b=4 
in fun: a=3 b=3 
main--2: a=2 b=4 
main--3: a=5 b=5 
main--4: a=5 b=3 
main--6: a=5 b=5 

```

我们可以得出这样的规律：

- 变量的作用域有大有小，外部变量在函数内可以访问，而函数中的本地变量，只有本地才可以访问。
- 变量的作用域，从声明以后开始。
- 在函数里，我们可以声明跟外部变量相同名称的变量，这个时候就覆盖了外部变量。

下面这张图直观地显示了示例代码中各个变量的作用域：

<img src="https://static001.geekbang.org/resource/image/2e/fc/2ea46e1b2d1a6c863f6830a7af5fd3fc.jpg" alt="">

另外，C语言里还有块作用域的概念，就是用花括号包围的语句，if和else后面就跟着这样的语句块。块作用域的特征跟函数作用域的特征相似，都可以访问外部变量，也可以用本地变量覆盖掉外部变量。

你可能会问：“其他语言也有块作用域吗？特征是一样的吗？”其实，各个语言在这方面的设计机制是不同的。比如，下面这段用Java写的代码里，我们用了一个if语句块，并且在if部分、else部分和外部分别声明了一个变量c：

```
/**
 * Scope.java
 * 测试Java的作用域
 */
public class ScopeTest{

    public static void main(String args[]){
        int a = 1;
        int b = 2;

        if (a &gt; 0){
            //int b = 3; //不允许声明与外部变量同名的变量
            int c = 3;
        }
        else{
            int c = 4;   //允许声明另一个c，各有各的作用域
        }
        
        int c = 5;  //这里也可以声明一个新的c
    }
}

```

你能看到，Java的块作用域跟C语言的块作用域是不同的，它不允许块作用域里的变量覆盖外部变量。那么和C、Java写起来很像的JavaScript呢？来看一看下面这段测试JavaScript作用域的代码：

```
/**
 * Scope.js
 * 测试JavaScript的作用域
 */
var a = 5;
var b = 5;
console.log(&quot;1: a=%d b=%d&quot;, a, b);

if (a &gt; 0) {
    a = 4;
    console.log(&quot;2: a=%d b=%d&quot;, a, b);
    var b = 3; //看似声明了一个新变量，其实还是引用的外部变量
    console.log(&quot;3: a=%d b=%d&quot;, a, b);
}
else {
    var b = 4;
    console.log(&quot;4: a=%d b=%d&quot;, a, b);
}

console.log(&quot;5: a=%d b=%d&quot;, a, b);

for (var b = 0; b&lt; 2; b++){  //这里是否能声明一个新变量，用于for循环？
    console.log(&quot;6-%d: a=%d b=%d&quot;,b, a, b);
}

console.log(&quot;7: a=%d b=%d&quot;, a, b);

```

这段代码编译后运行，结果是：

```
1: a=5 b=5
2: a=4 b=5
3: a=4 b=3
5: a=4 b=3
6-0: a=4 b=0
6-1: a=4 b=1
7: a=4 b=2

```

你可以看到，JavaScript是没有块作用域的。我们在块里和for语句试图重新定义变量b，语法上是允许的，但我们每次用到的其实是同一个变量。

对比了三种语言的作用域特征之后，你是否发现原来看上去差不多的语法，内部机理却不同？这种不同其实是语义差别的一个例子。**你要注意的是，现在我们讲的很多内容都已经属于语义的范畴了，对作用域的分析就是语义分析的任务之一。**

## 生存期（Extent）

了解了什么是作用域之后，我们再理解一下跟它紧密相关的生存期。它是变量可以访问的时间段，也就是从分配内存给它，到收回它的内存之间的时间。

在前面几个示例程序中，变量的生存期跟作用域是一致的。出了作用域，生存期也就结束了，变量所占用的内存也就被释放了。这是本地变量的标准特征，这些本地变量是用栈来管理的。

但也有一些情况，变量的生存期跟语法上的作用域不一致，比如在堆中申请的内存，退出作用域以后仍然会存在。

下面这段C语言的示例代码中，fun函数返回了一个整数的指针。出了函数以后，本地变量b就消失了，这个指针所占用的内存（&amp;b）就收回了，其中&amp;b是取b的地址，这个地址是指向栈里的一小块空间，因为b是栈里申请的。在这个栈里的小空间里保存了一个地址，指向在堆里申请的内存。这块内存，也就是用来实际保存数值2的空间，并没有被收回，我们必须手动使用free()函数来收回。

```
/*
extent.c
测试生存期。
 */
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;

int * fun(){
    int * b = (int*)malloc(1*sizeof(int)); //在堆中申请内存
    *b = 2;  //给该地址赋值2
   
    return b;
}

int main(int argc, char **argv){
    int * p = fun();
    *p = 3;

    printf(&quot;after called fun: b=%lu *b=%d \n&quot;, (unsigned long)p, *p);
 
    free(p);
}

```

类似的情况在Java里也有。Java的对象实例缺省情况下是在堆中生成的。下面的示例代码中，从一个方法中返回了对象的引用，我们可以基于这个引用继续修改对象的内容，这证明这个对象的内存并没有被释放：

```
/**
 * Extent2.java
 * 测试Java的生存期特性
 */
public class Extent2{
 
    StringBuffer myMethod(){
        StringBuffer b = new StringBuffer(); //在堆中生成对象实例
        b.append(&quot;Hello &quot;);
        System.out.println(System.identityHashCode(b)); //打印内存地址
        return b;  //返回对象引用，本质是一个内存地址
    }

    public static void main(String args[]){
        Extent2 extent2 = new Extent2();
        StringBuffer c = extent2.myMethod(); //获得对象引用
        System.out.println(c);
        c.append(&quot;World!&quot;);         //修改内存中的内容
        System.out.println(c);

        //跟在myMethod()中打印的值相同
        System.out.println(System.identityHashCode(c));
    }
}

```

因为Java对象所采用的内存超出了申请内存时所在的作用域，所以也就没有办法自动收回。所以Java采用的是自动内存管理机制，也就是垃圾回收技术。

那么为什么说作用域和生存期是计算机语言更加基础的概念呢？其实是因为它们对应到了运行时的内存管理的基本机制。虽然各门语言设计上的特性是不同的，但在运行期的机制都很相似，比如都会用到栈和堆来做内存管理。

好了，理解了作用域和生存期的原理之后，我们就来实现一下，先来设计一下作用域机制，然后再模拟实现一个栈。

## 实现作用域和栈

在之前的PlayScript脚本的实现中，处理变量赋值的时候，我们简单地把变量存在一个哈希表里，用变量名去引用，就像下面这样：

```
public class SimpleScript {
    private HashMap&lt;String, Integer&gt; variables = new HashMap&lt;String, Integer&gt;();
    ...
}

```

但如果变量存在多个作用域，这样做就不行了。这时，我们就要设计一个数据结构，区分不同变量的作用域。分析前面的代码，你可以看到作用域是一个树状的结构，比如Scope.c的作用域：

<img src="https://static001.geekbang.org/resource/image/2d/c8/2d3fc83aba7fe2fd7b29227e97184fc8.jpg" alt="">

面向对象的语言不太相同，它不是一棵树，是一片树林，每个类对应一棵树，所以它也没有全局变量。在我们的playscript语言中，我们设计了下面的对象结构来表示Scope：

```
//编译过程中产生的变量、函数、类、块，都被称作符号
public abstract class Symbol {
    //符号的名称
    protected String name = null;

    //所属作用域
    protected Scope enclosingScope = null;

    //可见性，比如public还是private
    protected int visibility = 0;

    //Symbol关联的AST节点
    protected ParserRuleContext ctx = null;
}

//作用域
public abstract class Scope extends Symbol{
    // 该Scope中的成员，包括变量、方法、类等。
    protected List&lt;Symbol&gt; symbols = new LinkedList&lt;Symbol&gt;();
}

//块作用域
public class BlockScope extends Scope{
    ...
}

//函数作用域
public class Function extends Scope implements FunctionType{
    ...  
}

//类作用域
public class Class extends Scope implements Type{
    ...
}

```

目前我们划分了三种作用域，分别是块作用域（Block）、函数作用域（Function）和类作用域（Class）。

我们在解释执行playscript的AST的时候，需要建立起作用域的树结构，对作用域的分析过程是语义分析的一部分。也就是说，并不是有了AST，我们马上就可以运行它，在运行之前，我们还要做语义分析，比如对作用域做分析，让每个变量都能做正确的引用，这样才能正确地执行这个程序。

解决了作用域的问题以后，再来看看如何解决生存期的问题。还是看Scope.c的代码，随着代码的执行，各个变量的生存期表现如下：

- 进入程序，全局变量逐一生效；
- 进入main函数，main函数里的变量顺序生效；
- 进入fun函数，fun函数里的变量顺序生效；
- 退出fun函数，fun函数里的变量失效；
- 进入if语句块，if语句块里的变量顺序生效；
- 退出if语句块，if语句块里的变量失效；
- 退出main函数，main函数里的变量失效；
- 退出程序，全局变量失效。

通过下面这张图，你能直观地看到运行过程中栈的变化：

<img src="https://static001.geekbang.org/resource/image/51/06/51f278ccd4fc7f28c6840e1d6b20bd06.jpg" alt="">

代码执行时进入和退出一个个作用域的过程，可以用栈来实现。每进入一个作用域，就往栈里压入一个数据结构，这个数据结构叫做**栈桢（Stack Frame）**。栈桢能够保存当前作用域的所有本地变量的值，当退出这个作用域的时候，这个栈桢就被弹出，里面的变量也就失效了。

你可以看到，栈的机制能够有效地使用内存，变量超出作用域的时候，就没有用了，就可以从内存中丢弃。我在ASTEvaluator.java中，用下面的数据结构来表示栈和栈桢，其中的PlayObject通过一个HashMap来保存各个变量的值：

```
private Stack&lt;StackFrame&gt; stack = new Stack&lt;StackFrame&gt;();

public class StackFrame {
    //该frame所对应的scope
    Scope scope = null;

    //enclosingScope所对应的frame
    StackFrame parentFrame = null;

    //实际存放变量的地方
    PlayObject object = null;
}

public class PlayObject {
    //成员变量
    protected Map&lt;Variable, Object&gt; fields = new HashMap&lt;Variable, Object&gt;();
}

```

目前，我们只是在概念上模仿栈桢，当我们用Java语言实现的时候，PlayObject对象是存放在堆里的，Java的所有对象都是存放在堆里的，只有基础数据类型，比如int和对象引用是放在栈里的。虽然只是模仿，这不妨碍我们建立栈桢的概念，在后端技术部分，我们会实现真正意义上的栈桢。

要注意的是，栈的结构和Scope的树状结构是不一致的。也就是说，栈里的上一级栈桢，不一定是Scope的父节点。要访问上一级Scope中的变量数据，要顺着栈桢的parentFrame去找。我在上图中展现了这种情况，在调用fun函数的时候，栈里一共有三个栈桢：全局栈桢、main()函数栈桢和fun()函数栈桢，其中main()函数栈桢的parentFrame和fun()函数栈桢的parentFrame都是全局栈桢。

## 实现块作用域

目前，我们已经做好了作用域和栈，在这之后，就能实现很多功能了，比如让if语句和for循环语句使用块作用域和本地变量。以for语句为例，visit方法里首先为它生成一个栈桢，并加入到栈中，运行完毕之后，再从栈里弹出：

```
BlockScope scope = (BlockScope) cr.node2Scope.get(ctx);  //获得Scope
StackFrame frame = new StackFrame(scope);  //创建一个栈桢
pushStack(frame);    //加入栈中

...

//运行完毕，弹出栈
stack.pop();

```

当我们在代码中需要获取某个变量的值的时候，首先在当前桢中寻找。找不到的话，就到上一级作用域对应的桢中去找：

```
StackFrame f = stack.peek();       //获取栈顶的桢
PlayObject valueContainer = null;
while (f != null) {
    //看变量是否属于当前栈桢里
    if (f.scope.containsSymbol(variable)){ 
        valueContainer = f.object;
        break;
    }
    //从上一级scope对应的栈桢里去找  
    f = f.parentFrame;
}

```

运行下面的测试代码，你会看到在执行完for循环以后，我们仍然可以声明另一个变量i，跟for循环中的i互不影响，这证明它们确实属于不同的作用域：

```
String script = &quot;int age = 44; for(int i = 0;i&lt;10;i++) { age = age + 2;} int i = 8;&quot;;

```

进一步的，我们可以实现对函数的支持。

## 实现函数功能

先来看一下与函数有关的语法：

```
//函数声明
functionDeclaration
    : typeTypeOrVoid? IDENTIFIER formalParameters ('[' ']')*
      functionBody
    ;
//函数体
functionBody
    : block
    | ';'
    ;
//类型或void
typeTypeOrVoid
    : typeType
    | VOID
    ;
//函数所有参数
formalParameters
    : '(' formalParameterList? ')'
    ;
//参数列表
formalParameterList
    : formalParameter (',' formalParameter)* (',' lastFormalParameter)?
    | lastFormalParameter
    ;
//单个参数
formalParameter
    : variableModifier* typeType variableDeclaratorId
    ;
//可变参数数量情况下，最后一个参数
lastFormalParameter
    : variableModifier* typeType '...' variableDeclaratorId
    ;
//函数调用    
functionCall
    : IDENTIFIER '(' expressionList? ')'
    | THIS '(' expressionList? ')'
    | SUPER '(' expressionList? ')'
    ;

```

在函数里，我们还要考虑一个额外的因素：**参数。**在函数内部，参数变量跟普通的本地变量在使用时没什么不同，在运行期，它们也像本地变量一样，保存在栈桢里。

我们设计一个对象来代表函数的定义，它包括参数列表和返回值的类型：

```
public class Function extends Scope implements FunctionType{
    // 参数
    protected List&lt;Variable&gt; parameters = new LinkedList&lt;Variable&gt;();

    //返回值
    protected Type returnType = null;
    
    ...
}

```

在调用函数时，我们实际上做了三步工作：

- 建立一个栈桢；
- 计算所有参数的值，并放入栈桢；
- 执行函数声明中的函数体。

我把相关代码放在了下面，你可以看一下：

```
//函数声明的AST节点
FunctionDeclarationContext functionCode = (FunctionDeclarationContext) function.ctx;

//创建栈桢
functionObject = new FunctionObject(function);
StackFrame functionFrame = new StackFrame(functionObject);

// 计算实参的值
List&lt;Object&gt; paramValues = new LinkedList&lt;Object&gt;();
if (ctx.expressionList() != null) {
    for (ExpressionContext exp : ctx.expressionList().expression()) {
        Object value = visitExpression(exp);
        if (value instanceof LValue) {
            value = ((LValue) value).getValue();
        }
        paramValues.add(value);
    }
}

//根据形参的名称，在栈桢中添加变量
if (functionCode.formalParameters().formalParameterList() != null) {
    for (int i = 0; i &lt; functionCode.formalParameters().formalParameterList().formalParameter().size(); i++) {
        FormalParameterContext param = functionCode.formalParameters().formalParameterList().formalParameter(i);
        LValue lValue = (LValue) visitVariableDeclaratorId(param.variableDeclaratorId());
        lValue.setValue(paramValues.get(i));
    }
}

// 调用方法体
rtn = visitFunctionDeclaration(functionCode);

// 运行完毕，弹出栈
stack.pop();

```

你可以用playscript测试一下函数执行的效果，看看参数传递和作用域的效果：

```
String script = &quot;int b= 10; int myfunc(int a) {return a+b+3;} myfunc(2);&quot;;

```

## 课程小结

本节课，我带你实现了块作用域和函数，还跟你一起探究了计算机语言的两个底层概念：作用域和生存期。你要知道：

- 对作用域的分析是语义分析的一项工作。Antlr能够完成很多词法分析和语法分析的工作，但语义分析工作需要我们自己做。
- 变量的生存期涉及运行期的内存管理，也引出了栈桢和堆的概念，我会在编译器后端技术时进一步阐述。

我建议你在学习新语言的时候，先了解它在作用域和生存期上的特点，然后像示例程序那样做几个例子，借此你会更快理解语言的设计思想。比如，为什么需要命名空间这个特性？全局变量可能带来什么问题？类的静态成员与普通成员有什么区别？等等。

下一讲，我们会尝试实现面向对象特性，看看面向对象语言在语义上是怎么设计的，以及在运行期有什么特点。

## 一课一思

既然我强调了作用域和生存期的重要性，那么在你熟悉的语言中，有哪些特性是能用作用域和生存期的概念做更基础的解读呢？比如，面向对象的语言中，对象成员的作用域和生存期是怎样的？欢迎在留言区与大家一起交流。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

今天讲的功能照样能在playscript-java项目中找到示例代码，其中还有用playscript写的脚本，你可以多玩一玩。

- playscript-java（项目目录）： [码云](https://gitee.com/richard-gong/PlayWithCompiler/tree/master/playscript-java)  [GitHub](https://github.com/RichardGong/PlayWithCompiler/tree/master/playscript-java)
- PlayScript.java（入口程序）：[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.java)   [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.java)
- PlayScript.g4（语法规则）：[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.g4)   [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.g4)
- ASTEvaluator.java（解释器）：[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ASTEvaluator.java)   [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ASTEvaluator.java)
- BlockScope.play（演示块作用域）：[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/examples/BlockScope.play)   [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/examples/BlockScope.play)
- function.play（演示基础函数功能）：[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/examples/function.play)   [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/examples/function.play)
- lab/scope目录（各种语言的作用域测试）：[码云](https://gitee.com/richard-gong/PlayWithCompiler/tree/master/lab/scope)    [GitHub](https://github.com/RichardGong/PlayWithCompiler/tree/master/lab/scope)


