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")

这里的缩进层级无法按照已有的缩进栈解释,可能得到 IndentationErrorSyntaxError

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,后者中的 ab 是两个名称 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
)

续行中的缩进只用于提高可读性,不会像代码块开头的缩进那样产生 INDENTDEDENT。续行中也允许出现注释和空行。(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

其中:

  • headeriffordef 等子句头;
  • : 表示后面将出现受控制的语句组;
  • INDENT 表示进入一个缩进层级;
  • DEDENT 表示退出一个或多个缩进层级;
  • suite 是代码块本身。

Python 的 ifforwhiletrywithmatch、函数定义和类定义都使用这种复合语句结构。(docs.python.org)

3.2 INDENTDEDENT 的生成算法

词法分析器维护一个缩进栈。初始状态为:

[0]

假设源代码为:

if ready:
    prepare()
    if valid:
        execute()
    cleanup()
finish()

可以按逻辑行观察缩进变化:

逻辑行 缩进宽度 缩进栈变化 生成的 Token
if ready: 0 [0] NEWLINE
prepare() 4 [0, 4] INDENTNEWLINE
if valid: 4 [0, 4] NEWLINE
execute() 8 [0, 4, 8] INDENTNEWLINE
cleanup() 4 [0, 4] DEDENTNEWLINE
finish() 0 [0] DEDENTNEWLINE
文件结束 [] DEDENTENDMARKER

算法可以概括为:

  1. 第一行之前把 0 压入栈;
  2. 当前行缩进等于栈顶时,不生成缩进 Token;
  3. 当前行缩进大于栈顶时,压入新宽度并生成一个 INDENT
  4. 当前行缩进小于栈顶时,弹出所有更大的缩进宽度,每弹出一个就生成一个 DEDENT
  5. 如果当前缩进宽度不在栈中,则缩进不一致,词法分析失败。

因此,DEDENT 不依赖某个显式的结束符,而是由后续行的缩进宽度推导出来。文件结束时,剩余的缩进层级也会自动生成对应的 DEDENT。(docs.python.org)

3.3 缩进宽度不是固定的四个空格

从语言规则看,Python 不要求每一级必须使用四个空格。以下代码在缩进一致时可以合法运行:

if True:
  print("two spaces")

但是,同一个代码块内必须保持可解释的一致层级:

if True:
  print("first")
    print("second")

第二个 print 的缩进宽度为 4,但当前栈中可能只有 02,因此它无法回到已有层级,也不能作为一个新的合法层级直接插入,通常会触发缩进错误。

实际工程中通常统一使用四个空格。这里要区分两件事:

  • 语言规范:缩进层级由前导空白计算,合法层级不要求必须是四个空格;
  • 工程约定:统一四空格可减少阅读和编辑器配置带来的歧义;
  • 错误风险:混合 Tab 和空格可能导致不同编辑器对同一行计算出不同的缩进宽度,Python 可能抛出 TabError

制表符会按从左到右的规则扩展到下一个八列边界,而不是简单地“等于四个空格”。因此,视觉上对齐不代表词法上的缩进宽度相同。(docs.python.org)

3.4 IndentationErrorTabErrorSyntaxError

下面几类失败经常被混称为“缩进错误”,但原因并不完全相同:

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 的关键字包括 ifelseforwhiledefreturnclasstrywith 等。(docs.python.org)

软关键字只在特定语法上下文中具有关键字意义。Python 3.14 中,matchcase_ 在模式匹配语境中是软关键字,type 在类型语句语境中是软关键字;在其他上下文中,它们仍可以作为普通名称:

match = 10
case = 20
type = 30

但在模式匹配中:

match value:
    case 1:
        print("one")

这里的 matchcase 被解析器按特定语境处理。软关键字的关键点是:它们在词法阶段通常仍是 NAME,语法分析器根据上下文赋予其特殊含义。(docs.python.org)

4.2 字面量

字面量是源代码中直接表示值的文本,例如:

42
3.14
"hello"
b"data"
None
True
False
...

数字、字符串和字节串属于词法层面的字面量;NoneTrueFalse 是关键字形式的内置常量,... 表示 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:

其中:

  • +-*/ 是算术运算符;
  • ==<isin 用于比较、身份和成员测试;
  • andornot 用于布尔运算;
  • ()[]{} 用于分组、索引、调用以及集合、列表、字典等结构;
  • : 用于切片、字典项、复合语句和其他语法结构;
  • , 用于构造元素序列、参数列表和解包结构。

词法分析通常采用“最长合法 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()

执行逻辑可理解为:

  1. 求值 items
  2. 求值 index
  3. 求值 make_value()
  4. 将结果写入 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

常见优先级从高到低可以概括为:

  1. 分组、列表、字典、集合等显示形式;
  2. 索引、切片、调用和属性访问;
  3. 幂运算 **
  4. 一元运算 +x-x~x
  5. 乘法、矩阵乘法、除法、整除和取余;
  6. 加法和减法;
  7. 移位;
  8. 按位与、异或、或;
  9. 比较、成员测试和身份测试;
  10. not
  11. and
  12. or
  13. 条件表达式;
  14. lambda
  15. 赋值表达式 :=

同一优先级的大多数二元运算符从左向右结合,但幂运算和条件表达式存在从右向左结合的规则。比较、成员测试和身份测试还支持链式写法。(docs.python.org)

2 ** 3 ** 2

解析为:

2 ** (3 ** 2)

结果是 512,不是 (2 ** 3) ** 264

比较链:

1 < x <= 10

概念上类似于:

1 < x and x <= 10

x 只需要求值一次。比较链不是把所有比较结果先算出来再组合,而是由 Python 的比较语法直接定义。

当表达式复杂或优先级容易误读时,括号不仅改变结果,也能明确代码意图:

if (is_ready and has_permission) or is_admin:
    ...

六、布尔表达式不是只返回 TrueFalse

6.1 真值测试

在条件位置,以下对象被视为假:

  • False
  • None
  • 数值零;
  • 空字符串;
  • 空列表、空元组、空字典、空集合等空容器。

其他对象通常被视为真。用户自定义对象还可以通过 __bool__() 定义真值行为。(docs.python.org)

因此:

items = []

if items:
    print("有元素")
else:
    print("为空")

会输出:

为空

这并不意味着 items == False。空列表仍然是一个列表对象,只是在布尔上下文中为假。

6.2 andor 返回操作数

andor 具有短路求值,并且返回参与运算的对象,而不一定是布尔值:

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 语句。条件表达式首先求值条件:

  1. 求值 score >= 60
  2. 如果为真,求值并返回 "pass"
  3. 否则求值并返回 "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 求值一次,然后依次绑定到 ab

x, y = 10, 20

右侧逗号构造出一个二元组,随后解包到两个目标:

(10, 20) -> x = 10, y = 20

下面的形式也成立:

x, y = y, x

求值步骤是:

  1. 先求右侧 y, x,得到临时的 (旧 y, 旧 x)
  2. 再把两个值分别赋给 xy

因此不需要额外的临时变量。

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 = 1y = 2 都会执行。分号的绑定关系高于此处的冒号。(docs.python.org)

8.2 复合语句

复合语句包含一个或多个子句,并控制一组语句的执行。常见结构包括:

if condition:
    ...

for item in items:
    ...

while condition:
    ...

try:
    ...
except Exception:
    ...

with resource:
    ...

def function():
    ...

class User:
    ...

每个子句通常由关键字开头、冒号结束,再跟一个 suite。elifelseexceptfinally 等续接子句与前一个复合语句共享结构层级。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 与控制流主题的关系

ifforwhilebreak 和循环 else 都是语句层面的结构,但其条件、迭代对象和判断内容通常是表达式:

if score >= 60:
    ...

for item in records:
    ...

while remaining > 0:
    ...

for 为例:

  1. 先求值 records
  2. 得到一个可迭代对象;
  3. 创建迭代器;
  4. 逐项取值并赋给 item
  5. 执行缩进 suite;
  6. 迭代器耗尽后执行 else suite,除非通过 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")

ifTrue、换行、缩进和 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;括号内 firstsecond 的缩进只是续行格式,不代表嵌套代码块。

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))

执行过程是:

  1. 创建参数绑定 score = 75
  2. 求值 score >= 60,结果为真;
  3. 执行内层 suite;
  4. 求值字符串 "pass"
  5. 执行 return,结束函数调用;
  6. 外层 print 输出返回值。

输出:

pass

如果调用:

print(classify(40))

条件为假,内层 if suite 不执行,else suite 返回 "fail"

这段简单代码同时展示了本文的核心关系:

缩进
  → INDENT / DEDENT Token
  → suite 与复合语句
  → 条件表达式求值
  → return 语句控制执行

Python 的语法基础不是“记住冒号后面缩进四格”这么简单。完整模型应当是:

源字符
  → 物理行与逻辑行
  → Token
  → INDENT / DEDENT / NEWLINE 等结构 Token
  → 表达式与语句
  → 代码块和执行模型

缩进解释了代码块边界,Token 解释了源文本如何被识别,表达式解释了值如何产生,语句解释了执行如何推进,注释则说明哪些源代码会在词法阶段被识别、却不会进入语法语义。掌握这几个层次后,SyntaxErrorIndentationErrorTabError 和运行时异常就不再是同一种“Python 不接受代码”的模糊现象,而是可以根据处理阶段分别定位的问题。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。