技术博客

借 GDScript 的 tokenizer 练手:写一个微型脚本语言前端

第 95 / 149 篇 · 2026-09-04 · godot · GDScript编译原理实战笔记

GDScript 编译管线那篇(五-1)讲了从 tokenizer 到 VM 的全链路,这篇落到最前端:tokenizer 本身。它是整个编译器里最适合"抄一段走"的部件——无外部依赖、无正则、逻辑全是状态判断,而且有一个大多数语言没有的好题材:缩进栈。读完这篇你能写出一份可运行的迷你 tokenizer,同时真正理解引擎那份 gdscript_tokenizer.cpp 为什么长那样。

引擎里的设计取舍

gdscript_tokenizer.cpp 四千行,骨架极简:一遍扫描,逐字符分类。没有用正则,也没有用 flex 那种生成器。手写的原因很实际:

  • 性能可预期。 编译发生在编辑器保存的瞬间和运行时加载的瞬间,tokenizer 是这条链上第一个热点。手写循环每个分支都是可见的,正则引擎的黑箱延迟在这里不可接受。
  • 错误定位是刚需。 每个 token 要携带行列号,报错时精确到字符。生成器能带位置,但想给"你这里少了个冒号,我猜你想续行"这种启发式错误提示,自己控制扫描状态才做得动。
  • 缩进敏感语言不适合传统词法工具。 flex 按正则切 token,不管列语义;GDScript 的 INDENT/DEDENT 要在 token 流里凭空造出来,必须能在"行首空白"这个状态上挂自定义逻辑。

token 的表示也体现了"位置一等公民"的设计:每个 token 是 (类型, 字面量, 行, 列) 四元组,字面量对关键字和标识符都保留原始字符串,语义分析阶段才区分。

缩进栈:GDScript 与 Python 共用的机制

缩进生成 token 的核心是一个栈,存每行开头测得的缩进宽度。算法一行行走:

  • 新行首测到缩进 $w$,栈顶为 $t$。
  • $w > t$:压栈,发出 INDENT。
  • $w < t$:弹栈直到栈顶等于 $w$;每次弹出发一个 DEDENT;弹完栈顶不等于 $w$ → 缩进错误(你的缩进和任何外层都对不齐)。
  • $w = t$:什么都不发,正常继续。

NEWLINE 在行尾发(续行 \、括号未闭合的行不发)。解析器拿到的就是干净的 INDENT … DEDENT NEWLINE 流,和 C 系语言的花括号一个表达力,但多出一个编译期白捡的检查:任何一次弹栈失败都是语法错误,混用 tab 和空格在这里立刻爆炸——引擎直接报"inconsistent use of tabs/spaces",比运行时行为诡异好一万倍。

迷你 tokenizer:可运行版

照引擎的思路(砍掉字符串插值和三引号这些枝叶)写一个,约五十行:

KEYWORDS = {"if", "else", "while", "return", "var", "func"}
INDENT_W = 4  # 一个缩进级 = 4 空格

def tokenize(src: str):
    toks, indent_stack = [], [0]
    logical = ""          # 逻辑行缓冲(处理续行后)
    start_line = 1
    for lineno, raw in enumerate(src.splitlines(), 1):
        if not raw.strip() or raw.lstrip().startswith("#"):
            continue                     # 空行与整行注释不进缩进计算
        line = raw.rstrip("\\") if raw.endswith("\\") else raw
        continued = raw.endswith("\\")
        if continued:
            logical += line
            continue
        logical += line
        if logical.count("(") > logical.count(")"):   # 括号内换行:续行
            continue
        w = len(logical) - len(logical.lstrip(" "))
        if w > indent_stack[-1]:
            indent_stack.append(w); toks.append(("INDENT", "", start_line))
        while w < indent_stack[-1]:
            indent_stack.pop(); toks.append(("DEDENT", "", start_line))
        i = w
        while i < len(logical):           # 行内逐字符扫描
            c = logical[i]
            if c == " ": i += 1; continue
            if logical[i:i+2] in ("==", "!=", "<=", ">="):
                toks.append(("OP", logical[i:i+2], start_line)); i += 2; continue
            if c in "+-*/=<>():":
                toks.append(("OP", c, start_line)); i += 1; continue
            if c.isdigit():
                j = i
                while j < len(logical) and logical[j].isdigit(): j += 1
                toks.append(("NUM", logical[i:j], start_line)); i = j; continue
            if c.isalpha() or c == "_":
                j = i
                while j < len(logical) and (logical[j].isalnum() or logical[j] == "_"): j += 1
                word = logical[i:j]
                toks.append(("KW" if word in KEYWORDS else "IDENT", word, start_line))
                i = j; continue
            raise SyntaxError(f"第 {lineno} 行: 无法识别的字符 {c!r}")
        toks.append(("NEWLINE", "", start_line))
        logical = ""
        start_line = lineno + 1
    while len(indent_stack) > 1:          # 文件末尾补齐 DEDENT
        indent_stack.pop(); toks.append(("DEDENT", "", start_line))
    toks.append(("EOF", "", start_line))
    return toks

跑一个用例看缩进栈的工作:

src = """
func f(x):
    if x > 1:
        return x
    return 0
"""
for t in tokenize(src):
    print(t)

输出开头是 INDENT, INDENT, …,中间两次 NEWLINE 之间夹着语句,return 0 后是两个 DEDENT(连跳两级缩进),末尾自动补齐。把这个输出和源文件对照着看一遍,GDScript 的 gdscript_tokenizer.cpp 里那个 _indent_stack 就不再是源码里的陌生人了。

练手的价值在哪

抄完这一份,收获的不只是 tokenizer:你会拿到对编译器前端的所有直觉——为什么 token 要带位置、为什么续行要按"逻辑行"缓冲、为什么缩进语言的一致性检查放在词法层最划算。这些直觉回头看 GDScript 的 analyzer 和 VM 都是通用的。而且这份五十行代码就是后续五篇练手(递归下降 parser、 Pratt 表达式、树遍历解释器)的地基——自己造的语言自己懂每一行,调试任何一层都没有黑箱。

tokenizer 的扫描循环与缩进栈 源码字符流 按物理行读入 逻辑行缓冲 续行 \ 与括号未闭合 的行先缓冲不结束 行首缩进 w 与栈顶 t 比较 决定 INDENT/DEDENT token 流 (类型,字面量,行,列) 缩进栈(初始 [0]) 0(栈底) 4 ← w>t 压栈发 INDENT 8 ← 同上 w<t 弹栈,每弹一个发 DEDENT 行内逐字符扫描 数字 → 贪婪读完整数段 → NUM 字母下划线 → 贪婪读标识符 → 查关键字表 双字符运算符先匹配(<= 优先于 <) 其余单字符直接分类 弹完栈顶 ≠ w → 缩进不一致错误