
在编程教育领域一直存在一个经典难题初学者用的教学语言往往功能太弱写着写着就碰到能力边界而功能完整的工业级语言又过于复杂新手很容易被语法细节淹没。最近在 Hacker News 上看到 ABC v0.1 这个项目正好踩在这个痛点上——一个原本只服务于课堂演示的教学语言开始向“可用的教学工具”进化。这篇文章就围绕教学语言的设计思路、解释器实现的核心模块以及如何用 Python 快速搭建一个可运行的教学语言原型展开。无论你是正在设计课程实验的教师还是想深入了解解释器原理的开发者这篇文章都会给你一套可以直接落地的思路和代码。1. 教学语言是什么为什么需要它1.1 从“玩具语言”到“教学语言”的定位差异很多人听到“教学语言”第一反应是“玩具语言”比如只用来打印几行字符、算几道数学题的小脚本。但真正合格的教学语言定位并不是“弱化版的生产语言”而是在“表达力”和“学习成本”之间取一个平衡点。一个教学语言至少需要满足三个条件语法规则少关键字数量可控学生半天之内能读完语法手册。报错信息足够友好最好能直接指出“你这里少了一个冒号”“这个变量还没定义”。运行反馈即时适合在解释器里一行一行敲代码边敲边看结果。ABC v0.1 正是这样一个项目。从标题里的 “A teaching language grows up a little” 可以看出它原本的定义是教学工具但在 v0.1 版本里开始补上一些“正经语言”才有的能力。这种演进方向很有代表性教学语言不能永远停留在打印星号和计算阶乘它需要在保持简单的前提下逐步支持函数、作用域、数据结构甚至简单的错误处理机制。1.2 教学语言与工业语言的核心区别为了把概念说清楚这里用一张对比表来说明教学语言与通用编程语言的差异维度教学语言工业级语言语法规模小通常只有几十个关键字大往往有上百个语法规则类型系统动态类型为主或者干脆弱类型静态类型、泛型、接口、特质等错误信息面向初学者提示具体修改方向面向开发者包含堆栈、上下文、代码位置运行模型解释执行或简单编译JIT、GC、并发模型、内存模型适用场景课堂教学、入门实验、算法演示生产系统、大型项目、底层开发扩展能力弱通常只提供最小核心强提供库、生态、插件机制不过需要注意这个边界正在模糊。Python 本身就被大量用于教学但它同时也是工业级语言。所以更准确的说法是教学语言不一定是 Python 这样的通用语言也可以是一门“为了教学而设计的小型语言”ABC 就属于后者。1.3 ABC v0.1 的定位理解从标题和社区讨论看ABC 是一个用于编程入门教学的解释型语言v0.1 是它的第一个正式版本。所谓 “grows up a little”我理解为三层含义语法层面不再只是“表达式求值器”开始支持控制流、函数定义等结构化编程能力。实现层面不再只是几十行脚本开始具备词法分析、语法树、求值器这样的完整解释器架构。工程层面开始有测试、文档、示例程序让它能走进真实课堂而不是停留在 Demo。这篇文章不打算复刻 ABC 的全部源码而是带着你从零实现一个具有相同设计思路的教学语言原型——如果你能理解这个原型的每个模块再看 ABC v0.1 的源码就会轻松很多。2. 环境准备与总体架构设计2.1 开发环境与工具版本本文的示例代码使用 Python 实现教学语言解释器这是最主流的解释器快速原型方案。操作系统Windows 10 / macOS / Linux 均可 Python 版本3.8 及以上推荐 3.10 开发工具VS Code 或 PyCharm 均可 依赖不需要任何第三方库纯标准库实现如果你还没有安装 Python可以从 Python 官网下载稳定版本。安装完成后可以在终端验证python --version建议新建一个独立目录来存放项目代码mkdir teaching-lang cd teaching-lang2.2 解释器核心架构拆解一个最小的解释器由四个部分组成这也是 ABC v0.1 这类教学语言最常见的架构源代码 - 词法分析器(Scanner) - Token流 - 语法分析器(Parser) - 语法树(AST) - 求值器(Evaluator) - 输出结果各模块职责如下模块职责类比词法分析器把字符串拆成有意义的单词和符号把英文句子拆成单词和标点语法分析器按语法规则把 Token 组合成树状结构把单词按句式组装成句子语法树描述程序结构的树形数据句子的语法成分分析图求值器遍历语法树并执行运算理解句子意思并回答问题内置环境存放变量、函数定义的字典记录词语含义的词典为什么教学语言要采用解释器而不是编译器因为解释器更适合交互式学习——学生输入一行代码立刻看到结果这种“即时反馈”是教学场景最看重的体验。ABC 这类教学语言选择解释执行路线也是基于同样的考虑。3. 核心实现原理拆解3.1 词法分析让字符串变成 Token 流词法分析是整个解释器的第一步。它的输入是一段源代码字符串输出是一系列带有类型的 Token。举个例子对于以下输入let x 10 5词法分析器应该输出Token(TYPE_KEYWORD, let) Token(TYPE_IDENTIFIER, x) Token(TYPE_ASSIGN, ) Token(TYPE_NUMBER, 10) Token(TYPE_OPERATOR, ) Token(TYPE_NUMBER, 5)实现一个基础但健壮的词法分析器并不复杂。核心是遍历每个字符判断它属于哪一类。# 文件路径teaching_lang/scanner.py import re # Token 类型定义 TYPE_KEYWORD KEYWORD TYPE_IDENTIFIER IDENTIFIER TYPE_NUMBER NUMBER TYPE_OPERATOR OPERATOR TYPE_LPAREN LPAREN TYPE_RPAREN RPAREN TYPE_ASSIGN ASSIGN TYPE_EOF EOF # 教学语言保留字 KEYWORDS {let, print, if, else, while, func, return, true, false} class Token: def __init__(self, token_type, value): self.type token_type self.value value def __repr__(self): return fToken({self.type}, {self.value}) class Scanner: def __init__(self, source): self.source source self.position 0 def scan(self): tokens [] while not self._at_end(): # 跳过空白字符 if self.source[self.position].isspace(): self.position 1 continue # 解析关键字和标识符 if self.source[self.position].isalpha(): tokens.append(self._scan_word()) continue # 解析数字 if self.source[self.position].isdigit(): tokens.append(self._scan_number()) continue # 解析运算符和标点 char self.source[self.position] if char : tokens.append(Token(TYPE_OPERATOR, )) self.position 1 continue if char -: tokens.append(Token(TYPE_OPERATOR, -)) self.position 1 continue if char *: tokens.append(Token(TYPE_OPERATOR, *)) self.position 1 continue if char /: tokens.append(Token(TYPE_OPERATOR, /)) self.position 1 continue if char : tokens.append(Token(TYPE_ASSIGN, )) self.position 1 continue if char (: tokens.append(Token(TYPE_LPAREN, ()) self.position 1 continue if char ): tokens.append(Token(TYPE_RPAREN, ))) self.position 1 continue raise SyntaxError(f无法识别的字符: {char} 在位置 {self.position}) tokens.append(Token(TYPE_EOF, None)) return tokens def _at_end(self): return self.position len(self.source) def _scan_word(self): start self.position while not self._at_end() and (self.source[self.position].isalnum() or self.source[self.position] _): self.position 1 text self.source[start:self.position] token_type TYPE_KEYWORD if text in KEYWORDS else TYPE_IDENTIFIER return Token(token_type, text) def _scan_number(self): start self.position while not self._at_end() and self.source[self.position].isdigit(): self.position 1 text self.source[start:self.position] return Token(TYPE_NUMBER, int(text))在这个实现中有几个设计点值得说明关键字和标识符在词法层面用 KEYWORDS 集合区分这样语法分析器就不需要重复判断。数字直接解析成 Python 的 int求值阶段可以直接参与算术运算。遇到无法识别的字符立即抛出 SyntaxError并携带位置信息这比在语法分析阶段才报错更容易定位问题。3.2 语法分析从 Token 流到语法树语法分析器的任务是把扁平的一组 Token 组织成有层次的树形结构。对于表达式10 5 * 2我们希望得到的语法树是() / \ 10 (*) / \ 5 2因为数学运算有优先级5 * 2应该先计算所以乘法节点位置更靠近子树。这就是语法树的价值用树形结构显式表达运算优先级。为了简化教学语言的语法分析我采用“递归下降解析”思路实现加法、减法、乘法、除法、括号表达式和变量引用。# 文件路径teaching_lang/ast_nodes.py class ASTNode: pass class NumberNode(ASTNode): def __init__(self, value): self.value value def __repr__(self): return fNumberNode({self.value}) class VariableNode(ASTNode): def __init__(self, name): self.name name def __repr__(self): return fVariableNode({self.name}) class BinaryOpNode(ASTNode): def __init__(self, left, op, right): self.left left self.op op self.right right def __repr__(self): return fBinaryOpNode({self.left} {self.op} {self.right}) class ListNode(ASTNode): def __init__(self, items): self.items items class PrintNode(ASTNode): def __init__(self, expression): self.expression expression class LetNode(ASTNode): def __init__(self, name, value_expr): self.name name self.value_expr value_expr class BlockNode(ASTNode): def __init__(self, statements): self.statements statements语法分析器 parser.py 的核心代码如下# 文件路径teaching_lang/parser.py from .scanner import Scanner, TYPE_NUMBER, TYPE_IDENTIFIER, TYPE_KEYWORD, TYPE_OPERATOR, TYPE_LPAREN, TYPE_RPAREN, TYPE_ASSIGN, TYPE_EOF from .ast_nodes import NumberNode, VariableNode, BinaryOpNode, ListNode, PrintNode, LetNode, BlockNode class Parser: def __init__(self, tokens): self.tokens tokens self.current 0 def parse(self): statements [] while not self._check(TYPE_EOF): statements.append(self._statement()) return BlockNode(statements) def _statement(self): if self._check_keyword(let): return self._let_statement() if self._check_keyword(print): return self._print_statement() return self._expression_statement() def _let_statement(self): self._consume_keyword(let) name_token self._consume(TYPE_IDENTIFIER, 变量名) self._consume(TYPE_ASSIGN, ) expr self._expression() return LetNode(name_token.value, expr) def _print_statement(self): self._consume_keyword(print) expr self._expression() return PrintNode(expr) def _expression_statement(self): return self._expression() def _expression(self): return self._additive() def _additive(self): node self._multiplicative() while self._match_operator() or self._match_operator(-): op self._previous().value right self._multiplicative() node BinaryOpNode(node, op, right) return node def _multiplicative(self): node self._primary() while self._match_operator(*) or self._match_operator(/): op self._previous().value right self._primary() node BinaryOpNode(node, op, right) return node def _primary(self): if self._match(TYPE_NUMBER): return NumberNode(self._previous().value) if self._match(TYPE_IDENTIFIER): return VariableNode(self._previous().value) if self._match(TYPE_LPAREN): expr self._expression() self._consume(TYPE_RPAREN, )) return expr raise SyntaxError(f无法解析的 Token: {self._peek()}) # 辅助方法 def _match(self, token_type): if self._check(token_type): self.current 1 return True return False def _match_operator(self, op): if self._check(TYPE_OPERATOR) and self._peek().value op: self.current 1 return True return False def _check(self, token_type): return self._peek().type token_type def _check_keyword(self, keyword): return self._check(TYPE_KEYWORD) and self._peek().value keyword def _consume(self, token_type, message): if self._check(token_type): return self._advance() raise SyntaxError(f期望 {message}但实际是 {self._peek()}) def _consume_keyword(self, keyword): if self._check_keyword(keyword): return self._advance() raise SyntaxError(f期望关键字 {keyword}) def _peek(self): return self.tokens[self.current] def _previous(self): return self.tokens[self.current - 1] def _advance(self): self.current 1 return self.tokens[self.current - 1]这里所有解析方法都采用“拿当前 Token 判断然后递归调用下一层”的模式。_additive和_multiplicative的分层设计直接解决了运算符优先级问题——乘法层先被调用因此绑定更紧。3.3 求值器让语法树真正运行起来语法树只描述了程序的结构真正执行运算的是求值器。求值器遍历语法树对不同的节点类型执行不同操作。环境Environment用于存储变量和函数定义。为了支持后面的作用域概念这里用一个简单的字典作为全局环境。# 文件路径teaching_lang/evaluator.py from .ast_nodes import NumberNode, VariableNode, BinaryOpNode, ListNode, PrintNode, LetNode, BlockNode class Evaluator: def __init__(self): self.environment {} def evaluate(self, node): if isinstance(node, BlockNode): result None for statement in node.statements: result self.evaluate(statement) return result if isinstance(node, NumberNode): return node.value if isinstance(node, VariableNode): if node.name not in self.environment: raise NameError(f变量 {node.name} 未定义) return self.environment[node.name] if isinstance(node, BinaryOpNode): left self.evaluate(node.left) right self.evaluate(node.right) if node.op : return left right if node.op -: return left - right if node.op *: return left * right if node.op /: if right 0: raise ZeroDivisionError(除数不能为 0) return left / right raise RuntimeError(f未知运算符 {node.op}) if isinstance(node, PrintNode): value self.evaluate(node.expression) print(value) return value if isinstance(node, LetNode): value self.evaluate(node.value_expr) self.environment[node.name] value return value raise RuntimeError(f未知节点类型: {type(node)})需要注意为了让用户能在交互式命令行里逐步输入代码解释器还需要一个 REPLRead-Eval-Print Loop读取-求值-打印循环。# 文件路径main.py from teaching_lang.scanner import Scanner from teaching_lang.parser import Parser from teaching_lang.evaluator import Evaluator def run_source(source): tokens Scanner(source).scan() parser Parser(tokens) ast parser.parse() evaluator Evaluator() return evaluator.evaluate(ast) def repl(): print(ABC 教学语言 v0.1 原型 REPL) print(输入代码执行输入 exit 退出) print() evaluator Evaluator() while True: try: line input( ) except EOFError: break if line.strip() exit: break try: tokens Scanner(line).scan() parser Parser(tokens) ast parser.parse() evaluator.evaluate(ast) except Exception as e: print(f错误: {e}) if __name__ __main__: repl()这里特意把 Evaluator 放在循环外面创建这样let x 10之后后面还能继续引用x保持了交互式会话的“记忆”能力。4. 完整实战案例让教学语言“长大一点”4.1 创建项目目录结构我们现在把上面的代码组装成一个完整的项目目录结构如下teaching-lang/ ├── main.py ├── teaching_lang/ │ ├── __init__.py │ ├── scanner.py │ ├── parser.py │ ├── ast_nodes.py │ └── evaluator.py └── examples/ └── demo.tl执行以下命令创建mkdir -p teaching-lang/teaching_lang mkdir -p teaching-lang/examples cd teaching-lang touch teaching_lang/__init__.py4.2 编写示例程序在examples/demo.tl中写入let x 3 5 * 2 print x let y (3 5) * 2 print y print x y由于 REPL 模式一次输入一行而你希望执行整个脚本需要在main.py中补充一个run_file函数def run_file(filepath): with open(filepath, r, encodingutf-8) as f: source f.read() run_source(source)4.3 运行与验证进入项目根目录执行python -m main将会进入交互式命令行。手动输入 let x 3 5 * 2 print x 13执行示例脚本python -c from main import run_file; run_file(examples/demo.tl)预期输出13 16 294.4 输出结果分析第一个表达式3 5 * 2输出 13说明乘法优先于加法语法树的层级设计生效了。第二个表达式(3 5) * 2输出 16说明括号可以改变结合顺序。第三次x y输出 29说明变量在环境中被正确保存了。这个原型虽然只有几百行代码但已经具备了一个教学语言最核心的能力算术表达式、括号、变量、打印输出。它足以支撑初学者的前几节课。5. 从原型到真实教学语言的扩展方向ABC v0.1 之所以说 “grows up a little”是因为它不止于此。要成为一个真正可以走进课堂的教学语言至少还需要以下几个扩展。5.1 控制流if 和 while没有控制流的语言只能做计算器不能做算法教学。增加 if 和 while 的步骤是扩展 Token 类型关键字扩展语法分析器识别块结构扩展求值器支持条件判断和循环。# 思路示例求值器中的 while 节点 if isinstance(node, WhileNode): while self.evaluate(node.condition): self.evaluate(node.body) return None注意这里self.evaluate(node.body)内部需要一个 BlockNode 求值逻辑BlockNode 现在已经存在所以实现 while 只需要新增 WhileNode 和对应解析逻辑。5.2 函数定义与调用函数是教学语言从“算术语言”走向“结构化语言”的关键一步。函数定义需要保存参数名和函数体语法树函数调用需要建立新的局部环境。# 文件路径teaching_lang/evaluator.py 扩展片段 class FunctionNode(ASTNode): def __init__(self, name, params, body): self.name name self.params params self.body body # 求值器中的函数调用 if isinstance(node, FunctionNode): self.environment[node.name] node return None if isinstance(node, CallNode): func self.environment.get(node.func_name) if not func or not isinstance(func, FunctionNode): raise NameError(f函数 {node.func_name} 未定义) local_frame dict(zip(func.params, [self.evaluate(arg) for arg in node.args])) old_env self.environment self.environment {**self.environment, **local_frame} result self.evaluate(func.body) self.environment old_env return result这个实现方式很朴素但也很好理解函数体在自己的局部变量帧中执行执行完毕恢复外层环境。对于教学目的来说这种简化反而比复杂的闭包机制更容易向学生解释。5.3 数据结构列表有了列表教学语言就能讲排序、查找这些经典算法了。可以通过[1, 2, 3]这样的字面量语法来支持列表与解析括号表达式类似但需要在新的_list解析方法中区分“括号表达式”和“列表字面量”。5.4 更友好的错误信息教学语言最容易拉开差距的地方就是错误信息。比如错误: 变量 x 未定义 建议: 在赋值前先使用 let x 值 来创建变量这比 Python 原生报错“NameError: name x is not defined”更友好因为它直接告诉初学者下一步该做什么。6. 常见问题与排查思路6.1 报错“无法识别的字符”问题现象常见原因解决思路输入中文冒号报错词法分析器不支持全角符号改用英文冒号:输入花括号{ }报错当前词法分析器未定义花括号 Token如果不需要块语法去掉花括号输入let x 3.14报错_scan_number只处理了整数扩展支持浮点数输入// 注释报错未实现注释功能增加注释跳过逻辑6.2 报错“期望变量名但实际是 Token(...)”这是因为let后面没有跟标识符。常见情况是写成了let 5。在语法分析器中_let_statement会调用_consume(TYPE_IDENTIFIER, 变量名)此时检测到于是抛出异常。排查时可以先确认let和变量名之间是否有空格。6.3 REPL 中变量丢失如果每次循环都重新创建 Evaluator上一轮定义的变量就会消失。正确做法是像第 3.4 节的 REPL 实现那样在循环外创建 Evaluator让它保持同一个 environment 字典。6.4 浮点数计算结果不正确目前_scan_number只解析整数10 / 4输出 2.5 没问题但输入let x 3.14会直接报错。扩展方式是在数字扫描中加一个状态检测小数点后继续解析并在 Token 创建时用float(text)或者保留 int 和 float 的区分。7. 最佳实践与工程建议7.1 为教学语言编写测试用例解释器是典型的分层结构非常适合单元测试。建议至少覆盖以下场景词法分析单字符 Token、多字符数字、未知字符抛错。语法分析运算符优先级、括号匹配、let语法错误。求值器加法、除法、变量未定义、整数除法行为。用 Python 官方自带的unittest就能写不需要额外依赖。7.2 日志与错误追踪虽然教学语言本身是给人学习的但解释器代码是给开发者维护的。建议为 Scanner、Parser、Evaluator 三个模块加上简单的日志开关方便调试import logging logging.basicConfig(levellogging.INFO) # 在关键位置 logging.debug(f当前 Token: {self._peek()})7.3 版本管理与向后兼容教学语言一旦进入课堂就可能有一批学生作业依赖它的语法。v0.1 阶段就要考虑语法兼容问题新增关键字时考虑旧代码是否能用其他方式表达。如果必须破坏兼容需要提供迁移脚本或明确文档。错误信息变化尽量只增不减原有的提示模式不要突然改变。7.4 安全边界教学语言的来源往往是学生输入的代码。解释器执行时要注意资源限制防止恶意死循环或超大内存申请。在设计 while 循环时可以外加一层迭代次数保护MAX_ITERATIONS 10000如果教学语言要嵌入 Web 端做在线编程练习那还需要考虑超时、文件系统隔离、网络权限等问题。这是教学语言走向线上平台时必须补上的安全课。7.5 文档与示例代码编写教学语言的文档和平常 API 文档不同应该以“课程单元”为单位组织。例如第 1 课打印输出和数字运算。第 2 课变量与 let 绑定。第 3 课if/else 条件分支。第 4 课while 循环。第 5 课函数定义与调用。每一课都要给出“错误示例 - 学习修改 - 正确示例”的教学路径。8. 总结与学习路线通过本文的实践你已经从零实现了一个教学语言的解释器原型。从词法分析到语法分析再到求值器你可以清楚地看到一段源代码经历怎样的旅程最终变成输出结果。如果想让这个项目继续长大建议按以下顺序推进增加 if/else 和 while让语言具备控制流程能力。增加函数定义和调用让语言支持结构化编程。增加列表和字符串等基础数据结构。完善错误提示让每种常见错误都能给出可操作的修改建议。编写自动化测试防止增加新功能时破坏旧语法。设计一个小的标准库比如数学函数、断言工具。动手写解释器是理解编程语言最直接的方式。当你亲手实现完词法分析器的那一刻再看任何语言的官方文档都会有一种“原来如此”的感觉。如果你正在学习编译器理论或者在准备一门编程入门课程建议不要只停留在阅读这篇文章实际动手敲一遍代码把let、print、if、while、func这些关键字逐个加进你的解释器里。教学语言的意义不只是教学生编程也教会我们这些开发者重新理解一门语言最核心的语法其实可以简洁到让人惊叹。