18. 代币化
18. 代币化
标记化是 CPython 编译管道中的第一个结构阶段。
它接收 Python 源文本并生成令牌流。解析器使用该令牌流并从中构建语法结构。
在这个阶段,CPython 还不知道程序是否有意义。它只识别词汇单元:名称、数字、字符串、运算符、换行符、缩进和文件结束标记。
分词器将其转变为:python def add(a, b): return a + b 成这样的流:text NAME "def" NAME "add" LPAR "(" NAME "a" COMMA "," NAME "b" RPAR ")" COLON ":" NEWLINE "\n" INDENT " " NAME "return" NAME "a" PLUS "+" NAME "b" NEWLINE "\n" DEDENT "" ENDMARKER "" 确切的标记名称和解析器接口因 CPython 版本而异,但核心思想是稳定的。 Python 标准库通过以下方式公开了 Python 级别的分词器:tokenize,而 CPython 的解析器在内部使用自己的 C 分词器。公众tokenize模块还返回注释和初始编码标记,这使得它适合格式化程序和语法突出显示等工具。 ([Python 文档][1])
18.1 在编译管道中的位置
完整的源代码到执行路径是:```text bytes from file or string ↓ encoding detection ↓ decoded source text ↓ tokenizer ↓ token stream ↓ parser ↓ abstract syntax tree ↓ symbol table ↓ code object ↓ bytecode execution
分词器回答以下问题:```text
Where does this logical line end?
Is this identifier a name?
Is this numeric literal well-formed?
Is this string literal closed?
Did indentation increase or decrease?
Is this character part of an operator?
Has the source reached end-of-file?
```解析器回答不同的问题:```text
Is this a valid function definition?
Is this expression allowed here?
Does this statement match a grammar rule?
Does this sequence form a valid pattern match?
How should these tokens be grouped into an AST?
```分词器不构建 AST。它只产生一系列词汇事件。
## 18.2 源输入和编码
Python 源代码通常以字节开头。
在继续进行标记化之前,CPython 必须确定如何将这些字节解码为文本。 Python 源文件默认为 UTF-8,但文件可以在顶部附近声明另一种编码。
典型的编码声明:```python
# -*- coding: latin-1 -*-
```或者:```python
# coding: utf-8
```分词器必须尽早处理这个问题,因为在源被解码之前它无法可靠地对源字符进行分类。
在Python级别,`tokenize.tokenize()`首先返回一个`ENCODING`令牌。这`token`文档指出这`ENCODING`Python 需要 token`tokenize`模块,并且 CPython 的 C 分词器不以相同的方式使用它。 ([Python 文档][2])
实用模型为:```text
read first source lines
detect encoding declaration if present
decode source bytes
normalize line handling
begin lexical scanning
```这就是为什么标记化不仅仅是字符循环。它还拥有外部源字节和内部源文本之间的边界。
## 18.3 物理线和逻辑线
Python源代码有物理行和逻辑行。
物理行是源文件中的一行。
逻辑行是解析器看到的一个完整的 Python 语句或表达式单元。
通常它们是相同的:```python
x = 1
y = 2
```这里,每条物理线也是一条逻辑线。
但 Python 允许使用反斜杠显式连接行:```python
x = 1 + \
2 + \
3
```这是一条分布在三条物理线路上的逻辑线路。
Python 还允许在圆括号、方括号和大括号内进行隐式行连接:```python
values = [
1,
2,
3,
]
```在分组分隔符内,换行符不会结束逻辑语句。分词器跟踪嵌套深度,因此它可以区分重要的换行符和非重要的换行符。
从概念上讲:```text
paren_level = 0
when "(" or "[" or "{" appears:
paren_level += 1
when ")" or "]" or "}" appears:
paren_level -= 1
when newline appears:
if paren_level == 0:
emit NEWLINE
else:
ignore as statement terminator
```这条规则对于 Python 的可读多行语法至关重要。
## 18.4`NEWLINE`和`NL`Python 级别的标记化区分逻辑行换行符和非终止换行符。
在公众场合`tokenize`级别:
|代币|意义|
| ---------| ---------------------------------------------------- |
|`NEWLINE`|结束逻辑行 |
|`NL`|不结束逻辑行的换行符 |
例子:```python
x = (
1 +
2
)
y = 3
```括号内的换行符不是语句终止符。解析器不应该处理`1 +`作为一个完整的陈述。这些换行符是为了布局而不是语法而存在的。
在面向工具的分词器中,它们显示为`NL`。在面向解析器的模型中,它们被忽略或与真正的逻辑换行符区别对待。
这种区别对于格式化程序和 linter 很重要。格式化程序可能关心每个物理换行符。解析器只需要逻辑结构。
## 18.5 缩进作为标记
Python 使用缩进作为语法。
这意味着标记生成器必须将前导空格转换为标记。
例子:```python
if ready:
run()
log()
finish()
```解析器无法仅使用名称和标点符号来理解该源。它需要明确的块边界。
分词器发出:```text
NAME "if"
NAME "ready"
COLON ":"
NEWLINE "\n"
INDENT " "
NAME "run"
LPAR "("
RPAR ")"
NEWLINE "\n"
NAME "log"
LPAR "("
RPAR ")"
NEWLINE "\n"
DEDENT ""
NAME "finish"
LPAR "("
RPAR ")"
NEWLINE "\n"
ENDMARKER ""
```缩进创建虚拟块开始。缩进创建虚拟块端。
CPython 维护一个缩进堆栈。在逻辑行的开头,分词器会测量前导空格并将其与当前的缩进级别进行比较。
简化模型:```text
indent_stack = [0]
at beginning of logical line:
col = indentation_column()
if col > indent_stack[-1]:
push col
emit INDENT
else if col == indent_stack[-1]:
emit no indentation token
else:
while col < indent_stack[-1]:
pop
emit DEDENT
if col != indent_stack[-1]:
report indentation error
```这个堆栈规则解释了为什么在正常解析可以继续之前不一致的缩进是一个词法错误。
## 18.6 制表符、空格和缩进列
缩进是按列而不是原始字符来测量的。
空格前进一列。制表符前进到下一个制表位。 Python 的制表符处理是为了兼容性而存在,但混合制表符和空格可能会产生不明确的缩进和错误。
例子:```python
if x:
\tprint("tab")
print("spaces")
```视觉对齐可能取决于编辑器设置。 CPython 无法信任人类编辑器如何显示它。它使用语言的制表符规则计算缩进,并在缩进不一致时引发错误。
重要的内部要点是缩进不存储为“前导字符数”。它被转换为缩进级别。这些级别与缩进堆栈进行比较。
## 18.7 空行和注释行
空行通常不会产生对解析器有意义的标记。
例子:```python
x = 1
y = 2
```空行不会终止块或创建语句。
仅注释行对于解析器的行为类似:```python
x = 1
# comment
y = 2
```公众`tokenize`模块返回注释,因为工具需要它们。 CPython 的解析器不将注释视为语法。
这个区别很重要:
|消费者 |需要评论吗? |原因 |
| ------------------ | --------------: | ---------------------------------- |
|解析器| 没有 |注释不影响语法 |
|格式化程序| 是的 |评论必须保留 |
|语法荧光笔 | 是的 |评论需要样式 |
|短绒 | 是的 |评论可能包含指令 |
|类型检查器 | 有时|评论可能包含类型评论 |
公共分词器是一个工具 API。 C 分词器是编译器前端的一部分。
## 18.8 名称、关键字和软关键字
标识符被标记为名称。
例子:```python
total = price + tax
```分词器看到:```text
NAME "total"
EQUAL "="
NAME "price"
PLUS "+"
NAME "tax"
```传统的 Python 关键字包括以下单词:```text
def
class
if
else
while
for
try
except
return
yield
import
from
with
lambda
```在词汇层面,这些是名称形状的序列。分词器或解析器可以根据语法需要对它们进行分类。
现代Python也有软关键字。软关键字仅在特定语法位置上像关键字一样。
示例包括模式匹配使用的单词:```python
match value:
case 0:
pass
match和case在语法允许的其他上下文中仍然可以用作普通名称。
这是标记化和解析必须配合的原因之一。永久转换每次出现的分词器match进入硬关键字标记将拒绝上下文中的有效代码match只是一个名字。
实际规则:```text hard keyword: reserved everywhere soft keyword: special only in selected grammar positions name: ordinary identifier
Python 标识符可能包含许多 Unicode 字符。
例子:```python
π = 3.14159
面积 = 42
```分词器必须根据 Python 的标识符规则识别标识符起始字符和延续字符。这为 Python 源代码提供了广泛的 Unicode 支持。
但标识符仍然根据语言规则进行规范化和检查。并非每个 Unicode 字符在名称中都是合法的,并且一些视觉上相似的字符可能是不同的。
从内部角度来看,标识符处理需要:```text
Unicode-aware character classification
identifier start validation
identifier continuation validation
normalization rules
error reporting for invalid characters
```这使得 Python 标记化比纯 ASCII 语言标记化器更加复杂。
## 18.10 数字文字
分词器在解析器构建表达式之前识别数字文字。
示例:```python
123
0b1010
0o755
0xff
1_000_000
3.14
10.
.5
1e9
1.2e-3
3j
```这些成为数字标记。
分词器必须验证词汇形式:```text
base prefixes
digits allowed in each base
underscore placement
decimal points
exponents
imaginary suffix
```一些无效数字在标记化过程中失败:```python
0b102
1__2
```分词器不评估任意算术。它只识别文字标记。稍后的编译阶段将标记文本转换为相应的 Python 对象。
例子:```python
x = 1 + 2
```代币化可以看到:```text
NAME "x"
EQUAL "="
NUMBER "1"
PLUS "+"
NUMBER "2"
```事实是`1 + 2`可以折叠成`3`属于后期编译器优化,而不是标记化。
## 18.11 字符串文字
字符串标记化比数字标记化更复杂。
Python 支持:```python
"hello"
'hello'
"""hello"""
'''hello'''
r"\n"
b"bytes"
f"value={x}"
fr"path={name}\n"
```分词器必须识别:```text
string prefixes
quote style
single-line or triple-quoted form
raw strings
bytes strings
f-strings
escape sequences
line continuation rules
string termination
```普通字符串标记被识别为一个词汇单元:```python
x = "hello"
```令牌流:```text
NAME "x"
EQUAL "="
STRING "\"hello\""
```三引号字符串可以跨越物理行:```python
text = """
line 1
line 2
"""
```分词器必须不断扫描,直到找到匹配的三引号。
未终止的字符串是标记器错误:```python
x = "missing end
```解析器无法从未终止的字符串中恢复有意义的语法,因为标记器无法生成有效的标记流。
## 18.12 F 弦
F 字符串很特殊,因为它们既包含文字字符串内容又包含嵌入的 Python 表达式。
例子:```python
name = "Ada"
text = f"hello {name.upper()}"
```在字符串内部,这部分是文字文本:```text
hello
```这部分是Python表达式语法:```python
name.upper()
```分词器和解析器必须合作来处理这种嵌套结构。
从概念上讲:```text
enter f-string mode
scan literal characters
when "{" starts expression:
tokenize embedded Python expression
parse embedded expression
return to f-string literal scanning
finish at closing quote
```嵌套表达式处理使 f 字符串比普通字符串文字更加丰富。它们不仅仅是带有稍后文本替换的字符串标记。它们包含必须解析为表达式节点的语法。
## 18.13 运算符和分隔符
Python 运算符和分隔符包括单字符和多字符形式。
示例:```text
+ - * / // % **
= == != < <= > >=
:= -> @ @=
( ) [ ] { }
, : . ; ...
```分词器通常应用最长匹配行为。
例如,阅读时`**=`,它应该产生一个权力分配操作员令牌而不是`*`, `*`, 和`=`。
简化逻辑:```text
if next characters form "**=":
emit DOUBLESTAR_EQUAL
else if next characters form "**":
emit DOUBLESTAR
else if next character is "*":
emit STAR
```此规则在标记器中很常见。它使解析器不必从较小的片段重建多字符运算符。
## 18.14 错误标记和词汇错误
解析之前会出现一些错误。
示例:```python
x = "unterminated
if x:
a = 1
b = 2
x = 0b123
```这些是词汇或缩进错误。
分词器必须报告足够的信息以进行有用的诊断:```text
filename
line number
column offset
source line
error type
error message
```常见的标记化阶段错误包括:
|错误 |原因 |
| ------------------ | ------------------------------------------- |
|`SyntaxError`|无效的词汇结构或标记序列 |
|`IndentationError`|无效的缩进级别 |
|`TabError`|制表符和空格的缩进不明确 |
|`TokenError`|输入不完整的公共分词器错误 |
不是每个`SyntaxError`起源于标记化。许多来自解析。但是标记生成器存在错误,导致有效标记流无法存在。
## 18.15 文件结尾和合成凹痕
在文件末尾,CPython 必须关闭所有打开的缩进块。
例子:```python
if x:
if y:
run()
```当两个缩进级别仍处于活动状态时,源代码结束。分词器发出合成的`DEDENT`之前的令牌`ENDMARKER`。
从概念上讲:```text
NAME "if"
NAME "x"
COLON ":"
NEWLINE
INDENT
NAME "if"
NAME "y"
COLON ":"
NEWLINE
INDENT
NAME "run"
LPAR
RPAR
NEWLINE
DEDENT
DEDENT
ENDMARKER
```即使没有显式的右大括号,这也可以让解析器看到块结尾。
因此,分词器创建的标记在源文件中没有直接字符。`INDENT`, `DEDENT`, 和`ENDMARKER`是结构性标记。
## 18.16 分词器状态
分词器是有状态的。
它必须记住:```text
current input pointer
current line
current column
current indentation stack
current nesting level
whether scanning begins a line
whether inside a string
whether inside an f-string expression
whether an encoding was detected
whether interactive mode is active
pending INDENT or DEDENT tokens
error state
```无状态扫描器对于 Python 来说是不够的,因为含义取决于布局和上下文。
例子:```python
x = [
1,
2,
]
```之后的换行符`1,`出现在括号内。它不应该成为一个逻辑`NEWLINE`。
例子:```python
if x:
y = 1
z = 2
```前面的前导空格`z`导致`DEDENT`。
这些决定需要记住状态。
## 18.17 交互式代币化
交互式输入有特殊情况。
在 REPL 中,CPython 通常需要判断输入是否完整。
例子:```python
>>> if x:
...
```这是不完整的,因为需要一个块体。
例子:```python
>>> x = (1 +
...
```这是不完整的,因为括号内的表达式仍然是开放的。
分词器和解析器合作决定是请求另一行还是引发错误。因此,交互模式不同于文件模式。文件中的输入结束意味着真正的 EOF。 REPL 中的输入结束可能意味着“要求更多文本”。
## 18.18 公开`tokenize`模块
标准库通过以下方式公开标记化`tokenize`。
例子:```python
from io import BytesIO
import tokenize
src = b"x = 1 + 2\n"
for tok in tokenize.tokenize(BytesIO(src).readline):
print(tok)
```输出的形状如下:```text
TokenInfo(type=ENCODING, string='utf-8', ...)
TokenInfo(type=NAME, string='x', ...)
TokenInfo(type=OP, string='=', ...)
TokenInfo(type=NUMBER, string='1', ...)
TokenInfo(type=OP, string='+', ...)
TokenInfo(type=NUMBER, string='2', ...)
TokenInfo(type=NEWLINE, string='\n', ...)
TokenInfo(type=ENDMARKER, string='', ...)
```公共分词器可用于:```text
formatters
linters
code generators
syntax highlighters
refactoring tools
documentation tools
source-to-source transforms
```这`tokenize`文档将其描述为 Python 源代码的词法扫描器,并指出它将注释作为标记返回,这使得它对于漂亮的打印机和着色器非常有用。 ([Python 文档][1])
## 18.19 C 分词器与 Python 分词器
CPython 中有两个相关的分词器概念:
|组件|地点 |目的|
| ----------------- | --------------------------------- | ----------------------------------- |
| C 分词器 | CPython 解析器/编译器内部结构 |在编译期间向解析器提供数据 |
|`Lib/tokenize.py`|标准库 |向 Python 工具公开标记化 |
它们不是相同的接口。
C 分词器针对 CPython 的编译器管道进行了优化。它产生解析器需要的东西。
Python 分词器是一个公共工具接口。它保留注释,公开编码,返回丰富的内容`TokenInfo`对象,并且是为外部消费者设计的。
这种区别解释了为什么令牌流从`tokenize`可能包含解析器忽略的信息。
## 18.20 标记化示例详细信息
考虑这个来源:```python
def area(r):
pi = 3.14159
return pi * r * r
```简化的令牌流:```text
NAME "def"
NAME "area"
LPAR "("
NAME "r"
RPAR ")"
COLON ":"
NEWLINE "\n"
INDENT " "
NAME "pi"
EQUAL "="
NUMBER "3.14159"
NEWLINE "\n"
NAME "return"
NAME "pi"
STAR "*"
NAME "r"
STAR "*"
NAME "r"
NEWLINE "\n"
DEDENT ""
ENDMARKER ""
```要点:
1.`def`在词汇上是名称形状,但在语法上充当关键字。
2. 函数体开始,因为之后缩进增加`NEWLINE`。
3.`3.14159`是单个数字标记。
4.`return pi * r * r`是一个逻辑线。
5. 函数体通过合成结束`DEDENT`。
6. 文件结束于`ENDMARKER`。
解析器接收该流并将其与函数定义、套件、赋值、返回语句和表达式的语法规则进行匹配。
## 18.21 标记化不理解完整的语义
分词器不知道该名称未定义:```python
print(missing_name)
```它不知道这个调用会失败:```python
1()
```它不知道此导入是否存在:```python
import does_not_exist
```它只发出令牌。
语义检查稍后进行,通常在运行时进行。
标记化故意很浅。它识别词汇形式,而不是程序含义。
## 18.22 为什么代币化很重要
标记化看起来很小,但它塑造了整个语言。
它定义:```text
how indentation becomes syntax
how source bytes become characters
how comments are ignored or preserved
how strings are delimited
how f-strings embed expressions
how operators are recognized
how logical lines are formed
how parser input is structured
```对于 CPython 贡献者来说,分词器错误可能会影响语法、诊断、工具、兼容性和安全性。一个小的词汇变化就可以改变每个 Python 文件的解析方式。
对于工具作者来说,标记化通常是最好的工作层。它保留了 AST 丢弃的源级信息,包括注释、精确间距、物理行和运算符拼写。
## 18.23 最小心智模型
使用这个模型:```text
The tokenizer reads decoded Python source.
It emits lexical tokens.
It tracks indentation, nesting, strings, and line boundaries.
It inserts structural tokens such as INDENT, DEDENT, and ENDMARKER.
It reports lexical errors before parsing.
The parser consumes tokens and builds syntax structure.
```这是从原始源文本到语法的桥梁。