🐍 第1章 认识 Python 语言
📄 一句话概括

电脑自己只会做三件事:搬数、算数、比大小。 所谓程序语言,就是把这三件事包装成人能读的句子。 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 秒实验

  1. 把源码里的 a * b + 1 改成两个星号的乘方写法,重跑,看 BINARY_OP 的编号变成几。
  2. 把 print(c) 删掉再编译,看字节码少了几条指令——少的正是 LOAD_NAME print 那几条。
  3. 在命令行敲 python -c "import sys; print(sys.version)",把你机器上的版本记下来, 和本书的 3.13 比一比。

常见误区

  1. 「装 Python 就是装一个软件。」装进来的是三样:解释器、标准库、pip。 很多「装好了却用不了」的问题,缺的是第三样或者 PATH。
  2. 「解释执行就是没有编译。 」Python 有编译这一步,只是编译器藏在 import 里,产物是字节码不是机器码。 没有编译,就没有 .pyc。
  3. 「版本越新越好。」新版本有些第三方包还没跟上(第 5 章装包时最容易撞上)。 本书用 3.13,你装 3.10 以上的任何一个版本都能跟。
自测三问(点开对答案) 1. 同一份 `.py` 文件,为什么在 Windows 和 Linux 上都能跑? —— 因为它先被编成字节码,字节码不针对任何 CPU。 每个平台各有一份虚拟机,负责把同样的字节码翻译成本机指令。 跨平台的是这一层中间语言,不是你的源码本身。 2. 字节码里没有「乘法」这个词,那 Python 怎么知道这里要乘? —— 靠 BINARY_OP 后面的编号。这个编号叫操作码,5 表示乘、0 表示加。指令名一样,参数不同。 也正因为乘法只是「一个取值」,你才能在类里写 `__mul__` 把它接管过去。 3. `.pyc` 有 268 字节,源码才 35 字节,那缓存是不是亏了? —— 小文件亏,大文件赚。268 字节里大头是 16 字节文件头和指令表的结构开销,这部分基本是固定的。 源码越长,指令越多,省下的编译时间越明显。 而且 `.pyc` 省的是时间不是空间,比较两个字节数本来就不是它的目的。

下一篇换个问题:代码写在哪里、从哪儿跑起来。同一个文件其实有三条路。

相关笔记