Python 基础体系 · 第 4/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 词法与语法基础:缩进、Token、表达式、语句和注释
Python 代码从源文件到执行,大致经过这样一条路径:
flowchart LR
A[源文件字节] --> B[编码识别与解码]
B --> C[词法分析器 Tokenizer]
C --> D[Token 流]
D --> E[语法分析器 Parser]
E --> F[代码对象 Code Object]
F --> G[执行帧与运行时]
源文件首先被按编码解码为 Unicode 文本;词法分析器再把字符组织成 Token 流,语法分析器根据语言文法判断这些 Token 是否构成合法的 Python 程序。语法通过后,解释器才会执行代码对象。Python 语言参考把“词法分析”和“语法”明确区分为两个层次:前者关注字符如何组成 Token,后者关注 Token 如何组成表达式和语句。(docs.python.org)
理解这条路径很重要,因为以下错误并不属于同一阶段:
if True
print("ok")
这里缺少冒号,属于语法结构不完整,通常会得到 SyntaxError。
if True:
print("ok")
print("bad")
这里的缩进层级无法按照已有的缩进栈解释,可能得到 IndentationError 或 SyntaxError。
print(unknown_name)
这段代码的词法和语法都合法,但执行时名称查找失败,得到 NameError。因此,“代码不能运行”不能笼统地归因于语法错误。
一、先建立三个层次:字符、Token 和语法结构
1.1 字符不是 Token
源代码最初只是字符序列:
total = price * count # subtotal
词法分析器会把它识别为若干 Token,概念上可以表示为:
NAME("total")
OP("=")
NAME("price")
OP("*")
NAME("count")
COMMENT("# subtotal")
NEWLINE
ENDMARKER
空格通常不是 Token,而是用于分隔 Token。比如:
ab
a b
前者中的 ab 是一个名称 Token,后者中的 a 和 b 是两个名称 Token。只有当相邻字符拼接后可能被解释为另一个 Token 时,空白才是必要的分隔符。(docs.python.org)
因此,下面两种写法在词法上等价:
x + 1
x+1
+ 和 x 本身已经能够被区分,空格不是必须的。不过,工程代码通常保留空格以表达结构,而不是因为解释器需要它。
1.2 Token 是语法分析的输入
Python 3.14 的词法分析结果包含几类重要 Token:
NAME:名称、关键字和软关键字在词法层通常都属于这一类;NUMBER:整数、浮点数和虚数等数字字面量;STRING:字符串字面量;OP:运算符和分隔符;NEWLINE:逻辑行结束;INDENT:进入新的缩进层级;DEDENT:退出缩进层级;COMMENT:注释;ENDMARKER:输入结束。
在 tokenize 模块的公共输出中,运算符和分隔符通常以通用的 OP 表示,并可通过 exact_type 得到更具体的类型;COMMENT 会被标识出来,但语法分析器会忽略它。NEWLINE 表示逻辑行结束,而多行表达式中间的换行通常表现为 NL,不是 NEWLINE。(docs.python.org)
1.3 语法是 Token 的组合规则
语法规则描述哪些 Token 序列有效。例如,简化后的赋值语句可以写成:
assignment_stmt: target "=" expression
这并不意味着任意两个 Token 之间都可以放一个 =:
x = 1 # 合法
x + 1 = 2 # 不合法
左侧必须是可赋值目标,例如名称、属性引用、下标引用或解包目标;x + 1 是一个表达式,但不是赋值目标。
Python 语言参考使用一种结合了 EBNF 和 PEG 风格的文法表示法。例如:
name: letter (letter | digit | "_")*
其中:
:表示定义一个规则;|表示多个候选分支;(...)表示分组;*表示重复零次或多次;- 引号中的内容表示字面上的关键字或符号。
所以,上面的规则表示:name 必须以一个 letter 开始,后面可以跟任意多个字母、数字或下划线。(docs.python.org)
二、逻辑行、物理行与换行
2.1 物理行不一定等于逻辑行
物理行是由换行符结束的一行文本。Python 能识别 Unix 的 LF、Windows 的 CRLF 以及经典 Mac 风格的 CR,并将其作为换行处理。
逻辑行是语法意义上的一行。一个逻辑行可以由多个物理行组成,例如:
result = (
first_value
+ second_value
+ third_value
)
括号中的多个物理行属于同一个逻辑表达式,行间不会产生普通的 NEWLINE 语义。(docs.python.org)
2.2 隐式续行:括号、方括号和花括号
当表达式处于未闭合的 (), [] 或 {} 内时,可以直接跨行:
numbers = [
10,
20,
30,
]
config = {
"host": "localhost",
"port": 5432,
}
value = (
condition_a
and condition_b
)
续行中的缩进只用于提高可读性,不会像代码块开头的缩进那样产生 INDENT 和 DEDENT。续行中也允许出现注释和空行。(docs.python.org)
这解释了为什么下面的代码合法:
total = (
1
# 第二项
+ 2
)
而下面的代码不合法:
total = 1 +
2
第二段没有处于括号等隐式续行环境中,普通语句不能在运算符后无条件跨越物理行。
2.3 显式续行:反斜杠
也可以使用反斜杠显式连接物理行:
value = 1 + 2 + \
3 + 4
反斜杠必须是物理行最后一个有效字符,不能在它后面继续写注释:
value = 1 + 2 + \ # 错误
3 + 4
反斜杠续行是词法规则,不是普通的运算符。它会删除反斜杠和紧随其后的换行,使两行合并为一个逻辑行。括号续行通常更稳健,因为它允许注释和尾随逗号。(docs.python.org)
2.4 空白行
只包含空格、制表符、换页符或注释的逻辑行会被忽略,不会产生普通的 NEWLINE Token:
x = 1
# 这也是一个被忽略的逻辑行
y = 2
交互式解释器对空行有额外行为:在输入多行语句时,一个完全空的逻辑行可能结束当前输入。脚本文件与交互式解释器在这里不能完全等同。(docs.python.org)
三、缩进:视觉格式背后的语法 Token
3.1 缩进决定代码块
Python 不使用 {} 标记代码块,而是使用冒号和缩进:
if score >= 60:
print("pass")
print("continue")
print("done")
冒号引出一个 suite,也就是由该子句控制的一组语句。缩进后的两次 print 属于 if 块,最后一次 print 回到外层。
复合语句的基本结构可以抽象为:
compound_stmt:
header ":" suite
suite:
simple_stmt NEWLINE
| NEWLINE INDENT statement+ DEDENT
其中:
header是if、for、def等子句头;:表示后面将出现受控制的语句组;INDENT表示进入一个缩进层级;DEDENT表示退出一个或多个缩进层级;suite是代码块本身。
Python 的 if、for、while、try、with、match、函数定义和类定义都使用这种复合语句结构。(docs.python.org)
3.2 INDENT 和 DEDENT 的生成算法
词法分析器维护一个缩进栈。初始状态为:
[0]
假设源代码为:
if ready:
prepare()
if valid:
execute()
cleanup()
finish()
可以按逻辑行观察缩进变化:
| 逻辑行 | 缩进宽度 | 缩进栈变化 | 生成的 Token |
|---|---|---|---|
if ready: |
0 | [0] |
NEWLINE |
prepare() |
4 | [0, 4] |
INDENT、NEWLINE |
if valid: |
4 | [0, 4] |
NEWLINE |
execute() |
8 | [0, 4, 8] |
INDENT、NEWLINE |
cleanup() |
4 | [0, 4] |
DEDENT、NEWLINE |
finish() |
0 | [0] |
DEDENT、NEWLINE |
| 文件结束 | — | [] |
DEDENT、ENDMARKER |
算法可以概括为:
- 第一行之前把
0压入栈; - 当前行缩进等于栈顶时,不生成缩进 Token;
- 当前行缩进大于栈顶时,压入新宽度并生成一个
INDENT; - 当前行缩进小于栈顶时,弹出所有更大的缩进宽度,每弹出一个就生成一个
DEDENT; - 如果当前缩进宽度不在栈中,则缩进不一致,词法分析失败。
因此,DEDENT 不依赖某个显式的结束符,而是由后续行的缩进宽度推导出来。文件结束时,剩余的缩进层级也会自动生成对应的 DEDENT。(docs.python.org)
3.3 缩进宽度不是固定的四个空格
从语言规则看,Python 不要求每一级必须使用四个空格。以下代码在缩进一致时可以合法运行:
if True:
print("two spaces")
但是,同一个代码块内必须保持可解释的一致层级:
if True:
print("first")
print("second")
第二个 print 的缩进宽度为 4,但当前栈中可能只有 0 和 2,因此它无法回到已有层级,也不能作为一个新的合法层级直接插入,通常会触发缩进错误。
实际工程中通常统一使用四个空格。这里要区分两件事:
- 语言规范:缩进层级由前导空白计算,合法层级不要求必须是四个空格;
- 工程约定:统一四空格可减少阅读和编辑器配置带来的歧义;
- 错误风险:混合 Tab 和空格可能导致不同编辑器对同一行计算出不同的缩进宽度,Python 可能抛出
TabError。
制表符会按从左到右的规则扩展到下一个八列边界,而不是简单地“等于四个空格”。因此,视觉上对齐不代表词法上的缩进宽度相同。(docs.python.org)
3.4 IndentationError、TabError 与 SyntaxError
下面几类失败经常被混称为“缩进错误”,但原因并不完全相同:
def f():
print("missing indent")
函数定义后必须有一个缩进的 suite,这是语法结构不完整,通常是 IndentationError。
if True:
print("ok")
print("bad")
外层缩进没有回到已知的缩进栈层级,属于不一致的缩进。
if True:
\tprint("tab")
print("spaces")
如果 Tab 与空格的组合使缩进意义依赖于 Tab 宽度,可能得到 TabError。诊断时应查看编辑器显示空白字符、检查文件是否混用了 Tab 和空格,而不是只盯着报错行。
四、Token 的主要类别
4.1 名称、关键字和软关键字
名称通常由字母、下划线、数字和符合 Unicode 名称规则的非 ASCII 字符组成,但不能以数字开头,且区分大小写:
user_id = 1
用户编号 = 2
下面的名称不合法:
2fast = True # 不能以数字开头
user-name = "x" # `-` 是运算符,不是名称的一部分
Python 关键字不能作为普通变量名:
class = "User" # SyntaxError
Python 3.14 的关键字包括 if、else、for、while、def、return、class、try、with 等。(docs.python.org)
软关键字只在特定语法上下文中具有关键字意义。Python 3.14 中,match、case 和 _ 在模式匹配语境中是软关键字,type 在类型语句语境中是软关键字;在其他上下文中,它们仍可以作为普通名称:
match = 10
case = 20
type = 30
但在模式匹配中:
match value:
case 1:
print("one")
这里的 match 和 case 被解析器按特定语境处理。软关键字的关键点是:它们在词法阶段通常仍是 NAME,语法分析器根据上下文赋予其特殊含义。(docs.python.org)
4.2 字面量
字面量是源代码中直接表示值的文本,例如:
42
3.14
"hello"
b"data"
None
True
False
...
数字、字符串和字节串属于词法层面的字面量;None、True、False 是关键字形式的内置常量,... 表示 Ellipsis。(docs.python.org)
一个容易误解的例子是负数:
-3
-3 在语法上不是一个整体的数字字面量,而是对数字字面量 3 应用一元负号运算:
UnaryMinus(Number(3))
同理:
3 + 4j
是实部、加号和虚部组成的算术表达式,而不是一个单独的普通字面量。(docs.python.org)
字符串字面量可以使用单引号、双引号或三引号:
'a'
"b"
"""多行
字符串"""
常见前缀包括:
b:字节串;r:原始字符串;f:格式化字符串;t:模板字符串;u:为兼容历史代码而保留,对 Python 3 字符串没有额外效果。
Python 3.14 文档明确列出了 t 字符串前缀。不要把 t"..." 与普通字符串或 f-string 混为一谈;它属于 Python 3.14 语法范围中的模板字符串能力,具体行为应按对应标准库文档理解。(docs.python.org)
4.3 运算符和分隔符
运算符和分隔符帮助构造表达式与语句:
a + b
items[0]
user.name
f(x, y)
if condition:
其中:
+、-、*、/是算术运算符;==、<、is、in用于比较、身份和成员测试;and、or、not用于布尔运算;()、[]、{}用于分组、索引、调用以及集合、列表、字典等结构;:用于切片、字典项、复合语句和其他语法结构;,用于构造元素序列、参数列表和解包结构。
词法分析通常采用“最长合法 Token”原则。例如:
a == b
中的 == 应识别为一个比较运算符,而不是两个连续的 =。
五、表达式:计算并产生值的语法结构
5.1 表达式的定义
表达式是可以被求值并产生结果的语法结构。最简单的表达式包括名称和字面量:
42
name
None
更复杂的表达式由运算符、调用、属性访问、索引、条件表达式、推导式等组成:
price * quantity
user.name
items[0]
max(values)
x if condition else y
[value * 2 for value in values]
Python 语言参考把名称、字面量以及括号、列表、字典、集合和生成器形式归为表达式中的基本元素。名称求值时会查找其绑定的对象;如果名称未绑定,则执行时抛出 NameError。(docs.python.org)
5.2 表达式和语句不是同一概念
下面是表达式:
2 + 3
下面是语句:
result = 2 + 3
2 + 3 负责计算值;赋值语句负责把这个值绑定到名称 result。
表达式也可以单独作为表达式语句:
2 + 3
在交互式解释器中,如果结果不是 None,解释器会使用 repr() 显示它:
>>> 2 + 3
5
但在脚本文件中,单独写一个表达式通常不会自动打印:
# script.py
2 + 3
运行脚本时没有输出。若要输出,需要显式调用:
print(2 + 3)
表达式语句还常用于调用函数:
log_message()
items.append(value)
调用的副作用是主要目的,返回值可能被丢弃。(docs.python.org)
5.3 求值顺序:总体从左到右
Python 通常按从左到右的顺序求表达式:
result = first() + second() + third()
调用顺序为:
first()
second()
third()
但赋值有一个关键规则:右侧先求值,再处理左侧目标:
items[index] = make_value()
执行逻辑可理解为:
- 求值
items; - 求值
index; - 求值
make_value(); - 将结果写入
items[index]。
右侧先完成,是因为赋值需要先得到要写入的对象。语言参考明确规定赋值右侧先于左侧目标求值。(docs.python.org)
可以用副作用验证:
def get_items():
print("items")
return [0]
def get_index():
print("index")
return 0
def get_value():
print("value")
return 42
get_items()[get_index()] = get_value()
输出为:
items
index
value
这里左侧目标内部的对象和索引先被确定,但右侧值仍然必须在实际赋值前求出。不要把“右侧先求值”简单理解成“左侧所有文本都最后处理”;真正规则由赋值目标的具体形式决定。
5.4 运算符优先级与结合性
表达式的解析不是单纯从左到右,而是先按照优先级组成结构,再按照求值顺序执行。
result = 2 + 3 * 4
等价于:
result = 2 + (3 * 4)
而不是:
result = (2 + 3) * 4
常见优先级从高到低可以概括为:
- 分组、列表、字典、集合等显示形式;
- 索引、切片、调用和属性访问;
- 幂运算
**; - 一元运算
+x、-x、~x; - 乘法、矩阵乘法、除法、整除和取余;
- 加法和减法;
- 移位;
- 按位与、异或、或;
- 比较、成员测试和身份测试;
not;and;or;- 条件表达式;
lambda;- 赋值表达式
:=。
同一优先级的大多数二元运算符从左向右结合,但幂运算和条件表达式存在从右向左结合的规则。比较、成员测试和身份测试还支持链式写法。(docs.python.org)
2 ** 3 ** 2
解析为:
2 ** (3 ** 2)
结果是 512,不是 (2 ** 3) ** 2 的 64。
比较链:
1 < x <= 10
概念上类似于:
1 < x and x <= 10
但 x 只需要求值一次。比较链不是把所有比较结果先算出来再组合,而是由 Python 的比较语法直接定义。
当表达式复杂或优先级容易误读时,括号不仅改变结果,也能明确代码意图:
if (is_ready and has_permission) or is_admin:
...
六、布尔表达式不是只返回 True 或 False
6.1 真值测试
在条件位置,以下对象被视为假:
FalseNone- 数值零;
- 空字符串;
- 空列表、空元组、空字典、空集合等空容器。
其他对象通常被视为真。用户自定义对象还可以通过 __bool__() 定义真值行为。(docs.python.org)
因此:
items = []
if items:
print("有元素")
else:
print("为空")
会输出:
为空
这并不意味着 items == False。空列表仍然是一个列表对象,只是在布尔上下文中为假。
6.2 and 与 or 返回操作数
and 和 or 具有短路求值,并且返回参与运算的对象,而不一定是布尔值:
a = "" or "fallback"
b = "ready" and 42
结果为:
a == "fallback"
b == 42
规则是:
x and y:如果x为假,返回x;否则求值并返回y;x or y:如果x为真,返回x;否则求值并返回y。
例如:
user_name = supplied_name or "anonymous"
当 supplied_name 是空字符串时,结果为 "anonymous"。但如果业务上允许空字符串作为有效值,这种写法就会把“空字符串”和“未提供”混为一谈,此时应使用显式的 is None 判断。(docs.python.org)
6.3 条件表达式
条件表达式返回两个值中的一个:
label = "pass" if score >= 60 else "fail"
它等价于一个产生值的条件选择,而不是普通的 if 语句。条件表达式首先求值条件:
- 求值
score >= 60; - 如果为真,求值并返回
"pass"; - 否则求值并返回
"fail"。
未被选择的分支不会求值:
result = expensive_value() if use_cache else load_from_disk()
只有一个函数会被调用。(docs.python.org)
七、赋值也是语句,但右侧通常是表达式
7.1 名称绑定不是“复制对象”
x = [1, 2]
y = x
y.append(3)
结果:
x == [1, 2, 3]
y == [1, 2, 3]
赋值把名称 x 和对象绑定起来;y = x 又让 y 指向同一个列表对象,并没有自动复制列表。赋值语句可以重新绑定名称,也可以修改可变对象的属性或元素。(docs.python.org)
user.name = "Alice"
items[0] = "updated"
这两种目标分别由对象的属性设置逻辑和下标赋值逻辑处理,具体对象可能拒绝赋值并抛出异常。
7.2 多目标赋值
a = b = 0
右侧的 0 求值一次,然后依次绑定到 a 和 b。
x, y = 10, 20
右侧逗号构造出一个二元组,随后解包到两个目标:
(10, 20) -> x = 10, y = 20
下面的形式也成立:
x, y = y, x
求值步骤是:
- 先求右侧
y, x,得到临时的(旧 y, 旧 x); - 再把两个值分别赋给
x和y。
因此不需要额外的临时变量。
7.3 解包与星号目标
普通解包要求元素数量匹配:
first, second = [10, 20]
以下代码失败:
first, second = [10, 20, 30]
因为目标数量是 2,而可迭代对象提供了 3 个元素。
星号目标可以接收剩余元素:
first, *middle, last = [1, 2, 3, 4, 5]
结果为:
first == 1
middle == [2, 3, 4]
last == 5
星号目标接收的是一个列表,即使它只接收零个元素:
first, *rest = [1]
结果是:
first == 1
rest == []
赋值解包要求右侧是可迭代对象;普通目标数量必须匹配,星号目标则吸收剩余元素。(docs.python.org)
7.4 增强赋值
count += 1
它不是简单的文本替换:
count = count + 1
两者在不可变数值上通常结果相同,但增强赋值只计算左侧目标一次,并且可变对象可能通过原地操作改变自身:
items = [1, 2]
items += [3]
列表通常会被原地扩展。
对于属性或下标目标,左侧对象和索引的求值次数也可能不同,因此不能仅凭表面形式判断两种写法完全等价。
八、语句:驱动程序执行的单位
8.1 简单语句
简单语句可以放在一个逻辑行内。Python 3.14 的简单语句类别包括:
- 表达式语句;
assert;- 赋值、增强赋值和带注解赋值;
pass;del;return;yield;raise;break;continue;import;from __future__ import ...;global;nonlocal;type语句。
多条简单语句可以使用分号放在同一逻辑行:
x = 1; y = 2; print(x + y)
但分号并不会把复合语句变成可嵌套的普通语句:
if condition: x = 1; y = 2
这里冒号后的整个语句列表都属于 if 的 suite;如果条件为真,x = 1、y = 2 都会执行。分号的绑定关系高于此处的冒号。(docs.python.org)
8.2 复合语句
复合语句包含一个或多个子句,并控制一组语句的执行。常见结构包括:
if condition:
...
for item in items:
...
while condition:
...
try:
...
except Exception:
...
with resource:
...
def function():
...
class User:
...
每个子句通常由关键字开头、冒号结束,再跟一个 suite。elif、else、except、finally 等续接子句与前一个复合语句共享结构层级。Python 通过缩进解决了许多语言中的“悬空 else”问题:嵌套的 if 必须通过缩进明确归属。(docs.python.org)
下面的写法不合法:
if first:
if second:
print("nested")
else:
print("second is false")
这段其实是合法的,else 对应内层 if。
如果希望 else 对应外层条件,需要显式写出结构:
if first:
if second:
print("nested")
else:
print("first is false")
else 的归属由缩进决定,而不是由最近出现的 if 文本决定。
8.3 与控制流主题的关系
if、for、while、break 和循环 else 都是语句层面的结构,但其条件、迭代对象和判断内容通常是表达式:
if score >= 60:
...
for item in records:
...
while remaining > 0:
...
以 for 为例:
- 先求值
records; - 得到一个可迭代对象;
- 创建迭代器;
- 逐项取值并赋给
item; - 执行缩进 suite;
- 迭代器耗尽后执行
elsesuite,除非通过break提前退出。
因此,for 的语法骨架连接了表达式、赋值目标、缩进代码块和控制流。(docs.python.org)
循环 else 的关键不是“没有进入循环”,而是“循环没有被 break 中断”:
for number in [1, 3, 5]:
if number % 2 == 0:
print("found")
break
else:
print("not found")
输出:
not found
因为迭代正常耗尽,没有执行 break。如果列表中包含偶数,break 执行后,else 不执行。while 的规则相同。(docs.python.org)
九、注释:词法上存在,语法上被忽略
9.1 普通注释
注释从不属于字符串内部的 # 开始,到物理行结束:
x = 1 # 说明 x 的用途
词法分析器可以识别出 COMMENT Token,但语法分析器忽略它。注释的内容不会变成运行时表达式,也不会影响变量绑定。(docs.python.org)
# x = 100
x = 1
执行后只有 x == 1,注释中的 x = 100 从未执行。
9.2 字符串不是注释
下面是字符串表达式,不是注释:
"this is a string"
在脚本中,如果它没有被赋值或调用,通常不会产生可见输出;但它仍然是一个合法的表达式语句。
更特殊的是,函数、类或模块开头的字符串表达式可能成为文档字符串:
def add(a, b):
"""返回两个数的和。"""
return a + b
这里字符串被保存为函数对象的 __doc__:
print(add.__doc__)
输出:
返回两个数的和。
因此,文档字符串是运行时可观察的字符串对象,不等同于以 # 开头的普通注释。
9.3 注释中的 # 与字符串中的 #
text = "# not a comment"
字符串中的 # 不启动注释。
text = r"\# still inside string"
即使是原始字符串,# 仍然属于字符串内容。
# the rest of this line is ignored
只有不在字符串字面量中的 # 才启动注释。词法分析器必须先知道自己是否位于字符串内部,才能正确判断 # 的意义。(docs.python.org)
9.4 注释与显式续行
反斜杠不能连接注释:
value = 1 + \ # 这里不能写注释
2
如果需要在多行表达式中写注释,应使用括号:
value = (
1 # 第一部分
+ 2 # 第二部分
)
括号提供隐式续行环境,注释只结束当前物理行,不结束整个逻辑表达式。(docs.python.org)
9.5 编码声明也是特殊注释
如果 Python 文件的第一行或第二行注释匹配类似下面的形式:
# -*- coding: utf-8 -*-
该注释会被解释为源文件编码声明。Python 3 默认使用 UTF-8;如果没有编码声明,通常按 UTF-8 解码源文件。编码声明必须位于第一行或第二行,并且需要满足规定格式。(docs.python.org)
现代 Python 项目通常直接使用 UTF-8,不需要显式写编码声明。但当工具链处理历史文件、特殊编码或自动生成源代码时,编码声明仍可能影响词法分析阶段。
十、用 tokenize 观察真实 Token 流
Python 标准库提供了 tokenize 模块,可以观察源代码被切分后的结果。下面的程序不执行待分析代码,只读取字符串并输出 Token:
from io import StringIO
import tokenize
source = """\
if ready:
total = price * count # calculate total
"""
for token in tokenize.generate_tokens(StringIO(source).readline):
print(
f"{token.type:>2} "
f"{tokenize.tok_name[token.type]:<12} "
f"{token.string!r} "
f"start={token.start} end={token.end}"
)
输入中的代码是:
if ready:
total = price * count # calculate total
输出会包含类似以下内容:
1 NAME 'if' start=(1, 0) end=(1, 2)
1 NAME 'ready' start=(1, 3) end=(1, 8)
54 OP ':' start=(1, 8) end=(1, 9)
4 NEWLINE '\n' start=(1, 9) end=(1, 10)
5 INDENT ' ' start=(2, 0) end=(2, 4)
1 NAME 'total' start=(2, 4) end=(2, 9)
54 OP '=' start=(2, 10) end=(2, 11)
1 NAME 'price' start=(2, 12) end=(2, 17)
54 OP '*' start=(2, 18) end=(2, 19)
1 NAME 'count' start=(2, 20) end=(2, 25)
61 COMMENT '# calculate total' start=(2, 27) end=(2, 43)
4 NEWLINE '\n' start=(2, 43) end=(2, 44)
6 DEDENT '' start=(3, 0) end=(3, 0)
0 ENDMARKER '' start=(3, 0) end=(3, 0)
不同 Python 3.14 小版本或 tokenize 展示方式可能在具体数值、运算符类型展示上有所差异,但关键结构是稳定的:
if ready:
...
会形成:
NAME NAME OP NEWLINE INDENT ... NEWLINE DEDENT ENDMARKER
可以进一步比较隐式续行:
source = """\
values = (
1
+ 2
)
"""
这里括号内的物理换行不会像普通语句换行那样产生多个 NEWLINE;通常会看到 NL,因为这些换行并没有结束逻辑行。tokenize 的职责是提供源代码的词法扫描结果,不能代替完整的语法编译检查。(docs.python.org)
十一、从 Token 到语法错误的诊断方法
11.1 词法阶段失败
词法分析失败通常意味着字符无法按 Python 的 Token 规则解释:
name = "unterminated
字符串没有结束引号,词法分析器无法形成完整的字符串 Token。
if True:
\tprint("tab")
print("space")
缩进混用可能在词法阶段触发 TabError。
诊断重点是:
- 引号是否闭合;
- 字符串前缀是否正确;
- 反斜杠是否出现在合法位置;
- 缩进是否能映射到一致的缩进栈;
- 源文件编码是否能正确解码。
11.2 语法阶段失败
词法上合法的 Token 序列,未必满足语法:
if True
print("missing colon")
if、True、换行、缩进和 print 都可以分别识别,但组合不符合 if 语句要求:
if assignment_expression ":" suite
缺少 :,所以语法分析失败。
再如:
x + 1 = 2
Token 序列本身合法,但 x + 1 不能作为赋值目标,语法或编译阶段会拒绝它。
11.3 运行阶段失败
语法合法后,仍可能在执行时失败:
def divide(a, b):
return a / b
divide(1, 0)
代码能够编译,函数调用也符合语法,但除法操作产生 ZeroDivisionError。
def f():
print(value)
value = 1
这里语法合法,但函数体内出现了对 value 的赋值,因此 value 被视为局部名称;在赋值执行前读取它会导致 UnboundLocalError。名称绑定规则属于执行模型,而不是缩进或 Token 本身。(docs.python.org)
因此,诊断应按阶段逐层排查:
源文件能否解码
↓
字符能否组成 Token
↓
Token 能否组成合法语法
↓
名称、类型和运行时操作是否有效
十二、几个容易混淆的边界
12.1 缩进是语法的一部分,但续行缩进不一定是
下面两处空白含义不同:
if condition:
result = (
first
+ second
)
第一行之后的四个空格产生代码块的 INDENT;括号内 first 和 second 的缩进只是续行格式,不代表嵌套代码块。
12.2 换行有时结束语句,有时只是格式
x = 1
y = 2
第一行的换行产生 NEWLINE,结束一个逻辑行。
x = (
1
+ 2
)
中间换行属于未完成表达式的内部格式,不结束逻辑行。
12.3 # 有时是注释起点,有时只是字符串内容
a = "#"
b = 1 # comment
第一个 # 位于字符串内部,第二个 # 启动注释。
12.4 表达式可以成为语句,但语句不一定能成为表达式
value = 1 + 2 # 赋值语句
print(value) # 调用表达式作为表达式语句
1 + 2 是表达式;value = 1 + 2 是赋值语句。赋值语句整体不能像普通表达式一样出现在所有需要值的位置:
# if (x = 1): # SyntaxError
如果确实需要在表达式中绑定名称,应使用赋值表达式:
if (length := len(items)) > 0:
print(f"{length} items")
赋值表达式有特定的语法限制,在某些上下文中必须加括号;它不等同于把普通赋值语句随意嵌入表达式。(docs.python.org)
12.5 注释不会改变语法结构,但可能改变物理行结构
value = (
1 # first
+ 2
)
注释本身被忽略,但它结束当前物理行,因此后续内容必须仍处于括号提供的隐式续行环境中。如果移除括号,注释可能间接导致表达式被截断。
十三、把一段代码完整拆开
观察下面的代码:
def classify(score):
# classify one score
if score >= 60:
return "pass"
else:
return "fail"
13.1 词法层
可以抽象出:
NAME("def")
NAME("classify")
OP("(")
NAME("score")
OP(")")
OP(":")
NEWLINE
INDENT
COMMENT("# classify one score")
NAME("if")
NAME("score")
OP(">=")
NUMBER("60")
OP(":")
NEWLINE
INDENT
NAME("return")
STRING('"pass"')
NEWLINE
DEDENT
NAME("else")
OP(":")
NEWLINE
INDENT
NAME("return")
STRING('"fail"')
NEWLINE
DEDENT
DEDENT
ENDMARKER
注释在这里可以被词法工具观察到,但不会参与后续语法结构。
13.2 语法层
这些 Token 被组织为:
function definition
└── suite
└── if statement
├── condition: score >= 60
├── suite: return "pass"
└── else suite: return "fail"
def classify(score): 本身并不调用函数,而是定义并绑定一个函数对象;函数体只有在调用 classify(...) 时才执行。函数定义是复合语句,函数体是由缩进界定的 suite。(docs.python.org)
13.3 执行层
调用:
print(classify(75))
执行过程是:
- 创建参数绑定
score = 75; - 求值
score >= 60,结果为真; - 执行内层 suite;
- 求值字符串
"pass"; - 执行
return,结束函数调用; - 外层
print输出返回值。
输出:
pass
如果调用:
print(classify(40))
条件为假,内层 if suite 不执行,else suite 返回 "fail"。
这段简单代码同时展示了本文的核心关系:
缩进
→ INDENT / DEDENT Token
→ suite 与复合语句
→ 条件表达式求值
→ return 语句控制执行
Python 的语法基础不是“记住冒号后面缩进四格”这么简单。完整模型应当是:
源字符
→ 物理行与逻辑行
→ Token
→ INDENT / DEDENT / NEWLINE 等结构 Token
→ 表达式与语句
→ 代码块和执行模型
缩进解释了代码块边界,Token 解释了源文本如何被识别,表达式解释了值如何产生,语句解释了执行如何推进,注释则说明哪些源代码会在词法阶段被识别、却不会进入语法语义。掌握这几个层次后,SyntaxError、IndentationError、TabError 和运行时异常就不再是同一种“Python 不接受代码”的模糊现象,而是可以根据处理阶段分别定位的问题。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 执行模型:源码、AST、字节码、解释器帧与调用栈
- 下一篇:Python 名称、对象与绑定:变量不是盒子,赋值不是复制
- 延伸:Python 控制流:if、for、while、break、else 与作用域
- 延伸:Python 函数与参数:位置、关键字、默认值、解包和返回
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论