ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用 30-seconds-of-code 构建 Smallfuck 解释器:从零实现一个图灵完备的 Esolang 解释器

用 30-seconds-of-code 构建 Smallfuck 解释器:从零实现一个图灵完备的 Esolang 解释器 教程文档【免费下载链接】30-seconds-of-codeCoding articles to level up your development skills项目地址https://gitcode.com/gh_mirrors/30/30-seconds-of-code点击查看免费下载Smallfuck 是一种仅有六个命令、直接在比特带上运行的图灵完备极简 Esolang深奥编程语言。本文以 30-seconds-of-code 仓库中的 Smallfuck 解释器实现 为核心自底向上逐步拆解一个完整解释器的实现过程从终止条件、输入解析、位翻转、指针移动等基础构件到匹配括号的循环跳转再到位翻转与指针移动两类性能优化。读完后你将掌握 Esolang 解释器的一般架构能够把同一套「基础构件 主循环 括号匹配」模式复用到 Brainfuck、数学表达式解析等更复杂的解释器任务中。Smallfuck 语言速览六个命令一张比特带Smallfuck 与 Brainfuck 同属极简 Esolang 家族但比 Brainfuck 更加克制它运行在由0/1字符组成的**比特带tape**上指针初始指向第 0 格语言一共只有六个命令命令说明指针右移 1 格指针左移 1 格*翻转当前格的值0↔1[若当前格值为0跳到与之匹配的]之后]若当前格值非0跳回与之匹配的[之后与 Brainfuck 最大的区别在于Smallfuck 没有输入/输出命令。解释器只需要执行程序并返回带的最终状态。程序的终止条件也只有两个程序执行完代码指针到达程序末尾指针越界指针小于0或大于等于带的长度。这两个特性让 Smallfuck 解释器的核心逻辑异常干净非常适合作为学习解释器设计的第一课。该实现属于仓库中 JavaScript Tokenizers Interpreters 集合 的一部分与 Brainfuck 解释器第一部分、数学表达式分词器 等文章形成了一条由浅入深的学习路径。从零搭建解释器先造构件再组装主循环直接抛出一段完整解释器代码往往让人难以消化。更好的做法是先把解释器拆成几个小的、可独立理解与测试的构件再逐步组装进主循环。整个实现最终会落到这些函数上boundCheck、codeCompleteCheck、parseTape、parseCode、interpretBitFlip、interpretPointerMove、createLoopPairs、pairMatch、createLoopInterpreter和smallfuckInterpreter。终止条件两个柯里化函数程序什么时候该停下来我们把它拆成两个判断函数。boundCheck判断指针是否超出带的边界codeCompleteCheck判断程序是否已经执行完毕const boundCheck length n n 0 || n length; const codeCompleteCheck length n n length;这两个函数都是柯里化的先传入带长/程序长度返回真正用于循环内逐次判断的函数。柯里化在这里的价值是参数预置——把不变的信息长度提前绑定让后续每次调用只关心变化的量指针。关于柯里化的完整讲解可以参考仓库中的 currying 文章它展示了如何用递归把任意多参函数转换为一系列单参函数。输入解析字符串拆分与字符过滤解释器接收两个字符串参数tape比特带的初始状态和codeSmallfuck 程序源码。解析工作分两部分const parseTape tape tape.split(); const parseCode code code.replace(/[^*\[\]]/g, );parseTape把带的字符串拆成字符数组。之所以不用0/1直接做算术运算是因为位翻转本质上就是字符0与1之间的互换保留字符可以避免「字符 ↔ 整数」的反复转换开销。parseCode用正则/[^*\[\]]/g过滤掉所有不属于 Smallfuck 的字符。真实场景中程序文本里可能混有注释、空白或其它符号这一步保证了代码指针永远落在有效命令上。值得注意的是parseCode的返回值在后续步骤中会被反复改写这一点在优化章节会看到。位翻转直接在带上原地修改*命令翻转当前格的值。由于tapeData是一个数组而 JavaScript 中对象/数组按值传递的是引用详见仓库中的 pass-by-reference-or-pass-by-value 文章我们可以在函数内直接修改带的元素调用方无需接收返回值const interpretBitFlip (symbol, pointer, tape) { if (symbol *) tape[pointer] tape[pointer] 1 ? 0 : 1; };判断条件使用字符1而非数字1这是为了与parseTape产生的字符数组保持一致。若symbol不是*函数什么都不做这在主循环中被设计为幂等调用——每个符号都会经过一次位翻转判断但只有*产生副作用。指针移动左右各一越界交给主循环与分别让指针右移、左移一格。这个函数刻意不检查越界因为越界是终止条件之一统一在主循环开头处理避免在每个符号上都重复边界判断const interpretPointerMove (symbol, pointer) symbol ? pointer 1 : symbol ? pointer - 1 : pointer;主循环第一版跑通无循环程序有了以上构件就可以写第一个可运行的smallfuckInterpreter。它负责解析输入 → 构造两个终止判断函数 → 初始化指针 → 进入while (true)主循环每轮先查终止条件再依次「移动指针 → 位翻转 → 代码指针 1」const smallfuckInterpreter (code, tape) { const tapeData parseTape(tape); const codeData parseCode(code); const isTapeOutOfBounds boundCheck(tapeData.length); const isCodeCompleted codeCompleteCheck(codeData.length); let tapePointer 0, codePointer 0; while (true) { if (isTapeOutOfBounds(tapePointer) || isCodeCompleted(codePointer)) return tapeData.join(); const codeSymbol code[codePointer]; tapePointer interpretPointerMove(codeSymbol, tapePointer); interpretBitFlip(codeSymbol, tapePointer, tapeData); codePointer; } };这里有一个实现细节值得注意主循环从code取符号而终止判断用的是codeData.length。由于parseCode已过滤掉非命令字符codeData.length反映的是有效命令数只要代码里不含无效字符两者是一致的但为了严谨后续修订版会把取值来源统一为codeData。终止时用tapeData.join()把字符数组重新拼回字符串返回。用无循环的示例验证一下smallfuckInterpreter(*****, 00101100); // 11111111手动推演初始带00101100指针在 0。*翻转 →10101100右移*翻转第 1 格 →11101100右移到第 3 格*翻转 →11111100右移到第 6 格*翻转 →11111110右移*翻转第 7 格 →11111111。结果与注释一致。循环支持用栈构建匹配括号映射真正让解释器变复杂的是[与]。其核心难点在于找到与当前括号配对的另一个括号的索引。这恰好是仓库另一篇文章 find-matching-bracket-pairs匹配括号对 的主题——那里详细讲解了用**栈Stack**匹配括号的思路遇到[入栈、遇到]弹出栈顶并记录配对。这正是 Smallfuck 循环实现的理论基础。createLoopPairs一次遍历双向建图用Array.prototype.reduce()配合栈一次扫描即可生成记录所有配对关系的Map。注意Map中两个方向都存储既存[ → ]也存] → [这样无论从哪一侧都能 O(1) 查到配对索引const createLoopPairs code [...code].reduce(({ pairs, stack }, symbol, i) { if (symbol [) stack.push(i); else if (symbol ]) { const openIndex stack.pop(); pairs.set(openIndex, i); pairs.set(i, openIndex); } return { pairs, stack }; }, { pairs: new Map(), stack: [] }).pairs;这段实现假设程序括号结构合法这是 Kata 的输入保证。若要在生产级解释器中处理非法程序可参考 find-matching-bracket-pairs 中「栈为空遇到闭括号 → 抛错」的防御性写法。pairMatch 与 createLoopInterpreter跳转逻辑有了配对映射跳转判断就简单了。pairMatch同样采用柯里化预置loopPairs映射后接收代码指针直接返回配对索引const pairMatch loopPairs codePointer loopPairs.get(codePointer);createLoopInterpreter封装循环跳转的完整语义——当且仅当「[且当前值为0」或「]且当前值为1」时代码指针跳到配对括号const createLoopInterpreter loopPairs (symbol, codePointer, tapeValue) { const getMatchingLoopPair pairMatch(loopPairs); if ( (symbol [ tapeValue 0) || (symbol ] tapeValue 1) ) return getMatchingLoopPair(codePointer); return codePointer; };注意tapeValue同样是字符0/1。当条件不满足[且值为1或]且值为0时返回原指针——配合主循环末尾的codePointer[处继续执行循环体、]处回到[之后形成循环。主循环修订版把跳转装进循环将循环解释器接入主循环得到最终版本const smallfuckInterpreter (code, tape) { const tapeData parseTape(tape); const codeData parseCode(code); const isTapeOutOfBounds boundCheck(tapeData.length); const isCodeCompleted codeCompleteCheck(codeData.length); const loopPairs createLoopPairs(codeData); const interpretLoop createLoopInterpreter(loopPairs); let tapePointer 0, codePointer 0; while (true) { if (isTapeOutOfBounds(tapePointer) || isCodeCompleted(codePointer)) return tapeData.join(); const codeSymbol codeData[codePointer]; tapePointer interpretPointerMove(codeSymbol, tapePointer); interpretBitFlip(codeSymbol, tapePointer, tapeData); codePointer interpretLoop(codeSymbol, codePointer, tapeData[tapePointer]); codePointer; } };与第一版相比的关键改动有两处代码符号统一从codeData读取此时codeData仍是字符串保证过滤后的程序与长度判断使用同一数据源interpretLoop在codePointer之前调用。这是循环语义正确性的关键当[触发跳转时代码指针被设置为配对]的索引随后codePointer使其落在]之后——即「跳过循环体」当]触发跳回时代码指针被设置为配对[的索引codePointer后重新进入循环体。无论跳与不跳增量都在跳转之后统一执行逻辑因此保持简单。用包含嵌套循环的示例验证smallfuckInterpreter([[]***], 000); // 000 smallfuckInterpreter(*[[]*]*, 100); // 100 smallfuckInterpreter([*[*]], 11001); // 01100 smallfuckInterpreter([[***]], 10110); // 10101这些示例覆盖了嵌套循环、[跳过、]回跳等多种组合是检验循环配对逻辑正确性的有效测试集。性能优化合并相邻命令Smallfuck 程序里出现频率最高的是*翻转和/移动。优化思路非常直观相邻的同类命令可以合并成一条等效命令从而减少主循环的迭代次数。两类优化都在parseCode阶段完成且不改变语言语义因此在循环内部同样安全。优化一偶数次翻转直接抵消连续执行*两次等价于什么都没做因此一串连续的*可以只保留奇数次。改造parseCode用第二个正则捕获连续的*按长度的奇偶决定是删除还是保留单个*const parseCode code code. replace(/[^*\[\]]/g, ). replace(/\*{2,}/g, (match) match.length % 2 0 ? : *);测试验证smallfuckInterpreter(******, 000); // 011推演**合并后删除2 为偶数*翻转第 1 格 →010*翻转第 2 格 →011**删除。最终带为011。注意此优化在循环内同样成立——因为循环体中的**无论执行多少次奇偶性不变。优化二指针移动合并为净位移和相邻时一段「右移 2 左移 1」的序列等价于「右移 1」。优化后的parseCode不再返回字符串而是返回令牌数组token array连续的/被压缩为形如[, 2]、[, 1]的二元数组其余命令保持原字符const parseCode code { const minifiedCode code. replace(/[^*\[\]]/g, ). replace(/\*{2,}/g, (match) match.length % 2 0 ? : *); const codeArray []; let pointer 0; const flushPointer () { if (pointer 0) codeArray.push([, pointer]); if (pointer 0) codeArray.push([, -pointer]); pointer 0; }; for (const symbol of minifiedCode) { if (symbol ) pointer; else if (symbol ) pointer--; else { flushPointer() codeArray.push(symbol); } } flushPointer(); return codeArray; };flushPointer把累计的净位移以单个令牌形式压入数组遇到非移动命令时先冲刷累积的位移再压入命令本身循环结束后再冲刷一次确保末尾的位移不丢失。这种「扫描输入、按类型归并、产出紧凑令牌流」的模式与仓库中 数学表达式分词器 的 tokenizer 思想一脉相承——本质都是把原始文本规约为语义等价的紧凑中间表示。相应地interpretPointerMove必须适配新格式只有二元数组令牌才触发移动按第二元素携带的计数一次性完成位移const interpretPointerMove (symbol, pointer) { if (!Array.isArray(symbol)) return pointer; const [command, count] symbol; if (command ) return pointer count; if (command ) return pointer - count; return pointer; };优化后的解释器对之前的示例依然给出相同结果smallfuckInterpreter(*****, 00101100); // 11111111两类优化叠加后长序列的*、、从「逐命令执行」降为「单条合并命令执行」显著减少主循环迭代次数。从源码结构看parseCode的正则过滤非命令字符剔除、偶次翻转剔除与流式令牌归并相互独立顺序组合后仍保持语义等价。完整实现汇总将上述所有构件组合起来就得到完整的解释器加注释版可供直接阅读与复用// Termination conditions (curried functions) const boundCheck length n n 0 || n length; const codeCompleteCheck length n n length; // Input parsing functions (tape and code) const parseTape tape tape.split(); const parseCode code { // Discard non-Smallfuck characters, then optimize bit flipping const minifiedCode code .replace(/[^*\[\]]/g, ) .replace(/\*{2,}/g, match (match.length % 2 0 ? : *)); const codeArray []; let pointer 0; const flushPointer () { if (pointer 0) codeArray.push([, pointer]); if (pointer 0) codeArray.push([, -pointer]); pointer 0; }; // Convert sequences of and commands to single commands for (const symbol of minifiedCode) { if (symbol ) pointer; else if (symbol ) pointer--; else { flushPointer(); codeArray.push(symbol); } } flushPointer(); return codeArray; }; // Bit flipping function (handles * commands) const interpretBitFlip (symbol, pointer, tape) { if (symbol *) tape[pointer] tape[pointer] 1 ? 0 : 1; }; // Pointer movement function (handles sequences of and commands) const interpretPointerMove (symbol, pointer) { if (!Array.isArray(symbol)) return pointer; const [command, count] symbol; if (command ) return pointer count; if (command ) return pointer - count; return pointer; }; // Loop pair creation function (finds matching brackets) const createLoopPairs code [...code].reduce( ({ pairs, stack }, symbol, i) { if (symbol [) stack.push(i); else if (symbol ]) { const openIndex stack.pop(); pairs.set(openIndex, i); pairs.set(i, openIndex); } return { pairs, stack }; }, { pairs: new Map(), stack: [] } ).pairs; // Matching bracket retrieval function (curried function) const pairMatch loopPairs codePointer loopPairs.get(codePointer); // Loop interpretation function (handles [ and ] commands, curried function) const createLoopInterpreter loopPairs (symbol, codePointer, tapeValue) { const getMatchingLoopPair pairMatch(loopPairs); if ( (symbol [ tapeValue 0) || (symbol ] tapeValue 1) ) return getMatchingLoopPair(codePointer); return codePointer; }; // Main interpreter function const smallfuckInterpreter (code, tape) { // Parse input data const tapeData parseTape(tape); const codeData parseCode(code); // Prepare termination conditions, loop pairs, and loop interpreter const isTapeOutOfBounds boundCheck(tapeData.length); const isCodeCompleted codeCompleteCheck(codeData.length); const loopPairs createLoopPairs(codeData); const interpretLoop createLoopInterpreter(loopPairs); // Initialize pointers and start interpreter loop let tapePointer 0, codePointer 0; // Main interpreter loop while (true) { // Exit if tape pointer is out of bounds or code is completed if (isTapeOutOfBounds(tapePointer) || isCodeCompleted(codePointer)) return tapeData.join(); // Interpret current symbol, move pointer, flip bit, and interpret loop const codeSymbol codeData[codePointer]; tapePointer interpretPointerMove(codeSymbol, tapePointer); interpretBitFlip(codeSymbol, tapePointer, tapeData); codePointer interpretLoop(codeSymbol, codePointer, tapeData[tapePointer]); // Move to next symbol codePointer; } };延伸思考这套模式能迁移到哪里回顾整个实现它的可复用性远超「一个 Esolang 玩具」栈匹配括号是解析器领域的通用工具。仓库中 find-matching-bracket-pairs 还演示了把同样的栈算法推广到()、[]、{}乃至 HTML 标签等多对括号的场景柯里化预置参数让boundCheck、pairMatch、createLoopInterpreter这类「先绑定上下文、再逐次调用」的函数在循环内保持单参数签名代码更紧凑流式令牌化 语义归并的思路即优化章节的做法正是 数学表达式分词器 与编译器前端的核心思想可以平滑迁移到更复杂的语言由于 Smallfuck 与 Brainfuck 的循环、指针、翻转语义高度同源本实现的createLoopPairs、主循环骨架几乎可以直接沿用到仓库中后续的 Brainfuck 解释器系列只需扩展命令集与加入输入/输出处理。如果你正在练习 CodeWars 上的 Esolang 解释器系列 Kata或想亲手搭建一个极简编程语言解释器这个「先拆构件、再组主循环、最后做语义等价优化」的路径就是一个经过验证、可以照搬的最小工程范式。赞分享教程文档【免费下载链接】30-seconds-of-codeCoding articles to level up your development skills项目地址https://gitcode.com/gh_mirrors/30/30-seconds-of-code点击查看免费下载相关推荐30 seconds of code用 JavaScript 从零实现通用 Graph图数据结构30 seconds of code用 JavaScript 从零实现通用 Graph图数据结构 在 30 seconds of code 项目的 Jav教程文档30 seconds of code用 JavaScript 从零实现一个数学表达式分词器Math Expression Tokenizer30 seconds of code用 JavaScript 从零实现一个数学表达式分词器Math Expression Tokenizer 本指南以 3教程文档30 Seconds of Code 实战手写一份带逐条注释的现代 CSS Reset30 Seconds of Code 实战手写一份带逐条注释的现代 CSS Reset 浏览器在过去几十年里不断演进如今的 HTML 默认渲染已经相当一致教程文档上一篇Meshery Catalog 设计实战用「Pod Life Cycle」管理 Kubernetes Pod 生命周期下一篇GSYGithubAppFlutter Release 功能修改实战双链路验证、源码定位与回归清单创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表