第 22 章:局部变量(Local Variables)
原文:Robert Nystrom, Crafting Interpreters, Chapter 22。原书以 CC BY-NC-SA 4.0 协议发布;本译文用于学习与研究。
想象力赋予未知事物以形体,诗人的笔把它们化为具体形状,并为缥缈的虚无给出一个本地住处和一个名字。
William Shakespeare,《仲夏夜之梦》
上一章为 clox 加入了变量,但只有全局变量。本章加入块、块作用域与局部变量。在 jlox 中,它们和全局变量能挤进同一章;在 clox 中,一方面 C 的每件事都更费工,另一方面局部变量的实现方式会与全局变量完全不同。
Lox 的全局变量是晚绑定的,也就是在编译之后才解析;这使编译器简单,却不利于性能。局部变量是语言中使用最频繁的特性之一,局部变量慢,程序就慢。函数参数同样高频,它们也是局部变量,因此会采用同样技术。
幸运的是,词法作用域允许仅查看程序文本就解析局部变量,它们不是晚绑定的。在编译器中完成的工作便无需留给运行时,所以本章实现会大量依赖编译器。
22.1 表示局部变量(Representing Local Variables)
C 和 Java 通常把局部变量放在原生栈上;在 clox 的虚拟世界里,我们也有自己的值栈。此前它只保存求值期间短命的临时值。只要不干扰临时值,也可把局部变量放到此栈:分配只需递增 stackTop,释放只需递减;访问已知槽位就是数组下标访问。
这要求新局部变量只能分配在栈顶,且只有上方没有其他东西时才可释放。Lox 的设计正好满足:局部变量由声明语句创建,语句不嵌入表达式,所以一条声明开始执行时栈中不会留临时值;块严格嵌套,块结束带走的正是最内层、最后声明、也位于栈顶的变量。这并非巧合,Lox 被设计为适合单遍编译成栈式字节码;语言历史也深受单遍编译与栈架构影响,Lox 的块作用域可追溯到 BCPL。

编译器不仅知道变量在栈上,还能精确模拟其位置:它知道每一位置哪些局部变量在作用域内。因此,读取和写入局部变量的字节码操作数直接记录栈偏移量,运行时只做数组索引。本章局部变量从 VM 栈数组的底部开始;函数加入后每个函数会有自己的参数和局部变量区域。
jlox 用一串环境 HashMap 跟踪作用域;clox 采用更贴近底层的扁平数组:
typedef struct {
Token name;
int depth;
} Local;
typedef struct {
Local locals[UINT8_COUNT];
int localCount;
int scopeDepth;
} Compiler;
数组按变量声明出现的顺序排列。局部变量指令的槽位操作数是一字节,所以同一时刻最多 256 个局部变量;相应地 common.h 定义:
#define UINT8_COUNT (UINT8_MAX + 1)
localCount 是当前使用的数组槽数,scopeDepth 是当前代码外围块的层数:全局为零,最外层块为一,再嵌套一层为二。每个 Local 保存名字以解析标识符,并保存声明它的块深度,以便块结束时找出应丢弃的变量。
原则上前端每个函数都该接收 Compiler* 参数;为了避免大量机械改动,本书将当前编译器放入全局指针。这不适用于多线程或多个编译器并行的应用:
Parser parser;
Compiler* current = NULL;
Chunk* compilingChunk;
static void initCompiler(Compiler* compiler) {
compiler->localCount = 0;
compiler->scopeDepth = 0;
current = compiler;
}
bool compile(const char* source, Chunk* chunk) {
initScanner(source);
Compiler compiler;
initCompiler(&compiler);
compilingChunk = chunk;
/* ... */
}
编译器已有状态,接下来为它添加创建/销毁作用域、添加/解析变量等操作。
22.2 块语句(Block Statements)
局部作用域来自函数体和块;函数留待后续章节,本章先实现块。新语法为:
statement -> exprStmt
| printStmt
| block ;
block -> "{" declaration* "}" ;
“block”这个名字略奇怪:隐喻中的 block 常指不可分割的小单位,Algol 60 却用它表示一组语句的复合结构;Algol 58 更把 begin 和 end 叫作“语句括号”。

块是语句,读到左花括号后进入一个作用域、编译块、再退出作用域:
static void statement() {
if (match(TOKEN_PRINT)) {
printStatement();
} else if (match(TOKEN_LEFT_BRACE)) {
beginScope();
block();
endScope();
} else {
expressionStatement();
}
}
static void block() {
while (!check(TOKEN_RIGHT_BRACE) && !check(TOKEN_EOF)) {
declaration();
}
consume(TOKEN_RIGHT_BRACE, "Expect '}' after block.");
}
block() 持续解析声明和语句直至右花括号。循环同时检查 EOF,避免缺少右花括号的错误程序让编译器卡住。执行块只是顺序执行内部语句,语义上的重点是创建作用域:
static void beginScope() {
current->scopeDepth++;
}
static void endScope() {
current->scopeDepth--;
}
创建作用域只需加深度,远快于 jlox 为每个作用域分配一张 HashMap。稍后会扩展 endScope() 来释放局部变量。
22.3 声明局部变量(Declaring Local Variables)
变量声明、标识符表达式和赋值早已能解析和编译,只是现有代码把所有变量当作全局变量。因此无需新解析规则,只需把新作用域语义接入已有流程。
原先 parseVariable() 消费标识符、将词素作为字符串放进常量表并返回索引;初始化器编译后,defineVariable() 发出 OP_DEFINE_GLOBAL。局部变量需修改这两个函数:首先登记变量;若位于局部作用域,运行时不用名字查找,无需写入常量表,返回任意占位索引即可:
static uint8_t parseVariable(const char* errorMessage) {
consume(TOKEN_IDENTIFIER, errorMessage);
declareVariable();
if (current->scopeDepth > 0) return 0;
return identifierConstant(&parser.previous);
}
static void defineVariable(uint8_t global) {
if (current->scopeDepth > 0) return;
emitBytes(OP_DEFINE_GLOBAL, global);
}
局部变量在运行时没有“创建”指令。初始化器(或默认的 nil)已执行,其结果正位于栈顶,也是新局部变量本应占用的位置;临时值直接变为局部变量,无法更高效。

声明的含义是:在编译器的当前作用域变量列表中记录它。全局变量晚绑定,编译器无需跟踪;局部变量则必须记录:
static void declareVariable() {
if (current->scopeDepth == 0) return;
Token* name = &parser.previous;
addLocal(*name);
}
static void addLocal(Token name) {
if (current->localCount == UINT8_COUNT) {
error("Too many local variables in function.");
return;
}
Local* local = ¤t->locals[current->localCount++];
local->name = name;
local->depth = current->scopeDepth;
}
Local 直接复制标识符 token。token 的 start 指向原始源字符串,源字符串在整个编译期间都必须存在,所以指针有效。256 个上限既避免无法编码槽位,也避免越界写入 locals 数组。
顶层允许重定义名称,适合 REPL;同一局部作用域重复声明多半是错误:
{
var a = "first";
var a = "second"; // Error.
}
这不同于允许的遮蔽(shadowing):内层块的 a 可以遮蔽外层 a。从数组末尾向前查找,只需检查当前作用域的条目;遇到属于外层作用域的已初始化变量即可停止:
static bool identifiersEqual(Token* a, Token* b) {
if (a->length != b->length) return false;
return memcmp(a->start, b->start, a->length) == 0;
}
static void declareVariable() {
if (current->scopeDepth == 0) return;
Token* name = &parser.previous;
for (int i = current->localCount - 1; i >= 0; i--) {
Local* local = ¤t->locals[i];
if (local->depth != -1 && local->depth < current->scopeDepth) break;
if (identifiersEqual(name, &local->name)) {
error("Already a variable with this name in this scope.");
}
}
addLocal(*name);
}
先比较长度能快速排除大量不同名称,长度相同才以 memcmp() 比较字符;因此要包含 <string.h>。depth != -1 的特殊判断将在本章末解释。
块结束时,变量不能继续停留:编译器数组要删记录,运行时值栈也要释放对应槽位。为每个离开作用域的变量发出 OP_POP:
static void endScope() {
current->scopeDepth--;
while (current->localCount > 0 &&
current->locals[current->localCount - 1].depth >
current->scopeDepth) {
emitByte(OP_POP);
current->localCount--;
}
}
多个局部变量同时离开时会有连续 OP_POP;可扩展为接收数量操作数的 OP_POPN 进行优化。

22.4 使用局部变量(Using Locals)
局部声明已能执行,值也已位于栈上。变量读取和赋值共享同一套选择逻辑:先解析为局部,未找到才按全局处理:
static void namedVariable(Token name, bool canAssign) {
uint8_t getOp, setOp;
int arg = resolveLocal(current, &name);
if (arg != -1) {
getOp = OP_GET_LOCAL;
setOp = OP_SET_LOCAL;
} else {
arg = identifierConstant(&name);
getOp = OP_GET_GLOBAL;
setOp = OP_SET_GLOBAL;
}
if (canAssign && match(TOKEN_EQUAL)) {
expression();
emitBytes(setOp, (uint8_t)arg);
} else {
emitBytes(getOp, (uint8_t)arg);
}
}
核心 resolveLocal() 倒序扫描当前局部数组。倒序确保最内层、最后声明的同名变量先被找到,从而正确实现遮蔽。局部数组的布局与运行时栈布局完全一致,所以数组索引正是槽位号;找不到返回 -1,调用方将它当作全局变量:
static int resolveLocal(Compiler* compiler, Token* name) {
for (int i = compiler->localCount - 1; i >= 0; i--) {
Local* local = &compiler->locals[i];
if (identifiersEqual(name, &local->name)) return i;
}
return -1;
}
22.4.1 解释局部变量(Interpreting local variables)
编译器发出两条新指令。OP_GET_LOCAL 以一字节操作数取得局部变量槽位,从对应栈位置加载值并压到栈顶:
OP_GET_LOCAL,
case OP_GET_LOCAL: {
uint8_t slot = READ_BYTE();
push(vm.stack[slot]);
break;
}
值本已在较低栈槽,仍须压栈,因为其他栈式字节码只从栈顶取操作数。寄存器式字节码可避免这种搬运,但指令会更大、操作数更多。
OP_SET_LOCAL 从栈顶取赋值结果并写回槽位,不弹栈,因为赋值表达式自身的值就是被赋的值:
OP_SET_LOCAL,
case OP_SET_LOCAL: {
uint8_t slot = READ_BYTE();
vm.stack[slot] = peek(0);
break;
}
反汇编器使用字节操作数指令助手:
static int byteInstruction(const char* name, Chunk* chunk, int offset) {
uint8_t slot = chunk->code[offset + 1];
printf("%-16s %4d\n", name, slot);
return offset + 2;
}
case OP_GET_LOCAL:
return byteInstruction("OP_GET_LOCAL", chunk, offset);
case OP_SET_LOCAL:
return byteInstruction("OP_SET_LOCAL", chunk, offset);
局部名称从未写入 chunk,性能很好,但反汇编只能显示槽位而不能显示名称。日后实现调试器时,需额外输出一份“每个栈槽在何时对应哪个局部名称”的信息。
22.4.2 另一个作用域边界情况(Another scope edge case)
遮蔽和同作用域重名之外,还要处理 jlox 中见过的情况:
{
var a = "outer";
{
var a = a;
}
}
变量声明分两阶段:声明开始、初始化器之前,名称已加入当前作用域但处于“未初始化”状态;初始化器编译完后才标记为可用。初始化器内若解析回自身,即报告错误。

无需增加 Local 字段,depth = -1 作为哨兵即可。添加局部变量时写入该值;局部 defineVariable() 完成时调用 markInitialized(),把最后一个局部的深度改成当前作用域深度:
static void addLocal(Token name) {
/* capacity check omitted */
Local* local = ¤t->locals[current->localCount++];
local->name = name;
local->depth = -1;
}
static void markInitialized() {
current->locals[current->localCount - 1].depth = current->scopeDepth;
}
static void defineVariable(uint8_t global) {
if (current->scopeDepth > 0) {
markInitialized();
return;
}
emitBytes(OP_DEFINE_GLOBAL, global);
}
因此,declare 是将变量加入作用域,define 是让变量可以使用。解析局部引用时检查哨兵:
if (identifiersEqual(name, &local->name)) {
if (local->depth == -1) {
error("Can't read local variable in its own initializer.");
}
return i;
}
至此有了块、局部变量与真正的词法作用域。新的大部分代码位于编译器,运行时仅增加两条小指令。这种把工作提前到编译期、避免运行时查找的做法,是 clox 相比 jlox 持续采用的优化趋势。静态类型是极端例子:编译期完成类型分析与错误处理,运行时不再检查操作数类型;在 C 等语言中,运行时甚至完全没有类型表示,只保留裸比特。
挑战(Challenges)
- 这个简单局部数组使槽位计算容易,却让每次解析变量引用都要线性扫描。设计更高效的方案,并评估额外复杂度是否值得。
- 其他语言如何处理
var a = a;?若由你设计语言,你会如何规定,为什么? - 为 Lox 增加一次赋值变量。Java 的
final禁止再赋值,JavaScript 用const,Swift 用let,Scala/Kotlin 用val。选择关键字并说明理由;对该变量赋值应成为编译错误。 - 扩展
clox,使同时处于作用域内的局部变量可以超过 256 个。