编程语言解释器编译器语言运行时教程【免费下载链接】craftinginterpretersRepository for the book Crafting Interpreters项目地址https://gitcode.com/gh_mirrors/cr/craftinginterpreters点击查看免费下载导读本文基于《Crafting Interpreters》仓库craftinginterpreters中 book/closures.md 一章深入拆解 clox 字节码虚拟机C 实现见 c/ 目录如何为 Lox 语言实现词法闭包。你将从「问题动机 → 运行时对象设计 → 编译期变量解析 → 开放/封闭 upvalue 生命周期管理」的完整脉络中掌握闭包捕获的两种实现策略栈内快路径与堆上慢路径、OP_CLOSURE/OP_GET_UPVALUE/OP_SET_UPVALUE/OP_CLOSE_UPVALUE四条指令的编码与执行语义以及开源实现中如何复用同一套Obj对象系统为即将到来的垃圾回收器见 book/garbage-collection.md铺路。问题函数为何抓不住外层变量上一章book/calls-and-functions.md为 clox 加入了可调用函数但函数体只能访问自己的局部变量与全局变量无法引用「声明在外层函数中的局部变量」。先看一个最小失败案例var x global; fun outer() { var x outer; fun inner() { print x; // 期望打印 outer } inner(); } outer();在加入闭包之前clox 运行这段程序会打印global——因为编译器把inner()内无法在本函数栈窗口内解析的x一律当作全局变量处理。要修复它就必须在解析变量时把所有外围函数的词法作用域纳入考虑。栈语义为何失效难点在于 clox 与 jlox 不同jlox 把所有变量装进 Java 堆上的Environment对象天然允许变量存活期超过函数调用而 clox 的局部变量存放在运行时值栈中遵循「逆序创建、逆序销毁」的栈语义。闭包打破了这个假设fun makeClosure() { var local local; fun closure() { print local; } return closure; } var closure makeClosure(); closure(); // 此时 local 早已不在栈上makeClosure()返回后local所在的栈槽被弹出但闭包仍持有对它的引用。闭包「逃逸」escape了——局部变量必须活得比声明它的函数调用更久。两条腿走路的取舍如果像 jlox 那样把所有局部变量都动态分配在堆上就能一举解决但代价是牺牲所有变量的性能——而绝大多数局部变量并不会被闭包捕获、本就具有栈语义。C 与 Java 之所以把局部变量放在栈上正是因为这个理由。因此 clox 采用与 Lua VM 同源的双轨策略快路径未被闭包捕获的局部变量原样留在栈上享受栈的分配/回收速度慢路径被闭包捕获的局部变量在其逃逸之前被「提升」hoist到堆上存活期随需要延长。书中明确说明这套设计来自 Lua VM关键词closure conversion、lambda lifting 可继续深挖特点是内存节俭、代码量小且能自然嵌入 clox 与 Lua 共有的单遍编译器中。第一步ObjClosure 运行时对象从 ObjFunction 到 ObjClosure此前函数在运行时的代表是ObjFunction编译期创建、放在常量表中、由OP_CONSTANT装载并绑定名字运行期没有任何「创建函数」的操作——函数声明在 Lox 里本质是一种字面量。但这无法表达「同一份声明、捕获不同值」的场景fun makeClosure(value) { fun closure() { print value; } return closure; } var doughnut makeClosure(doughnut); var bagel makeClosure(bagel); doughnut(); // doughnut bagel(); // bagel同一份嵌套函数声明closure被实例化两次却闭包于不同的值。因此需要一种运行期表示ObjClosure持有对裸ObjFunction的引用共享同一份字节码与常量表Lua 称之为 prototype再加上闭包捕获变量的运行时状态。定义见 c/object.htypedef struct { Obj obj; ObjFunction* function; ObjUpvalue** upvalues; // 动态分配的 upvalue 指针数组 int upvalueCount; } ObjClosure;clox 会把每一个函数声明都包进 ObjClosure即使它一个变量都没捕获。这在当前实现中略有浪费却让 VM 可以无条件假设「正在调用的函数一定是 ObjClosure」从而统一调用路径。对应新的对象类型OBJ_CLOSUREc/object.h与创建函数newClosure()c/object.c。编译期发射 OP_CLOSURE编译器在函数声明末尾不再只发射OP_CONSTANT而是改为发射新的OP_CLOSURE指令见 c/compiler.c 与 c/chunk.hObjFunction* function endCompiler(); emitBytes(OP_CLOSURE, makeConstant(OBJ_VAL(function))); // 随后为每个 upvalue 发射一对操作数1捕获局部变量 / 0捕获外层 upvalue 索引解释器端c/vm.c读取该指令后从常量表取出ObjFunction调用newClosure()包装成ObjClosure压栈。而顶层脚本的入口interpret()c/vm.c同样把编译结果ObjFunction包成闭包再call()——先压入裸函数再弹掉、改为压入闭包的多余栈操作是为即将到来的 GC 保持堆对象可达性的惯用手法。调用路径全面换血既然运行期不再出现「裸函数」callValue()中原来的OBJ_FUNCTION分支被替换为OBJ_CLOSURE分支c/vm.ccall()的签名从ObjFunction*改为ObjClosure*c/vm.cCallFrame中的function字段也换成closurec/vm.h。随之而来的连锁修改包括READ_CONSTANT宏经由frame-closure-function-chunk取常量c/vm.cDEBUG_TRACE_EXECUTION下的反汇编与runtimeError()栈回溯均改为从闭包取底层函数c/vm.c运行期错误信息与printObject()中OBJ_CLOSURE的打印c/object.c都复用printFunction()对用户透明——ObjFunction 与 ObjClosure 的差异纯粹是隐藏实现细节。第二步编译期解析 upvalue为什么不能用「相对栈偏移」最朴素的思路是让新指令携带一个可越过当前函数窗口的栈槽偏移。但这要求被捕获变量永远在栈上而上一节已证明它会逃逸。另一种思路是把所有被闭包捕获的变量从一开始就放在堆上——这又和单遍编译器冲突请看fun outer() { var x 1; // (1) 编译 x 的声明 x 2; // (2) 编译对 x 的赋值 fun inner() { // (3) 此时才发现 x 被闭包捕获 print x; } inner(); }编译器编译(1)、(2)时根本不知道(3)处x会被捕获无法回头改写已发射的字节码。因此需要一个允许被捕获变量在栈上正常生活、直到被捕获那一刻才特殊处理的方案——这就是 Lua 的upvalue。resolveUpvalue 与 addUpvalueupvalue 是指向外层函数某个局部变量的间接引用。每个闭包维护一个 upvalue 数组对应它用到的每个外围局部变量。编译期解析逻辑见 c/compiler.cstatic int resolveUpvalue(Compiler* compiler, Token* name) { if (compiler-enclosing NULL) return -1; // 无外层 → 当作全局 int local resolveLocal(compiler-enclosing, name); if (local ! -1) { compiler-enclosing-locals[local].isCaptured true; // 标记被捕获 return addUpvalue(compiler, (uint8_t)local, true); } int upvalue resolveUpvalue(compiler-enclosing, name); // 递归穿透多层 if (upvalue ! -1) { return addUpvalue(compiler, (uint8_t)upvalue, false); } return -1; }先在当前函数的 local 作用域内查找原有逻辑见resolveLocal()查不到时调用resolveUpvalue()如果外层函数compiler-enclosing存在先看其局部变量命中则addUpvalue(..., isLocaltrue)若外层也是经由更外层传递的捕获isLocalfalse则递归调用resolveUpvalue()穿透整条 Compiler 链。addUpvalue()c/compiler.c维护每个函数的 upvalue 表并把 upvalue 数量写入ObjFunction-upvalueCountc/object.h——这是编译器与运行时之间的又一座「桥」与常量和 arity 同类。它还会去重如果该函数已经为同一槽位创建过 upvalue直接复用其索引。编译器中的Upvalue结构c/compiler.ctypedef struct { uint8_t index; // 被捕获变量的本地槽号或外层函数中的 upvalue 索引 bool isLocal; // true捕获外层局部变量false捕获外层 upvalue } Upvalue;isLocal字段正是「扁平化」的关键。由于OP_GET_UPVALUE/OP_SET_UPVALUE的索引操作数是单字节一个函数最多捕获UINT8_COUNT256个 upvalue编译器用固定大小数组Upvalue upvalues[UINT8_COUNT]c/compiler.c并检查溢出Too many closure variables in function.c/compiler.c。扁平化递归穿透多层嵌套多层嵌套时inner()访问的变量可能声明在outer()而非紧邻的middle()fun outer() { var x 1; fun middle() { fun inner() { print x; // x 声明在 outer() } } }更刁钻的是outer()可能在inner()声明执行之前就已返回x早已离栈fun outer() { var x value; fun middle() { fun inner() { print x; } print create inner closure; return inner; } print return from outer; return middle; } var mid outer(); // 打印 return from outer var in mid(); // 打印 create inner closure此时 x 已不在栈上 in(); // 打印 value解决方案是「upvalue 捕获 upvalue」middle()捕获outer()的局部变量x并存入自己的 upvalue即使middle()自己不引用x当inner()声明执行时其闭包再去捕获middle()的upvalue。每个函数只从紧邻的外层函数捕获局部或 upvalue而紧邻外层在内部函数声明执行时必然还在。因此resolveUpvalue()是递归的递归调用发生在函数中部既有「下行」也有「回程」。递归触底时捕获到真正的局部变量基本情形逐层返回时每层都把上一层的 upvalue 索引作为自己的 upvalue 记录isLocalfalse。OP_CLOSURE 的可变长编码与反汇编OP_CLOSURE是 clox 中少见的变长指令操作数不是固定的一个字节而是每个 upvalue 一对单字节操作数——第一个字节1表示捕获外层局部变量、0表示捕获外层 upvalue第二个字节为对应的槽位/索引。以书中嵌套三层的例子反汇编inner()的创建指令为例源码fun outer() { var a1; var b2; fun middle() { var c3; var d4; fun inner() { print a c b d; } } }0004 9 OP_CLOSURE 2 fn inner 0006 | upvalue 0 0008 | local 1 0010 | upvalue 1 0012 | local 2a、b经由middle()的 upvalue 间接捕获upvalue 0、upvalue 1c、d则是middle()的直接局部变量local 1、local 2。反汇编器因此需要专属逻辑c/debug.c并且 debug 模块需要引入object.h以调用AS_FUNCTION()c/debug.c。OP_GET_UPVALUE/OP_SET_UPVALUE则只是普通单字节操作数指令c/debug.c。第三步运行期 upvalue 对象与开放/封闭ObjUpvalue指向「变量」而非「值」upvalue 的运行时表示复用 clox 已有的对象系统c/object.h这样下一章实现垃圾回收器时upvalue 的内存也能被 GC 统一管理typedef struct ObjUpvalue { Obj obj; Value* location; // 指向被捕获变量的指针栈槽或自身 closed 字段 Value closed; // 变量从栈提升到堆后的栖身之所 struct ObjUpvalue* next; // 侵入式链表指针见下文开放 upvalue 列表 } ObjUpvalue;关键设计是location是一个指向 Value 的指针而不是 Value 本身——它引用的是「变量」这个存储位置而非「值」。这意味着通过 upvalue 赋值时写入的是真正的变量而不是副本。验证如下对应 test/closure/assign_to_closure.loxfun outer() { var x before; fun inner() { x assigned; } inner(); print x; // 必须打印 assigned } outer();upvalue 不是 Lox 用户可访问的一等值它只是借对象系统的壳来获得内存管理printObject()中OBJ_UPVALUE分支c/object.c实际上永远不会执行仅为满足 switch 覆盖。创建函数newUpvalue(Value* slot)c/object.c初始化closed NIL_VAL、next NULL。解释 OP_CLOSURE 的操作数捕获 local 还是 upvalueOP_CLOSURE的解释代码c/vm.c逐对读取操作数for (int i 0; i closure-upvalueCount; i) { uint8_t isLocal READ_BYTE(); uint8_t index READ_BYTE(); if (isLocal) { closure-upvalues[i] captureUpvalue(frame-slots index); } else { closure-upvalues[i] frame-closure-upvalues[index]; } }isLocal 1捕获当前外层函数栈窗口中的局部槽槽基址是frame-slots indexisLocal 0直接取当前 CallFrame 闭包即外层函数闭包的 upvalue 数组中的第index个——因为执行到函数声明末尾时当前函数的闭包正好就在栈顶 CallFrame 中。开放 upvalue 的共享与链表当两个兄弟闭包捕获同一个局部变量时若各自创建独立 ObjUpvalue则变量提升到堆后会发生「一个 upvalue 持有值、另一个孤儿化」的错误嵌套场景下 VM 会共享 upvalue兄弟场景则不会。因此captureUpvalue()c/vm.c在创建前先搜索已存在的 upvalueVM 维护一条按栈槽位置排序的开放 upvalue 链表头指针是VM.openUpvaluesc/vm.h在resetStack()中初始化为 NULLc/vm.c链表以侵入式next指针内嵌在ObjUpvalue中搜索从头部最靠近栈顶的 upvalue开始用指针比较upvalue-location local跳过槽位更高的 upvalue同时记录前驱节点。循环有三种出口① 恰好命中同槽 upvalue → 直接复用② 链表耗尽NULL→ 未找到③ 遇到槽位更低者 → 已越过目标槽必然不存在。新 upvalue 按位置插入链表插入头部或前驱之后c/vm.c。这样同一局部槽至多存在一个 ObjUpvalue所有捕获它的闭包共享同一对象写者改值、读者可见。闭合把变量从栈提升到堆被捕获变量「尽可能晚」地离栈——在其作用域结束的那一刻最安全此后编译器已保证不再有代码从栈上访问它。编译器端为每个被捕获的局部变量在作用域结束时发射OP_CLOSE_UPVALUE而非OP_POP见endScope()c/compiler.c为此Local结构新增isCaptured标志c/compiler.c在addUpvalue()命中局部变量时置位c/compiler.c。解释器执行OP_CLOSE_UPVALUEc/vm.ccase OP_CLOSE_UPVALUE: closeUpvalues(vm.stackTop - 1); // 关闭指向该槽及其以上槽的所有开放 upvalue pop(); break;closeUpvalues(Value* last)c/vm.c从链表头开始凡是location last的 upvalue 一律关闭while (vm.openUpvalues ! NULL vm.openUpvalues-location last) { ObjUpvalue* upvalue vm.openUpvalues; upvalue-closed *upvalue-location; // 1. 把值拷入 closed 字段 upvalue-location upvalue-closed; // 2. 重定向 location vm.openUpvalues upvalue-next; // 3. 出链表 }这里有一个精妙的技巧OP_GET_UPVALUE/OP_SET_UPVALUE的解释代码一行都不用改c/vm.c因为它们统一通过location指针间接访问。变量从栈槽搬进closed字段后只需把location重定向到upvalue-closed读写指令依旧正确——保持了指令的简单与快速。函数返回时同样调用closeUpvalues(frame-slots)c/vm.c一次性关闭该函数栈窗口内的所有残留开放 upvalue编译器不为最外层函数体作用域发射关闭指令因为运行时在弹栈帧时已隐式丢弃全部栈槽。指令执行语义速查指令操作数执行语义源码位置OP_CLOSURE常量表索引 N 对字节isLocal,index从常量表取 ObjFunction 包成 ObjClosure逐对捕获 local 或外层 upvaluec/vm.cOP_GET_UPVALUE单字节 upvalue 索引push(*frame-closure-upvalues[slot]-location)c/vm.cOP_SET_UPVALUE单字节 upvalue 索引*frame-closure-upvalues[slot]-location peek(0)不弹栈赋值是表达式c/vm.cOP_CLOSE_UPVALUE无关闭指向栈顶槽及以上所有开放 upvalue随后pop()c/vm.c验证与回归仓库 test/closure/ 目录提供了完整回归用例覆盖闭包行为的方方面面可作为实现正确性的验收清单共享与赋值可见性test/closure/assign_to_closure.lox 验证两个兄弟闭包捕获同一变量时f()的写入对g()可见这正是 upvalue 共享机制的验收捕获函数参数test/closure/close_over_function_parameter.lox、test/closure/close_over_method_parameter.lox多层嵌套与穿透捕获test/closure/nested_closure.lox闭包槽位复用test/closure/reuse_closure_slot.lox、test/closure/unused_closure.lox生命周期边界test/closure/closed_closure_in_function.lox、test/closure/open_closure_in_function.lox被遮蔽变量的捕获test/closure/assign_to_shadowed_later.lox、test/closure/shadow_closure_with_local.lox。此外 note/answers/chapter25_closures/ 收录了章节习题的参考解答其中 1 号挑战只对需要 upvalue 的函数包 ObjClosure其余仍走OP_CONSTANT加载裸函数给出了一套完整的优化实现与基准对比它需要在CallFrame中存Obj*、以getFrameFunction()按类型取底层函数、并把call()拆为callClosure()/callFunction()实测对不使用闭包的旧 fib 程序会慢几个百分点多了一层类型判断而在一个大量创建闭包的病态基准程序中约快 24%省去了大量无谓的闭包包装。设计注循环变量被捕获的语义选择本章附注讨论了一个著名语言设计议题闭包捕获的是值还是变量。Lox与大多数主流语言的答案是变量——即捕获「值存放的位置」。示例如下预期先打印 one 再打印 twovar globalOne; var globalTwo; fun main() { { var a one; fun one() { print a; } globalOne one; } { var a two; fun two() { print a; } globalTwo two; } } main(); globalOne(); globalTwo();但循环变量就暧昧了。书中的对比实验JavaScript 的var i在for循环中只有一个变量两个闭包打印的都是 3循环结束值改用let后每次迭代都是新变量打印 1 和 2。Python 无块级作用域、变量隐式提升到函数级两个闭包捕获同一变量打印 2 2Ruby 的命令式for i in 1..2同样是 2 2而基于each的迭代式写法中「循环变量」实为函数参数、每次调用独立打印 1 2。C# 曾因foreach不创建新变量而频繁引发用户困惑最终在 C# 5 以破坏性变更让每次迭代创建新变量。clox 目前的实现for语句由forStatement()编译循环变量在beginScope()/endScope()中管理c/compiler.c遵循 C 风格整个循环只有一个a两个闭包共享它。书中挑战题 2 要求读者思考 Lox 应否效仿 JavaScriptlet即「看起来像赋值、实际上每次迭代创建新变量」。从实现角度看clox 的栈式局部变量让「每次迭代新变量」的改造并不需要推翻架构——它只需要在循环体作用域上多做一些变量生命周期处理这再次印证了本文的核心设计让用户只为自己用到的特性付费捕获变量才走堆提升其余变量始终享受栈的速度。小结clox 的闭包实现是一场「编译期解析 运行期对象」的接力编译期resolveUpvalue()沿 Compiler 链递归解析外围局部变量addUpvalue()去重登记并计数function()在声明末尾发射OP_CLOSURE及变长 upvalue 操作数对endScope()对被捕获局部发射OP_CLOSE_UPVALUE运行期OP_CLOSURE解释时经captureUpvalue()建立/共享开放 upvalueOP_GET_UPVALUE/OP_SET_UPVALUE经location指针读写变量作用域结束或函数返回时closeUpvalues()把变量搬入堆上closed字段并重定向指针。最终效果是词法作用域在 clox 中完整可用ObjClosure、ObjUpvalue等大量堆对象与相互指针的出现也为下一章 book/garbage-collection.md 的标记-清除收集器埋下了伏笔——本书后续会用一套 GC 统一管理这些对象的生命周期。/DSMLparameter /DSMLinvoke /DSMLtool_calls赞分享编程语言解释器编译器语言运行时教程【免费下载链接】craftinginterpretersRepository for the book Crafting Interpreters项目地址https://gitcode.com/gh_mirrors/cr/craftinginterpreters点击查看免费下载相关推荐Crafting Interpreters 源码精读clox 如何用堆分配与结构体继承为字节码虚拟机实现字符串Crafting Interpreters 源码精读clox 如何用堆分配与结构体继承为字节码虚拟机实现字符串 本指南以《Crafting Interpret编程语言解释器编译器语言运行时教程在 DataGrip 中连接 StarRocks 的完整教程JDBC 原生驱动与 MySQL 驱动一次配通在 DataGrip 中连接 StarRocks 的完整教程JDBC 原生驱动与 MySQL 驱动一次配通 想在 DataGrip 里直接浏览和查询 Star编程语言解释器编译器语言运行时教程Crafting Interpreters 实战在 clox 字节码虚拟机中为 Lox 语言实现 switch/case 语句Crafting Interpreters 实战在 clox 字节码虚拟机中为 Lox 语言实现 switch/case 语句 本文基于仓库 note/ans编程语言解释器编译器语言运行时教程上一篇Talon 配置加固模型基于运行时验证的角色防御、配置面管控与可回滚变更机制下一篇Joplin 同步目标快照与 E2EE 加密数据格式深度解析从 syncTargetSnapshots/3/e2ee 单文件读懂端到端加密同步机制创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考