引言
前两篇我们完成了两件事:弄清楚「为什么学 Python」(第 1 篇),并亲手把它装进了电脑(第 2 篇,验证三连通过)。现在把问题的视角从「怎么装」转向「装了什么」:你敲下的 python hello.py 到底发生了什么?一串字符串如何变成程序的输出?
这篇是本章原理含量最高的一篇。它不需要你写复杂的代码,但要求你建立一幅准确的运行全景图:源代码 → 字节码(bytecode)→ 虚拟机执行。理解这幅图之后,你会明白 Python 常被误解的两个特性(「纯解释」「没有编译」)真实的样子,也会在第 10 章的性能优化、第 17 章的并发(GIL 主题)中直接受益。读者只需掌握前两篇的概念:解释器(interpreter)、包(package)、PATH,以及 python --version 能输出版本号。
概念与原理
Python 是语言,CPython 是实现
「Python」指两样东西:一套语言规范(语法、语义、标准库的行为约定),和若干实现(implementation)——即把规范变成可执行程序的软件。最重要的实现是 CPython:它是用 C 语言写的官方参考实现,也就是你从 python.org 下载、随绝大多数系统发行版安装的那个 python。名字里的 C 指实现语言:CPython 的解释器本体是 C 代码编译出的原生程序,因此解释了「字节码执行效率尚可、但绕不开 C」这类说法的由来。
其它实现各有侧重:PyPy 用 Python 实现(含即时编译 JIT,纯 Python 代码有时可提速数倍);Jython 运行在 Java 虚拟机上、可与 Java 类库互操作;MicroPython 面向单片机等嵌入式环境,体积极小。它们共享同一套语法和大多数标准库行为,但性能和边界各有取舍。顺带记住一个易错事实:著名的 GIL(全局解释器锁,上一篇文章提过)是 CPython 的特性而非 Python 语言的特性——PyPy 等实现并没有 GIL。这个锁的机制与代价属于课程并发章节(第 17 篇)的主线,这里先埋下这个伏笔。
「编译 + 虚拟机」:比「纯解释」更准确的图景
教材常把 Python 归为「解释型语言」,与 C 的「编译型」对立。严格说,CPython 走的是第三条路:先编译,后解释。一行源码的完整旅程是:
- 词法分析(lexing):把字符流切成单词(token),如
total = (1 + 2) * 3被切成total、=、(、1、+…; - 语法分析(parsing):按语法规则把 token 组装成一棵抽象语法树(AST,Abstract Syntax Tree)——树的结构直接对应表达式的嵌套关系;
- 编译(compilation):把 AST 翻译成字节码——一条条面向 Python 虚拟机的指令,类似汇编之于 CPU,但更抽象、更少;
- 执行:虚拟机(virtual machine)逐条取出字节码指令并执行,指令操作的对象是 Python 对象(第 6 章的主角),由 C 引擎完成真正的计算。
语法错误在哪一步暴露?在第 2 步——这就是为什么 Python「还没运行」就会报 SyntaxError,它绝不是边读边执行才发现的。而很多「运行时」才发现的问题(比如变量不存在、类型不对)发生在第 4 步。更精确的说法是:Python 的错误检查分两级——语法级(编译时)与语义级(运行时)。
字节码长什么样:亲手拆解
「字节码」听起来抽象,好在标准库 dis 模块(disassembler,反汇编器)可以把任意函数、代码对象拆成人类可读的指令序列。先看一个最简单的加法函数:
import dis
def add(a, b):
return a + b
dis.dis(add)
Python 3.12 实机输出:
4 0 RESUME 0
5 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 0 (+)
10 RETURN_VALUE
逐行解读:RESUME 是 3.11+ 引入的帧初始化指令(可忽略);LOAD_FAST 0 (a) 把局部变量 a 压入求值栈;LOAD_FAST 1 (b) 压入 b;BINARY_OP 0 (+) 弹出栈顶两值相加并把结果压回;RETURN_VALUE 弹出结果返回。注意没有一行指令叫「加法」,加法由 BINARY_OP 参数 0 指定——这就是可变参数指令,字节码指令数因此被压缩。
再来一个带 if 的函数,看分支如何翻译:
import dis
def classify(n):
if n % 2 == 0:
return "even"
return "odd"
dis.dis(classify)
Python 3.12 实机输出:
4 0 RESUME 0
5 2 LOAD_FAST 0 (n)
4 LOAD_CONST 1 (2)
6 BINARY_OP 6 (%)
10 LOAD_CONST 2 (0)
12 COMPARE_OP 40 (==)
16 POP_JUMP_IF_FALSE 1 (to 20)
6 18 RETURN_CONST 3 ('even')
7 >> 20 RETURN_CONST 4 ('odd')
COMPARE_OP 40 (==) 完成比较,POP_JUMP_IF_FALSE 在比较为假时跳转到第 20 号指令(>> 标记即跳转目标)——if/else 在字节码层面就是条件跳转,这在所有语言里殊途同归。
执行上下文:帧与调用栈
函数调用在字节码层面对应 CALL 指令,而运行时 CPython 为每一个尚未返回的函数调用分配一块执行上下文,称为帧(frame):它记录当前执行到哪条指令、局部变量有哪些、返回值往哪送。帧按调用关系叠成一摞——调用栈(call stack)。递归函数不断调用自己,就是不断往栈上叠帧;叠得太多,CPython 会用 RecursionError 喊停。实测看一眼:
import sys
print("recursionlimit:", sys.getrecursionlimit())
def boom(n):
return boom(n + 1)
try:
boom(0)
except RecursionError as e:
print("RecursionError:", e)
Python 3.12 实机输出:
recursionlimit: 1000
RecursionError: maximum recursion depth exceeded
sys.getrecursionlimit() 返回 CPython 允许的最大调用深度(默认 1000)——它限制的不是「递归次数」而是「栈帧数量」,超过即抛异常,防止 Python 进程把操作系统栈耗尽导致崩溃。这个上限可以调(sys.setrecursionlimit),但调高只推迟崩溃点;正确的解法是改成循环或用尾递归消除技巧,这部分属于课程函数与算法章节。
有趣的真相:常量折叠
看一个「欺骗眼睛」的例子。反汇编 total = (1 + 2) * 3 这条赋值语句:
import dis
code = compile("total = (1 + 2) * 3", "<demo>", "exec")
dis.dis(code)
Python 3.12 实机输出:
0 0 RESUME 0
1 2 LOAD_CONST 0 (9)
4 STORE_NAME 0 (total)
6 RETURN_CONST 1 (None)
注意:没有加法、没有乘法指令。CPython 的编译器在编译期就能算出 (1 + 2) * 3 = 9,直接把常量 9 写进字节码——这叫常量折叠(constant folding)。这就是为什么「写 (1 + 2) * 3 和写 9 性能毫无差别」:编译器替你做了事。这类「编译期优化」证明 CPython 绝非简单的逐行翻译器,它有一个真正的编译前端。
字节码缓存:pycache 的秘密
你也许注意过项目里凭空出现的 __pycache__ 目录。当 import 一个模块时,CPython 会把编译出的字节码序列化保存为 .pyc 文件(如 hello_mod.cpython-312.pyc),下次导入时先比对源码的时间戳与大小,没变就直接加载缓存,省下重复编译的开销。文件名里的 cpython-312 是缓存标签(cache tag),由实现名+次版本组成——这保证不同实现/版本的字节码缓存互不冲突。文件名后缀 .pyc 即「Python 编译」(Python Compile)。删掉 __pycache__ 完全无害,下次导入会自动重建;正因如此,它从不应当被提交进版本库(Git 的 .gitignore 中建议忽略)。
操作/实现
把上述机制串起来,做一次「源码 → 字节码 → 缓存」的完整观察。
先写一个模块文件并导入,观察字节码缓存如何生成(以本机 Windows/Python 3.12 为例,文件放任意空目录即可,目录名请替换为自己的):
python -c "import hello_mod, sys; print(hello_mod.__file__); print(hello_mod.__cached__)"
其中 hello_mod.py 内容如下:
def greet(name):
return f"Hello, {name}!"
本机实测输出:
C:\Users\Administrator\AppData\Local\Temp\pyc_demo\hello_mod.py
C:\Users\Administrator\AppData\Local\Temp\pyc_demo\__pycache__\hello_mod.cpython-312.pyc
__file__ 是源码路径,__cached__ 指向缓存字节码文件。可以立刻验证:第一次导入生成了 __pycache__/hello_mod.cpython-312.pyc;删掉 __pycache__ 后再次导入,文件会重新出现。
最后用 sys 模块确认「我在跑哪个实现、哪个版本」,这个信息在排查环境问题时一锤定音:
import sys
print("implementation:", sys.implementation.name)
print("cache tag :", sys.implementation.cache_tag)
print("version :", sys.version_info[:3])
本机输出:
implementation: cpython
cache tag : cpython-312
version : (3, 12, 10)
sys.implementation.name 为 cpython 即官方实现;若你跑的是 PyPy,这里会显示 pypy——一行代码识别实现身份。
易错点与陷阱
易错点 1:以为 Python「不编译、逐行解释」,所以错误都是在运行时暴露的。 事实是语法错误(SyntaxError)在编译阶段就被捕获,python xxx.py 会立刻报错而不会执行任何代码;只有语义错误(变量名拼错、类型不匹配)才发生在运行时。同理,「Python 直接执行源码」的说法不准确——它先编译出字节码再交给虚拟机。理解这一点就不会问出「为什么 SyntaxError 之前那行代码没执行」这类问题。
易错点 2:把 CPython 的特性当成 Python 语言的特性。 典型代表是 GIL:「Python 的多线程不能并行」是 CPython 的约束,不是语言禁令——PyPy 无 GIL,3.13 起 CPython 也引入了实验性的自由线程(free-threaded)构建。讨论任何「Python 特性」时,先问一句:这是语言的,还是 CPython 的?
易错点 3:把 __pycache__ / .pyc 文件提交进 Git,或随手删除后疑惑「程序坏了」。 .pyc 是纯缓存,删了自动重建,程序不会坏;提交进仓库则会造成噪音与跨平台冲突。正确姿势是在 .gitignore 中忽略 __pycache__/ 与 *.pyc。另外注意:如果系统时间戳异常(如时钟回拨),CPython 可能频繁重编译导致启动变慢——罕见但真实。也值得知道两个「取消缓存」的开关:运行 Python 时加 -B 参数,或设置环境变量 PYTHONDONTWRITEBYTECODE=1,可以禁止写 .pyc(只读文件系统、打包发布场景会用);它们影响的是缓存写入,不影响执行。
小结
本文建立了 Python 运行的完整图景:Python 是语言规范,CPython 是 C 语言写的官方实现(另有 PyPy、Jython、MicroPython 等)。一行代码要走完词法分析、语法分析(AST)、编译为字节码、虚拟机执行的链路——因此它是「先编译、后解释」的混合体。我们用 dis 模块反汇编了加法函数、if 分支与常量折叠实例,看到了 LOAD_FAST/BINARY_OP 等真实指令;用帧与调用栈解释了 RecursionError 的来源;接着观察了 __pycache__ 字节码缓存机制,并用 sys.implementation 确认实现身份。三个易错点提醒我们:语法错误发生在编译期、GIL 属于 CPython 而非语言、.pyc 是无需管理的缓存。理解了「解释器如何跑代码」,下一篇文章解决的是「怎么把解释器和一篮子依赖装进一个干净的盒子」——虚拟环境。
练习与思考题
- 写一个用
for循环累加 1 到 100 的函数,用dis.dis反汇编,找出FOR_ITER指令,并数一数循环体总共有几条指令。(答案导向:循环在字节码层面由FOR_ITER+ 跳回指令组成,循环体指令数即循环内每条语句展开的指令数之和。) - 解释:为什么
total = (1 + 2) * 3的字节码里没有加法指令?(答案导向:编译期常量折叠,编译器算出结果 9 直接写入常量表。) - 修改
hello_mod.py的内容(比如把问候语改掉)再导入,观察__pycache__何时重建,并说出触发重建的判断依据。(答案导向:CPython 比对源码文件的时间戳与大小,变了就重新编译覆盖 .pyc。) - 思考题:如果让你设计一个「把 Python 源码缓存为机器码、直接执行」的实现,你会遇到什么问题?(提示:动态类型与运行时绑定;答案导向:动态类型使很多信息到运行时才确定,原生编译需类型推断或回退到解释执行——这正是 PyPy 的 JIT 技术的动机。)