本章

基础篇 / 第 1 章

第 1 章 · 环境搭建与第一行代码

本章导读:Python 的学习不需要折腾半天配置,本章带你在 10 分钟内装好解释器、建好虚拟环境、敲下第一行 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 直接返回元组
要点Python 是动态类型语言:类型属于「值」而不是「变量名」。x = 1 之后写 x = "a" 完全合法,因为 x 只是换了张标签。
技巧选编辑器时优先看两件事:有没有实时补全、有没有一键运行。Python 的报错信息对新手非常友好,一个能自动补全的编辑器能让你少查十次文档。

安装 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)。安装向导第一屏有一个默认未勾选的复选框:

常见坑「Add python.exe to PATH」没勾是新用户最高频的报错来源。症状是在命令行敲 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
要点本教程全部基于 Python 3.12。3.10 引入了结构化模式匹配与 X | Y 类型写法,3.11 大幅提升性能,3.12 带来 f-string 嵌套引号放宽、type 语句等改进。下面是版本特性速查,看不懂先有个印象即可。
版本重要变化什么时候能用
3.8:= 海象运算符、f"{x=}"新项目不必再兼容
3.9内置泛型 list[int]、字典合并 |类型注解写法简化
3.10match 结构化模式匹配、X | Y 联合类型写状态机/解析器很香
3.11性能提升 10–60%、异常分组 except*解释器明显更快
3.12type 语句、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
常见坑Windows 的 Microsoft Store 版本会弹出一个「应用别名」商店页面而不是命令行。where python 出现两条路径、或版本与你刚装的不一致,说明机器上有多个解释器,请用绝对路径或 py -3.12 明确指定。Windows 上的官方启动器命令是 py -3.12 --version。

2.5 常见安装故障速查表

现象原因解决
'python' 不是内部或外部命令没勾 PATH 或终端没重开重装勾 PATH,或手动改环境变量
Store 页面弹出App Execution Alias 拦截设置 → 应用 → 高级应用设置 → 应用执行别名,关掉 python.exe
command not found: pythonmacOS/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.ps1Set-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
常见坑Linux 上直接 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 装完之后必须做的三件小事

  1. 确认版本:python --version 与 python3 --version 都试试;macOS/Linux 上 python 可能根本不存在,这是正常的。
  2. 升级 pip:python -m pip install --upgrade pip。用 python -m pip 而非裸 pip,保证装到当前解释器对应的那个 pip。
  3. 装一个第三方包验证:python -m pip install requests 然后 python -c "import requests; print(requests.__version__)"。

理解 Python 的执行方式

Python 解释器拿到一个 .py 文件后到底做了什么?理解这条链路,能解释你未来会遇到的绝大多数「诡异现象」,比如为什么修改代码不生效、为什么 __pycache__ 突然出现。

3.1 从源码到字节码

  1. 词法分析(tokenize):把源码切成「记号流」,例如 x、=、1。
  2. 语法分析(parse):按语法规则把记号组织成抽象语法树(AST, Abstract Syntax Tree),像一棵树一样描述程序结构。
  3. 编译(compile):把 AST 编译成字节码(bytecode),即一串面向虚拟机的低层指令。
  4. 执行(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正式写代码、写项目从上到下逐行执行
交互增强 REPLpython -i demo.py跑完脚本后停在 REPL 里查状态调试神器
Jupyter Notebookjupyter lab数据分析、教学本质是「可反复执行的 .py 文件」
JupyterLab / IPythonipython需要补全、语法高亮的 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] —— 两个名字,一个列表
技巧REPL 里输入 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开启开发者模式警告(暴露隐患)
技巧写自动化 shell 脚本时,习惯性地用 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
常见坑Python 3 的 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)。
要点永远不要往系统 Python 里 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 CommunityIDEJava 转 Python、需要导航/重构的人社区版免费
PyCharm ProIDEDjango、远程开发、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 文件,右上角会出现 ▷ 运行按钮
要点新手阶段一个编辑器就够。不要同时学 Vim 快捷键和 IDE 插件,注意力分散反而拖慢进度。等第 22 章讲工程化时再逐步引入 ruff(格式化)、mypy(类型检查)、pytest(测试)。

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语句 print "hi"函数 print("hi")
除法7/2 == 37/2 == 3.5,整除用 //
整数int / long统一 int,任意精度
字典遍历d.iteritems()d.items()(返回视图)
字符串bytes 是 strstr 与 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}  字典推导式
常见坑中文网络教程仍大量使用 Python 2 语法。看到 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                 退出调试器
常见坑调试最花时间的往往不是设断点,而是改完代码忘了保存,然后发现断点位置始终对不上。养成「Ctrl+S 再运行」的习惯,能省下大量困惑。

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 第一周自检清单

读完本章,下面这些命令你应该都能不看资料敲出来。做不到就回去重读对应小节。

  1. 打印 Python 版本、解释器路径、当前工作目录。
  2. 创建一个名为 .venv 的虚拟环境并激活,知道怎么退出。
  3. 在虚拟环境里安装一个包并确认它来自虚拟环境而不是全局。
  4. 写一个脚本接收两个命令行参数,计算它们的和。
  5. 用 python -i 在脚本结束后停在 REPL 里查看变量。
  6. 故意制造一个 TypeError 并读懂它。
  7. 用 dis 反汇编一个简单函数。
  8. 说出 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 / evalcode 对象任意插件系统、模板引擎
反汇编dis.discode 对象指令文本看优化结果、排查性能

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])
偏移长度字段含义失效判定作用
04magic number字节码版本标记,含 cpython-312 字样版本不符 → 直接重编译
44flags0 = 校验模式(时间戳+大小);1 = 时间戳模式决定后面读哪两个字段
84mtime源文件最后修改时间(秒级)与源文件当前 mtime 比
124size源文件字节数与源文件当前大小比
16不定marshal 数据序列化的 code 对象(常量、名字表、字节码)真正的字节码从这里开始
要点校验模式(hash-based pyc,--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标准库模块的导入与初始化启动阶段数十 ms3.11+ 默认内建,无需操作
技巧python -X importtime -c "import requests" 会打印每一层导入的耗时(单位为微秒),最后一行 cumulative 最大的是瓶颈。注意它统计的是导入,不含解释器自身启动;两者要分开看,下一节专门讲启动开销。

E1.5 动手实验清单

  1. 把 python -c "import mymod" 的结果与 python -v -c "import mymod" 对照,观察 __pycache__ 是在哪一步被写入的。
  2. 手动改一次 demo.py 的 mtime 但不改内容,观察 assert 是否仍能命中缓存;再用 --check-hash-based-pycs always 跑一次对比。
  3. 用 dis.dis(compile(src, "x", "exec")) 对比 "exec" 与 "eval" 两种模式产出的指令差异。
  4. 用 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'"。
技巧需要「无论从哪运行都能 import 到自己的模块」时,用包内相对导入或在项目根用 python -m 运行;命令行工具则在 pyproject.toml 里声明 [project.scripts] 入口点,装完后由 pip 生成一个 .venv/bin/你的命令 启动器,它会自动把 CWD 设为脚本目录——这是工程上最干净的解法。

E2.5 动手实验清单

  1. 用 measure() 分别测 import re 与 import requests,量化本机上的差距。
  2. 用 -X importtime 找出你项目里最慢的 5 个导入,然后改成函数内延迟导入,再测一次冷启动。
  3. 在虚拟环境里加一个空的 zzz.py 验证 -S 的效果,再故意 import requests 观察报错,确认自己理解了副作用。
  4. 建 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:原始解释器的根,指向系统 Python
  • sys.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/python3proj/.venv/bin/python你调用了哪个 python
sys.prefix/usrproj/.venvpyvenv.cfg
sys.base_prefix/usr/usr(不变)解释器内置
site-packages/usr/lib/python3/dist-packagesproj/.venv/lib/python3.12/site-packagessite.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-packagespyvenv.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 + venvuv差距来源
创建环境python -m venv(秒级)uv venv(毫秒级)uv 直接写文件,不跑 ensurepip
装包解析较慢,串行下载并行下载、缓存命中极快uv 有全局 HTTP 缓存与硬链接安装
版本锁定需额外工具(pip-tools / poetry)内置 uv.lockuv 用 Rust 重写了解析器
解释器管理需 pyenv 或手动装内置 uv pythonuv 自带独立解释器下载与发现
运行即用对环境必须先 activateuv 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 动手实验清单

  1. 建两个虚拟环境,分别装不同版本的同名包,打印 pip list 与 module.__file__,亲眼确认互不干扰。
  2. 故意用全局 pip install 装一个包,再运行 env_report(),理解 in_venv=False 时的风险。
  3. 用一个带 include-system-site-packages = true 的环境,验证它能看到系统包,据此解释「本地好好的、服务器上崩」的一类故障。
  4. 安装 uv,用 uv init + uv add + uv sync + uv run 走一遍,对比它与 pip+venv 的命令差异。

跨平台与可复现:从入口点到环境自检的一份检查清单

最后一节把前四节的原理收成一件事:让同一份代码在你的电脑、队友电脑、服务器、Docker 里跑出同样结果。这是「代码写完了却到处出问题」的唯一根源,也是从「会写 Python」到「能交付 Python」的分水岭。本节给出可执行的检查清单与脚本,而不是抽象原则。

E4.1 三个环境必须记录的三件事

任何一次「在我这能跑」的争论,最终都会落到三个问题上:哪个解释器、哪个依赖、什么路径。把这三件事显式记录并固定下来,跨平台问题就消失了大半。

维度必须固定的内容记录方式不固定的后果
解释器版本精确到次版本,如 3.12.7.python-version / requires-python3.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-8open(p, encoding="utf-8")
open() 写文本同上UTF-8同上
文件换行符\r\n\nnewline="" 或显式指定
print 到终端可能是 GBK,中文 emoji 报 UnicodeEncodeErrorUTF-8PYTHONIOENCODING=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))
要点Windows 上 Python 3.6 以后仍然默认使用 locale 编码打开文件,但 PEP 597 与 3.15 的计划正把默认推向 UTF-8。提前统一写死 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. 第 1 周:只做 3.12 解释器 + 编辑器 + 每个项目一个 .venv,本节内容先跳过。
  2. 第 1 个月:养成三个纪律——所有 I/O 显式 encoding、路径只用 pathlib、装包只用 python -m pip。
  3. 开始做项目后:引入 pyproject.toml 声明依赖与 [project.scripts] 入口点,替换手写的目录拼接。
  4. 协作或部署时:引入 uv(或 pip-tools)生成锁文件,把自检脚本接进 CI。
要点本章所有「深入内容」的落点其实是同一句话:Python 的环境问题几乎全部源于「隐式状态」——隐式的解释器、隐式的路径、隐式的编码、隐式的缓存。看清这些隐式状态并把它们显式化(版本文件、锁文件、显式编码、显式路径、显式自检),跨平台与可复现就从「运气」变成「工程」。

本章小结

  • 环境三件套: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 装包。

练习题

  1. 环境自检:编写一个脚本 env_check.py,打印当前解释器路径、Python 版本、操作系统平台、当前工作目录,并判断自己是否运行在虚拟环境中(检查 sys.prefix != sys.base_prefix)。分别在虚拟环境内和激活前运行一次,对比输出差异。
  2. 参考答案要点
    import sys, os, platform
    print("解释器:", sys.executable)
    print("版本:", sys.version.split()[0])
    print("平台:", platform.platform())
    print("工作目录:", os.getcwd())
    print("在虚拟环境中:", sys.prefix != sys.base_prefix)
  3. 参数解析:把 sys.argv 的含义搞清楚,写一个脚本,运行时传入 python greet.py 张三 88,输出「张三,你今年 88 岁」。要求对缺失参数和非法数字给出友好提示。
  4. 参考答案要点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} 岁")
  5. 输出练习:只用 print 的 sep 和 end 参数,把下面这行输出打印成 2024-01-15 | 周一 | 晴天 | 25°C。
    print("2024", "01", "15", sep="-")
    print("周一", "晴天", "25°C", sep=" | ")
  6. 逐行注释:写一个 first_steps.py,要求包含:导入 math 模块、定义一个函数、调用 input、做一次类型转换、打印格式化后的结果、打印 __file__ 与 __name__。每一行都要写中文注释说明作用。
  7. 参考答案要点核心是 __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__)
  8. 除法辨析:写出 4 个表达式,分别让 / 与 //、% 配合,得到 3、3.5、1、-3.5 四种结果,并解释 -7 // 2 与 -7 % 2 的值(提示:向下取整,不是向零截断)。
  9. 参考答案要点7/2=3.5、7//2=3、7%2=1、-7/2=-3.5;-7//2 == -4、-7%2 == 1。口诀「余数与除数同号」。
  10. 报错猎人:故意写出 5 段有错的代码(如 print(1 + "1")、[1,2][9]、int("x")、undefined_var、1 / 0),逐一运行,把报错信息抄到笔记里并写下原因。
  11. 参考答案要点分别对应 TypeError / IndexError / ValueError / NameError / ZeroDivisionError。练习目标是能一眼区分「语法错误(SyntaxError,代码本身不合法)」和「运行时错误(类型、名称、数值问题)」。
  12. 虚拟环境实战:创建一个 myenv 虚拟环境,安装 requests,用 pip freeze 生成 requirements.txt,然后删除虚拟环境并用该文件在同名新环境里重建成功。
  13. 参考答案要点流程:python -m venv myenv → 激活 → pip install requests → pip freeze > requirements.txt(Windows CMD 用 >,PowerShell 需 | Out-File -Encoding utf8)→ deactivate → 删除目录 → 重建 → pip install -r requirements.txt。
  14. 字节码观察:用 import dis; dis.dis() 分别反汇编两个只有 a + b 与 a + b + c 差异的函数,数一数指令条数,说说为什么函数调用有固定开销。
  15. 参考答案要点每个函数体开头都有 RESUME、结尾都有 RETURN_VALUE(Python 3.11+ 为 3.12 常见)。结论:Python 函数调用不是零成本,热点循环里减少函数调用能带来可观提升。
  16. 改进小游戏:给 guess_number.py 增加「难度选择」(简单 1-20 / 普通 1-100 / 困难 1-1000),限制最多 7 次机会,并统计平均猜测次数(用 random.seed() 固定随机数便于测试)。
  17. 参考答案要点用字典 {"简单": 20, "普通": 100, "困难": 1000} 存难度;超过次数上限提示正确答案并退出;用 random.seed(42) 让随机序列可复现,便于写断言测试。
Python 完全教程 · 第 1 章