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 表达式、树遍历解释器)的地基——自己造的语言自己懂每一行,调试任何一层都没有黑箱。