第 21 章:全局变量(Global Variables)
原文:Robert Nystrom, Crafting Interpreters, Chapter 21。原书以 CC BY-NC-SA 4.0 协议发布;本译文用于学习与研究。
要是能有一种发明,能把记忆像香气一样装进瓶里就好了。它永不褪色,也永不过期;当人想重温时,只须拔开瓶塞,就像再活一次那段时光。
Daphne du Maurier,《蝴蝶梦》
上一章围绕一个重要而基础的数据结构进行了漫长探索,其中有理论、概念、大 O 记号与算法。本章没有那么大的野心:它是一组直接的工程任务。完成之后,虚拟机将支持变量。
准确地说,先支持全局变量;局部变量留给下一章。在 jlox 中,二者可用同一种技术实现:每个作用域一张环境哈希表,串成一直通向最外层的链。这种方案清楚而适合学习状态管理,但速度不佳。每次进入块或调用函数都分配哈希表,绝不是高性能 VM 的道路;变量使用极频繁,变量慢,程序几乎所有部分都会慢。
成熟语言实现经常为同一种语言特性准备多套实现,各自针对不同使用模式优化。例如 JavaScript VM 往往为类似类实例的对象提供比属性集合任意变化的对象更快的表示;C/C++ 编译器也会按 case 数量及其密集程度选择不同的 switch 编译方式。
回顾 Lox 语义:全局变量采用晚绑定(late binding),即动态解析。代码可以在全局变量定义之前被编译,只要在变量定义之前不执行那段代码即可。因此函数体可以引用稍后声明的变量:
fun showVariable() {
print global;
}
var global = "after";
showVariable();
这对互相递归的函数很有用,也更适合 REPL:可先输入函数,再在下一行定义其使用的变量。局部变量则不同:局部声明总是在使用前出现,所以即使是单遍编译器也能在编译期解析它们,并使用更聪明的表示。现在先只处理全局变量。
21.1 语句(Statements)
变量通过变量声明出现,因此编译器也该支持语句了。Lox 把语句分为两类:声明(declaration)绑定新名称和值;控制流、print 等其余种类统称语句。我们不允许直接在控制流语句内部声明:
if (monday) var croissant = "yes"; // Error.
否则变量作用域会变得令人困惑。和许多语言一样,Lox 通过单独的语法规则,在语法层禁止它:
statement -> exprStmt
| forStmt
| ifStmt
| printStmt
| returnStmt
| whileStmt
| block ;
declaration -> classDecl
| funDecl
| varDecl
| statement ;
顶层脚本和块内部使用 declaration;控制流体只使用 statement。由于 block 本身是语句,仍可以在控制流中嵌套一个块来放置声明。块之于声明,近似于圆括号之于表达式:它让“较低优先级”的声明能放进只允许“较高优先级”非声明语句的位置。
本章暂时只实现两类语句和一种声明:
statement -> exprStmt | printStmt ;
declaration -> varDecl | statement ;
此前 VM 把“程序”当作单个表达式;完整 Lox 程序则是一串声明。compile() 不再只调用一次 expression(),而是一直编译到文件结束:
advance();
while (!match(TOKEN_EOF)) {
declaration();
}
endCompiler();
初始版本的 declaration() 只转发给 statement();二者将来都会递归,因而先写出前置声明:
static void expression();
static void statement();
static void declaration();
static ParseRule* getRule(TokenType type);
static void declaration() {
statement();
}
static void statement() {
if (match(TOKEN_PRINT)) {
printStatement();
}
}
21.1.1 print 语句(Print statements)
先由 match() 识别 print:若当前 token 类型匹配,消费它并返回 true;不匹配则保持 token 不动并返回 false。它依赖 check(),后者只检查当前 token 类型。小函数看似多余,但手写解析器发展到数千行后,这种短小的动词式工具函数很值得保留。
static bool check(TokenType type) {
return parser.current.type == type;
}
static bool match(TokenType type) {
if (!check(type)) return false;
advance();
return true;
}
print 先编译表达式、消费分号,最后发出打印指令:
static void printStatement() {
expression();
consume(TOKEN_SEMICOLON, "Expect ';' after value.");
emitByte(OP_PRINT);
}
在 chunk.h 的 OpCode 枚举加入 OP_PRINT,VM 执行它时弹出栈顶、打印并换行:
case OP_PRINT: {
printValue(pop());
printf("\n");
break;
}
这里显示了表达式和语句在 VM 中的关键区别。每条字节码都有栈效果(stack effect):OP_ADD 弹两个值、压一个值,故效果为 -1。

一整个表达式编译出的指令,其栈效果总和为 1:表达式留下一个结果。完整语句的总和则是 0;语句不产生值,最终保持栈不变。这一点很重要,因为控制流和循环会执行很长的语句序列;若每条语句都增长或缩减栈,最终会溢出或下溢。
原先 OP_RETURN 为单个表达式的临时代码负责输出结果。既然有了 print,删除这段输出,仅退出解释器循环:
case OP_RETURN: {
// Exit interpreter.
return INTERPRET_OK;
}
函数加入后还会再修改它。同时在反汇编器中加入:
case OP_PRINT:
return simpleInstruction("OP_PRINT", offset);
现在可以运行任意多条语句:
print 1 + 2;
print 3 * 4;
21.1.2 表达式语句(Expression statements)
若不是 print,便是表达式语句:
static void statement() {
if (match(TOKEN_PRINT)) {
printStatement();
} else {
expressionStatement();
}
}
static void expressionStatement() {
expression();
consume(TOKEN_SEMICOLON, "Expect ';' after expression.");
emitByte(OP_POP);
}
表达式语句就是表达式后接分号;它让表达式可出现在需要语句的地方,通常是为了调用函数或计算赋值的副作用:
brunch = "quiche";
eat(brunch);
语义上它求值后丢弃结果,因此编译器发出 OP_POP。在 chunk.h、run() 和反汇编器中分别增加:
OP_POP,
case OP_POP: pop(); break;
case OP_POP:
return simpleInstruction("OP_POP", offset);
目前还没有有副作用的表达式,表达式语句不显眼;函数加入后它不可或缺。现实中 C 一类语言的大多数语句正是表达式语句;作者统计本章末的 compiler.c 有 149 条语句,其中 80 条属于这一类。
21.1.3 错误同步(Error synchronization)
现在可以补上数章前留下的恐慌模式恢复。和 jlox 一样,clox 在编译错误后进入 panic mode,以减少连锁错误;到达同步点时退出该模式。Lox 选语句边界作同步点:
static void declaration() {
statement();
if (parser.panicMode) synchronize();
}
static void synchronize() {
parser.panicMode = false;
while (parser.current.type != TOKEN_EOF) {
if (parser.previous.type == TOKEN_SEMICOLON) return;
switch (parser.current.type) {
case TOKEN_CLASS:
case TOKEN_FUN:
case TOKEN_VAR:
case TOKEN_FOR:
case TOKEN_IF:
case TOKEN_WHILE:
case TOKEN_PRINT:
case TOKEN_RETURN:
return;
default:
; // Do nothing.
}
advance();
}
}
它不加区分地跳过 token,直到找到像语句边界的东西:前一个 token 是分号,或下一个 token 是声明、控制流或 print、return 等语句开头关键字。
21.2 变量声明(Variable Declarations)
只会打印的语言还拿不到“语言博览会”的奖项。变量需要支持三种操作:用 var 声明新变量、以标识符读取变量值、用赋值表达式向已有变量存入新值。后两项依赖已有变量,因此从声明开始。
declaration() 识别 var,其余情况仍按普通语句处理;之后若出现错误则同步:
static void declaration() {
if (match(TOKEN_VAR)) {
varDeclaration();
} else {
statement();
}
if (parser.panicMode) synchronize();
}
变量名由 parseVariable() 编译;有 = 时编译初始化表达式,没有时隐式发出 OP_NIL。两种写法生成相同字节码:
var a;
var a = nil;
static void varDeclaration() {
uint8_t global = parseVariable("Expect variable name.");
if (match(TOKEN_EQUAL)) {
expression();
} else {
emitByte(OP_NIL);
}
consume(TOKEN_SEMICOLON, "Expect ';' after variable declaration.");
defineVariable(global);
}
全局变量在运行时按名字查找,因此 VM 必须持有名称。整段字符串不能直接塞进字节码操作数,编译器便将标识符文本复制为字符串常量,指令只保存该常量表索引:
static uint8_t parseVariable(const char* errorMessage) {
consume(TOKEN_IDENTIFIER, errorMessage);
return identifierConstant(&parser.previous);
}
static uint8_t identifierConstant(Token* name) {
return makeConstant(OBJ_VAL(copyString(name->start, name->length)));
}
static void defineVariable(uint8_t global) {
emitBytes(OP_DEFINE_GLOBAL, global);
}
OP_DEFINE_GLOBAL 最后发出:运行时初始化器先把值留在栈上,指令再取该值保存。后续函数和类声明同样会声明变量,变量和赋值表达式也会访问名称,故这些辅助函数并非只为当前一处服务。
在 chunk.h 加入 OP_DEFINE_GLOBAL,在 VM 中用第 20 章实现的哈希表保存它:
#define READ_STRING() AS_STRING(READ_CONSTANT())
case OP_DEFINE_GLOBAL: {
ObjString* name = READ_STRING();
tableSet(&vm.globals, name, peek(0));
pop();
break;
}
必须在 tableSet() 后才弹出值。哈希表扩容会分配内存,并可能恰在此时触发 GC;栈上仍保留该值,GC 才能找到它。READ_STRING() 把一个字节操作数解释为常量表索引,再将该常量转为字符串;编译器保证这个位置必为字符串,所以无需运行时检查。解释结束前也应 #undef READ_STRING。
全局表保存于 VM 中,使其在 clox 运行期间持续存在,并在启动和退出时初始化、释放:
typedef struct {
/* ... */
Table globals;
Table strings;
} VM;
void initVM() {
/* ... */
initTable(&vm.globals);
initTable(&vm.strings);
}
void freeVM() {
freeTable(&vm.globals);
freeTable(&vm.strings);
freeObjects();
}
Lox 允许重复定义全局变量,tableSet() 覆盖已有值而不报错;这在 REPL 中很实用。反汇编器把它作为带常量操作数的指令输出:
case OP_DEFINE_GLOBAL:
return constantInstruction("OP_DEFINE_GLOBAL", chunk, offset);
21.3 读取变量(Reading Variables)
和所有语言一样,Lox 通过名字读取变量。将标识符接入 Pratt 解析规则:
[TOKEN_IDENTIFIER] = {variable, NULL, PREC_NONE},
它复用声明时的 identifierConstant(),再发出读取指令:
static void variable() {
namedVariable(parser.previous);
}
static void namedVariable(Token name) {
uint8_t arg = identifierConstant(&name);
emitBytes(OP_GET_GLOBAL, arg);
}
OP_GET_GLOBAL 从操作数取得名称,在全局表中查询;没有该键就是运行时错误,否则把值压栈:
case OP_GET_GLOBAL: {
ObjString* name = READ_STRING();
Value value;
if (!tableGet(&vm.globals, name, &value)) {
runtimeError("Undefined variable '%s'.", name->chars);
return INTERPRET_RUNTIME_ERROR;
}
push(value);
break;
}
反汇编器同样使用 constantInstruction()。至此可以运行:
var beverage = "cafe au lait";
var breakfast = "beignets with " + beverage;
print breakfast;
21.4 赋值(Assignment)
在 jlox 中赋值很容易;字节码 VM 的单遍编译器一边识别语法一边生成代码,没有中间 AST。对于:
menu.brunch(sunday).beverage = "mimosa";
解析器直到读到很后的 =,才知道左边是赋值目标而非常规表达式;此时先前的字节码已经发出。不过问题没有想象中严重:接收者 menu.brunch(sunday) 仍是普通表达式,语义真正不同的只是在 = 前紧邻的最右端标识符。

变量更简单,它只是在 = 前的单个标识符。对一个既可作为赋值目标也可作为读取表达式的语法,在编译前查看后续 =:有则编译赋值,没有则编译读取。暂时只需处理变量:
static void namedVariable(Token name) {
uint8_t arg = identifierConstant(&name);
if (match(TOKEN_EQUAL)) {
expression();
emitBytes(OP_SET_GLOBAL, arg);
} else {
emitBytes(OP_GET_GLOBAL, arg);
}
}
OP_SET_GLOBAL 把栈顶值写入已有全局变量。tableSet() 若返回 true,说明键原本不存在;它虽已暂时插入“僵尸”值,但 Lox 不允许隐式声明,因此立即删除并报告运行时错误。赋值是表达式,必须保留赋值结果以供外层表达式使用,所以不弹栈:
case OP_SET_GLOBAL: {
ObjString* name = READ_STRING();
if (tableSet(&vm.globals, name, peek(0))) {
tableDelete(&vm.globals, name);
runtimeError("Undefined variable '%s'.", name->chars);
return INTERPRET_RUNTIME_ERROR;
}
break;
}
在枚举和反汇编器中加入 OP_SET_GLOBAL:
case OP_SET_GLOBAL:
return constantInstruction("OP_SET_GLOBAL", chunk, offset);
看起来完成了,但这会错误地接受:
a * b = c + d;
按 Lox 语法,= 优先级最低,应近似解析为 (a * b) = (c + d),故左侧不是有效赋值目标,应报语法错误。

旧代码却在 binary() 解析右操作数 b 时看见 =,把它解析为赋值,得到:

问题在于 variable() 没考虑外围表达式的优先级。它若处于中缀或一元表达式的操作数位置,外围优先级高于允许赋值的级别。修复办法是:parsePrecedence() 判断当前上下文是否允许赋值,并把一个布尔值一路传给解析函数:
bool canAssign = precedence <= PREC_ASSIGNMENT;
prefixRule(canAssign);
while (precedence <= getRule(parser.current.type)->precedence) {
advance();
ParseFn infixRule = getRule(parser.previous.type)->infix;
infixRule(canAssign);
}
if (canAssign && match(TOKEN_EQUAL)) {
error("Invalid assignment target.");
}
variable() 与 namedVariable() 接受并转交该标记,只有低优先级上下文才消费 =:
static void variable(bool canAssign) {
namedVariable(parser.previous, canAssign);
}
static void namedVariable(Token name, bool canAssign) {
uint8_t arg = identifierConstant(&name);
if (canAssign && match(TOKEN_EQUAL)) {
expression();
emitBytes(OP_SET_GLOBAL, arg);
} else {
emitBytes(OP_GET_GLOBAL, arg);
}
}
高优先级处的 = 不会被 namedVariable() 吞掉,返回 parsePrecedence() 后也没有对应中缀解析函数,于是末尾的检查报告“无效赋值目标”。函数指针表要求所有解析函数签名一致,因此即便大多数解析函数不支持赋值,也都要接收这个参数:
typedef void (*ParseFn)(bool canAssign);
static void binary(bool canAssign) { /* ... */ }
static void literal(bool canAssign) { /* ... */ }
static void grouping(bool canAssign) { /* ... */ }
static void number(bool canAssign) { /* ... */ }
static void string(bool canAssign) { /* ... */ }
static void unary(bool canAssign) { /* ... */ }
未来 setter 也会使用该标记;若 Lox 有数组,下标中缀 [ 也应允许 array[index] = value。现在可运行:
var breakfast = "beignets";
var beverage = "cafe au lait";
breakfast = "beignets with " + beverage;
print breakfast;
挑战(Challenges)
- 编译器每次遇到全局变量名,都在常量表新增一个字符串常量;同一函数多次引用同一名称也重复。优化它,并比较该优化对编译期和运行期性能的影响:这个取舍合适吗?
- 每次通过哈希表按名称读取全局变量仍较慢。能否在不改变语义的前提下,设计更高效的全局变量存储与访问方式?
- REPL 中,用户可先写引用未知全局变量的函数、下一行再声明变量,Lox 应正常处理。但运行脚本时,编译器拥有完整源文本,例如
useVar()引用了oops,脚本只声明了ooops,且该函数甚至未被调用。此时能静态知道oops从未定义,却不会产生运行时错误。是否应将这类错误提前报告为编译错误?说明理由,并考察其他脚本语言的选择。