为什么要引入作用于和生存期呢?我们来看看下面几个问题:
- 如果要实现函数功能,要升级变量管理机制;
- 引入作用域机制,来保证变量的引用指向正确的变量定义;
- 提升变量存储机制,不能只把变量和它的值简单地扔到一个 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
我们可以得出这样的规律:
- 变量的作用域有大有小,外部变量在函数内可以访问,而函数中的本地变量,只有本地才可以访问。
- 变量的作用域,从声明以后开始。
- 在函数里,我们可以声明跟外部变量相同名称的变量,这个时候就覆盖了外部变量。
另外,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 循环语句使用块作用域和本地变量。
当我们在代码中需要获取某个变量的值的时候,首先在当前桢中寻找。找不到的话,就到上一级作用域对应的桢中去找。
实现函数功能
在函数里,我们还要考虑一个额外的因素:参数。在函数内部,参数变量跟普通的本地变量在使用时没什么不同,在运行期,它们也像本地变量一样,保存在栈桢里。
在调用函数时,我们实际上做了三步工作:
- 建立一个栈桢;
- 计算所有参数的值,并放入栈桢;
- 执行函数声明中的函数体。
总结
- 对作用域的分析是语义分析的工作。
- 栈桢能够保存当前作用域的所有本地变量的值,当退出这个作用域的时候,这个栈桢就被弹出,里面的变量也就失效了。
参考课程:《极客时间-编译原理之美》
转载:https://blog.csdn.net/weixin_41960890/article/details/105264833