很多人会写 Python,却不知道代码到底是怎么跑起来的

写代码的时候,我们通常只看到两件事。

第一件事,是自己敲进去的源码。
第二件事,是程序运行后的结果。

中间到底发生了什么,很多人并不清楚。比如下面这行代码:

print(1 + 2)

为什么它能输出 3。
Python 到底是直接读懂了这行字符,还是先做了别的转换。
解释器、字节码、执行流程这些词,和这段代码又是什么关系。

这些问题看起来偏底层,但其实非常有用。因为你一旦知道 Python 程序是怎么跑起来的,后面很多知识点都会突然变得顺手,比如:

为什么导入模块第一次慢,后面快
为什么有些错误在运行时才暴露
为什么函数、类、模块都能当对象处理
为什么有 .py 文件,有时还会看到 .pyc 文件
为什么有些优化能提速,有些优化只是自我感动

高阶编程,很多时候不是学更复杂的语法,而是先搞懂程序背后的运行逻辑。

先记住一句最重要的话:Python 不是直接运行源码的

很多初学者有个天然印象:我写了一个 .py 文件,然后 Python 直接把它跑起来了。

这种理解不算全错,但不够准确。

更准确的过程是:

你写的是源码
Python 先把源码编译成字节码
再由 Python 虚拟机逐条执行这些字节码

也就是说,源码并不是最终直接执行的东西。
真正被执行的,是字节码。

这个过程有点像做菜。

源码像菜谱。
字节码像把菜谱翻译成厨房里统一能识别的操作步骤。
Python 虚拟机像厨师,按照这些步骤一步步执行。

所以,Python 常被叫做解释型语言,但它并不是简单粗暴地逐字符解释源码。它内部其实也有编译过程,只不过编译出来的不是机器码,而是字节码。

这一步理解到位后,很多现象就能解释了。

Python 程序从写完到运行,通常会经历这几步

先看一个最简单的程序:

name = "Python"
print("Hello,", name)

它从文件到输出,大致会走下面这个流程。

第一步,读取源码。
解释器先把你的 .py 文件内容读进来。

第二步,词法分析。
把源码拆成一个个有意义的单元,比如变量名、等号、字符串、函数名、括号。

第三步,语法分析。
检查这些单元拼起来是不是符合 Python 的语法规则。

比如你写成这样:

if 3 > 2
    print("ok")

少了冒号,语法分析这一步就过不去,程序直接报错,不会继续执行。

第四步,编译为字节码。
如果语法没问题,Python 会把源码编译成字节码指令。

第五步,交给 Python 虚拟机执行。
虚拟机一条条读取字节码,然后完成变量创建、函数调用、表达式计算、对象操作这些动作。

你可以把整个流程理解成:

源码
变成字节码
再被虚拟机执行

这就是 Python 程序真正跑起来的主线。

解释器到底是什么,不要把它想得太神秘

解释器这个词,听起来很玄,其实它就是一个程序。

你平时输入:

python demo.py

本质上是启动了 Python 解释器,然后告诉它去运行 demo.py 这个文件。

解释器主要负责几件事。

一是读取你的源码。
二是检查语法。
三是编译字节码。
四是调用 Python 虚拟机执行。
五是管理运行时环境,比如模块加载、内存管理、异常处理等。

所以,解释器不是只做“翻译”这一件事,它更像整个运行系统的总调度。

平时大家说 Python 解释器,很多场景下默认指的是 CPython,也就是官方最常用的实现。

你安装 Python 时,绝大多数情况下装的就是它。

它的特点是最主流、生态最好、兼容性最强。
你平时写的绝大多数 Python 代码,默认都是跑在 CPython 上。

什么是字节码,为什么 Python 不直接变成机器码

先分清三个概念。

源码,是你写的 .py 文件。
字节码,是 Python 编译后生成的一种中间指令。
机器码,是 CPU 能直接执行的底层指令。

为什么 Python 不像 C 语言那样,直接把源码编译成机器码再运行。

因为 Python 追求的是跨平台和开发效率。

如果直接生成机器码,那么不同操作系统、不同 CPU 架构,生成结果就可能不同。
而字节码是一种中间层。只要不同平台上都有对应的 Python 虚拟机,就可以执行同一份字节码。

你可以把它理解成:

源码先被翻译成一套 Python 自己内部的统一指令
然后每个平台上的 Python 虚拟机负责真正执行它

这样一来,跨平台就容易了。

同一个 .py 文件,在 Windows、macOS、Linux 上通常都能跑,前提就是这些平台上都有对应的 Python 解释器环境。

.py 和 .pyc 到底是什么关系

很多人写 Python 时,只认识 .py 文件。
后来在项目目录里看到 pycache 和 .pyc 文件,就有点懵。

先说结论。

.py 是源码文件。
.pyc 是编译后的字节码缓存文件。

比如你有一个模块 utils.py,当它被导入时,Python 往往会把它编译后的结果缓存下来,放进 pycache 目录里。下次再导入时,如果源码没变,环境也匹配,Python 就可以直接用缓存好的字节码,省掉重复编译的时间。

这就是 .pyc 文件存在的意义。

注意两点。

第一,.pyc 不是机器码,它还是字节码。
第二,.pyc 不能脱离 Python 虚拟机单独运行,它只是加快加载,不是把 Python 变成了原生可执行程序。

看一个简单例子。

假设有两个文件。

main.py

import helper

print("主程序执行")
helper.say_hello()

helper.py

def say_hello():
    print("你好,欢迎学习 Python")

第一次运行 main.py 时,Python 会读取 helper.py,编译成字节码,并可能生成对应的 .pyc 缓存文件。
第二次运行时,如果 helper.py 没变,就能更快加载。

这就是为什么项目里经常会看到 pycache 目录。

为什么有的错误一运行就报,有的运行到一半才报

这和 Python 的执行流程关系很大。

先看一个语法错误:

if 5 > 3
    print("ok")

这类错误在语法分析阶段就会被发现,程序连正式执行都进不去。

再看一个运行时错误:

print(10 / 0)

这段代码语法没问题,所以能通过编译。
但执行到除法时,才发现除数不能为 0,于是报错。

再看一种更常见的情况:

def calc():
    return 1 / 0

print("程序开始")
print("这里先正常执行")
calc()

前两行打印没问题。
程序真正跑到 calc() 里面那条除法语句时,才报错。

这说明 Python 的很多问题,不是在“读代码”阶段暴露,而是在“执行代码”阶段暴露。

所以你以后看到报错时,要先判断它属于哪一类。

语法错误,通常是代码结构写错了。
运行时错误,通常是逻辑、数据或状态出了问题。

这两种问题的排查思路完全不同。

Python 执行代码,不是从头到尾机械扫一遍那么简单

很多人刚开始会觉得,Python 程序就是从上往下依次跑。

这句话对一半。

在最外层,确实大体是从上往下执行。
但函数、类、导入模块这些东西,会让执行流程变得更立体。

先看这个例子:

print("第一步")

def greet():
    print("第三步")

print("第二步")
greet()
print("第四步")

输出结果是:

第一步
第二步
第三步
第四步

这里要注意,定义函数时,函数体并不会立刻执行。
Python 只是先创建了这个函数对象,并把名字 greet 绑定到它。

真正执行函数体,是在调用 greet() 的时候。

这就是很多初学者容易混淆的地方。
看到函数定义,就以为里面代码已经跑过了。其实没有。

类定义也类似。

class User:
    role = "member"

    def say(self):
        print("hello")

print(User.role)

类定义执行时,Python 会创建类对象。
方法体里的代码不会因为你写了 def 就自动运行,它只是先成为类的一部分。

所以,Python 程序虽然表面上是顺序执行,但内部其实不断在做对象创建、名字绑定、函数调用、作用域切换这些动作。

名字、对象、绑定,这三个词会一直影响后面的学习

Python 的执行流程里,有一件特别重要的事:名字绑定对象。

看例子:

x = 10
y = x
x = 20

print(x)
print(y)

输出是:

20
10

为什么 y 没跟着变成 20。

因为 Python 里变量更像名字标签,而不是固定小盒子。
执行 x = 10 时,是名字 x 绑定到对象 10。
执行 y = x 时,是名字 y 也绑定到对象 10。
执行 x = 20 时,是名字 x 重新绑定到对象 20。
y 仍然绑定着原来的 10。

这件事为什么和执行流程有关。

因为 Python 在运行时,核心动作之一就是不断建立这种绑定关系。
模块名绑定模块对象,函数名绑定函数对象,类名绑定类对象,变量名绑定数据对象。

理解这一点,后面学作用域、参数传递、可变对象、闭包时,会轻松很多。

导入模块时,Python 到底做了什么

很多人写 import osimport math 已经很熟练了,但没认真想过 import 背后做了什么。

其实 import 不是一句简单的“拿来用”。

它背后大致会做这些事。

先查模块是否已经加载过。
如果已经加载过,直接复用。
如果没加载过,就去找模块文件。
找到后,读取源码,编译字节码,执行模块顶层代码。
然后生成模块对象,并把模块名绑定到这个对象。

这里有个非常重要的点:模块在第一次导入时,顶层代码会执行。

看例子。

test_module.py

print("模块开始执行")

value = 100

def show():
    print("value =", value)

print("模块执行结束")

main.py

import test_module

print("主程序继续")
test_module.show()

运行 main.py,输出大概是:

模块开始执行
模块执行结束
主程序继续
value = 100

这说明 import 的时候,不只是把模块名字记下来,而是真的执行了模块的顶层代码。

所以,模块里如果写了大量初始化逻辑,第一次导入就可能很慢。
也正因为导入会执行顶层代码,很多项目会把测试代码写在下面这种结构里:

def main():
    print("执行主要逻辑")

if __name__ == "__main__":
    main()

这样做的目的,是让模块既能被导入,也能单独运行,而且不会在被导入时误执行测试逻辑。

函数调用时,程序内部发生了什么

函数看起来很普通,但它其实是执行流程里很关键的一环。

看例子:

def add(a, b):
    result = a + b
    return result

total = add(3, 5)
print(total)

当执行到 add(3, 5) 时,Python 大致会做这些事。

先找到 add 这个名字对应的函数对象。
创建一个新的局部作用域。
把参数 a 绑定到 3,把参数 b 绑定到 5。
执行函数体里的代码。
遇到 return,把结果返回给调用位置。
函数局部作用域结束。

所以,函数不是简单地“跳进去再跳出来”,而是在运行时创建了一套新的执行上下文。

这件事以后会影响很多知识点,比如:

为什么函数内部变量默认外部不能直接访问
为什么同名变量在函数内外可以共存
为什么递归调用会一层一层压进去
为什么局部变量在函数结束后就不再可用

说白了,函数调用本质上是在切换执行现场。

if、for、while 在执行时,本质是在控制字节码走向

你平时写 if、for、while,会感觉只是普通语法。
但从执行角度看,它们其实是在控制程序下一步走哪条路径。

比如:

score = 85

if score >= 60:
    print("及格")
else:
    print("不及格")

解释器执行时,会先计算 score >= 60 的真假。
然后根据结果决定后面执行哪一块字节码。

循环也是一样。

for i in range(3):
    print(i)

这不是把三次代码提前写死,而是虚拟机不断重复一套逻辑:

取下一个值
绑定给 i
执行循环体
再取下一个值
直到结束

所以,控制流语句的本质,就是影响程序执行路径。

你以后学异常处理、上下文管理器、协程时,会发现它们本质上也都和“控制执行流程”有关,只是比 if 和 for 更复杂一些。

为什么说 Python 是动态语言,这和执行流程有什么关系

动态语言最直观的特点,就是很多事情是在运行时决定的。

看例子:

x = 10
x = "hello"
x = [1, 2, 3]

print(x)

同一个名字 x,先后绑定了整数、字符串、列表。
Python 不会要求你事先声明它只能是哪一种类型。

再看函数参数:

def show(data):
    print(data)

show(123)
show("abc")
show([1, 2, 3])

同一个函数,能接收不同类型的数据。
这也是运行时动态处理的体现。

这和执行流程关系很大。
因为 Python 在运行时,才真正去看某个对象是什么类型、支持什么操作、该怎么调用。

这就是为什么 Python 灵活,也为什么很多错误会延迟到运行阶段暴露。

比如这段代码:

def get_length(data):
    return len(data)

print(get_length("python"))
print(get_length([1, 2, 3]))
print(get_length(100))

前两次调用正常。
第三次运行到 len(100) 时才报错。

因为 Python 是运行时才去确认这个对象能不能做 len 操作。

这就是动态语言的魅力,也是它的代价。

用一个完整例子,把整个执行流程串起来

下面写一个稍微完整一点的例子,看看从源码到运行到底发生了什么。

def calc_total(prices):
    total = 0
    for price in prices:
        total += price
    return total

goods = [99, 199, 299]
result = calc_total(goods)

if result >= 500:
    print("订单金额较高")
else:
    print("订单金额一般")

print("总金额:", result)

这段程序的执行过程,大致可以这样理解。

Python 先读取整段源码。
检查语法没有问题。
编译成字节码。
开始执行顶层代码。

执行到函数定义 def calc_total(prices): 时,并不会立即进入函数体循环。
而是先创建一个函数对象,并把名字 calc_total 绑定到它。

接着执行 goods = [99, 199, 299],创建列表对象并绑定给 goods。
再执行 result = calc_total(goods),这时才真正调用函数。

调用函数时,创建新的局部作用域。
参数 prices 绑定到传入的 goods 列表。
执行函数内部的 total 初始化和 for 循环。
每次循环把列表里的数字累加到 total。
执行 return,把 597 返回给外部。
外部名字 result 绑定到 597。

然后执行 if 判断。
因为 result >= 500 成立,于是执行第一个分支,打印订单金额较高。
最后打印总金额。

如果你能把这段流程在脑子里顺下来,说明你已经不再只是“看见代码结果”,而是在开始理解程序运行的真实路径。

为什么懂执行流程后,写代码会更稳

很多人一开始觉得,理解解释器、字节码、虚拟机这些东西好像离日常开发很远。

其实一点也不远。

你懂执行流程后,会直接获得这些好处。

第一,更容易看懂报错。
知道错误发生在语法阶段、导入阶段,还是运行阶段,排查会快很多。

第二,更容易写出结构清晰的代码。
知道函数定义不会立刻执行,知道模块导入会执行顶层代码,写组织结构时就更有分寸。

第三,更容易理解性能问题。
知道模块加载、函数调用、对象创建都有成本,就不会乱堆逻辑。

第四,更容易学后面的高级特性。
装饰器、闭包、迭代器、生成器、协程,本质上都和对象模型、执行流程、作用域切换有关。

第五,更容易建立工程思维。
程序不是魔法,它只是在按规则一步步运行。你越了解规则,代码就越不容易失控。

本章小结

Python 程序并不是直接执行源码,而是先把源码编译成字节码,再交给 Python 虚拟机执行。函数定义、模块导入、条件判断、循环控制、名字绑定,这些都发生在具体的执行流程里。理解这一层,后面再学作用域、对象模型、装饰器和并发时,思路会顺很多。

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐