电脑自己只会做三件事:搬数、算数、比大小。 所谓程序语言,就是把这三件事包装成人能读的句子。 Python 多了一道手续:源码先编成字节码,再由虚拟机一条条跑,所以同一份源码换个系统照样能用。
这是全书第一篇,不假设你写过任何代码。 教材第 1 章开头两节是【认识程序语言】和【在个人电脑上安装 Python 3.x 版本】。 本篇把这两节连成一条线:电脑能干什么 → 语言在中间干什么 → 你装的那个东西到底是什么。 下一篇才动手敲代码。这一篇先把地基说清楚,后面 40 篇都站在这上面。
一、电脑能做的事,只有三类
拆开任何一段程序,落到最底下,都是同一批动作在重复:把一个数搬到某个地方、把两个数加一下、 比一比谁大、根据比的结果决定往哪跳。 这批动作有个名字,叫指令集。CPU 认识的就是这几十上百条指令,多一条都没有。 所以电脑并不聪明。它一秒能做几十亿次加减,但它不知道「算一下这个月的工资」是什么意思。 它只知道「把地址 A 的数搬进寄存器,加上地址 B 的数」。
早年间写程序就是直接写这串数字。 后来的人受不了了,一层层往上包:汇编包一层,C 语言再包一层,Python 又包一层。 每包一层,人写得越来越省事,电脑跑得越来越绕。 这就是「程序语言」的全部意义:它不是让电脑变强,是让你少写几万行「搬数、加、比」这种句子。
二、编译和解释,只差在哪一步换语言
你写的东西 CPU 看不懂,必须翻成指令。翻的时机有两种,这就分出了两大类语言。 编译型语言(C、C++、Go)在运行之前一次性翻完,产出一个 exe 或一个可执行文件。 翻完之后跟源码没关系了,双击就能跑。 解释型语言不预先翻。它一边读你的句子,一边翻,一边跑。 好处是改一行立刻见效,坏处是每次跑都得翻一遍。
Python 夹在中间,走的是第三条路:
先把源码翻成一种不针对任何 CPU 的中间语言,叫字节码;再让一个叫虚拟机的程序去读这串字节码。
这一步很关键。
字节码里没有「x86 的乘法指令」,只有一条笼统的「做一次二元运算」,后面跟个编号说明是加还是乘。
哪个系统上的虚拟机,就负责把这条翻译成自己 CPU 认的样子。
演示里把 a * b + 1 和 a + b + 1 各编译一遍,指令名完全一样,只有那个编号不同:乘法是 5,
加法是 0。
换句话说,在字节码这一层,「乘法」并不特殊,它只是同一条指令的一个取值。
想通这点,后面很多事就顺了:
为什么 Python 里可以给自己的类定义乘法(写一个 __mul__ 方法就能接管这个编号),
为什么同一份代码在 Windows 和 Linux 上都能跑。
三、为什么第一门课选 Python
教材选 Python 有四条理由,逐条都站得住。
第一,缩进就是语法。别的语言用大括号圈代码块,Python 用空格。
刚开始会别扭,习惯之后你会发现代码长得都一样,读别人的不费劲。
第二,不用声明类型。写 n = 3 就行,不用先写「这是一个整数」。
省下的这半行,在写小程序时特别明显。
第三,标准库是全的。读写文件、发网络请求、算日期、压缩、连数据库,import 一句就有,不用装东西。
本书绝大部分例子只用到标准库。
第四,装第三方包只要一行:pip install 包名。第 5 章专门讲这件事。
代价也要说清楚:Python 慢。 同样一段循环,C 可能快几十倍。
但本书做的事情——备份文件、抓网页、起个小网站、处理几张图片——瓶颈几乎都不在语言本身,而在等硬盘、
等网络。
什么时候该换语言?等你真的测出来「时间全花在 Python 的循环里」。在此之前,先用顺手的。
四、你装的那个东西,其实是三样
从 python.org 下下来的安装包,装完在硬盘上留下的不是「一个软件」,是三样东西。
| 装进来的 | 是什么 | 少了它会怎样 |
|---|---|---|
解释器 python.exe |
读源码、编字节码、跑字节码的那个程序 | 什么都不能跑 |
| 标准库 | 几百个 .py 文件,import 就能用 |
连 import os 都报错 |
| pip | 包管理器,负责从网上装第三方包 | 第 5 章往后全做不了 |
这三样装在同一个目录里。
Windows 下一般是用户目录下的 AppData\Local\Programs\Python\Python313,
macOS 和 Linux 上通常是 /usr/local/bin 和 /usr/lib/python3.13。
安装界面上有一个勾选框,叫 Add Python to PATH。
这个框的意思是:把这个目录写进系统的 PATH 变量,这样你在任何目录敲 python 都能找到它。
没勾这个框,是新手第一个卡点。 表现是:Python 明明装好了,
一敲 python 就提示「不是内部或外部命令」。
补救办法有两条:重跑安装包选 Modify 把框勾上;或者手动把那个目录加进环境变量。
两条都行,第一条省事。
装完怎么确认?开一个命令行,敲 python --version。回你一行 Python 3.13.14 之类,就算成了。
本书的演示全部在 3.13 上跑,3.10 以上基本一致。
五、同一份源码,为什么换台电脑还能跑
因为字节码不是给 CPU 看的,是给虚拟机看的。虚拟机每个平台各有一份,你的源码只有一份。
第一次跑一个 .py 文件时,Python 顺手把编译结果存成 .pyc,放在 __pycache__ 目录里。
下次跑同一个文件,只要源码没改,就直接拿 .pyc 用,省掉编译那一步。
演示里那个 4 行的小程序:源码 35 字节,.pyc 268 字节。
缓存比源码大七倍多,因为它多了 16 个字节的文件头,还有指令表本身的开销。
文件头里记着魔数、解释器版本、源码的修改时间和大小。
你改一个字符,时间对不上,.pyc 立刻作废重编——这就是为什么改完代码马上生效,不用手动清缓存。
那是不是真的「一次编写,到处运行」?不完全。
字节码通用,但跟系统打交道的地方不通用:路径分隔符 Windows 是反斜杠、Linux 是正斜杠;
默认编码、换行符、大小写敏不敏感,也都不一样。
这些坑第 4 章讲文件路径时会一个个碰到。这里先记住一句:跨平台的是语言,不是你的程序。
六、动手 Demo:一行源码是怎么变成结果的
写一个 4 行的小程序,把它编译出来的字节码打印出来看。 再改一个运算符,对比字节码差在哪。 最后真跑一遍,确认退出码和输出。
import dis, py_compile, subprocess, sys, os
src = 'a = 3\nb = 4\nc = a * b + 1\nprint(c)\n'
open('demo.py', 'w').write(src)
# ① 源码
print('源文件 demo.py 一共 %d 行:' % len(src.strip().split(chr(10))))
for ln in src.strip().split(chr(10)):
print(' ' + ln)
# ② 编译成字节码,逐条列出来
code = compile(src, 'demo.py', 'exec')
print('编译出来是一串指令,叫字节码:')
for ins in dis.get_instructions(code):
print(' %-4d %-20s %s' % (ins.offset, ins.opname, ins.arg))
# ③ 缓存文件多大
py_compile.compile('demo.py', cfile='demo.pyc')
print('%-16s %d 字节' % ('demo.py(源码)', os.path.getsize('demo.py')))
print('%-16s %d 字节' % ('demo.pyc(字节码)', os.path.getsize('demo.pyc')))
# ④ 换个运算符,看字节码差在哪
def ops(text):
c = compile(text, '<s>', 'exec')
return [(i.opname, i.arg) for i in dis.get_instructions(c)
if i.opname == 'BINARY_OP']
print('a * b + 1 的 BINARY_OP 参数:', ops('c = a * b + 1'))
print('a + b + 1 的 BINARY_OP 参数:', ops('c = a + b + 1'))
# ⑤ 真跑一遍
p = subprocess.run([sys.executable, 'demo.py'], capture_output=True, text=True)
print('退出码 %d,输出 %s' % (p.returncode, p.stdout.strip()))
实跑输出:
--------------------------------------------------------------------
一行源码是怎么变成结果的
--------------------------------------------------------------------
① 先看这段程序
--------------------------------------------------------------------
源文件 demo.py 一共 4 行:
a = 3
b = 4
c = a * b + 1
print(c)
② 电脑看不懂这 4 行,它只认字节码
--------------------------------------------------------------------
编译出来是一串指令,叫字节码:
0 RESUME 0
2 LOAD_CONST 3
4 STORE_NAME a
6 LOAD_CONST 4
8 STORE_NAME b
10 LOAD_NAME a
12 LOAD_NAME b
14 BINARY_OP 5
18 LOAD_CONST 1
20 BINARY_OP 0
24 STORE_NAME c
26 LOAD_NAME print
28 PUSH_NULL
30 LOAD_NAME c
注意里面没有 multiplication 这种词,只有 BINARY_OP 加一个操作码。
乘法到底是乘还是加,看的是操作码后面那个参数。
这一串指令不针对任何 CPU。它是给 Python 虚拟机看的,所以同样的源码换个操作系统照样跑。
③ 编译结果会被缓存成 .pyc
--------------------------------------------------------------------
| 文件 | 字节数 |
|---|---:|
| demo.py(源码) | 35 |
| demo.pyc(字节码) | 268 |
pyc 开头的 16 个字节是文件头:魔数、解释器版本标记、源码修改时间、源码大小,后面才是字节码本体。
源码一改,文件头里的时间和大小对不上,pyc 立刻作废重编——这就是为什么你改完代码马上生效。
换个大版本的解释器,魔数对不上也一样作废。
④ 改一行源码,看字节码变不变
--------------------------------------------------------------------
a * b + 1 的 BINARY_OP 参数:[('BINARY_OP', 5), ('BINARY_OP', 0)]
a + b + 1 的 BINARY_OP 参数:[('BINARY_OP', 0), ('BINARY_OP', 0)]
指令名字一模一样,只有那个整数参数不同。这个整数叫操作码,虚拟机靠它区分是加还是乘。
所以「乘法」在字节码这一层并不特殊,它只是 BINARY_OP 的一个取值。
这也是为什么 Python 的算符可以自己定义——类里写 __mul__ 就能接管这个操作码。
⑤ 真跑一遍,确认结果
--------------------------------------------------------------------
退出码 0,输出 13
整条链路:源码 → 编译 → 字节码 → 虚拟机逐条执行 → 打印。
没有一步生成 exe,也没有一步叫「编译成机器码」。
第二步那串指令要横着看:左边是位置,中间是指令名,右边是参数。 LOAD_CONST 是把常量搬上来,STORE_NAME 是存进变量,BINARY_OP 是做一次二元运算。 注意里面没有 multiplication 这种词。 乘法到底是乘还是加,看的是 BINARY_OP 后面那个数字:5 是乘,0 是加。 第三步那两个字节数别只看大小。 源码 35、缓存 268,多出来的部分是 16 字节文件头加上指令表本身的开销。 小文件里缓存反而更大,文件大了才划算。 第五步的退出码 0 是「跑成了」的意思。这个数字第 1.2 篇还要专门讲,它是程序留给外面看的唯一凭据。
30 秒实验
- 把源码里的
a * b + 1改成两个星号的乘方写法,重跑,看 BINARY_OP 的编号变成几。 - 把
print(c)删掉再编译,看字节码少了几条指令——少的正是LOAD_NAME print那几条。 - 在命令行敲
python -c "import sys; print(sys.version)",把你机器上的版本记下来, 和本书的 3.13 比一比。
常见误区
- 「装 Python 就是装一个软件。」装进来的是三样:解释器、标准库、pip。 很多「装好了却用不了」的问题,缺的是第三样或者 PATH。
- 「解释执行就是没有编译。
」Python 有编译这一步,只是编译器藏在
import里,产物是字节码不是机器码。 没有编译,就没有.pyc。 - 「版本越新越好。」新版本有些第三方包还没跟上(第 5 章装包时最容易撞上)。 本书用 3.13,你装 3.10 以上的任何一个版本都能跟。
自测三问(点开对答案)
1. 同一份 `.py` 文件,为什么在 Windows 和 Linux 上都能跑? —— 因为它先被编成字节码,字节码不针对任何 CPU。 每个平台各有一份虚拟机,负责把同样的字节码翻译成本机指令。 跨平台的是这一层中间语言,不是你的源码本身。 2. 字节码里没有「乘法」这个词,那 Python 怎么知道这里要乘? —— 靠 BINARY_OP 后面的编号。这个编号叫操作码,5 表示乘、0 表示加。指令名一样,参数不同。 也正因为乘法只是「一个取值」,你才能在类里写 `__mul__` 把它接管过去。 3. `.pyc` 有 268 字节,源码才 35 字节,那缓存是不是亏了? —— 小文件亏,大文件赚。268 字节里大头是 16 字节文件头和指令表的结构开销,这部分基本是固定的。 源码越长,指令越多,省下的编译时间越明显。 而且 `.pyc` 省的是时间不是空间,比较两个字节数本来就不是它的目的。下一篇换个问题:代码写在哪里、从哪儿跑起来。同一个文件其实有三条路。
- ☐ (本篇完,下一篇 1.2 命令行、IDE 与第一个程序)