bigywater.tech
Python · 83

tokenize:源码如何被拆成 token

语法树之前还有一步:词法分析。tokenize 模块能让你看到这一步的原始产物——每个 token 的类型、文本、起止位置。这篇把它从头到尾摊开:注释去哪了、缩进怎么变成 token、f-string 怎么被拆开、哪些错误发生在这一步。

8 个 token
一行 total = price * 3 # 加税 的切分结果
INDENT / DEDENT
缩进被当成 token,而不是字符
NL ≠ NEWLINE
括号里的换行不结束一条语句

验证一:一行代码被切成哪些 token

源码 total = price * 3 # 加税
实测 · 本机执行输出
tokenize.generate_tokens(...)
  NAME     'total'    起 (1, 0) 止 (1, 5)
  OP       '='        起 (1, 6) 止 (1, 7)
  NAME     'price'    起 (1, 8) 止 (1, 13)
  OP       '*'        起 (1, 14) 止 (1, 15)
  NUMBER   '3'        起 (1, 16) 止 (1, 17)
  COMMENT  '# 加税'     起 (1, 19) 止 (1, 23)
  NEWLINE  '\n'       起 (1, 23) 止 (1, 24)
  ENDMARKER ''         起 (2, 0) 止 (2, 0)

每个 token 带有类型、字符串、起止行列。空白不产生 token(只体现在位置上);行末的 NEWLINE 标志一条逻辑语句结束;ENDMARKER 标志文件结束。注释是一个 COMMENT token——它出现在 tokenize 里,却不会出现在语法树里(见下面验证三)。

验证二:缩进是 token

实测 · 本机执行输出
  NAME('if') NAME('x') OP(':') NEWLINE INDENT NAME('y') OP('=') NUMBER('1') NEWLINE NAME('if') NAME('z') OP(':') NEWLINE INDENT NAME('w') OP('=') NUMBER('2') NEWLINE DEDENT DEDENT NAME('v') OP('=') NUMBER('3') NEWLINE ENDMARKER

Python 不用花括号,块结构由缩进决定。词法分析器为此维护一个缩进栈:某行缩进比栈顶深,输出一个 INDENT;变浅,就输出一个(或几个)DEDENT。上面最后的 w = 2 之后连续两个 DEDENT,是因为下一行 v = 3 直接退回了最外层,要一口气弹出两层。

验证三:tokenize 与 ast 看到的东西不同

实测 · 本机执行输出
  COMMENT token: ['# 加税']
  ast.dump 里能找到注释吗? False

注释在 tokenize 里有,在 ast 里没有(ast.dump 里找不到「加税」)。所以需要保留注释和原始格式的工具(格式化、重构)要在 tokenize 层工作。另外,def、return 这样的关键字和普通变量一样,在 tokenize 里都是 NAME;是不是关键字要靠 keyword 模块判断。软关键字(match、case、_、type)在别处可以当变量名用,更没法在这一层区分。

验证四:f-string 被拆开了

实测 · 本机执行输出
   [('FSTRING_START', 'f"'), ('FSTRING_MIDDLE', 'a'), ('OP', '{'), ('NAME', 'x'), ('OP', '+'), ('NUMBER', '1'), ('OP', '}'), ('FSTRING_MIDDLE', 'b'), ('FSTRING_END', '"'), ('NEWLINE', '\n'), ('ENDMARKER', '')]

本机这版 Python 里 f"a{x + 1}b" 不再是一个整体的 STRING,而是 FSTRING_START、FSTRING_MIDDLE、花括号里的普通 token(x、+、1)、再一个 FSTRING_MIDDLE、FSTRING_END。所以花括号里的表达式和普通代码用同一套规则切分。

验证五:括号里换行不算一条语句结束

实测 · 本机执行输出
   ['NAME', 'OP', 'OP', 'NUMBER', 'OP', 'NL', 'NUMBER', 'OP', 'NEWLINE', 'NAME', 'OP', 'NUMBER', 'NEWLINE', 'ENDMARKER']
  NL(非逻辑换行)与 NEWLINE(逻辑行结束)出现次数: Counter({'NEWLINE': 2, 'NL': 1})

括号没闭合时的换行是 NL(非逻辑换行),不是 NEWLINE。两行文字里 x = (1 +\n 2) 只产生一个 NEWLINE,加上后面 y = 3 的一个,共 2 个;NL 是 1 个。这也是为什么在括号里可以随意折行。

验证六:哪些错误在词法分析阶段出现

实测 · 本机执行输出
  "x = 'abc\n": TokenError: ('unterminated string literal (detected at line 1)', (1, 5))
     compile: SyntaxError: unterminated string literal (detected at line 1)
  'x = (1 + 2\n': TokenError: ('unexpected EOF in multi-line statement', (1, 0))
     compile: SyntaxError: '(' was never closed
  'if x:\n  a = 1\n    b = 2\n': tokenize 没报错
     compile: IndentationError: unexpected indent

字符串没闭合、括号没闭合,tokenize 直接抛 TokenError,compile 报的是 SyntaxError,两者位置说法略有不同。有意思的是最后一条:缩进不一致的那段代码 tokenize 完全没报错,compile 才报 IndentationError——tokenize 模块和真正的编译器词法分析并非一回事,它是给工具用的宽松版本。用它做静态检查时要记住这一点。

验证七:真实文件里的 token 分布

实测 · 本机执行输出
  json/decoder.py 364 行: {'NAME': 722, 'OP': 703, 'NEWLINE': 222, 'STRING': 73, 'INDENT': 72, 'DEDENT': 72, 'NL': 58, 'NUMBER': 53, 'COMMENT': 15}

364 行的 json/decoder.py:NAME 和 OP 加起来占了绝大多数,72 对 INDENT/DEDENT 说明有 72 个缩进块,15 个注释。

分步演示:一行代码的切分,以及缩进栈

每一步都是 tokenize 的真实输出
tokenize 逐个 token 读源码
刚读到的 token

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,标准库 tokenize、token、keyword、ast
    • 没有验证:C 解析器内部的词法分析与 tokenize 模块在边界情况上的其他差异、tab 与空格混用的处理、字节流输入(tokenize.tokenize)与编码声明
    京ICP备2026067502号-1京公网安备11010502063487号