基础篇 / 第 1 章
第 1 章 · 环境搭建与第一行代码
print,并真正理解「Python 代码是怎么被执行的」。无需任何前置知识,只要有一台电脑即可开始。学完本章,你手上会有一套在任何机器上都能复现的开发环境,并且知道自己的第一份代码存放在哪里、怎么运行、出错时去哪看。为什么是 Python:先把语言选对
在安装任何东西之前,值得花两分钟想清楚:Python 到底是一门什么样的语言?这决定了你后面该用什么心态去学、用什么标准去判断代码写得好不好。
Python 由 Guido van Rossum 于 1991 年首次发布,设计哲学可以在官网的 Zen of Python 里看到,翻译成中文大致是这样:
在解释器里查看 Python 之禅
import this
# 输出(节选):
# The Zen of Python, by Tim Peters
# Beautiful is better than ugly.
# Explicit is better than implicit.
# Simple is better than complex.
# Readability counts.
# There should be one-- and preferably only one --obvious way to do it.
# If it is easy to do something, and it could be made easier, do it.
把这几句话当作 Python 的「审美宪法」,你会发现自己写的代码风格会自然地和社区一致。举一个具体对照,感受一下「可读性优先」意味着什么:
| 维度 | 其他语言的做法 | Python 的做法 |
|---|---|---|
| 编译 | 先编译成字节码/机器码再执行 | 直接由解释器逐条翻译执行(.py 源码) |
| 声明变量 | 必须写类型:int a = 1; | 无需声明,运行时绑定:a = 1 |
| 结束语句 | 用 ; 或花括号 | 用缩进(缩进就是语法的一部分) |
| 变量含义 | 一个「盒子」,存的值固定类型 | 一个「标签」,可以随时指向别的对象 |
| 集合字面量 | 需要先声明再添加 | [1,2]、{1,2} 直接写 |
| 多返回值 | 常需封装成数组/结构体 | return a, b 直接返回元组 |
x = 1 之后写 x = "a" 完全合法,因为 x 只是换了张标签。安装 Python 解释器
Python 解释器(interpreter)就是「能读懂并执行 .py 文件的程序」。它和 Java 的 JDK、Node 的运行时是一回事。先装解释器,再装编辑器,顺序反了会让你分不清是谁的问题。
2.1 确认你的操作系统
| 系统 | 推荐方式 | 说明 |
|---|---|---|
| Windows 10/11 | 官网安装包 或 Microsoft Store | 安装时务必勾选 Add python.exe to PATH |
| macOS | 官网 .pkg 或 Homebrew | 系统自带 3.9,建议单独装 3.12 |
| Linux | 包管理器 或 pyenv | 发行版自带版本可能偏旧 |
Windows 用户请到 python.org/downloads 下载 3.12.x 的 Windows installer(64-bit)。安装向导第一屏有一个默认未勾选的复选框:
python 提示「不是内部或外部命令」。补救二选一:卸载重装并勾上;或手动把 C:\Users\你的用户名\AppData\Local\Programs\Python\Python312\ 与它的 Scripts\ 加进系统 PATH。macOS 与 Linux 推荐用 Homebrew(macOS: brew install python@3.12)或 pyenv(可多版本切换,适合需要维护老项目的开发者):
用 pyenv 管理多个 Python 版本
# 1. 安装 pyenv(macOS)
brew install pyenv
# 2. 让 shell 初始化时加载它
echo 'eval "$(pyenv init -)"' -> ~/.zshrc
# 3. 安装并切换版本
pyenv install 3.12.7 # 下载编译该版本
pyenv global 3.12.7 # 全局默认版本
python -V # 应输出 Python 3.12.7
# 4. 项目级版本(推荐:进目录自动切换)
pyenv local 3.11.9 # 在当前目录生成 .python-version
X | Y 类型写法,3.11 大幅提升性能,3.12 带来 f-string 嵌套引号放宽、type 语句等改进。下面是版本特性速查,看不懂先有个印象即可。| 版本 | 重要变化 | 什么时候能用 |
|---|---|---|
| 3.8 | := 海象运算符、f"{x=}" | 新项目不必再兼容 |
| 3.9 | 内置泛型 list[int]、字典合并 | | 类型注解写法简化 |
| 3.10 | match 结构化模式匹配、X | Y 联合类型 | 写状态机/解析器很香 |
| 3.11 | 性能提升 10–60%、异常分组 except* | 解释器明显更快 |
| 3.12 | type 语句、f-string 嵌套引号放宽、per-interpreter GIL 实验 | 本教程基准版本 |
| 3.13 | 实验性自由线程模式(无 GIL)、更好的 REPL | 尝鲜 |
3.10+ 的模式匹配:像 switch 一样清晰
def handle(command: str) -> str:
match command.split(): # 拆成命令与参数列表
case ["quit"]: # 精确匹配
return "退出"
case ["go", direction] if direction in ("n", "s", "e", "w"):
return f"向{direction}移动" # 守卫条件
case ["add", int(x), int(y)]: # 带类型与绑定
return f"结果是 {x + y}"
case _: # 兜底,等价 default
return "未知命令"
print(handle("quit")) # 退出
print(handle("add 3 4")) # 结果是 7
print(handle("go up")) # 未知命令
2.2 验证安装是否成功
打开终端(Windows 是 PowerShell 或 CMD,macOS/Linux 是 Terminal),依次执行:
在终端里检查解释器
python --version
# 期望输出:Python 3.12.7
python -c "import sys, platform; print(sys.executable); print(platform.platform())"
# 期望输出(C:\\Users\\you\\AppData\\...\\python.exe)
# Windows-10-...
where python # Windows:看看装了几份
which python # macOS / Linux
where python 出现两条路径、或版本与你刚装的不一致,说明机器上有多个解释器,请用绝对路径或 py -3.12 明确指定。Windows 上的官方启动器命令是 py -3.12 --version。2.5 常见安装故障速查表
| 现象 | 原因 | 解决 |
|---|---|---|
'python' 不是内部或外部命令 | 没勾 PATH 或终端没重开 | 重装勾 PATH,或手动改环境变量 |
Store 页面弹出 | App Execution Alias 拦截 | 设置 → 应用 → 高级应用设置 → 应用执行别名,关掉 python.exe |
command not found: python | macOS/Linux 无 python 命令 | 改用 python3,或做软链接/别名 |
externally-managed-environment | 试图往系统 Python 装包 | 建虚拟环境;或 pip install --break-system-packages(不推荐) |
pip install 超慢 | 走默认官方源 | 换镜像源,见下一节 |
Permission denied | 缺权限 | 不要用 sudo,改虚拟环境或 --user |
ModuleNotFoundError: No module named xxx | 装到了别的解释器 | python -m pip install xxx |
| PowerShell 报「禁止运行脚本」 | 执行策略限制 Activate.ps1 | Set-ExecutionPolicy -Scope CurrentUser RemoteSigned |
换用国内镜像源加速安装
# 临时使用
pip install requests -i https://pypi.tuna.tsinghua.edu.cn/simple
# 永久设置(推荐,写进 pip 配置文件)
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
pip config set install.trusted-host pypi.tuna.tsinghua.edu.cn
# 或者在项目根目录放 pip.ini(仅对该项目生效)
# [global]
# index-url = https://pypi.tuna.tsinghua.edu.cn/simple
2.3 各系统安装步骤逐条拆解
下面把三个平台的完整操作流程列出来,建议照着一步步做,不要跳步。
Windows 安装流程
1. 打开 https://www.python.org/downloads/
2. 下载 "Windows installer (64-bit)",对应 3.12.x
3. 双击安装包
4. 【关键】安装向导最下方勾选 "Add python.exe to PATH"
5. 可选勾选 "Install launcher for all users"(提供 py 命令)
6. 点 Install,等待完成
7. 关掉所有已开的终端,重新打开一个(PATH 改动需要新进程)
8. 执行 python --version 验证
macOS 安装流程(三种选择)
# 方式 A:官网安装包(最省事)
# 下载 python-3.12.x-macos11.pkg,双击,一路下一步
# 方式 B:Homebrew(推荐,可管理多个版本)
brew install python@3.12
brew link --overwrite --force python@3.12 # 让 python3 指向 3.12
# 方式 C:源码编译(高级,用于定制)
curl -O https://www.python.org/ftp/python/3.12.7/Python-3.12.7.tgz
tar -xzf Python-3.12.7.tgz
cd Python-3.12.7 && ./configure --prefix=$HOME/.local
make -j8 && make altinstall
# 注意用 altinstall 而不是 install,避免覆盖系统 python
Linux(以 Ubuntu/Debian 为例)
# 发行版自带版本通常较旧,先确认
python3 --version
# 安装编译依赖(需要 3.11+,否则编译新版会失败)
sudo apt update
sudo apt install -y build-essential zlib1g-dev libssl-dev \
libffi-dev libsqlite3-dev libbz2-dev liblzma-dev
# 编译安装 3.12 到 ~/.local
cd /tmp
wget https://www.python.org/ftp/python/3.12.7/Python-3.12.7.tgz
tar -xf Python-3.12.7.tgz
cd Python-3.12.7
./configure --prefix=$HOME/.local --enable-optimizations
make -j$(nproc) # -j 用满多核,快很多
sudo make altinstall
echo 'export PATH="$HOME/.local/bin:$PATH"' -> ~/.bashrc
source ~/.bashrc
python3.12 --version
sudo pip install 或 sudo python -m pip install 会把包装进系统目录,可能搞坏 apt 自身的 Python 工具(例如 apt 报错)。正确做法是永远用虚拟环境,或用 --user 装到用户目录。uv:一条命令 curl -LsSf https://astral.sh/uv/install.sh | sh 装好后,uv python install 3.12、uv init、uv run main.py 全套搞定,速度比 pip 快一个数量级。这是 2024 年后新项目的流行做法。2.4 装完之后必须做的三件小事
- 确认版本:
python --version与python3 --version都试试;macOS/Linux 上python可能根本不存在,这是正常的。 - 升级 pip:
python -m pip install --upgrade pip。用python -m pip而非裸pip,保证装到当前解释器对应的那个 pip。 - 装一个第三方包验证:
python -m pip install requests然后python -c "import requests; print(requests.__version__)"。
理解 Python 的执行方式
Python 解释器拿到一个 .py 文件后到底做了什么?理解这条链路,能解释你未来会遇到的绝大多数「诡异现象」,比如为什么修改代码不生效、为什么 __pycache__ 突然出现。
3.1 从源码到字节码
- 词法分析(tokenize):把源码切成「记号流」,例如
x、=、1。 - 语法分析(parse):按语法规则把记号组织成抽象语法树(AST, Abstract Syntax Tree),像一棵树一样描述程序结构。
- 编译(compile):把 AST 编译成字节码(bytecode),即一串面向虚拟机的低层指令。
- 执行(execute):Python 虚拟机(PVM, Python Virtual Machine)逐条执行字节码。
第 3 步产生的字节码会被缓存在 __pycache__/ 目录里,文件后缀是 .pyc。这解释了两个现象:一是你的项目目录里会莫名其妙多出这个文件夹,删掉无害;二是「改代码不生效」几乎总是因为改错文件、或者跑的是别的目录下的同名文件。
亲手观察字节码
import dis # 反汇编模块
def add(a, b):
return a + b
dis.dis(add)
# 输出(Python 3.12 节选):
# 2 0 RESUME 0
# 3 2 LOAD_FAST 0 (a)
# 4 LOAD_FAST 1 (b)
# 6 BINARY_OP 0 (+)
# 8 RETURN_VALUE
import add_module # 假设同目录下有 add_module.py
print(add_module.__cached__) # -> .../__pycache__/add_module.cpython-312.pyc
3.2 交互式 vs 脚本式
| 方式 | 启动 | 适用场景 | 注意 |
|---|---|---|---|
| 交互式 REPL | 终端里直接敲 python | 试一小段代码、看结果 | 关掉窗口历史就没了;退出用 exit() 或 Ctrl+D |
| 脚本式 | python demo.py | 正式写代码、写项目 | 从上到下逐行执行 |
| 交互增强 REPL | python -i demo.py | 跑完脚本后停在 REPL 里查状态 | 调试神器 |
| Jupyter Notebook | jupyter lab | 数据分析、教学 | 本质是「可反复执行的 .py 文件」 |
| JupyterLab / IPython | ipython | 需要补全、语法高亮的 REPL | 可安装 ipython 提升体验 |
REPL 里的小实验:对象就是内存里的那一块
x = 1000
id(x) # 例如 140234...,对象在内存中的地址
y = x
id(y) == id(x) # True,两个名字指向同一个对象
x = 1000 + 0 # Python 会把结果折叠成常量
id(x) == id(y) # True,说明小整数/常量被复用
a = [1, 2, 3]
b = a
b.append(4)
a # [1, 2, 3, 4] —— 两个名字,一个列表
import this 看语言哲学,help(len) 看内置函数文档,dir(对象) 看它有哪些属性。装上 IPython 后还有 %timeit 计时、%whos 列变量等,效率翻倍。3.3 导入机制与 sys.path
import 不是「魔法」,它就是按顺序在一串目录里找同名文件。这些目录记在 sys.path 列表中。
查看导入搜索路径
import sys
for i, p in enumerate(sys.path):
print(i, repr(p))
# 典型输出(Windows):
# 0 '' ← 空串表示"当前脚本所在目录"
# 1 'C:\\Users\\you\\AppData\\Roaming\\Python\\Python312\\site-packages'
# 2 'C:\\Python312\\Lib'
# 3 'C:\\Python312\\DLLs'
# 4 'C:\\Python312\\Lib\\site-packages'
import requests
print(requests.__file__) # 确认装到哪了
sys.path[0] 是脚本所在目录,所以「在脚本同级建一个 utils.py,直接 import utils」能工作;而在交互式 REPL 中它是空串(当前目录),行为一致。但如果你把脚本放到别处运行,就找不到同目录模块了——这正是「明明有这个文件却 import 失败」的根因。自己动手制造一次导入错误
# 目录结构
# project/
# run.py
# sub/
# helper.py ← 里面有 get_data()
# run.py 内容:
# from sub.helper import get_data
# -> ModuleNotFoundError: No module named 'sub'
# 修复方案一:把项目根目录加进 sys.path(不推荐但常见)
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).parent))
# 修复方案二:把 run.py 移到项目根目录(推荐)
# 修复方案三:把项目根目录做成包,用 pip install -e . 安装(工程化正解)
3.4 启动参数速查
| 命令 | 作用 |
|---|---|
python demo.py | 正常运行脚本 |
python -i demo.py | 跑完停在 REPL,便于检查中间状态 |
python -m pytest | 以模块方式运行(保证在正确环境里) |
python -m venv .venv | 创建虚拟环境 |
python -O demo.py | 关闭 assert(发布时用) |
python -B demo.py | 不生成 .pyc 文件 |
python -u demo.py | 无缓冲输出,日志实时打印 |
python -m pip list | 用当前解释器的 pip 列出包 |
python -c "code" | 执行一小段代码(写 shell 脚本常用) |
python -m pdb demo.py | 进调试器 |
python -X dev demo.py | 开启开发者模式警告(暴露隐患) |
python3 -u script.py,-u 能让 print 的内容立刻出现,不会因为缓冲而「卡住没输出」,这是排查卡死问题的第一步。第一行代码与第一个程序
现在可以正式动手了。建一个项目目录,并注意路径里不要有空格、中文或特殊字符——这是新手阶段最容易踩的「莫名其妙导入失败」的根源。
建立规范的目录结构
my-first-python/
├── hello.py # 第一个脚本
├── readme.md # 随手记录
└── .venv/ # 虚拟环境(第 5 节创建)
# 进入目录(绝对路径示例)
cd ~/projects/my-first-python
hello.py —— 你的第一份代码
"""我的第一个 Python 程序。"""
print("Hello, World!") # 最经典的一行
print("你好,Python 3.12!") # 中文也没问题
name = input("请输入你的名字:")
print(f"很高兴认识你,{name}!")
# 算一算 1 到 100 的和
total = sum(range(1, 101))
print("1 到 100 之和 =", total) # 输出 5050
运行结果
$ python hello.py
Hello, World!
你好,Python 3.12!
请输入你的名字:小明
很高兴认识你,小明!
1 到 100 之和 = 5050
4.1 print 的常用参数
| 参数 | 作用 | 示例 |
|---|---|---|
sep | 分隔符,默认一个空格 | print(1, 2, sep="-") → 1-2 |
end | 结尾字符,默认换行 | print("a", end="") 紧接输出 |
flush | 是否强制刷新缓冲 | 实时日志用 flush=True |
file | 输出到文件 | print("hi", file=open("log.txt","a")) |
print 的细节
print(1, 2, 3) # 1 2 3
print(1, 2, 3, sep=" | ") # 1 | 2 | 3
print("加载中", end="...") # 不换行
print("加载中...", flush=True) # 立即显示,常用于进度提示
# 多个值默认打成元组形式
print([1, 2], (3, 4)) # [1, 2] (3, 4)
# 格式化浮点数时先转成 str 再输出
print(f"{3.14159:.2f}") # 3.14
print(1 / 3) # 0.3333333333333333
print(f"{1 / 3:.4f}") # 0.3333
print 是函数,所以括号不能省(Python 2 的 print 是语句,可以不写括号)。另外别写 print "hi",那是 Python 2 的老写法。4.2 让程序读取真实输入
input() 总是返回字符串,这是新手最容易忽略的类型细节(第 2 章会系统讲类型转换)。
input 的三种典型写法
# 1. 直接拿到字符串
name = input("你的名字:") # 输入 张三 -> name == "张三"
print(name * 2) # 张三张三(字符串重复,不是报错!)
# 2. 需要数字时必须转换
age = input("你的年龄:") # 得到字符串 "18"
print(age + 1) # TypeError:str + int 不行
age = int(input("你的年龄:")) # 正确:显式转换
print(age + 1) # 19
# 3. 兼容异常输入(推荐写法)
while True:
raw = input("请输入一个整数:")
if not raw.lstrip("-+").isdigit():
print(" 只能是整数哦"); continue
number = int(raw)
print("你输入的是", number); break
"18" + 1 会报 TypeError;而 "18" * 3 不会报错,输出 181818——因为 * 对字符串是「重复」而不是「相乘」。"abc" - 1 也会报错。如果你的程序输出了诡异结果,先回头看是不是类型搞错了。一个带注释的「自我介绍卡」
# profile.py —— 读取信息并格式化输出
name = input("姓名:").strip() # strip 去掉首尾空格
birth_year = int(input("出生年份:"))
hobby = input("爱好(多个用逗号分隔):").split(",") # split 切成列表
age = 2026 - birth_year # 简单的年龄估算
# 用 f-string 一行拼好输出
card = (f"【{name}】\n"
f" 年龄:约 {age} 岁\n"
f" 爱好:{'、'.join(h.strip() for h in hobby)}\n"
f" 名片长度:{len(card) if False else 0}")
print("=" * 30)
print(card)
print("=" * 30)
运行结果
$ python profile.py
姓名: 李雷
出生年份: 1998
爱好(多个用逗号分隔): 爬山, 摄影, 咖啡
==============================
【李雷】
年龄:约 28 岁
爱好:爬山、摄影、咖啡
名片长度:0
==============================
input() 在管道(管道符 |)场景下同样工作,这是写 Shell 自动化脚本的基础:echo "李雷" | python profile.py。需要静音输入(不显示输入内容)时,可以用 getpass 模块,它属于标准库且跨平台。虚拟环境:每个项目一套依赖
虚拟环境(virtual environment)是 Python 工程文化的核心。它解决的问题是:项目 A 需要 requests==2.31.0,项目 B 需要 requests==2.26.0,如果所有包都装进同一个全局环境,必然冲突。虚拟环境让每个项目拥有独立的包目录。
用标准库 venv 创建虚拟环境
# 1. 创建(.venv 是约定的目录名,可自行取名)
python -m venv .venv
# 2. Windows 激活(PowerShell)
.venv\Scripts\Activate.ps1
# 如果报错「禁止运行脚本」,先执行一次:
# Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
# 2'. Windows 激活(CMD)
.venv\Scripts\activate.bat
# 3. macOS / Linux 激活
source .venv/bin/activate
# 4. 验证:命令行提示符前会出现 (.venv)
python -c "import sys; print(sys.prefix)"
# 输出路径里应包含 .venv
# 5. 退出
deactivate
.venv/bin/python 里的解释器装包,而不是全局 pip。一个自查方法是 pip list:虚拟环境刚创建时应该几乎是空的(只有 pip 和 setuptools)。pip install。Linux/macOS 上 Python 3 后面可能跟 externally-managed-environment 报错;Windows 上则会污染全局,导致别的项目莫名其妙坏掉。安装与管理第三方包
pip install requests # 装最新版
pip install requests==2.31.0 # 锁死版本(项目中推荐)
pip install -U requests # 升级
pip install -r requirements.txt # 按清单批量安装
pip uninstall requests
pip list # 看已装包
pip show requests # 看某个包的详细信息
pip freeze # 导出当前环境的全部包
# 现代项目更推荐用 pyproject.toml 声明依赖:
# pip install -e . 可编辑安装,改代码即时生效
| 概念 | 作用 | 类比 |
|---|---|---|
| 解释器 | 执行 Python 代码的程序本体 | 发动机 |
| 虚拟环境 | 为单个项目隔离的包目录 | 独立油箱 |
| pip | 安装/管理第三方包的官方工具 | 应用商店 |
| requirements.txt | 项目依赖清单 | 购物清单 |
| pyproject.toml | 现代项目元数据 + 依赖 + 构建配置 | 项目身份证 |
| conda | 能管 Python 之外的二进制依赖(CUDA 等) | 综合仓库 |
.venv 后,VS Code 通常能自动识别并把解释器切进去;PyCharm 需要手动在 Settings 里指定 Python Interpreter 指向 .venv/bin/python。切对了,编辑器里的自动补全才会认识你装的库。5.2 一个项目的依赖管理全流程
现代 Python 项目用 pyproject.toml 声明依赖,比单独维护 requirements.txt 更规范。它同时描述了项目名、版本、依赖和打包方式。
pyproject.toml —— 现代项目的依赖声明
[project]
name = "my-tools" # 项目名
version = "0.1.0" # 语义化版本
description = "我的命令行工具合集"
requires-python = ">=3.12" # 最低 Python 版本
dependencies = [ # 运行必需
"requests>=2.31.0",
"rich>=13.7", # 终端美化输出
]
[project.optional-dependencies] # 可选依赖组
dev = ["ruff", "pytest", "mypy"] # 开发依赖,用 pip install -e ".[dev]" 安装
[project.scripts] # 安装后自动生成的命令行入口
mytool = "my_tools.cli:main"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
主版本.次版本.修订号:>=2.31.0 是「2.x 及以上,3.0 会破坏兼容」;~=2.31.0(等价 >=2.31.0, <2.32.0)是「只允许小版本升级」;^2.31.0 是 npm 风格,Python 里不通用。可编辑安装:改代码立即生效
# 安装当前目录这个包,并让它指向源码目录(改 .py 不用重装)
pip install -e .
# 安装开发依赖
pip install -e ".[dev]"
# 之后就能直接调用命令
mytool --version
# 导出依赖清单(用于部署到服务器)
pip freeze > requirements.txt
| 文件 | 用途 | 是否进版本库 |
|---|---|---|
.venv/ | 虚拟环境本体 | 不进,写入 .gitignore |
pyproject.toml | 项目元数据与依赖 | 进 |
requirements.txt | 精确锁定版本 | 进(部署用) |
poetry.lock / uv.lock | 依赖锁文件 | 进 |
.gitignore | 排除规则 | 进 |
__pycache__/ | 字节码缓存 | 不进 |
.gitignore —— 每个 Python 项目都该有
# 虚拟环境
.venv/
venv/
env/
# 字节码
__pycache__/
*.py[cod]
*$py.class
# 构建产物
build/
dist/
*.egg-info/
# 编辑器
.idea/
.vscode/
# 测试与缓存
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
编辑器与 IDE 选择
解释器负责「跑」,编辑器负责「写」和「看」。二者分离,工具链才灵活。
| 工具 | 类型 | 适合 | 费用 |
|---|---|---|---|
| VS Code | 编辑器 + 插件 | 几乎所有人;Python 插件生态最丰富 | 免费 |
| PyCharm Community | IDE | Java 转 Python、需要导航/重构的人 | 社区版免费 |
| PyCharm Pro | IDE | Django、远程开发、Jupyter | 订阅制 |
| JupyterLab | 交互式开发环境 | 数据分析、教学 | 免费 |
| Thonny | 轻量 IDE | 零基础入门,界面极简 | 免费 |
| Vim / Neovim | 终端编辑器 | 服务器上快速改文件 | 免费 |
| IDLE | 官方自带 | 仅用于验证安装 | 免费 |
VS Code 中让 Python 跑起来的关键配置
1. 安装 VS Code
2. 安装扩展:ms-python.python(微软官方 Python 扩展)
可选:ms-python.vscode-pylance(类型检查与补全)
3. Ctrl+Shift+P -> Python: Select Interpreter
选择你的项目路径/.venv/bin/python(或 .venv\Scripts\python.exe)
4. 打开一个 .py 文件,右上角会出现 ▷ 运行按钮
6.2 装完即用的最小配置
编辑器只是写字的工具,真正提升效率的是三个插件。建议按下面顺序装:
| 工具 | 作用 | 安装 | 是否必需 |
|---|---|---|---|
| ruff | 极速 lint + 自动格式化 | pip install ruff | 强烈推荐 |
| mypy | 静态类型检查 | pip install mypy | 进阶推荐 |
| pytest | 测试框架 | pip install pytest | 第 22 章学 |
| ipython | 增强版交互解释器 | pip install ipython | 可选 |
| black | 代码格式化(与 ruff format 二选一) | pip install black | 可选 |
ruff 一次配置,全项目生效
# 在项目根目录新建 pyproject.toml,加这一段
[tool.ruff]
line-length = 88 # 单行最大长度
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"] # 常见错误/导入排序/升级建议/bugbear
# 命令行用法
ruff check . # 检查问题
ruff check --fix . # 自动修复可修的部分
ruff format . # 统一格式(等价于 black)
ruff check --statistics # 看看哪类问题最多
ruff check --fix . 一条命令就能自动整理好,比手动调整快十倍。版本差异:Python 2 与 Python 3
如果你在网上搜资料,会看到大量 Python 2 时代的写法。它们大多已经不适用,本教程一律使用 Python 3.12。这张对照表能帮你识别老资料。
| 功能 | Python 2(已停止维护) | Python 3.12 |
|---|---|---|
| 版本状态 | 2020 年 1 月 EOL | 当前维护中 |
语句 print "hi" | 函数 print("hi") | |
| 除法 | 7/2 == 3 | 7/2 == 3.5,整除用 // |
| 整数 | int / long | 统一 int,任意精度 |
| 字典遍历 | d.iteritems() | d.items()(返回视图) |
| 字符串 | bytes 是 str | str 与 bytes 严格分离 |
| 异常 | except E, e: | except E as e: |
| 中文标识符 | 不支持 | 支持(但仍不建议) |
| map/filter | 返回列表 | 返回迭代器,需 list() 转回 |
| 包管理 | pip 混乱期 | pip + venv 标准化 |
同写法的不同结果(Python 3)
print(7 / 2) # 3.5 真正的数学除法
print(7 // 2) # 3 向下取整
print(7 % 2) # 1 取余
print(2 ** 100) # 1267650600228229401496703205376 整数不会溢出
print(list(map(str, [1, 2, 3]))) # ['1', '2', '3'] 需显式转换
print({k: v for k, v in [("a", 1)]}) # {'a': 1} 字典推导式
print "、x.iteritems()、raw_input()、/ 当整除用,基本可以判定是老资料,直接跳过。第一个完整小程序
把本章所学串起来,写一个带命令行交互、能处理非法输入的小程序。它只用到了 print、input、int()、if 和 while——细节留到第 2、3 章展开。
guess_number.py —— 猜数字小游戏
"""猜数字小游戏:1-100 之间的整数,提示高了还是低了。"""
import random
def main():
answer = random.randint(1, 100) # 生成 1..100 的随机整数
attempts = 0
print("我想好了一个 1 到 100 的数字,来猜猜看!")
while True: # 死循环,靠 break 跳出
try:
guess = int(input("请输入你的猜测:"))
except ValueError:
print(" 请输入整数,例如 42")
continue
attempts += 1
if guess < answer:
print(" 太小了,再试试")
elif guess > answer:
print(" 太大了,再试试")
else:
print(f"恭喜,猜对了!一共用了 {attempts} 次")
break
if __name__ == "__main__":
main()
运行示例
$ python guess_number.py
我想好了一个 1 到 100 的数字,来猜猜看!
请输入你的猜测:abc
请输入整数,例如 42
请输入你的猜测:50
太大了,再试试
请输入你的猜测:25
太小了,再试试
请输入你的猜测:37
太小了,再试试
请输入你的猜测:42
恭喜,猜对了!一共用了 5 次
if __name__ == "__main__": 是 Python 的标准护栏写法:直接运行本文件时执行 main(),被别的模块 import 时则不执行。这样代码既能当脚本跑,也能当库被别人用。调试第一段代码
所有新手都会遇到「它不 work」的时刻。Python 的报错信息其实非常友好,学会读它比背语法更重要。
典型报错与解读
print("hello" # 少一个右括号
# SyntaxError: '(' was never closed
print(helo)
# NameError: name 'helo' is not defined
print(1 + "1")
# TypeError: unsupported operand type(s) for +: 'int' and 'str'
[1, 2, 3][10]
# IndexError: list index out of range
int("abc")
# ValueError: invalid literal for int() with base 10: 'abc'
open("不存在.txt")
# FileNotFoundError: [Errno 2] No such file or directory
报错信息从下往上读:最后一行是错误类型和原因,倒数第二行(以 File "...", line N 开头)告诉你出错的文件和行号,^ 指向具体位置。
File "...", line N 开头)告诉你出错的文件和行号,^ 指向具体位置。三类错误的划分
| 类别 | 何时报 | 典型信息 | 处理 |
|---|---|---|---|
| 语法错误 | 解析阶段,代码还没运行 | SyntaxError / IndentationError | 检查括号、冒号、缩进 |
| 运行时错误 | 执行到某行时 | TypeError / ValueError / NameError | 看最后一行描述 |
| 逻辑错误 | 不报错,但结果不对 | (没有报错) | 加打印、断言、单步调试 |
9.2 pdb 交互式调试
比 print 更专业的方式是内置调试器 pdb(Python Debugger),它无需安装。
用 pdb 找到「静默的逻辑错误」
def average(nums):
total = 0
for i in range(len(nums)): # 范围写错了:应为 1..len
total += nums[i]
return total / len(nums)
print(average([80, 90, 100])) # 26.67,明显不对
# 在 average 函数里打断点,然后运行:
# python -m pdb debug_me.py
#
# pdb 常用命令:
# (Pdb) b 4 在第 4 行设断点(break)
# (Pdb) c 继续执行(continue)
# (Pdb) n 执行下一行(next,不进函数)
# (Pdb) s 进入函数(step)
# (Pdb) p total 打印变量 total
# (Pdb) l 列出当前源码上下文
# (Pdb) w 打印堆栈(我在哪、怎么进来的)
# (Pdb) q 退出调试器
9.3 报错信息的十种高频类型
| 异常类型 | 触发原因 | 典型修复 |
|---|---|---|
| SyntaxError | 语法不合规则 | 看报错里箭头指向的位置 |
| IndentationError | 缩进不一致 | 统一用 4 个空格 |
| NameError | 用了未定义的名字 | 检查拼写、是否在使用前定义 |
| TypeError | 类型不支持该操作 | type(x) 看看实际是什么 |
| ValueError | 类型对但值不合法 | 检查输入内容 |
| IndexError | 下标越界 | 检查循环范围 |
| KeyError | 字典键不存在 | 用 .get(k, 默认) |
| AttributeError | 对象没有该属性 | dir(obj) 看有哪些 |
| ZeroDivisionError | 除以零 | 加判空保护 |
| ModuleNotFoundError | 模块没装或路径不对 | pip install / 检查 sys.path |
print() 在关键位置打印中间值;② python -m pdb script.py 进调试器,用 p 变量名 查看;③ 写最小可复现示例,把问题缩到 5 行以内再求助。9.4 第一周自检清单
读完本章,下面这些命令你应该都能不看资料敲出来。做不到就回去重读对应小节。
- 打印 Python 版本、解释器路径、当前工作目录。
- 创建一个名为
.venv的虚拟环境并激活,知道怎么退出。 - 在虚拟环境里安装一个包并确认它来自虚拟环境而不是全局。
- 写一个脚本接收两个命令行参数,计算它们的和。
- 用
python -i在脚本结束后停在 REPL 里查看变量。 - 故意制造一个
TypeError并读懂它。 - 用
dis反汇编一个简单函数。 - 说出
sys.path[0]是什么,以及它为什么会导致导入失败。
用一个「断言」替代 print 调试
def average(nums):
assert nums, "列表不能为空" # 失败时给出清晰信息
total = sum(nums)
return total / len(nums)
print(average([80, 90, 100])) # 90.0
print(average([]))
# AssertionError: 列表不能为空
一个把变量看清楚的小工具
def show(*values, title="结果"):
"""打印多个值及其类型,是新手最好用的调试工具之一。"""
print(f"--- {title} ---")
for v in values:
kind = type(v).__name__ # 取类型的名字,如 int / list
size = f"(长度 {len(v)})" if hasattr(v, "__len__") else ""
print(f" {kind:<8}{size:<12} = {v!r}")
x = 42
y = "42"
z = [4, 2]
show(x, y, z, title="类型对比")
# --- 类型对比 ---
# int = 42
# str (长度 2) = '42'
# list (长度 2) = [4, 2]
{v!r} 里的感叹号表示「用 repr(表示形式)打印」,字符串会带引号,这样就能一眼看出 '42' 和 42 的区别。调试时优先用 !r 而不是直接 {}。编译流水线拆解:tokenize、AST、字节码与 pyc 的失效判定
第 3 节用四个词概括了「源码 → 字节码 → 执行」。这一节我们把那条链路拆开亲手跑一遍:每一个阶段都能用标准库单独调用,每个阶段都有可观察的中间产物。理解到这一层,很多「玄学现象」会瞬间变成常识——比如为什么改完代码不生效、为什么删掉 __pycache__ 能救场、为什么把源码放到没有写权限的目录反而启动更快。
E1.1 第一站:词法分析器输出的记号流
词法分析(tokenize)阶段把字符流切成记号(token)。tokenize 模块不依赖源码能否通过语法检查,所以即使你的代码有语法错误,它依然能告诉你「第几行第几个字符开始不对劲」。
把一行源码切成记号流
import io
import tokenize
src = 'total = sum(range(1, 101)) # 求和'
# 生成器:tokenize 产出的是惰性序列,不会一次读入整个文件
for tok in tokenize.generate_tokens(io.StringIO(src).readline):
print(f"{tokenize.tok_name[tok.type]:<12} {tok.string!r:<14} 行{tok.start[0]} 列{tok.start[1]}")
# 典型输出(列):
# NAME 'total' 行1 列0
# OP '=' 行1 列6
# NAME 'sum' 行1 列8
# OP '(' 行1 列11
# ...
# COMMENT '# 求和' 行1 列33
# ENDMARKER '' 行1 列38
用记号流定位一个未闭合的括号
import token
import tokenize
bad = 'print("hello" # 少了一个右括号'
# tokenize.untokenize 会报错:TokenError: EOF in multi-line statement
try:
list(tokenize.generate_tokens(io.StringIO(bad).readline))
except tokenize.TokenError as e:
print("记号流就断了:", e) # EOF in multi-line statement
print("报错列号 =", e.args[1][1]) # 精确到第几个字符
# 实用价值:编辑器/格式化工具靠它做括号配对、语法高亮,
# 即使整份文件语法不合法也能继续工作。
# 不是注释、f-string 里的 { 不是花括号——这些规则都写在 tokenize 层的状态机里。正因为规则复杂,Python 后来干脆把整条流水线从 C 层的 scanner 换成了纯 Python 实现的 tokenizer,源码可读性大幅提升。E1.2 第二站:AST 是编译期唯一的事实来源
抽象语法树(AST, Abstract Syntax Tree)是语法分析(parse)阶段的产物。它是编译流程的中间表示(IR, Intermediate Representation):编译器的优化器只看 AST,解释器也只看 AST,谁都不再看源码文本。所有代码改写工具(格式化、lint、自动化重构)都建立在 AST 之上,而不是靠正则匹配文本。
打印一棵 AST,并统计节点类型
import ast
from collections import Counter
src = """
def f(x):
return x * 2 + 1
"""
tree = ast.parse(src, filename="<demo>", mode="exec")
# dump:把整棵树序列化成人能读的结构
print(ast.dump(tree, indent=2))
# 输出节选:
# Module(body=[FunctionDef(name='f', args=arguments(posonlyargs=[],
# args=[arg(arg='x')], ...), body=[Return(value=BinaryOp(
# left=BinaryOp(left=Name(id='x'), op=Mult(), right=Constant(value=2)),
# op=Add(), right=Constant(value=1)))])])
# 统计每种节点出现多少次,比人肉读树直观得多
counter = Counter(type(n).__name__ for n in ast.walk(tree))
print(counter.most_common())
# [('Module', 1), ('FunctionDef', 1), ('arguments', 1), ('Name', 1), ...]
手写一个 AST 改写器:把所有 -= 1 改写成 += -1 的等价形式做演示
import ast
class AugAssignToBinOp(ast.NodeTransformer):
"""把 x -= 1 重写成 x = x - 1,用来演示编译前的源码改写。"""
def visit_AugAssign(self, node: ast.AugAssign):
# 先把节点本身改造成 BinOp,再交给父节点处理
if isinstance(node.op, ast.Sub):
return ast.copy_location(
ast.Assign(
targets=[node.target],
value=ast.BinOp(left=node.target, op=ast.Sub(),
right=node.value),
),
node,
)
return node
src = "def f():\n n = 5\n n -= 1\n return n\n"
tree = ast.parse(src)
new_tree = ast.fix_missing_locations(AugAssignToBinOp().visit(tree))
print(ast.unparse(new_tree)) # unparse:AST 反向生成源码(3.9+)
# def f():
# n = 5
# n = n - 1
# return n
# ast.unparse + ast.parse 组合起来,就是 ruff format 干的事。
| 阶段 | 标准库入口 | 输入 | 输出 | 常见用途 |
|---|---|---|---|---|
| 词法分析 | tokenize.generate_tokens | 源码字符串 | 记号流 | 语法高亮、括号配对 |
| 语法分析 | ast.parse | 源码字符串 | AST 节点树 | lint、代码改写、静态分析 |
| 编译 | compile(src, name, mode) | AST 或源码 | code 对象(字节码 + 元数据) | 动态执行、exec |
| 执行 | exec / eval | code 对象 | 任意 | 插件系统、模板引擎 |
| 反汇编 | dis.dis | code 对象 | 指令文本 | 看优化结果、排查性能 |
E1.3 第三站:compile() 把编译和执行彻底分开
compile() 是「编译但不运行」的入口。它的第三个参数 mode 决定产物形态,这一点常被忽略:"exec" 产出模块级 code 对象(允许 return 之外的语句),"eval" 产出单个表达式 code 对象,"single" 产出交互式单条语句的 code 对象(REPL 用的就是它)。
三个 mode 的产物差别,用 dis 一眼看穿
import dis
# exec 模式:产出模块级 code 对象
c_exec = compile("a = 1\nb = a + 1", "<demo>", "exec")
# eval 模式:只能是一个表达式,产出表达式 code 对象
c_eval = compile("a + 1", "<demo>", "eval")
# single 模式:一条语句,仿 REPL 的交互式执行单元
c_single = compile("print('hi')", "<demo>", "single")
print("exec code 对象类型:", type(c_exec).__name__) # ModuleCode? 实际是 code
print("exec 的指令数:", len(list(dis.get_instructions(c_exec))))
print("eval 的指令数:", len(list(dis.get_instructions(c_eval))))
print("eval 顶层指令:", dis.get_instructions(c_eval)[0].opname) # RESUME
# 错误示例:eval 模式不接受赋值语句
try:
compile("a = 1", "<demo>", "eval")
except SyntaxError as e:
print("eval 模式不允许赋值:", e.msg) # invalid syntax
# 预编译 + 反复执行:把「编译一次」和「执行 N 次」分离
ns = {"x": 10}
code_obj = compile("[x * i for i in range(3)]", "<demo>", "eval")
for _ in range(3):
print(eval(code_obj, ns))
# [0, 10, 20] 三次
exec() 执行的是任意代码。永远不要对用户提供的字符串做 exec/eval——这不是「不太安全」,而是等同于把服务器交出去了。若确实需要表达式求值,用 ast.literal_eval,它只接受字面量。安全的求值边界:ast.literal_eval 与受限沙箱
import ast
ast.literal_eval("[1, 2, (3, 'x'), None]") # [1, 2, (3, 'x'), None]
ast.literal_eval("__import__('os').system('rm -rf /')")
# ValueError: malformed node or string
# → 危险调用被直接拒绝,因为它不是字面量节点
# 需要真正执行时,至少把可用名字收窄,并明确知道这不是安全边界
safe_globals = {"__builtins__": {}, "sum": sum, "len": len, "range": range}
exec(compile("sum(range(10))", "<calc>", "eval"), safe_globals)
# 45
# 更稳妥的路线:容器隔离(子进程 / Docker / seccomp),
# 而不是靠删 builtins 这种「黑名单」式的伪隔离。
E1.4 第四站:pyc 的二进制结构与「失效判定」
字节码默认会被写进 __pycache__/名字.cpython-312.pyc。下次导入时,解释器要判断这个缓存「还作数吗」——这就是所谓失效判定(invalidation)。看懂 .pyc 的二进制头,就完全知道它凭什么判断。
解析 .pyc 文件头 16 字节
import importlib.util
import struct
from pathlib import Path
src_file = Path("demo.py")
cache_file = Path(importlib.util.cache_from_source(str(src_file)))
print("缓存位置:", cache_file) # __pycache__/demo.cpython-312.pyc
if cache_file.exists():
raw = cache_file.read_bytes()[:16]
magic, flags = raw[:4], struct.unpack("<I", raw[4:8])[0]
mtime, size = struct.unpack("<II", raw[8:16])
print("magic number:", magic.hex()) # 与当前解释器匹配才对
print("flags:", flags) # 0 = 时间戳模式;1 = 校验模式
print("源文件 mtime:", mtime, "== 实际:", int(src_file.stat().st_mtime))
print("源文件大小:", size, "== 实际:", src_file.stat().st_size)
print("结论:", "缓存有效" if mtime == int(src_file.stat().st_mtime)
and size == src_file.stat().st_size else "缓存失效,将重新编译")
else:
print("还没有缓存,先跑一次这个模块再来看")
# 反过来,从 pyc 里读出原始源码的 mtime 与大小:
# import marshal, importlib.util
# code = marshal.loads(cache_file.read_bytes()[16:])
# print(code.co_filename, code.co_consts[0])
| 偏移 | 长度 | 字段 | 含义 | 失效判定作用 |
|---|---|---|---|---|
| 0 | 4 | magic number | 字节码版本标记,含 cpython-312 字样 | 版本不符 → 直接重编译 |
| 4 | 4 | flags | 0 = 校验模式(时间戳+大小);1 = 时间戳模式 | 决定后面读哪两个字段 |
| 8 | 4 | mtime | 源文件最后修改时间(秒级) | 与源文件当前 mtime 比 |
| 12 | 4 | size | 源文件字节数 | 与源文件当前大小比 |
| 16 | 不定 | marshal 数据 | 序列化的 code 对象(常量、名字表、字节码) | 真正的字节码从这里开始 |
--check-hash-based-pycs always)把时间戳与大小换成源码的 SipHash 摘要,逐字节内容比对,能发现「内容变了但 mtime 和大小没变」的极端情况(例如 git checkout 保留时间戳)。代价是每次导入都要读全文件并算哈希,默认不开。亲手触发一次「失效」,以及它的性能代价
import os
import sys
import time
from pathlib import Path
cache_dir = Path("__pycache__")
def timed_import(label: str):
"""在干净的子进程里测量首次导入耗时,避免本进程已缓存干扰。"""
import subprocess
script = (
"import time;t=time.perf_counter();"
"import my_heavy_module;"
"print(f'{time.perf_counter()-t:.4f}')"
)
out = subprocess.run([sys.executable, "-c", script],
capture_output=True, text=True)
print(f"{label}: {out.stdout.strip()} 秒")
# 第一次:有 pyc 缓存(如果之前导入过)
timed_import("使用 pyc 缓存 ")
# 清掉缓存,强制重新编译
if cache_dir.exists():
for f in cache_dir.glob("*.pyc"):
f.unlink()
timed_import("删除 pyc 后 ")
# 再导入一次,新 pyc 已生成
timed_import("重新生成后 ")
# 对照:完全禁止写 pyc
os.environ["PYTHONDONTWRITEBYTECODE"] = "1"
timed_import("禁止写 pyc ") # 每次都重新编译,导入会明显变慢
| 机制 | 省下的开销 | 典型量级 | 什么时候值得 |
|---|---|---|---|
| .pyc 缓存 | 语法分析 + 编译(tokenize/parse/compile) | 中等模块 1–20 ms | 几乎总是值得,默认开启 |
| 无 pyc 的源码导入 | —(反而多付编译成本) | 比有缓存慢 1.5–3 倍 | 只读容器中不建议关 |
| hash-based pyc | 同上,但判定更严格 | 多一次全文哈希,微秒级 | 文件系统时间戳不可靠时 |
| frozen stdlib | 标准库模块的导入与初始化 | 启动阶段数十 ms | 3.11+ 默认内建,无需操作 |
python -X importtime -c "import requests" 会打印每一层导入的耗时(单位为微秒),最后一行 cumulative 最大的是瓶颈。注意它统计的是导入,不含解释器自身启动;两者要分开看,下一节专门讲启动开销。E1.5 动手实验清单
- 把
python -c "import mymod"的结果与python -v -c "import mymod"对照,观察__pycache__是在哪一步被写入的。 - 手动改一次
demo.py的 mtime 但不改内容,观察assert是否仍能命中缓存;再用--check-hash-based-pycs always跑一次对比。 - 用
dis.dis(compile(src, "x", "exec"))对比"exec"与"eval"两种模式产出的指令差异。 - 用
ast.unparse(ast.parse(...))做一次 round-trip,确认「源码 → AST → 源码」的还原质量(这是格式化工具的原理)。
启动开销实测:一次 python -c pass 到底花在哪
写完第一行代码后,一个自然的问题是:python hello.py 大约要多长时间才能打印出第一行?答案是十几到几十毫秒,对脚本无所谓,对 Web 服务、CLI 工具、函数计算就是巨大成本。这一节把启动时间拆成可测量的部分,并给出四条真正有效的优化路径。
E2.1 先建立测量方法:计时要精确到微秒
不要用 time.time() 测毫秒级开销,它本身就要花几十微秒;也不要测「脚本运行总时间」然后混入解释器启动与文件 IO。用 time.perf_counter_ns()(纳秒精度、单调时钟),并且把每次测量的结果存下来再统计,而不是打印单次值。
测量解释器自身启动成本
import subprocess
import sys
import statistics
import time
def measure(cmd: list[str], n: int = 30) -> float:
"""返回 n 次运行的耗时中位数(毫秒)。中位数比平均数抗抖动。"""
samples: list[float] = []
for _ in range(n):
t0 = time.perf_counter_ns()
subprocess.run(cmd, check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
samples.append((time.perf_counter_ns() - t0) / 1e6)
return statistics.median(samples)
py = sys.executable
print(f"空解释器 -c pass : {measure([py, '-c', 'pass']):6.2f} ms")
print(f"纯 import sys,os : {measure([py, '-c', 'import sys, os']):6.2f} ms")
print(f"import json : {measure([py, '-c', 'import json']):6.2f} ms")
print(f"import re : {measure([py, '-c', 'import re']):6.2f} ms")
print(f"import typing : {measure([py, '-c', 'import typing']):6.2f} ms")
print(f"import pathlib : {measure([py, '-c', 'import pathlib']):6.2f} ms")
print(f"import subprocess : {measure([py, '-c', 'import subprocess']):6.2f} ms")
print(f"import dataclasses : {measure([py, '-c', 'import dataclasses']):6.2f} ms")
print(f"import requests : {measure([py, '-c', 'import requests']):6.2f} ms")
# 典型结论(数量级参考,随机器而变):
# 空解释器 15-25 ms
# + import re 再加 8-15 ms (re 顶层会编译几十条正则,很贵)
# + requests 再加 60-150 ms (urllib3 / ssl / http.client 一大串)
subprocess.run 每次要 fork/exec 并等回收,所以测出的绝对值包含进程创建开销。要看趋势,不要抠绝对毫秒数。同一台机器上前后对比即可。E2.2 把启动时间逐层拆开:-X importtime
-X importtime 让解释器在导入每个模块后打印一行耗时,最后一行是累计值。它能直接回答「我这个程序为什么启动慢」。
抓出最慢的导入并排序
import subprocess
import sys
# 跑一次 importtime,把 stderr 抓回来
proc = subprocess.run([sys.executable, "-X", "importtime", "-c", "import requests"],
capture_output=True, text=True)
rows = []
for line in proc.stderr.splitlines():
# 输出形如:import time: self [us] | cumulative | imported package
if not line.startswith("import time:"):
continue
body = line[len("import time:"):]
try:
self_us_part, rest = body.split("|", 1)
self_us = int(self_us_part.split("[")[1].split("]")[0])
cum_part, name = rest.split("|", 1)
cum_us = int(cum_part.strip())
except (IndexError, ValueError):
continue
rows.append((self_us, cum_us, name.strip()))
rows.sort(reverse=True) # 按自身耗时排序
print(f"{'self(us)':>10} {'cumulative(us)':>16} 模块")
for self_us, cum_us, name in rows[:12]:
print(f"{self_us:>10} {cum_us:>16} {name}")
# 解读方式:
# self 大 = 模块自己在顶层干了重活(如 re 编译正则、json 建表)
# cumulative 大 = 拖着一串依赖(requests -> urllib3 -> ssl -> ...)
# 数量多但每个都小 = 依赖树庞大,优化收益主要来自「少导」
| 启动阶段 | 做了什么 | 能否优化 | 手段 |
|---|---|---|---|
| 进程创建 + 动态链接 | 加载 libpython、进入解释器主函数 | 有限 | 静态链接 / 用 python3-static |
| 初始化运行时 | 分配类型对象、内建类型、GC 阈值 | 否 | 固定成本 |
导入 site | 扫描 site-packages、执行 .pth、处理 usercustomize | 能 | -S 跳过;清理多余 .pth |
| 导入 frozen 标准库 | os / codecs / io / site 等已在解释器内固化 | 已优化 | 3.11+ 默认,无需操作 |
| 执行你的脚本 | 读文件、编译(若无 pyc)、执行 | 能 | 保证有 pyc、延迟导入重模块 |
| atexit 清理 | 对象析构、缓冲刷新、GC 收尾 | 有限 | 避免大量待析构对象 |
E2.3 四条优化路径,按性价比排序
路径一:延迟导入(lazy import),收益最大且最通用
import time
# 错误示范:模块顶层就导入,启动必付代价
import json
import re
import statistics
import dataclasses
def main():
print("hi")
# 正确示范:把昂贵导入挪进真正需要它的函数里
def main():
import json # 只在调用 main 时才导入
print(json.dumps({"ok": True}))
if __name__ == "__main__":
t0 = time.perf_counter()
main()
print(f"main 用时 {(time.perf_counter()-t0)*1000:.2f} ms")
# 技巧:如果导入只是给类型注解用,加 TYPE_CHECKING 门
from typing import TYPE_CHECKING
if TYPE_CHECKING: # 仅类型检查器会执行这一块
from pandas import DataFrame
def summarize(df: "DataFrame") -> str:
return f"{len(df)} 行"
# 运行时字符串注解不会真的导入 pandas,启动零成本。
# PEP 563 之后字符串注解是官方写法(可用 from __future__ import annotations)。
路径二:用 -S / -E 等开关削掉固定开销
import subprocess
import sys
import time
def run_once(cmd: list[str]) -> float:
"""跑一次并返回耗时(毫秒)。"""
t0 = time.perf_counter_ns()
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL)
return (time.perf_counter_ns() - t0) / 1e6
base = [sys.executable, "-c", "pass"]
variants = {
"默认": base,
"-S 跳过 site.py": [sys.executable, "-S", "-c", "pass"],
"-E 忽略环境变量": [sys.executable, "-E", "-c", "pass"],
"-I 隔离模式": [sys.executable, "-I", "-c", "pass"],
"-S -E 组合": [sys.executable, "-S", "-E", "-c", "pass"],
}
for name, cmd in variants.items():
best = min(run_once(cmd) for _ in range(15)) # 取最小值:更接近「纯开销」
print(f"{name:<16}{best:.2f} ms")
# -S 的副作用:site-packages 不在 sys.path 里,第三方包全部 import 不到。
# 所以只适合「只用标准库且要极致启动速度」的程序(如 shell 补全脚本)。
-S 会让虚拟环境失效(site 是负责把 .venv/lib/python3.12/site-packages 加进 sys.path 的)。看到「明明激活了虚拟环境却 ImportError」,先检查启动命令里是不是混进了 -S 或 -E。路径三:看清 site 阶段到底做了什么
import site
import sys
# site.py 会:① 处理 -s/-S ② 计算前缀 ③ 建 site-packages 路径
# ④ 加载 .pth 文件(逐行执行以 import 开头的行)⑤ 检查用户目录
print("ENABLE_USER_SITE:", site.ENABLE_USER_SITE) # 是否有用户级包目录
print("检查过用户目录:", getattr(site, "check_enableusersite", lambda: None)() or "见下")
print("site 包目录:", site.getsitepackages())
print("用户包目录:", site.getusersitepackages())
# 检查是否有第三方 .pth 在慢启动(它们会在 site 阶段执行任意代码)
import pathlib
for sp in site.getsitepackages():
for pth in pathlib.Path(sp).glob("*.pth"):
print("发现 .pth:", pth.name)
# 逐行看有没有 import 开头的行 —— 那会在每次启动时真的被 import
for line in pth.read_text(errors="ignore").splitlines():
if line.startswith(("import ", "import\t")):
print(" 会执行:", line[:70])
# 典型元凶:某个包留下的 .pth 会 import 它的加速模块或版本探测代码。
# 删不删要看用途,先看清楚再决定。
路径四:把重活放到子进程或常驻进程
import sys
import time
# 思路:如果启动成本无法避免,就别反复付。
# 方案 A:常驻服务(Flask/FastAPI 的开发服务器、IPython、Jupyter)
# 方案 B:把「一次性初始化」和「多次调用」拆开——
# 一个 CLI 支持 --server 常驻与 --once 单次两种模式
def run_once(payload: bytes) -> bytes:
import json, re # 只在真正干活时导入
return json.dumps({"matched": bool(re.search(rb"\d+", payload))}).encode()
def serve() -> None:
"""常驻模式:初始化一次,之后每次只做请求处理。"""
import json, re
compiled = re.compile(rb"\d+") # 预编译,只花一次
for line in sys.stdin:
print(json.dumps({"matched": bool(compiled.search(line.encode()))}))
if len(sys.argv) > 1 and sys.argv[1] == "--server":
serve()
else:
print(run_once(sys.stdin.buffer.read()).decode())
# 用法:
# echo 123 | python tool.py # 付一次启动成本
# python tool.py --server <<EOF # 付一次,循环处理
# 123
# 456
# EOF
| 优化手段 | 典型收益 | 改造成本 | 风险 | 适用场景 |
|---|---|---|---|---|
| 延迟导入重模块 | 大(可达数十至数百 ms) | 低 | 几乎无 | 所有 CLI 与 Web 服务 |
| 清理多余 .pth | 小到中(1–20 ms) | 低 | 可能弄坏某个包 | 开发机长期优化 |
-S 跳过 site | 小(1–5 ms) | 低 | 虚拟环境失效 | 纯标准库小工具 |
| 常驻进程 / 服务化 | 极大(摊薄到一次) | 高 | 进程管理复杂度 | 高频调用、服务端 |
| 静态链接 / 冻结打包 | 中(省掉动态链接与部分导入) | 中 | 体积变大、调试变难 | 要发给别人的单文件工具 |
E2.4 顺带看清:sys.path 里的第 0 项为什么会骗人
第 3.3 节说过 sys.path[0] 是「脚本所在目录」。这个说法在 python script.py 下成立,在 python -m 下却不一样——这正是「同样两个文件,脚本能跑、模块方式跑不了导入」的根源。
三种启动方式对比 sys.path[0]
import subprocess
import sys
import textwrap
probe = textwrap.dedent("""
import sys
print(f" sys.path[0] = {sys.path[0]!r}")
print(f" __name__ = {__name__!r}")
""")
for label, cmd in {
"python probe.py": [sys.executable, "probe.py"],
"python -m probe": [sys.executable, "-m", "probe"],
"python -c ...": [sys.executable, "-c", probe],
}.items():
print(label)
subprocess.run(cmd, check=False)
# 典型输出(把 probe.py 放在 /home/me/pkg/ 下):
# python probe.py
# sys.path[0] = '/home/me/pkg' ← 脚本所在目录(硬编码进 sys.path)
# __name__ = '__main__'
# python -m probe
# sys.path[0] = '/home/me' ← 当前工作目录(CWD)
# __name__ = '__main__'
#
# 结论:在项目根目录执行 python -m pkg.probe,
# 父包 pkg 才会被正确找到;直接 python pkg/probe.py 则 sys.path 里只有 pkg/,
# 导致 "No module named 'pkg'"。
python -m 运行;命令行工具则在 pyproject.toml 里声明 [project.scripts] 入口点,装完后由 pip 生成一个 .venv/bin/你的命令 启动器,它会自动把 CWD 设为脚本目录——这是工程上最干净的解法。E2.5 动手实验清单
- 用
measure()分别测import re与import requests,量化本机上的差距。 - 用
-X importtime找出你项目里最慢的 5 个导入,然后改成函数内延迟导入,再测一次冷启动。 - 在虚拟环境里加一个空的
zzz.py验证-S的效果,再故意import requests观察报错,确认自己理解了副作用。 - 建
probe.py,分别用三种方式运行,把sys.path[0]的差异写进笔记。
虚拟环境的三种实现:venv、venv --copies 与 uv 的原理对比
第 5 节讲了「每个项目一个 .venv」,但没说清它凭什么能隔离依赖。这一节拆开 venv 的实现原理,并引入 2024 年后快速普及的新工具 uv(Astral 公司出品,同一团队还做了 ruff):你会看到虚拟环境的核心其实只有三条路径规则,以及打包器(pip / uv)如何在上面叠加差异。
E3.1 隔离的全部秘密:三个 sys.path 变量
venv 做的「隔离」并不是容器、不是命名空间、不是沙箱,只是把三个路径变量指向了不同目录:
sys.prefix:虚拟环境的根,指向.venv/sys.base_prefix:原始解释器的根,指向系统 Pythonsys.executable:当前正在跑的 python 可执行文件
判定「我在不在虚拟环境里」的唯一标准就是 sys.prefix != sys.base_prefix。这也是练习题第 1 题的答案依据。
创建一个环境并把路径关系打印出来
# 终端里执行
# python -m venv .venv
# .venv/bin/python -c "..." # macOS / Linux
# .venv\Scripts\python.exe ... # Windows
# 直接用「环境里的解释器」运行下面这个探针:
import sys
import sysconfig
print("sys.executable =", sys.executable) # .venv/bin/python
print("sys.prefix =", sys.prefix) # /home/me/proj/.venv
print("sys.base_prefix =", sys.base_prefix) # /usr ← 系统
print("在虚拟环境中 =", sys.prefix != sys.base_prefix)
print()
for key in ("purelib", "platlib", "scripts", "include"):
path = sysconfig.get_path(key)
print(f"{key:<10}", "->", path)
# 输出解读:
# purelib / platlib 都在 .venv/lib/python3.12/site-packages
# → pip 把包装到哪,就决定了「这个环境能不能看到它」
# scripts 在 .venv/bin(Windows 是 .venv\Scripts)
# → 所以 .venv/bin/pip 是个指向系统 pip 的壳,实际被重定向到本环境
| 变量 / 路径 | 系统环境里的值 | 虚拟环境里的值 | 谁负责改 |
|---|---|---|---|
sys.executable | /usr/bin/python3 | proj/.venv/bin/python | 你调用了哪个 python |
sys.prefix | /usr | proj/.venv | pyvenv.cfg |
sys.base_prefix | /usr | /usr(不变) | 解释器内置 |
| site-packages | /usr/lib/python3/dist-packages | proj/.venv/lib/python3.12/site-packages | site.py 读 pyvenv.cfg |
| 可执行文件类型 | 真实二进制 | 默认是指向 base python 的符号链接(Windows 是副本) | venv 创建时决定 |
读懂 pyvenv.cfg——整个虚拟环境只有这个文件在「配置」
# cat .venv/pyvenv.cfg
# home = /usr/bin
# include-system-site-packages = false ← 关键:不继承系统已装的包
# version = 3.12.7
# executable = /usr/bin/python3.12
# command = /usr/bin/python3.12 -m venv /home/me/proj/.venv
import pathlib
cfg = pathlib.Path(".venv/pyvenv.cfg")
print(cfg.read_text())
# 只看关键项
for line in cfg.read_text().splitlines():
if line.startswith(("include-system-site-packages", "home", "version")):
key, _, value = line.partition("=")
print(f"{key.strip():<32}{value.strip()}")
# include-system-site-packages = false 意味着:
# 系统里 apt 装的 python3-requests 在虚拟环境里彻底看不见。
# 这就是「隔离」二字的全部含义。
lib/python3.12/ 仍在 sys.path 里被引用,只有第三方包目录是各自独立的。因此升级系统 Python 可能影响所有环境——这也是推荐用 pyenv / uv 各自管理一份完整解释器的原因。E3.2 --copies、--system-site-packages 与 --prompt
venv 的四个常用参数各解决一个真实问题,值得逐个理解其后果。
四种参数组合的实测对比
import subprocess
import sys
import shutil
import tempfile
from pathlib import Path
def probe(venv_dir: Path) -> str:
"""在指定虚拟环境里跑探针,返回 site-packages 列表。"""
exe = venv_dir / ("Scripts/python.exe" if sys.platform == "win32"
else "bin/python")
code = (
"import sys, json;"
"print(json.dumps({"
"'prefix': sys.prefix,"
"'base': sys.base_prefix,"
"'in_venv': sys.prefix != sys.base_prefix,"
"'sp': [p for p in sys.path if 'packages' in p]"
"}, ensure_ascii=False))"
)
out = subprocess.run([str(exe), "-c", code],
capture_output=True, text=True)
return out.stdout.strip() or out.stderr.strip()[:120]
tmp = Path(tempfile.mkdtemp())
for label, extra in {
"默认(符号链接)": [],
"--copies(真实副本)": ["--copies"],
"--system-site-packages": ["--system-site-packages"],
"--prompt=myenv": ["--prompt=myenv"],
}.items():
d = tmp / label.split("(")[0]
subprocess.run([sys.executable, "-m", "venv", *extra, str(d)],
check=True, capture_output=True)
print(f"{label}\n {probe(d)}")
shutil.rmtree(tmp)
# 结论速记:
# --copies 解决「符号链接指向的解释器被删/被移动」问题,
# 代价是磁盘占用变大、升级不会自动同步
# --system-site-packages
# 让环境「看得见」系统已装的包,用于必须依赖系统级
# 二进制库(CUDA、GDAL)的场景,代价是隔离性下降
# --prompt=名字 改写激活后提示符前缀,影响体感受,不影响功能
# --clear 复用已有目录时先清空
| 参数 | 底层机制 | 解决的问题 | 副作用 |
|---|---|---|---|
--copies | 复制二进制而非建符号链接 | 跨设备移动环境、基础解释器被删后仍可用 | 占空间;基础 Python 升级后不同步 |
--system-site-packages | pyvenv.cfg 里改为 true | 复用系统级二进制依赖 | 依赖冲突风险回归,venv 意义被削弱 |
--prompt | 改写 activate 脚本里的 PS1 | 同时维护多个环境时不看错 | 无 |
--without-pip | 不跑 ensurepip | 环境只是用来跑脚本,不装包 | 没有 pip,需 uv pip 等替代 |
--upgrade | 重建 python 可执行文件与脚本 | 换基础解释器版本 | 已装的包通常保留,但可能有 ABI 不匹配 |
--system-site-packages 是「隔离失效」的头号来源。你以为环境里只有自己装的 requests,实际系统里的老版本也可见,pip list 还不会显示它,最终出现「本地跑得好好的、部署就崩」的灵异事件。除非你明确知道自己在做什么,否则不要加这个参数。E3.3 uv:用 Rust 重写的现代工具链
uv 由 Astral 出品,定位是「一体化 Python 包与环境管理器」:下载解释器、创建环境、解析依赖、装包、锁定版本全包圆活。它与 pip 的关系不是替代而是分层——uv pip 提供 pip 兼容接口(可以直接当 pip 用),uv run/uv sync 则提供了 pip 没有的「项目即环境」能力。
用 uv 走一遍项目初始化
# 1. 安装 uv
# curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows: powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
# 或者用 pip/pipx: pip install uv
# 2. 在项目目录初始化(会生成 pyproject.toml + uv.lock + .venv)
uv init my-project
cd my-project
# 3. 声明 Python 版本与依赖
uv add "requests>=2.31" # 加依赖并写入 pyproject.toml
uv add --dev ruff mypy pytest # 加开发依赖
uv remove pandas # 删依赖
# 4. 锁定:uv.lock 记录全部分支的精确版本与哈希
uv lock
uv sync # 严格按 lock 安装,自动建/更新 .venv
# 5. 运行:不激活也能用对解释器
uv run python main.py
uv run pytest -q
# 6. 装其他工具到隔离环境(不污染当前项目)
uv tool install ruff # 类似 pipx
uv tool run ruff format .
# 7. 指定解释器版本(uv 自己下载并管理,不需要 pyenv)
uv python install 3.12
uv python pin 3.12 # 写 .python-version
cat .python-version # 3.12
| 能力 | pip + venv | uv | 差距来源 |
|---|---|---|---|
| 创建环境 | python -m venv(秒级) | uv venv(毫秒级) | uv 直接写文件,不跑 ensurepip |
| 装包 | 解析较慢,串行下载 | 并行下载、缓存命中极快 | uv 有全局 HTTP 缓存与硬链接安装 |
| 版本锁定 | 需额外工具(pip-tools / poetry) | 内置 uv.lock | uv 用 Rust 重写了解析器 |
| 解释器管理 | 需 pyenv 或手动装 | 内置 uv python | uv 自带独立解释器下载与发现 |
| 运行即用对环境 | 必须先 activate | uv run 自动选 | 项目元数据驱动 |
| 兼容面 | 所有历史包与私有源 | 绝大多数包兼容;冷门私有源偶尔需配置 | 索引与认证实现有差异 |
uv.lock 提交进版本库、把 .venv/ 加进 .gitignore。团队成员 clone 下来只要 uv sync,就能得到逐位一致的依赖树——这比 pip freeze 生成的清单更严格,因为它同时锁定了传递依赖和分发包哈希。E3.4 用代码验证隔离真的有效
环境隔离经常「看起来对,其实漏了」。下面这段代码同时检查路径、可见包、解释器身份和系统包泄漏,适合做成项目开箱自检。
环境完整性探针(可放进项目的 tools/env_report.py)
import json
import os
import subprocess
import sys
import sysconfig
from pathlib import Path
def env_report() -> dict:
"""收集当前解释器的环境事实,做成可打印的字典。"""
in_venv = sys.prefix != sys.base_prefix
sp = [p for p in sys.path if p.endswith(("site-packages", "dist-packages"))]
return {
"executable": sys.executable,
"prefix": sys.prefix,
"base_prefix": sys.base_prefix,
"in_venv": in_venv,
"version": sys.version.split()[0],
"site_packages": sp,
"has_pyc_writable": os.access(sysconfig.get_path("cache"), os.W_OK),
"pid": os.getpid(),
}
def visible_versions(names: list[str]) -> dict:
"""报告一批包在本环境里能否导入、以及来自哪个路径。"""
import importlib.util
out: dict[str, str | None] = {}
for name in names:
spec = importlib.util.find_spec(name) # 找不到返回 None,不真的导入
out[name] = None if spec is None else (spec.origin or "内置/frozen")
return out
if __name__ == "__main__":
report = env_report()
print(json.dumps(report, ensure_ascii=False, indent=2))
print(json.dumps(visible_versions(["requests", "pip", "setuptools"]),
ensure_ascii=False, indent=2))
# 关键断言:解释器必须与虚拟环境在同一目录树下
if report["in_venv"]:
exe = Path(sys.executable).resolve()
venv = Path(sys.prefix).resolve()
assert exe.is_relative_to(venv), (
f"解释器 {exe} 不在虚拟环境 {venv} 内 —— 你可能用的是全局 pip"
)
else:
print("警告:当前不在虚拟环境中,装包会污染全局", file=sys.stderr)
# 检查 pip 与 python 是否匹配:这是「装到别的解释器」问题的通用自查
import pip
pip_path = getattr(pip, "__file__", "")
ok = Path(pip_path).resolve().is_relative_to(Path(sys.prefix).resolve())
print(f"pip 与解释器同环境:{ok} ({pip_path})")
python -m pip 与裸 pip 不是一回事。前者保证「用当前这个 python 对应的 pip」,后者取决于 PATH 里排第一的是哪个 pip。养成写 python -m pip 的习惯,可以消灭一整类「明明装了却 import 不到」的问题。E3.5 动手实验清单
- 建两个虚拟环境,分别装不同版本的同名包,打印
pip list与module.__file__,亲眼确认互不干扰。 - 故意用全局
pip install装一个包,再运行env_report(),理解in_venv=False时的风险。 - 用一个带
include-system-site-packages = true的环境,验证它能看到系统包,据此解释「本地好好的、服务器上崩」的一类故障。 - 安装 uv,用
uv init+uv add+uv sync+uv run走一遍,对比它与 pip+venv 的命令差异。
跨平台与可复现:从入口点到环境自检的一份检查清单
最后一节把前四节的原理收成一件事:让同一份代码在你的电脑、队友电脑、服务器、Docker 里跑出同样结果。这是「代码写完了却到处出问题」的唯一根源,也是从「会写 Python」到「能交付 Python」的分水岭。本节给出可执行的检查清单与脚本,而不是抽象原则。
E4.1 三个环境必须记录的三件事
任何一次「在我这能跑」的争论,最终都会落到三个问题上:哪个解释器、哪个依赖、什么路径。把这三件事显式记录并固定下来,跨平台问题就消失了大半。
| 维度 | 必须固定的内容 | 记录方式 | 不固定的后果 |
|---|---|---|---|
| 解释器版本 | 精确到次版本,如 3.12.7 | .python-version / requires-python | 3.12 与 3.13 的 f-string、PEP 695 行为有差异 |
| 依赖版本 | 全树锁定(含传递依赖) | uv.lock / poetry.lock / requirements.txt | 上游小版本改动引发难复现的回归 |
| 路径与工作目录 | 一律用相对项目根的路径 | pathlib.Path(__file__).parent | 换目录运行就崩,Windows 反斜杠陷阱 |
项目根定位:__file__、Path.cwd() 与三者的关系
from pathlib import Path
import os
import sys
# 1) 这个脚本文件自己的绝对路径
print("__file__ :", __file__)
print("file 的父目录 :", Path(__file__).resolve().parent)
# 2) 当前工作目录(取决于「你在哪敲的命令」,与脚本位置无关)
print("cwd :", os.getcwd())
# 3) 交互式环境下 __file__ 不存在,必须容错
script_dir = Path(__file__).resolve().parent if "__file__" in globals() \
else Path.cwd()
# 4) 跨平台的目录拼接:永远不要用 "\" 拼路径,也不要手拼 / 或 os.sep
data_dir = script_dir / "data" # pathlib 自动处理 Windows 的 \
config_file = script_dir / "data" / "config.toml"
print("配置文件:", config_file)
# 5) 写文件前先建目录(幂等,重复执行不会报错)
config_file.parent.mkdir(parents=True, exist_ok=True)
config_file.write_text("[general]\n", encoding="utf-8")
# 6) 统一的换行策略:读文本一律显式 encoding,写文本同理
# Windows 默认编码可能是 GBK,不显式指定会在中文机器上抛 UnicodeDecodeError
text = config_file.read_text(encoding="utf-8")
# 7) 绝对路径 vs 相对路径的取舍
# · 需要写进配置文件、日志、跨机器引用 → 必须绝对路径
# · 需要入库、部署、跨平台 → 必须相对路径
print("配置中的路径(应绝对):", str(config_file.resolve()))
print("项目内引用(应相对) :", config_file.relative_to(script_dir))
"路径/文件名.txt" 这种写法在 Windows 上会因为 / 与 \ 混用而出现难以定位的路径错误;字符串里出现 .. 跨层、或用 os.path.join 混 pathlib,同样会埋雷。全项目统一用 pathlib.Path 运算符 /,是最省心的纪律。E4.2 文件编码、换行符与标准输出的跨平台差异
「在我机器上打印中文乱码」几乎总是编码问题。Python 3 有一条铁律:文本 I/O 必须显式指定 encoding。下面这些默认值在不同平台并不相同。
| 场景 | Windows 默认 | Linux / macOS 默认 | 安全写法 |
|---|---|---|---|
open() 读文本 | locale 编码(常见 GBK/CP936) | UTF-8 | open(p, encoding="utf-8") |
open() 写文本 | 同上 | UTF-8 | 同上 |
| 文件换行符 | \r\n | \n | newline="" 或显式指定 |
print 到终端 | 可能是 GBK,中文 emoji 报 UnicodeEncodeError | UTF-8 | PYTHONIOENCODING=utf-8 |
subprocess 传参 | 参数是 Unicode,底层走 wide char | 字节数组 | 统一用 list 传参,不用 shell 字符串 |
一个能正确处理所有平台的日志写入器
import io
import os
import subprocess
import sys
from pathlib import Path
def setup_utf8_output() -> None:
"""把标准输出/错误统一成 UTF-8 + 不换行缓冲,解决中文终端乱码。"""
for stream_name in ("stdout", "stderr"):
stream = getattr(sys, stream_name, None)
if stream is None or stream.encoding:
continue
buffer = getattr(stream, "buffer", None)
if buffer is not None:
setattr(sys, stream_name,
io.TextIOWrapper(buffer, encoding="utf-8", errors="replace"))
def write_log(path: Path, message: str) -> None:
"""跨平台写日志:显式编码 + 显式换行符 + 追加模式。"""
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8", newline="\n") as fh:
fh.write(message.rstrip("\n") + "\n") # 手工加 \n,避免平台差异
if __name__ == "__main__":
setup_utf8_output()
print("中文与 emoji 都能正常显示 🎉")
log = Path(__file__).resolve().parent / "logs" / "app.log"
write_log(log, "程序启动")
write_log(log, "写入中文测试")
print("日志路径:", log)
# 子进程传参:一律用列表,绝不手工拼 shell 字符串
out = subprocess.run([sys.executable, "-c", "print('子进程 OK')"],
capture_output=True, text=True, encoding="utf-8")
print("子进程输出:", out.stdout.strip())
# 验证字节层面确实是 \n(而不是 Windows 的 \r\n)
raw = log.read_bytes()
print("换行符是 LF:", b"\r\n" not in raw)
print("全文字节数:", len(raw))
encoding="utf-8",未来切换解释器时无需改动任何代码。E4.3 subprocess 调用外部命令的跨平台写法
调用 ping、git、ffprobe 这类外部命令时,Windows 与 Unix 的参数语法完全不同。唯一稳妥的做法是列表传参 + shell=False(默认值)。
同一段代码在三个平台都能跑的命令调用器
import shutil
import subprocess
import sys
from pathlib import Path
def run(cmd: list[str], *, timeout: float = 30.0) -> subprocess.CompletedProcess:
"""列表传参 + 超时 + 编码固定 + 不开 shell。"""
return subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
check=True,
)
def which_or_raise(name: str) -> str:
"""统一的可执行文件查找,替代裸 shutil.which 的裸调用。"""
found = shutil.which(name)
if not found:
raise FileNotFoundError(f"命令行工具 {name!r} 不在 PATH 中,请先安装")
return found
def repo_size(path: Path) -> int:
"""统计目录字节数,跳过 .git 与 .venv(平台无关的路径判断)。"""
total = 0
skip = {".git", ".venv", "__pycache__", "node_modules"}
for root, dirs, files in Path(path).rglob("*"):
if skip & set(Path(root).parts):
dirs[:] = [] # 就地剪枝,避免继续深入
continue
for f in files:
fp = Path(root) / f
if fp.is_file():
total += fp.stat().st_size
return total
if __name__ == "__main__":
print("解释器:", sys.executable)
# 用例 1:调用 Git,三平台一致
try:
git = which_or_raise("git")
res = run([git, "rev-parse", "--abbrev-ref", "HEAD"])
print("当前分支:", res.stdout.strip())
except (FileNotFoundError, subprocess.CalledProcessError) as e:
print("跳过 Git 检查:", e)
# 用例 2:Windows 与 Unix 的 ping 参数不同 → 分支处理,
# 但调用方式保持一致
if sys.platform == "win32":
ping_cmd = ["ping", "-n", "1", "-w", "1000", "127.0.0.1"]
else:
ping_cmd = ["ping", "-c", "1", "-W", "1", "127.0.0.1"]
try:
print("ping:", run(ping_cmd).stdout.strip().splitlines()[0])
except FileNotFoundError:
print("本机没有 ping 命令,跳过")
# 反例:shell 字符串 + shell=True 在任何平台都不该出现在业务代码里
# subprocess.run(f"git checkout {branch}", shell=True) # ← 分支名含空格就炸,
# # 且是命令注入入口
print("当前目录字节数:", repo_size(Path.cwd()))
shell=True 配合字符串拼接,既会在 Windows 上因引号规则不同而失效(cmd 与 PowerShell 语法不同),又构成命令注入(command injection)漏洞。除非你真的需要管道或重定向,否则永远用列表。真需要时,把命令写死成常量,不要拼接外部输入。E4.4 环境自检脚本:CI 与本地通用
把前面所有检查固化成一份自检脚本,它应该同时能被人手动运行、也能在 CI 里自动执行,并把结果以人类可读的文本或 JSON 输出。
完整的项目环境自检器
"""env_doctor.py —— 打印并校验当前 Python 环境,退出码即结论。
退出码约定(便于 CI 判断):
0 一切正常
1 一般警告(例如不在虚拟环境)
2 硬性错误(例如缺少必需依赖)
"""
from __future__ import annotations # 前向注解,字符串注解即原生写法
import importlib.util
import json
import os
import sys
from pathlib import Path
REQUIRED = ["rich"] # 运行时必需
DEV_ONLY = ["pytest", "ruff", "mypy"] # 开发期才需要
MIN_PYTHON = (3, 12) # 最低版本,按项目调整
def check_python_version() -> str | None:
if sys.version_info < MIN_PYTHON:
need = ".".join(map(str, MIN_PYTHON))
have = ".".join(map(str, sys.version_info[:3]))
return f"Python 版本过低:需要 >={need},当前 {have}"
return None
def check_venv() -> str | None:
if sys.prefix == sys.base_prefix:
return "不在虚拟环境中,请先激活 .venv 再运行"
return None
def check_packages(names: list[str], required: bool) -> list[str]:
problems = []
for name in names:
if importlib.util.find_spec(name) is None:
kind = "必需依赖" if required else "开发依赖"
problems.append(f"缺少{kind} {name}")
return problems
def check_paths() -> list[str]:
problems = []
for key in ("VIRTUAL_ENV", "PYTHONPATH", "PYTHONHOME"):
value = os.environ.get(key)
if value and key in ("PYTHONPATH", "PYTHONHOME"):
problems.append(f"环境变量 {key} 已设置({value}),可能污染导入")
if "" in sys.path or os.getcwd() in map(str, map(Path, sys.path[:1])):
pass # sys.path[0] 为空是脚本模式的正常现象,仅提示
return problems
def main() -> int:
hard: list[str] = []
soft: list[str] = []
for fn in (check_python_version, check_venv):
msg = fn()
if msg:
soft.append(msg)
hard += check_packages(REQUIRED, required=True)
soft += check_packages(DEV_ONLY, required=False)
soft += check_paths()
facts = {
"python": sys.version.split()[0],
"executable": sys.executable,
"in_venv": sys.prefix != sys.base_prefix,
"platform": sys.platform,
"cwd": os.getcwd(),
"site_packages": [p for p in sys.path if "packages" in p],
}
print(json.dumps(facts, ensure_ascii=False, indent=2))
for m in hard + soft:
print((" [错误] " if m in hard else " [警告] ") + m)
print("\n结论:" + ("不合格" if hard else "有警告" if soft else "全部通过"))
return 2 if hard else 1 if soft else 0
if __name__ == "__main__":
raise SystemExit(main()) # 把退出码交给 shell / CI
| 场景 | 现象 | 根因 | 修复 |
|---|---|---|---|
| 本地正常、CI 失败 | ModuleNotFoundError | 依赖没锁或没装全 | 用 lock 文件 + uv sync --frozen |
| 换目录运行就崩 | 找不到相对路径的文件 | 用了 os.getcwd() 定位资源 | 改用 Path(__file__).parent |
| Windows 中文乱码 | UnicodeDecodeError | 未指定 encoding | 全部 I/O 显式 utf-8 |
| 带空格路径下失败 | 子进程找不到文件 | shell=True 字符串拼接 | 改列表传参 |
| 「明明装了却用不了」 | 装到了另一套解释器 | pip 与 python 不匹配 | python -m pip install |
| 部署后行为不同 | 版本不一致的诡异错误 | 解释器版本漂移 | 锁定版本 + CI 中校验 |
Makefile、justfile 或 noxfile.py 上(例如 make doctor),团队里每个人都跑同一条命令。同时在 CI 的第一步也跑它——环境问题的平均修复时间会从「半小时互相问」降到「看一行日志」。E4.5 上手顺序建议
- 第 1 周:只做 3.12 解释器 + 编辑器 + 每个项目一个
.venv,本节内容先跳过。 - 第 1 个月:养成三个纪律——所有 I/O 显式
encoding、路径只用pathlib、装包只用python -m pip。 - 开始做项目后:引入
pyproject.toml声明依赖与[project.scripts]入口点,替换手写的目录拼接。 - 协作或部署时:引入
uv(或 pip-tools)生成锁文件,把自检脚本接进 CI。
本章小结
- 环境三件套:Python 3.12 解释器 + 一个编辑器(VS Code / PyCharm)+ 每个项目一个虚拟环境
.venv。 - 安装校验:
python --version应输出 3.12.x;Windows 装解释器时务必勾选 Add python.exe to PATH。 - 执行链路:源码 → 记号流 → AST → 字节码(缓存为
__pycache__/*.pyc)→ Python 虚拟机逐条执行。 - 对象模型:变量是「指向对象的标签」,
a = b让两个名字指向同一个对象;这是后面理解可变/不可变的基础。 - 两种用法:REPL 适合试代码,
.py脚本适合真项目;python -i demo.py是好用的调试组合。 - 版本差异:print 是函数、
/是真除法、map返回迭代器——看到 Python 2 写法说明资料过时。 - 包管理:
pip install装包,requirements.txt记清单,永远装进虚拟环境而不是全局。 - 护栏写法:
if __name__ == "__main__":让文件既可运行又可被导入。 - 读报错:从最后一行读起,先看错误类型,再看文件和行号。
- 不要:路径含空格/中文;同时装多个解释器却不指定版本;往系统 Python 装包。
练习题
- 环境自检:编写一个脚本
env_check.py,打印当前解释器路径、Python 版本、操作系统平台、当前工作目录,并判断自己是否运行在虚拟环境中(检查sys.prefix != sys.base_prefix)。分别在虚拟环境内和激活前运行一次,对比输出差异。 - 参数解析:把
sys.argv的含义搞清楚,写一个脚本,运行时传入python greet.py 张三 88,输出「张三,你今年 88 岁」。要求对缺失参数和非法数字给出友好提示。 - 输出练习:只用
print的sep和end参数,把下面这行输出打印成2024-01-15 | 周一 | 晴天 | 25°C。print("2024", "01", "15", sep="-") print("周一", "晴天", "25°C", sep=" | ") - 逐行注释:写一个
first_steps.py,要求包含:导入math模块、定义一个函数、调用input、做一次类型转换、打印格式化后的结果、打印__file__与__name__。每一行都要写中文注释说明作用。 - 除法辨析:写出 4 个表达式,分别让
/与//、%配合,得到 3、3.5、1、-3.5 四种结果,并解释-7 // 2与-7 % 2的值(提示:向下取整,不是向零截断)。 - 报错猎人:故意写出 5 段有错的代码(如
print(1 + "1")、[1,2][9]、int("x")、undefined_var、1 / 0),逐一运行,把报错信息抄到笔记里并写下原因。 - 虚拟环境实战:创建一个
myenv虚拟环境,安装requests,用pip freeze生成requirements.txt,然后删除虚拟环境并用该文件在同名新环境里重建成功。 - 字节码观察:用
import dis; dis.dis()分别反汇编两个只有a + b与a + b + c差异的函数,数一数指令条数,说说为什么函数调用有固定开销。 - 改进小游戏:给
guess_number.py增加「难度选择」(简单 1-20 / 普通 1-100 / 困难 1-1000),限制最多 7 次机会,并统计平均猜测次数(用random.seed()固定随机数便于测试)。
import sys, os, platform
print("解释器:", sys.executable)
print("版本:", sys.version.split()[0])
print("平台:", platform.platform())
print("工作目录:", os.getcwd())
print("在虚拟环境中:", sys.prefix != sys.base_prefix)
sys.argv[0] 是脚本名,sys.argv[1:] 是用户参数,用 len(sys.argv) 与 try/except 做校验。
import sys
if len(sys.argv) < 3:
print("用法: python greet.py 名字 年龄"); sys.exit(1)
try:
age = int(sys.argv[2])
except ValueError:
print("年龄必须是整数"); sys.exit(1)
print(f"{sys.argv[1]},你今年 {age} 岁")
__file__(脚本路径)与 __name__(模块名,运行时为 "__main__")这两个内置变量,它们解释了 if __name__ == "__main__" 的原理。
import math
def circle_area(r):
return math.pi * r ** 2
r = float(input("请输入半径:"))
print(f"面积为 {circle_area(r):.2f}")
print(__file__, __name__)
7/2=3.5、7//2=3、7%2=1、-7/2=-3.5;-7//2 == -4、-7%2 == 1。口诀「余数与除数同号」。python -m venv myenv → 激活 → pip install requests → pip freeze > requirements.txt(Windows CMD 用 >,PowerShell 需 | Out-File -Encoding utf8)→ deactivate → 删除目录 → 重建 → pip install -r requirements.txt。RESUME、结尾都有 RETURN_VALUE(Python 3.11+ 为 3.12 常见)。结论:Python 函数调用不是零成本,热点循环里减少函数调用能带来可观提升。{"简单": 20, "普通": 100, "困难": 1000} 存难度;超过次数上限提示正确答案并退出;用 random.seed(42) 让随机序列可复现,便于写断言测试。