【小白打造编译器系列8】作用域和生存期:实现块作用域和函数

为什么要引入作用于和生存期呢?我们来看看下面几个问题:

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

 作用域(Scope)

作用域是指计算机语言中变量、函数、类等起作用的范围。我最早看到这个名词还是在 C Primier Plus 上,当时正在学C语言。

我们来看看下面的代码:

/*
scope.c
测试作用域。
 */
#include <stdio.h>

int a = 1;

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

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

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

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

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

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

    printf("main--6: a=%d b=%d \n", 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 

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

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

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

而不同语言的块作用域是不同的。比如 Java 的块作用域跟 C 语言的块作用域是不同的,它不允许块作用域里的变量覆盖外部变量。而JavaScript 是没有块作用域的。

这些都是 语义差别 的例子。对作用域的的分析就是语义分析的任务之一!

生存期(Extent)

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

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

/*
extent.c
测试生存期。
 */
#include <stdio.h>
#include <stdlib.h>

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("after called fun: b=%lu *b=%d \n", (unsigned long)p, *p);
 
    free(p);
}

实现作用域和栈

之前在写简单的编译器的时候,我们使用的是一个HashMap来记录变量的的值,从而实现可以通过变量名取引用。但如果变量存在多个作用域,这样做就不行了。这时,我们就要设计一个数据结构,区分不同变量的作用域。

我们观察一个变量的作用域,可以发现他其实就是一个树结构:

面向对象的语言不太相同,它不是一棵树,是一片树林,每个类对应一棵树,所以它也没有全局变量。我们设计了下面的对象结构来表示 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<Symbol> symbols = new LinkedList<Symbol>();
}

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

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

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

目前我们划分了三种作用域,分别是 块作用域(Block)函数作用域(Function)和 类作用域(Class)

我们在解析执行脚本的AST时候,需要建立其作用域的树结构,对作用域的分析过程是语义分析的一部分,也即是并不是有了AST我们就可以执行,而是在执行之前,需要进行语义分析,比如对作用域做分析,让每个变量都起到正确的引用。

还是看 Scope.c 的代码,随着代码的执行,各个变量的生存期表现如下:

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

我们来看看运行时栈的变化:

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

实现块作用域

目前,我们已经做好了作用域和栈,在这之后,就能实现很多功能了,比如让 if 语句和 for 循环语句使用块作用域和本地变量。

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

实现函数功能

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

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

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

总结

  • 对作用域的分析是语义分析的工作。
  • 栈桢能够保存当前作用域的所有本地变量的值,当退出这个作用域的时候,这个栈桢就被弹出,里面的变量也就失效了。

参考课程:《极客时间-编译原理之美》

发布了62 篇原创文章 · 获赞 34 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/weixin_41960890/article/details/105264833