在编程教育领域,一直存在一个经典难题:初学者用的教学语言往往功能太弱,写着写着就碰到能力边界;而功能完整的工业级语言又过于复杂,新手很容易被语法细节淹没。最近在 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 f'Token({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 f'NumberNode({self.value})' class VariableNode(ASTNode): def __init__(self, name): self.name = name def __repr__(self): return f'VariableNode({self.name})' class BinaryOpNode(ASTNode): def __init__(self, left, op, right): self.left = left self.op = op self.right = right def __repr__(self): return f'BinaryOpNode({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)}')需要注意,为了让用户能在交互式命令行里逐步输入代码,解释器还需要一个 REPL(Read-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', encoding='utf-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(level=logging.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这些关键字逐个加进你的解释器里。教学语言的意义不只是教学生编程,也教会我们这些开发者重新理解:一门语言最核心的语法,其实可以简洁到让人惊叹。