之前的文章讲解了编译的基础以及如何用 Antlr 重构了脚本解释器(感兴趣的可以去看下哈),对编译已经有了一定的认识。本篇将进一步讲解作用域和生存期相关的知识。主要解决如下几方面问题:
- 升级变量管理机制,实现函数功能;
- 引入作用域机制,来保证变量的引用指向正确的变量定义;
- 提升变量存储机制,不能只把变量和它的值简单地扔到一个 HashMap 里,要管理它的生存期,减少对内存的占用。
作用域 、生存期是计算机语言中更加基础的概念,可以帮我们深入地理解函数、块、闭包、面向对象、静态成员、本地变量和全局变量等概念。有助于我们解决在学习过程中可能遇到的一些问题,比如:
- 闭包的机理到底是什么?
- 为什么需要栈和堆两种机制来管理内存?它们的区别又是什么?
- 一个静态的内部类和普通的内部类有什么区别?
接下来我们一个个介绍啊
作用域(Scope)
作用域是指计算机语言中变量、函数、类等起作用的范围。一个变量的作用域是程序源代码中定义的这个变量的区域。 下面用JS 语言写了一个例子,在全局以及函数 fun1 中分别声明了 a 和 b 两个变量,这里我们在非严格模式下用var声明变量(用 let 会有不一样的结果哦) ,然后在代码里对这些变量做了赋值操作:
/*
scope.js
测试作用域。
*/
var a = 1;
function fun1() {
a = 2;
b = 2;
var a = 3;
var b = a;
console.log("inner", a, b);
}
var b = 4;
function testVar() {
console.log("outter1", a, b);
fun1();
console.log("outter2", a, b);
// 用本地变量覆盖全局变量
var c = 5;
var d = 5;
console.log("outter3", c, d);
// 块级作用域
if (c > 0) {
var c = 3; // 允许在块里覆盖外面的变量
console.log("block1", c, d);
} else {
var c = 4;
console.log("block2", c, d);
}
console.log("outter4", c, d);
}
testVar();
在chorme 中运行结果如下:
outter1 1 4
inner 3 3
outter2 1 4
outter3 5 5
block1 3 5
outter4 3 5
说到这里简单说下let 和 var 的区别
- 用var声明的变量会被提升到其作用域的顶部,并使用 undefined 值对其进行初始化。
- 用let声明的变量会被提升到其作用域的顶部,不会对值进行初始化。如果你尝试在声明前使用let变量,则会报Reference Error。
相关文章 严格模式与非严格模式的区别 ,let、var、const 区别
通过上面的例子我们可以得出这样的规律:
- 变量的作用域有大有小,外部变量在函数内可以访问,而函数中的本地变量,只有本地才可以访问。
- 变量的作用域,从声明以后开始。
- 在函数里,用var声明变量时,我们可以声明跟外部变量相同名称的变量,这个时候就覆盖了外部变量。
另外,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 > 0){
//int b = 3; // 不允许声明与外部变量同名的变量
int c = 3;
}
else{
int c = 4; // 允许声明另一个 c,各有各的作用域
}
int c = 5; // 这里也可以声明一个新的 c
}
}
能看到,Java 的块作用域跟 C 语言的块作用域是不同的,它不允许块作用域里的变量覆盖外部变量。那么和 C、Java 写起来很像的 JavaScript 呢?来看一看下面这两段测试 JavaScript 作用域的代码分别的运行结果:
用var声明变量
var a = 5;
var b = 5;
console.log("1: a=%d b=%d", a, b);
if (a > 0) {
a = 4;
console.log("2: a=%d b=%d", a, b);
var b = 3; // 看似声明了一个新变量,其实还是引用的外部变量
console.log("3: a=%d b=%d", a, b);
}
else {
var b = 4;
console.log("4: a=%d b=%d", a, b);
}
console.log("5: a=%d b=%d", a, b);
for (var b = 0; b< 2; b++){ // 这里是否能声明一个新变量,用于 for 循环?
console.log("6-%d: a=%d b=%d",b, a, b);
}
console.log("7: a=%d b=%d", 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
用let声明变量
let a = 5;
let b = 5;
console.log("1: a=%d b=%d", a, b);
if (a > 0) {
a = 4;
console.log("2: a=%d b=%d", a, b); // 引用报错了,不能访问外部的b变量
let b = 3; // 不能进行变量提升
console.log("3: a=%d b=%d", a, b);
} else {
let b = 4;
console.log("4: a=%d b=%d", a, b);
}
...同上面的例子
结果
1: a=5 b=5
VM1151:7 Uncaught ReferenceError: Cannot access 'b' before initialization
at <anonymous>:7:36
可以看到 JavaScript 中 用var声明变量时 是没有块作用域的 。改用 let 声明就产生了块作用域 这也是 let var 声明变量的主要区别。
对比了三种语言的作用域特征之后,你是否发现原来看上去差不多的语法,内部机理却不同?这种不同其实是语义差别的一个例子。本篇所提及的很多内容都已经属于语义的范畴了,对作用域的分析就是语义分析的任务之一。
了解了什么是作用域之后,我们再理解一下跟它紧密相关的生存期。
生存期(Extent)
生存期 是变量可以访问的时间段,也就是从分配内存给它,到收回它的内存之间的时间。
从前面几个示例程序中,可以看到变量的生存期跟作用域是一致的。出了作用域,生存期也就结束了,变量所占用的内存也就被释放了。这是本地变量的标准特征,本地变量是用栈来管理的。
也有一些情况,变量的生存期跟语法上的作用域不一致,比如在堆中申请的内存,退出作用域以后仍然会存在。
下面这段 C 语言的示例代码中,fun 函数返回了一个整数的指针。出了函数以后,本地变量 b 就消失了,这个指针所占用的内存(&b)就收回了,其中 &b 是取 b 的地址,这个地址是指向栈里的一小块空间,因为 b 是栈里申请的。在这个栈里的小空间里保存了一个地址,指向在堆里申请的内存。这块内存,也就是用来实际保存数值 2 的空间,并没有被收回,我们必须手动使用 free() 函数来收回。
// 引用自极客时间
#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);
}
我们在来看下 Java中的情况, Java 的对象实例缺省情况下是在堆中生成的。下面的示例代码中,从一个方法中返回了对象的引用,我们可以基于这个引用继续修改对象的内容,这证明这个对象的内存并没有被释放:
// 引用自极客时间
public class Extent2{
StringBuffer myMethod(){
StringBuffer b = new StringBuffer(); // 在堆中生成对象实例
b.append("Hello ");
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("World!"); // 修改内存中的内容
System.out.println(c);
// 跟在 myMethod() 中打印的值相同
System.out.println(System.identityHashCode(c));
}
}
因为 Java 对象所采用的内存超出了申请内存时所在的作用域,所以也就没有办法自动收回。所以 Java 采用的是自动内存管理机制,也就是垃圾回收机制。
对于JavaScript变量存储的可以参考我的这篇文章:传送门
为什么说作用域和生存期是计算机语言更加基础的概念呢?因为它们对应到了运行时的内存管理的基本机制。虽然各门语言设计上的特性是不同的,但在运行期的机制都很相似,比如都会用到栈和堆来做内存管理。
如何实现作用域和栈?
在之前的 文章提到过在处理变量赋值的时候,可以把变量存在一个哈希表里,用变量名去引用,就像下面这样:
public class SimpleScript {
private HashMap<String, Integer> variables = new HashMap<String, Integer>();
...
}
但如果变量存在多个作用域,这样做就不行了。这时,我们就要设计一个数据结构,区分不同变量的作用域。分析前面的代码,你可以看到作用域是一个树状的结构:
面向对象的语言不太相同,它不是一棵树,是一片树林,每个类对应一棵树,所以它也没有全局变量。
注意:javascript是基于对象的语言,因为它没有提供象抽象、继承、重载等有关面向对象语言的许多功能。只是把复杂对象统一起来,从而形成一个还算强大的对象系统。
下面是用Java语法设计的作用域,划分了三种作用域,分别是块作用域(Block)、函数作用域(Function)和类作用域(Class)
// 编译过程中产生的变量、函数、类、块,都被称作符号
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{
...
}
在解释执行 AST 的时候,需要建立起作用域的树结构,对作用域的分析过程是语义分析的一部分。也就是说,并不是有了 AST,我们马上就可以运行它,在运行之前,我们还要做语义分析,比如对作用域做分析,让每个变量都能做正确的引用,这样才能正确地执行这个程序。
再来看下 scope.js 执行过程中,各个变量的生存期表现:
- 进入程序,全局变量逐一生效;
- 进入 testVar 函数,testVar 函数里的变量顺序生效;
- 进入 fun1 函数,fun1 函数里的变量顺序生效;
- 退出 fun1 函数,fun1 函数里的变量失效;
- 进入 if 语句块,if 语句块里的变量顺序生效;
- 退出 if 语句块,if 语句块里的变量失效;
- 退出 testVar 函数,testVar 函数里的变量失效;
- 退出程序,全局变量失效。
下面是运行过程中栈的变化:
代码执行时进入和退出一个个作用域的过程,可以用栈来实现。每进入一个作用域,就往栈里压入一个数据结构,这个数据结构叫做栈桢(Stack Frame)。栈桢能够保存当前作用域的所有本地变量的值,当退出这个作用域的时候,这个栈桢就被弹出,里面的变量也就失效了。
栈的机制能够有效地使用内存,变量超出作用域的时候,就没有用了,就可以从内存中丢弃。用下面的数据结构来表示栈和栈桢,其中的 PlayObject 通过一个 HashMap 来保存各个变量的值:
// 栈
private Stack<StackFrame> stack = new Stack<StackFrame>();
// 栈桢
public class StackFrame {
// 该 frame 所对应的 scope
Scope scope = null;
//enclosingScope 所对应的 frame
StackFrame parentFrame = null;
// 实际存放变量的地方
PlayObject object = null;
}
public class PlayObject {
// 成员变量
protected Map<Variable, Object> fields = new HashMap<Variable, Object>();
}
目前,只是在概念上模仿栈桢,当我们用 Java 语言实现的时候,PlayObject 对象是存放在堆里的,Java 的所有对象都是存放在堆里的,只有基础数据类型,比如 int 和对象引用是放在栈里的。JavaScript中变量存储机制也是类似的,可以参看:JavaScript中 变量到底是存储在栈还是堆上呢?
要注意的是,栈的结构和 Scope 的树状结构是不一致的。也就是说,栈里的上一级栈桢,不一定是 Scope
的父节点。要访问上一级 Scope
中的变量数据,要顺着栈桢的 parentFrame
去找。我在上图中展现了这种情况,在调用 fun1
函数的时候,栈里一共有三个栈桢:全局栈桢、testVar
函数栈桢和 fun1
函数栈桢,其中 testVar
函数栈桢的 parentFrame 和 fun1
函数栈桢的 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 互不影响,这证明它们确实属于不同的作用域:
script = "int age = 44; for(int i = 0;i<10;i++) { age = age + 2;} int i = 8;";
实现函数功能
先来看一下与函数有关的语法:
// 函数声明
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<Variable> parameters = new LinkedList<Variable>();
// 返回值
protected Type returnType = null;
...
}
在调用函数时,我们实际上做了三步工作:
- 建立一个栈桢;
- 计算所有参数的值,并放入栈桢;
- 执行函数声明中的函数体。
相关代码如下:
// 函数声明的 AST 节点
FunctionDeclarationContext functionCode = (FunctionDeclarationContext) function.ctx;
// 创建栈桢
functionObject = new FunctionObject(function);
StackFrame functionFrame = new StackFrame(functionObject);
// 计算实参的值
List<Object> paramValues = new LinkedList<Object>();
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 < 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();
综上实现了块作用域和函数,以及探究了计算机语言的两个底层概念:作用域和生存期
- 对作用域的分析是语义分析的一项工作。Antlr 能够完成很多词法分析和语法分析的工作,但语义分析工作需要我们自己做。
- 变量的生存期涉及运行期的内存管理,也引出了栈桢和堆的概念。