类型注解 type hints 实战

Python 从 3.5 起就支持类型注解(type hints)。这是这门动态语言现代化最重要的一步——让代码在保持灵活性的同时,获得静态类型检查的好处:IDE 精准补全、bug 早发现、文档即代码。这也是这个 50 篇教程的最后一站——学会类型注解,你就完成了从”能写 Python”到”写好 Python”的进化。

一、为什么要用类型注解

看这段没有注解的代码:

1
2
def process(data, options):
...

调用方一头雾水:data 是列表?字典?options 是什么形式?只能翻源码猜。

加上注解:

1
2
def process(data: list[dict], options: ProcessOptions) -> Result:
...

一目了然:data 是”字典的列表”,options 是某个类,返回 Result。IDE 也能自动补全 data[0]. 后的字段。

注解不改变运行时行为——Python 不会真的检查。它是给 IDE + mypy/pyright 看的。

二、基础语法

1
2
3
4
5
6
7
8
9
10
name: str = "咖飞"
age: int = 3
prices: list[float] = [3.14, 2.71]
data: dict[str, int] = {"a": 1, "b": 2}

def add(a: int, b: int) -> int:
return a + b

def greet(name: str) -> None:
print(f"hi {name}")
  • 变量:名字: 类型 = 值= 值 可选);
  • 函数参数:参数: 类型
  • 返回值:-> 类型
  • 没有返回值用 -> None

三、常用内置类型

1
2
3
4
5
6
7
8
9
10
11
12
int          # 整数
float # 浮点
str # 字符串
bool # 布尔
bytes # 字节
None # 空值
list[X] # 元素为 X 的列表
tuple[X, Y] # 定长元组
tuple[X, ...] # 可变长同类型元组
dict[K, V] # 字典
set[X] # 集合
frozenset[X]

Python 3.9+ 直接用小写内置类型:list[int]。3.8 及以下要 from typing import ListList[int])。

四、Optional 与 Union

Optional[X] 表示”X 或 None”:

1
2
3
4
5
6
def find_user(id: int) -> Optional[User]:
"""找不到时返回 None。"""

# Python 3.10+ 更简洁:
def find_user(id: int) -> User | None:
...

Union[A, B] 表示”A 或 B”:

1
2
3
4
5
6
def parse(x: Union[str, int]) -> str:
return str(x)

# Python 3.10+
def parse(x: str | int) -> str:
...

五、Any:放弃类型检查

Any 表示”任意类型”——类型检查器会跳过它

1
2
3
4
from typing import Any

def call_anything(obj: Any) -> Any:
return obj.something() # 类型检查器不管

用得越少越好——它是”我不知道类型”的最后手段。

六、Callable:函数类型

1
2
3
4
5
6
7
8
from typing import Callable

def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
return func(a, b)

# 参数任意
def apply_any(func: Callable[..., int]) -> int:
return func()

七、Literal:字面量类型

限定值只能是几个特定值:

1
2
3
4
5
6
7
from typing import Literal

def align(text: str, side: Literal["left", "right", "center"]) -> str:
...

align("hi", "left") # ✅
align("hi", "top") # ⚠️ 类型检查报错

八、TypedDict:字典的字段约束

普通字典 dict[str, Any] 太宽松。给字典字段加约束用 TypedDict

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from typing import TypedDict

class UserDict(TypedDict):
name: str
age: int
email: str


def process(u: UserDict) -> None:
print(u["name"], u["age"])


process({"name": "咖飞", "age": 3, "email": "x@y.com"}) # ✅
process({"name": "咖飞", "age": 3}) # ⚠️ 缺 email

九、协议 Protocol:结构化类型

前面 抽象类与接口 讲过。让你能表达”有某个方法就行”的类型:

1
2
3
4
5
6
7
8
from typing import Protocol

class SupportsClose(Protocol):
def close(self) -> None: ...


def cleanup(x: SupportsClose) -> None:
x.close()

cleanup 接受任何有 close 方法的对象——不需要显式继承 SupportsClose。

十、泛型:TypeVar

想让函数”接受任意类型 T,返回同样的 T”:

1
2
3
4
5
6
7
8
9
10
from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T:
return items[0]


x: int = first([1, 2, 3]) # 类型检查器知道返回 int
s: str = first(["a", "b", "c"]) # 知道返回 str

约束泛型

1
2
3
4
5
6
7
8
9
10
from typing import TypeVar

Number = TypeVar("Number", int, float)

def double(x: Number) -> Number:
return x * 2

double(3) # int
double(3.14) # float
double("a") # ⚠️ 报错

Python 3.12+ 新语法(更简洁):

1
2
def first[T](items: list[T]) -> T:
return items[0]

十一、泛型类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from typing import Generic, TypeVar

T = TypeVar("T")

class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []

def push(self, item: T) -> None:
self._items.append(item)

def pop(self) -> T:
return self._items.pop()


s: Stack[int] = Stack()
s.push(1)
s.push("hi") # ⚠️ 报错

Python 3.12+:

1
2
class Stack[T]:
...

十二、dataclass 与类型注解

@dataclass 完美搭配注解——注解就是字段声明:

1
2
3
4
5
6
7
8
9
10
11
from dataclasses import dataclass

@dataclass
class User:
name: str
age: int
email: str = ""


u = User("咖飞", 3)
print(u.name) # IDE 补全

十三、装饰器与类型

装饰器保留类型信息需要一些技巧。简单的:

1
2
3
4
5
6
7
8
9
10
11
from typing import Callable, TypeVar
from functools import wraps

F = TypeVar("F", bound=Callable)

def log(func: F) -> F:
@wraps(func)
def wrapper(*args, **kwargs):
print(f"call {func.__name__}")
return func(*args, **kwargs)
return wrapper # 类型检查器认为返回类型和 func 一样

Python 3.10+ 的 ParamSpec 让参数类型精确传递:

1
2
3
4
5
6
7
8
9
from typing import ParamSpec, TypeVar, Callable

P = ParamSpec("P")
R = TypeVar("R")

def log(func: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func(*args, **kwargs)
return wrapper

十四、mypy:静态类型检查器

安装:

1
pip install mypy

用:

1
2
mypy my_script.py
mypy src/

会报出所有类型不匹配。可以配置严格程度:

1
2
3
4
5
# pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
warn_unused_ignores = true

类型注解只有配合 mypy(或 pyright/pylance)才有价值——单纯写注解不检查等于什么都没做。

十五、pyright / pylance

VS Code 的 Python 插件用 pylance(基于 pyright)实时检查类型——你写代码时红色波浪线就会立即出现。比 mypy 命令行更即时。

十六、TypeAlias 与 NewType

1
2
3
4
5
6
7
8
9
10
11
12
from typing import TypeAlias, NewType

# 别名
Vector: TypeAlias = list[float]

# NewType 是"逻辑上不同"的类型
UserId = NewType("UserId", int)

def get_user(uid: UserId) -> None: ...

get_user(123) # ⚠️ 是 int,不是 UserId
get_user(UserId(123)) # ✅

Python 3.12+ 新语法:

1
type Vector = list[float]

十七、Self(Python 3.11+)

方法里返回 self 类型:

1
2
3
4
5
6
7
8
9
from typing import Self

class Builder:
def add(self, x: int) -> Self:
self.items.append(x)
return self


b = Builder().add(1).add(2) # 链式,类型检查器知道每步返回 Builder

十八、注解的运行时用途

虽然默认不检查,但可以用 typing.get_type_hints 反射:

1
2
3
4
5
6
from typing import get_type_hints

def f(a: int, b: str) -> bool:
return True

print(get_type_hints(f)) # {'a': <class 'int'>, 'b': <class 'str'>, 'return': <class 'bool'>}

pydanticdataclassesFastAPI 都用这个来运行时校验数据。

十九、渐进式类型

Python 的类型注解是渐进式的——不用一次全部加上。策略:

  1. 先给公开 API 加(函数签名、类字段);
  2. 再给核心逻辑加
  3. 最后处理边缘代码
  4. 对无法确定的暂时用 Any

二十、类型注解的成本 vs 收益

成本:多打字、初期学习曲线。

收益

  • IDE 补全大幅提升;
  • 重构安全(改类型 mypy 立刻告诉你哪里坏);
  • 参数类型清晰,代码即文档;
  • 早发现 bug(None 传给不接受 None 的地方);
  • 团队协作更顺畅。

规则:任何超过 100 行的项目,都值得加类型注解。

二十一、一个完整例子:类型化的小工具库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# utils.py
"""一个类型注解齐全的工具库。"""

from __future__ import annotations

from collections.abc import Iterable, Callable
from typing import TypeVar, Protocol
from dataclasses import dataclass
from datetime import datetime


T = TypeVar("T")
K = TypeVar("K")


# ---------- 数据类 ----------

@dataclass
class Result[T]: # Python 3.12+ 泛型语法
ok: bool
value: T | None = None
error: str | None = None


# ---------- 协议 ----------

class Comparable(Protocol):
def __lt__(self, other: object) -> bool: ...


# ---------- 泛型工具 ----------

def first(items: Iterable[T], default: T | None = None) -> T | None:
for x in items:
return x
return default


def group_by[K, T](items: Iterable[T], key: Callable[[T], K]) -> dict[K, list[T]]:
result: dict[K, list[T]] = {}
for x in items:
result.setdefault(key(x), []).append(x)
return result


def find_max[T: Comparable](items: Iterable[T]) -> T | None:
it = iter(items)
try:
best = next(it)
except StopIteration:
return None
for x in it:
if best < x:
best = x
return best


# ---------- 使用 ----------

if __name__ == "__main__":
nums: list[int] = [3, 1, 4, 1, 5, 9, 2]
print(find_max(nums)) # 9
print(first([], default=-1)) # -1
print(group_by(["ap", "bc", "ad", "bb"], key=lambda s: s[0]))
# {'a': ['ap', 'ad'], 'b': ['bc', 'bb']}

二十二、常见陷阱

  1. 注解字符串(Forward Reference):类内部引用自己时要引号或 from __future__ import annotations
  2. 循环导入:注解触发的 import 陷入循环。放 if TYPE_CHECKING: 里。
  3. runtime 不检查:mypy 报错但代码还是能跑。别以为报了错就不能运行。
  4. Any 传染:一处 Any 会让检查失效一大片。
  5. 过度用泛型:小项目用不到。用得越多,读者理解成本越高。
  6. isinstance(x, list[int]):不能这么写——运行时 list[int] 不能用于 isinstance。用 isinstance(x, list)
  7. 注解与运行时行为不一致def f(x: int) -> None: 传字符串照跑不误——类型只是”建议”。

二十三、小结与延伸阅读

  • 类型注解是”运行时不检查、给工具看”的元数据;
  • 基础:intstrlist[X]dict[K, V]T | None
  • 泛型:TypeVarGeneric、3.12+ [T] 语法;
  • 复杂类型:CallableProtocolLiteralTypedDictNewType
  • @dataclass 时字段声明就是注解;
  • 装 mypy / 用 pylance 做静态检查;
  • 渐进式加,先公开 API,后内部;
  • 团队项目强烈推荐。

延伸阅读:


🎉 50 篇完结感言

Python 简介 开始,历经环境搭建、基础语法、数据结构、函数与模块、面向对象、异常与文件、迭代与生成器、装饰器、标准库、日志、HTTP、并发、异步、到今天的类型注解——50 篇文章正式结束

如果你从头到尾跟着敲了一遍代码:

  • 你已经掌握了 Python 从入门到中级的完整技能栈;
  • 你能读懂大部分开源项目的代码;
  • 你有能力独立完成 Web 服务、爬虫、数据处理、自动化脚本;
  • 你理解了 GIL、协程、装饰器背后的原理。

接下来的路:

  • 深入某个方向:Web(FastAPI/Django)、数据(Pandas/NumPy)、AI(PyTorch/LangChain)、爬虫(Scrapy/Playwright);
  • 读源码:Flask、requests、Django 都是很好的阅读对象;
  • 写项目:任何”你想做的”东西——博客、量化、机器人、聊天室;
  • 参与开源:GitHub 上找 good-first-issue 试试贡献。

“人生苦短,我用 Python”。愿你在 Python 世界里享受写代码的快乐。

—— 咖飞,2025 年 9 月 30 日

异步编程 asyncio 入门

asyncio 是 Python 3.4+ 引入、3.7+ 大幅简化的异步编程框架。它让单线程处理上万个并发 IO 任务成为可能——网络爬虫、Web 服务、聊天服务器、实时数据流都是它的主场。这一篇讲清楚:什么是协程、async/await 怎么写、asyncio.run / gather / create_task 的区别、以及为什么 asyncio 比多线程更适合大并发。

一、异步的核心思想

同步:一次只做一件事,卡住就干等。

异步:一件事等 IO 时主动让出控制权,去做别的事,IO 好了再回来。

想象你在餐厅点了 3 道菜:

  • 同步:点第 1 道,坐着等做好;再点第 2 道,坐着等……做完 3 道要 30 分钟;
  • 异步:一次性下 3 道单,等哪道好了就上哪道。10 分钟就吃完。

asyncio 就是让你的程序像”下多道单”一样。

二、协程:async def

1
2
3
4
5
6
7
8
import asyncio

async def hello():
print("开始")
await asyncio.sleep(1) # 让出 1 秒
print("结束")

asyncio.run(hello())
  • async def 定义协程函数
  • 调用它得到协程对象(不会立即执行);
  • await 等待另一个协程完成(期间让出 CPU 给别的协程);
  • asyncio.run(coro) 启动事件循环,运行协程。

注意:不能在 async def 之外用 await(会 SyntaxError)。

三、并发运行:asyncio.gather

想让多个协程并发跑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import asyncio
import time


async def task(name, delay):
print(f"{name} 开始")
await asyncio.sleep(delay)
print(f"{name} 结束")
return name


async def main():
start = time.perf_counter()
results = await asyncio.gather(
task("A", 2),
task("B", 1),
task("C", 3),
)
print(f"耗时 {time.perf_counter() - start:.2f}s,结果 {results}")


asyncio.run(main())

输出:

1
2
3
4
5
6
7
A 开始
B 开始
C 开始
B 结束
A 结束
C 结束
耗时 3.00s,结果 ['A', 'B', 'C']

3 个任务并发跑,总耗时等于最慢的那个(不是三个相加)。

四、create_task:立即调度

1
2
3
4
5
async def main():
task1 = asyncio.create_task(task("A", 2)) # 立即调度
task2 = asyncio.create_task(task("B", 1))
result1 = await task1
result2 = await task2

create_task 让协程立即开始await 只是等它结束。

gather vs create_task

  • 想统一等一批任务全部完成 → gather
  • 想任务立即跑,之后再取结果 → create_task

五、asyncio.sleep vs time.sleep

永远别在 async 函数里用 time.sleep——它会阻塞整个事件循环:

1
2
3
4
5
async def bad():
time.sleep(1) # ❌ 整个事件循环卡 1 秒

async def good():
await asyncio.sleep(1) # ✅ 让出控制

六、await 只能等”可等待对象”

  • 协程async def 定义的函数调用返回);
  • Taskcreate_task 返回);
  • Future(底层原语);
  • 实现了 __await__ 的自定义对象。

普通函数调用不能 await

1
2
3
4
async def main():
time.sleep(1) # ❌ 阻塞,不 await
await time.sleep(1) # ❌ time.sleep 返回 None,不能 await
await asyncio.sleep(1) # ✅

七、异步 HTTP:httpx / aiohttp

爬 100 个 URL 的对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 顺序 requests
import requests
for url in urls:
r = requests.get(url)
# 100 秒

# 异步 httpx
import httpx
import asyncio

async def fetch(client, url):
r = await client.get(url)
return r.status_code

async def main():
async with httpx.AsyncClient() as client:
results = await asyncio.gather(*[fetch(client, u) for u in urls])
return results

asyncio.run(main())
# 1-2 秒

100 个并发几乎没成本——单线程!

八、超时

1
2
3
4
5
6
7
8
9
try:
await asyncio.wait_for(some_coro(), timeout=5)
except asyncio.TimeoutError:
print("超时")


# Python 3.11+ 更优雅
async with asyncio.timeout(5):
await some_coro()

九、错误处理

协程里的异常会传播到 await 处:

1
2
3
4
5
6
7
8
async def bad():
raise ValueError("boom")

async def main():
try:
await bad()
except ValueError as e:
print(f"抓到:{e}")

gather 默认在任何一个协程抛异常时立即抛出,其他协程被取消:

1
2
3
4
5
6
7
8
try:
await asyncio.gather(bad(), good())
except ValueError:
...

# 不想让一个失败拖累全部:
results = await asyncio.gather(bad(), good(), return_exceptions=True)
# results 里失败的项是异常对象

十、TaskGroup(Python 3.11+)

新语法,比 gather 更结构化:

1
2
3
4
5
6
async def main():
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(task("A", 2))
t2 = tg.create_task(task("B", 1))
# 出块时会等所有 task 完成
# 一个抛异常会取消其他,最后一起 ExceptionGroup

十一、Semaphore:限并发

不想同时 10000 个请求打爆对方:

1
2
3
4
5
6
7
8
9
sem = asyncio.Semaphore(20)      # 最多 20 个并发

async def fetch(url):
async with sem:
# 同时最多 20 个进这里
return await client.get(url)


await asyncio.gather(*[fetch(u) for u in urls])

十二、锁 / 事件 / 队列

跟 threading 类似,asyncio 也有对应的同步原语:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
lock = asyncio.Lock()
event = asyncio.Event()
queue = asyncio.Queue()

async def worker(q):
while True:
item = await q.get()
if item is None:
break
process(item)
q.task_done()

# 生产者-消费者
async def main():
q = asyncio.Queue()
workers = [asyncio.create_task(worker(q)) for _ in range(5)]
for item in items:
await q.put(item)
for _ in workers:
await q.put(None)
await asyncio.gather(*workers)

十三、在同步代码里跑异步

1
2
3
4
# ✅ 一次性入口
asyncio.run(main())

# ❌ 在已有 event loop 里再 asyncio.run 会报错

Jupyter Notebook 里已经有 loop,用 await 直接跑或 nest_asyncio 补丁。

十四、把同步代码”变异步”

有些库只有同步 API,不能 await。放到线程池里跑:

1
2
3
4
5
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, blocking_func, arg1, arg2)

# 或者 Python 3.9+:
result = await asyncio.to_thread(blocking_func, arg1, arg2)

这样阻塞的 requests 也能在异步程序里用(但慢得多,能用 httpx 就用 httpx)。

十五、asyncio vs threading vs multiprocessing

场景 推荐
大量 IO 并发(爬虫、Web、DB) asyncio
现有同步库为主的项目 threading
CPU 密集计算 multiprocessing
简单的”下 20 个请求” threading(更简单)
长期运行的服务器(10000+ 并发) asyncio

十六、常见异步库

  • httpx:HTTP,同步 + 异步;
  • aiohttp:HTTP + WebSocket;
  • aiofiles:文件 IO;
  • asyncpgaiomysqlmotor:数据库;
  • FastAPI:异步 Web 框架;
  • redis-py(4.x+):Redis 支持 async。

十七、一个完整例子:并发爬虫

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# crawler.py
"""异步并发爬取多个 URL,展示 asyncio 的力量。"""

import asyncio
import httpx
import time


async def fetch(client, url, sem):
async with sem:
try:
r = await client.get(url, timeout=10)
return url, r.status_code, len(r.text)
except Exception as e:
return url, None, str(e)


async def main(urls, concurrency=20):
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient() as client:
tasks = [fetch(client, u, sem) for u in urls]
return await asyncio.gather(*tasks)


if __name__ == "__main__":
urls = [f"https://httpbin.org/status/{code}" for code in range(200, 300)]
start = time.perf_counter()
results = asyncio.run(main(urls))
print(f"共 {len(results)} 个,耗时 {time.perf_counter() - start:.2f}s")
ok = sum(1 for u, s, _ in results if s == 200)
print(f"200 状态:{ok}")

100 个请求几秒钟就能跑完。

十八、常见陷阱

  1. 在 async 函数里用 time.sleep:卡死事件循环。用 asyncio.sleep
  2. await 一个普通函数:报错。只能 await 协程/Task/Future。
  3. 协程创建了不 awaitRuntimeWarning: coroutine never awaited
  4. 在同步函数里用 async:只能通过 asyncio.run 入口。
  5. 一个协程被 await 多次:只能 await 一次;多次 await 用 create_task 包装。
  6. gather 里一个失败全挂:加 return_exceptions=True
  7. CPU 密集协程await 不会自动让出,一直占用事件循环。用 run_in_executor

十九、小结与延伸阅读

  • async def 定义协程,await 等待;
  • asyncio.run(main()) 是入口;
  • gather 并发跑一批,create_task 立即调度;
  • 用异步 IO 库(httpx、aiohttp、asyncpg);
  • 阻塞函数用 asyncio.to_thread
  • 限并发用 asyncio.Semaphore
  • 超时用 asyncio.timeoutwait_for
  • IO 密集用 asyncio;CPU 密集用 multiprocessing。

延伸阅读:

下一篇也是最后一篇 类型注解 type hints 实战 我们讲怎么让 Python 代码”看起来是静态类型语言”。

多进程 multiprocessing 基础

上一篇 多线程 threading 基础 讲了多线程受 GIL 限制,不适合 CPU 密集任务。这一篇讲多进程——每个进程一个独立 Python 解释器,各有自己的 GIL,真正并行。适合视频处理、数据分析、机器学习推理等 CPU 密集场景。

一、进程 vs 线程

对比项 线程 进程
共享内存 ✅ 天然共享 ❌ 各自独立,需要 IPC
创建开销 小(毫秒级) 大(几十毫秒到几百毫秒)
数据传递 直接引用 序列化后再发(pickle)
CPU 并行 ❌ 受 GIL 限制 ✅ 真正并行
崩溃影响 整个进程挂 只挂当前进程

规则

  • CPU 密集 → 多进程
  • IO 密集 → 多线程或异步

二、创建进程

1
2
3
4
5
6
7
8
9
from multiprocessing import Process

def worker(name):
print(f"{name} in pid {os.getpid()}")

if __name__ == "__main__":
p = Process(target=worker, args=("t1",))
p.start()
p.join()

关键if __name__ == "__main__": 不能省——尤其在 Windows/macOS 上。子进程会重新 import 主脚本,如果没有这个保护,会陷入无限递归创建进程。

三、ProcessPoolExecutor:Pythonic 姿势

跟线程池 API 完全一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from concurrent.futures import ProcessPoolExecutor
import math

def is_prime(n):
if n < 2:
return False
for i in range(2, int(math.sqrt(n)) + 1):
if n % i == 0:
return False
return True


if __name__ == "__main__":
numbers = range(10**6, 10**6 + 100)
with ProcessPoolExecutor(max_workers=4) as pool:
results = list(pool.map(is_prime, numbers))
print(sum(results))

max_workers 默认是 CPU 核心数。

四、Pool:老一代 API

multiprocessing.Pool 是更老的 API,功能类似:

1
2
3
4
from multiprocessing import Pool

with Pool(4) as p:
result = p.map(is_prime, numbers)

新代码优先用 ProcessPoolExecutor——它跟 ThreadPoolExecutor 的接口统一,方便切换。

五、任务函数的要求

传给多进程的函数必须能被 pickle

  • 可以:模块顶层的普通函数、类的方法(类必须是顶层的);
  • 不可以:lambda、内嵌函数、局部类的方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# ❌ Pool 里报错
def outer():
def inner(x):
return x * 2
with Pool(4) as p:
p.map(inner, range(10))

# ✅ 顶层函数
def double(x):
return x * 2

def outer():
with Pool(4) as p:
p.map(double, range(10))

六、共享数据

进程间不共享内存,要传数据用:

队列 Queue(多进程版)

1
2
3
4
5
6
7
8
9
10
11
from multiprocessing import Process, Queue

def worker(q):
q.put(42)

if __name__ == "__main__":
q = Queue()
p = Process(target=worker, args=(q,))
p.start()
p.join()
print(q.get()) # 42

Pipe:管道(双向通信)

1
2
3
from multiprocessing import Pipe

parent_conn, child_conn = Pipe()

Manager:共享对象

1
2
3
4
5
6
from multiprocessing import Manager

with Manager() as m:
shared_dict = m.dict()
shared_list = m.list()
# 多个进程可以像本地一样操作它们

Manager 比 Queue 慢——它把所有访问都变成 IPC 调用。只在需要”共享结构化状态”时用。

共享内存

Python 3.8+ 有更高性能的共享内存:

1
2
3
4
from multiprocessing.shared_memory import SharedMemory

shm = SharedMemory(create=True, size=1024)
shm.buf[0] = 42 # 直接操作字节

一般项目用不到,做高性能数据分析时才需要。

七、数据序列化开销

多进程之间传数据要经过 pickle——这是最大的性能陷阱:

  • 传大 numpy 数组:慢;
  • 传函数(lambda):不能 pickle,报错;
  • 传数据库连接、文件句柄:不能 pickle。

优化

  • 让子进程”自己去拿数据”(读文件而不是父进程传进来);
  • 数据分片,一次传一小片;
  • NumPy 数据用 shared_memory 或 Array/Value
  • 少用 Manager 共享结构。

八、进程池 vs 手动 Process

  • 进程池(Pool / ProcessPoolExecutor):推荐,自动管理生命周期;
  • 手动 Process:控制精细,适合特殊场景(长期后台进程)。

九、启动方式:fork vs spawn vs forkserver

  • fork(Linux/macOS 默认):复制父进程所有内存,快,但可能有资源泄漏;
  • spawn(Windows 唯一,macOS 3.8+ 默认):从头启动新进程,慢但干净;
  • forkserver:fork 一个”服务器进程”,其他都从它 fork。
1
2
import multiprocessing as mp
mp.set_start_method("spawn") # 显式设置

十、CPU 密集加速实测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def heavy(n):
return sum(i * i for i in range(n))


nums = [10**7] * 8

# 顺序
start = time.perf_counter()
list(map(heavy, nums))
print(f"顺序:{time.perf_counter() - start:.2f}s")

# 多线程(GIL 阻塞,基本没提升)
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=8) as p:
list(p.map(heavy, nums))
print(f"多线程:{time.perf_counter() - start:.2f}s")

# 多进程(4 核 CPU 上大概快 3-4 倍)
if __name__ == "__main__":
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=8) as p:
list(p.map(heavy, nums))
print(f"多进程:{time.perf_counter() - start:.2f}s")

十一、初始化每个进程

worker 进程启动时想做初始化(连数据库、加载模型):

1
2
3
4
5
6
7
8
9
def init_worker():
global model
model = load_expensive_model()

def worker(x):
return model.predict(x)

with ProcessPoolExecutor(max_workers=4, initializer=init_worker) as pool:
...

十二、任务超时

ProcessPoolExecutorfuture.result(timeout=...) 会等超时——但不会真的杀掉进程,只是抛 TimeoutError。想强制杀 worker,改用 pool.shutdown(cancel_futures=True)(Python 3.9+),或者用 pebble 第三方库。

十三、joblib:更简洁的并行

数据分析场景推荐 joblib(scikit-learn 用的库):

1
2
3
from joblib import Parallel, delayed

results = Parallel(n_jobs=-1)(delayed(heavy)(n) for n in nums)

n_jobs=-1 用所有核心。API 比 ProcessPoolExecutor 更紧凑。

十四、一个完整例子:批量图片处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# imgproc.py
"""对一个目录里的所有 JPG 做缩略图,多进程加速。"""

from PIL import Image
from pathlib import Path
from concurrent.futures import ProcessPoolExecutor, as_completed
import os


def make_thumb(src: Path, dst_dir: Path, size=(300, 300)) -> Path:
dst = dst_dir / src.name
with Image.open(src) as img:
img.thumbnail(size)
img.save(dst)
print(f"[pid={os.getpid()}] {src.name}{dst}")
return dst


def batch(src_dir: Path, dst_dir: Path, workers: int = None):
dst_dir.mkdir(parents=True, exist_ok=True)
imgs = list(src_dir.glob("*.jpg"))

with ProcessPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(make_thumb, p, dst_dir): p for p in imgs}
for f in as_completed(futures):
try:
f.result()
except Exception as e:
print(f"失败:{futures[f]} - {e}")


if __name__ == "__main__":
batch(Path("photos"), Path("thumbs"))

if __name__ == "__main__": 里调用是 Windows/macOS 上多进程的硬性要求

十五、常见陷阱

  1. if __name__ == "__main__"::Windows 上会陷入无限创建子进程。
  2. 传 lambda 给 Pool:pickle 报错。
  3. 进程数远超 CPU 核心:调度开销大,反而变慢。
  4. 传大数据:序列化开销比计算还大。让子进程自己读磁盘。
  5. 进程池里用 print:多个进程混着输出,看起来乱。用 logging 会好些。
  6. 共享 Manager 对象锁死:Manager 内部有锁,大量并发写会锁竞争。改用 shared_memory。
  7. 忘 shutdown:进程可能滞留。用 with 或显式 pool.shutdown()

十六、小结与延伸阅读

  • 多进程绕开 GIL,真正并行;
  • 优先用 ProcessPoolExecutor
  • 函数必须能 pickle(顶层普通函数);
  • 数据传递有序列化开销,尽量少传;
  • 进程数 = CPU 核心数附近;
  • 保护主入口 if __name__ == "__main__":
  • CPU 密集用它,IO 密集用 threading/asyncio。

延伸阅读:

下一篇 异步编程 asyncio 入门 我们讲现代 Python 里最流行的并发方式——协程。

多线程 threading 基础

程序想同时干多件事——比如同时下载 100 个 URL,或者一边读磁盘一边处理数据——就要用到并发。Python 的并发有三种主流方式:多线程(threading)多进程(multiprocessing)异步(asyncio)。这一篇讲多线程:什么时候用它、GIL 是什么、怎么用 threadingThreadPoolExecutor、共享数据的锁、以及常见陷阱。

一、什么是多线程

线程是操作系统调度的最小单位。一个进程里可以有多个线程,它们共享内存,可以并行执行代码(受 GIL 限制,见下节)。

Python 里创建一个线程:

1
2
3
4
5
6
7
8
9
10
11
import threading

def worker(name):
print(f"{name} 开始")
...
print(f"{name} 结束")


t = threading.Thread(target=worker, args=("t1",))
t.start() # 启动
t.join() # 等待结束

join() 让主线程等待子线程结束。多个线程:

1
2
3
4
5
threads = [threading.Thread(target=worker, args=(f"t{i}",)) for i in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()

二、GIL:Python 多线程的枷锁

GIL(Global Interpreter Lock) 是 CPython 里的一把全局锁——同一时刻只有一个线程能执行 Python 字节码

含义:

  • CPU 密集型任务(大量计算),多线程不能加速(甚至更慢);
  • IO 密集型任务(读文件、发 HTTP、访问数据库),多线程能显著加速——因为 IO 等待时会释放 GIL。

规则:

  • 计算密集 → 用 multiprocessingnumpy/cython 等原生库
  • IO 密集 → 用 threadingasyncio

Python 3.13 引入了实验性的 PEP 703(无 GIL) 构建,但目前生产用还是有 GIL 的版本。

三、ThreadPoolExecutor:更 Pythonic

几乎所有场景都推荐用 concurrent.futures 而不是手动 Thread

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests


def fetch(url):
r = requests.get(url, timeout=5)
return url, r.status_code


urls = ["https://httpbin.org/get"] * 20

with ThreadPoolExecutor(max_workers=10) as pool:
# map:按顺序返回
for url, status in pool.map(fetch, urls):
print(url, status)

# 或者用 submit + as_completed(完成一个处理一个)
futures = [pool.submit(fetch, url) for url in urls]
for future in as_completed(futures):
url, status = future.result()
print(url, status)

好处:

  • 自动管理线程池——不会一次开 100 个线程;
  • with 自动清理;
  • API 简洁;
  • 支持超时、取消、异常传递。

四、Future 对象

submit 返回 Future,代表”未来的结果”:

1
2
3
4
5
6
7
future = pool.submit(fetch, url)

future.done() # 是否完成
future.cancel() # 尝试取消
future.result(timeout=5) # 阻塞取结果,抛异常也会传上来
future.exception() # 取异常(如果有)
future.add_done_callback(lambda f: print("完成"))

五、共享数据:Lock

多个线程读写同一份数据要加锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
counter = 0
lock = threading.Lock()

def increment():
global counter
for _ in range(100000):
with lock: # 加锁块
counter += 1

threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()

print(counter) # 应该是 400000

不加锁的话,counter += 1 不是原子的——多线程会覆盖彼此的写。

其他同步原语:

  • RLock:可重入锁(同一线程可多次加);
  • Semaphore:信号量(限制并发数);
  • Event:一个线程等另一个 set;
  • Condition:条件变量。

六、queue.Queue:线程安全的队列

生产者-消费者模式的最佳搭档:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import queue
import threading
import time


def producer(q):
for i in range(10):
print(f"生产 {i}")
q.put(i)
time.sleep(0.1)
q.put(None) # 结束信号


def consumer(q):
while True:
item = q.get()
if item is None:
break
print(f"消费 {item}")


q = queue.Queue()
t1 = threading.Thread(target=producer, args=(q,))
t2 = threading.Thread(target=consumer, args=(q,))
t1.start(); t2.start()
t1.join(); t2.join()

queue.Queue 内部有锁,多线程安全。

七、守护线程

主线程退出时,普通线程会阻塞退出,守护线程会跟着挂

1
2
3
t = threading.Thread(target=worker, daemon=True)
t.start()
# 主线程退出时,t 也跟着挂(不管是否完成)

适合”后台心跳”、”定时任务”这种”退出就退出”的场景。

八、限制并发数:Semaphore

1
2
3
4
5
6
sem = threading.Semaphore(5)

def limited_task():
with sem:
# 同一时刻最多 5 个在这里
do_stuff()

ThreadPoolExecutor(max_workers=5) 已经内置了这个限制,不用另外加信号量。

九、线程本地存储

想让每个线程有独立的”变量”(比如线程各自的数据库连接):

1
2
3
4
5
local = threading.local()

def worker():
local.value = threading.current_thread().name # 每个线程独立
print(local.value)

十、错误处理

线程里未捕获的异常不会传到主线程

1
2
3
4
5
6
def bad():
raise RuntimeError("boom")

t = threading.Thread(target=bad)
t.start()
t.join() # 什么都不发生,异常"消失"了

用 ThreadPoolExecutor 就好——future.result() 会重抛异常。

十一、什么时候用 threading vs asyncio

场景 推荐
已有大量同步库(requests、mysql-connector)想并发 threading
从零开始的 IO 密集项目 asyncio + httpx
需要与 GUI 交互 threading(GUI 主线程不能阻塞)
处理外部子进程/系统调用 threading 或 subprocess
大量并发连接(10000+) asyncio

asyncio 的具体讲解见 异步编程 asyncio 入门

十二、性能测试对比

用 20 个 HTTP 请求做基准:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import time
import requests
from concurrent.futures import ThreadPoolExecutor

urls = ["https://httpbin.org/delay/1"] * 20

# 顺序:约 22 秒
start = time.perf_counter()
for u in urls:
requests.get(u)
print(f"顺序:{time.perf_counter() - start:.1f}s")

# 多线程:约 1-2 秒
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=20) as pool:
list(pool.map(requests.get, urls))
print(f"多线程:{time.perf_counter() - start:.1f}s")

多线程能把 IO 密集任务加速 10-100 倍。

十三、一个完整例子:批量下载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# downloader.py
"""并发下载多个 URL 到本地文件。"""

import requests
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
import logging

logger = logging.getLogger(__name__)


def download(url: str, out_dir: Path, timeout: float = 30) -> Path | None:
"""下载单个 URL 到 out_dir,返回本地路径。"""
try:
with requests.get(url, timeout=timeout, stream=True) as r:
r.raise_for_status()
fname = url.split("/")[-1] or "index.html"
out = out_dir / fname
with out.open("wb") as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
logger.info("下载 %s → %s", url, out)
return out
except Exception as e:
logger.error("下载 %s 失败:%s", url, e)
return None


def batch_download(urls: list[str], out_dir: Path, workers: int = 8) -> list[Path]:
out_dir.mkdir(parents=True, exist_ok=True)
results = []

with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(download, u, out_dir): u for u in urls}
for future in as_completed(futures):
path = future.result()
if path:
results.append(path)

return results


if __name__ == "__main__":
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(threadName)s %(message)s")

urls = [
"https://httpbin.org/uuid",
"https://httpbin.org/ip",
"https://httpbin.org/user-agent",
] * 5

downloaded = batch_download(urls, Path("downloads"), workers=5)
print(f"共下载 {len(downloaded)} 个文件")

十四、常见陷阱

  1. CPU 密集用多线程:GIL 让它更慢。用 multiprocessing。
  2. 共享数据不加锁counter += 1 竞态,数据错乱。
  3. 在子线程访问 GUI:Qt / Tkinter 都不允许,会崩。
  4. 线程数远超 CPU 核心:上下文切换开销大,反而变慢。IO 密集可以开 50-100 个,别开 10000。
  5. join():主线程先退出,子线程被杀。
  6. 子线程异常”消失”:用 ThreadPoolExecutor + future.result()
  7. daemon 线程写文件:主进程退出可能导致文件损坏(写到一半就挂)。

十五、小结与延伸阅读

  • 多线程适合IO 密集,不适合CPU 密集(因为 GIL);
  • 优先用 concurrent.futures.ThreadPoolExecutor
  • 共享数据加 threading.Lock
  • 队列用 queue.Queue
  • 每个线程独立变量用 threading.local
  • 异常靠 future.result() 传递;
  • 现代新项目建议 asyncio 替代多线程。

延伸阅读:

下一篇 多进程 multiprocessing 基础 我们讲怎么绕开 GIL 做 CPU 密集任务。

HTTP 请求:requests 库入门

Python 内置有 urllib.request 能发 HTTP 请求,但 API 又老又难用。requests 库是 Python 生态里公认最好的 HTTP 客户端——“HTTP for Humans”,让发请求变得跟说话一样自然。这一篇讲清 requests 的日常使用:GET / POST、参数、headers、认证、超时、Session、以及处理响应的正确姿势。

一、安装

1
pip install requests

二、最简单的 GET

1
2
3
4
5
6
7
import requests

r = requests.get("https://httpbin.org/get")

print(r.status_code) # 200
print(r.text) # 响应内容(字符串)
print(r.json()) # 直接解析成 dict

三、常用响应属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
r = requests.get(url)

r.status_code # 200
r.reason # 'OK'
r.headers # 响应头字典
r.text # 文本内容(str)
r.content # 二进制内容(bytes)
r.json() # JSON 解析
r.url # 最终 URL(重定向后)
r.encoding # 猜测的编码
r.cookies # 服务器 set 的 cookies
r.elapsed # 耗时(timedelta)
r.ok # True 如果 status_code < 400
r.raise_for_status() # 4xx/5xx 抛异常

四、URL 参数(GET)

1
2
3
4
5
6
7
8
9
10
# ❌ 手动拼
requests.get(f"https://api.com/search?q={q}&page=1")

# ✅ 用 params
r = requests.get("https://api.com/search", params={
"q": "python",
"page": 1,
"tags": ["web", "async"], # 会展开成多个 tags=web&tags=async
})
print(r.url)

requests 会自动 URL 编码。

五、POST 请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 表单
r = requests.post("https://httpbin.org/post", data={"name": "咖飞", "age": 3})

# JSON body(更常见)
r = requests.post("https://httpbin.org/post", json={"name": "咖飞", "age": 3})

# 上传文件
with open("photo.jpg", "rb") as f:
r = requests.post("https://httpbin.org/post", files={"photo": f})

# 组合
r = requests.post(url,
files={"photo": open("photo.jpg", "rb")},
data={"caption": "咖飞的照片"},
)

datajson 的区别

  • data=dict:application/x-www-form-urlencoded;
  • json=dict:application/json,自动 JSON 序列化,自动加 header。

六、headers

1
2
3
4
5
r = requests.get(url, headers={
"User-Agent": "MyApp/1.0",
"Accept": "application/json",
"Authorization": "Bearer xxx",
})

七、超时

永远设置超时

1
2
r = requests.get(url, timeout=5)         # 总超时 5 秒
r = requests.get(url, timeout=(3, 10)) # (连接超时, 读取超时)

不设的话可能被服务器”吊死”到永远。

八、错误处理

1
2
3
4
5
6
7
8
9
10
11
try:
r = requests.get(url, timeout=5)
r.raise_for_status() # 4xx / 5xx 抛 HTTPError
except requests.Timeout:
print("超时")
except requests.ConnectionError:
print("连接失败")
except requests.HTTPError as e:
print(f"HTTP {e.response.status_code}")
except requests.RequestException as e:
print(f"其他 requests 异常:{e}")

requests.RequestException 是所有 requests 异常的根。

九、认证

1
2
3
4
5
6
7
# Basic Auth
r = requests.get(url, auth=("user", "password"))

# Bearer token
r = requests.get(url, headers={"Authorization": "Bearer xxx"})

# 复杂 auth 用 HTTPDigestAuth / HTTPProxyAuth 等

十、Session:复用连接和 cookies

1
2
3
4
5
6
7
8
9
with requests.Session() as s:
# 登录:cookie 自动保存
s.post("https://example.com/login", data={"user": "x", "pass": "y"})

# 后续请求自动带 cookie
r = s.get("https://example.com/profile")

# 全局默认 headers
s.headers.update({"User-Agent": "MyBot/1.0"})

好处:

  • 连接池复用(TCP 连接不用重新握手);
  • 自动管理 cookies;
  • 统一 headers / auth。

批量请求同一个站点时永远用 Session——速度快 5-10 倍。

十一、重定向与代理

1
2
3
4
5
6
7
8
9
10
11
# 不跟随重定向
r = requests.get(url, allow_redirects=False)

# 代理
r = requests.get(url, proxies={
"http": "http://127.0.0.1:7890",
"https": "http://127.0.0.1:7890",
})

# SSL 校验(默认开,某些自签证书场景可关,但要谨慎)
r = requests.get(url, verify=False)

十二、流式下载大文件

1
2
3
4
5
with requests.get(url, stream=True) as r:
r.raise_for_status()
with open("bigfile.zip", "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)

stream=True 让 requests 不立刻下载整个 body,而是按块。

十三、上传大文件(流式)

1
2
3
# 内存不够时不能 `open(path).read()`
with open("bigfile.zip", "rb") as f:
r = requests.post(url, data=f) # 直接传文件对象

十四、超时重试

requests 本身没重试。用 urllib3.Retry 或自己写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry


def make_session():
s = requests.Session()
retry = Retry(
total=3,
backoff_factor=1, # 1s, 2s, 4s
status_forcelist=[500, 502, 503, 504],
allowed_methods=["GET", "POST"],
)
adapter = HTTPAdapter(max_retries=retry)
s.mount("http://", adapter)
s.mount("https://", adapter)
return s

或者用第三方 tenacity

1
2
3
4
5
6
7
from tenacity import retry, stop_after_attempt, wait_exponential

@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10))
def fetch(url):
r = requests.get(url, timeout=5)
r.raise_for_status()
return r

十五、异步 HTTP:httpx / aiohttp

requests 是同步库——一次请求会阻塞。想异步或高并发,用:

  • httpx:API 与 requests 兼容,支持同步和异步;
  • aiohttp:专注异步,更成熟。
1
2
3
4
5
6
7
# httpx 同步
import httpx
r = httpx.get(url)

# httpx 异步
async with httpx.AsyncClient() as client:
r = await client.get(url)

具体见 异步编程 asyncio 入门

十六、爬虫伦理与合规

用 requests 爬网站前请:

  • robots.txt(有些站点禁止爬);
  • 限速time.sleep(1) 或用 asyncio.Semaphore);
  • 加合理的 User-Agent(表明身份,不要伪装成浏览器);
  • 不爬对方付费/需要认证的内容;
  • 频率过高会被封 IP,甚至法律风险。

十七、一个完整例子:GitHub API 爬取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# github.py
"""用 requests 从 GitHub API 获取用户仓库信息。"""

import requests
from pathlib import Path
import json
import time


class GitHubClient:
BASE = "https://api.github.com"

def __init__(self, token: str | None = None):
self.s = requests.Session()
self.s.headers.update({
"Accept": "application/vnd.github+json",
"User-Agent": "MyPythonScript/1.0",
})
if token:
self.s.headers["Authorization"] = f"Bearer {token}"

def get(self, path: str, params: dict = None) -> dict | list:
r = self.s.get(f"{self.BASE}{path}", params=params, timeout=10)
# 处理限流
if r.status_code == 403 and "rate limit" in r.text.lower():
reset = int(r.headers.get("X-RateLimit-Reset", 0))
wait = max(0, reset - int(time.time()))
print(f"限流,等待 {wait} 秒")
time.sleep(wait + 1)
return self.get(path, params)
r.raise_for_status()
return r.json()

def user_repos(self, username: str) -> list:
return self.get(f"/users/{username}/repos", params={"per_page": 100})

def repo_readme(self, owner: str, repo: str) -> str:
r = self.get(f"/repos/{owner}/{repo}/readme")
# readme 内容是 base64
import base64
return base64.b64decode(r["content"]).decode("utf-8")


if __name__ == "__main__":
import os
client = GitHubClient(token=os.getenv("GITHUB_TOKEN"))
repos = client.user_repos("python")
for r in repos[:5]:
print(f"{r['name']:30}{r['stargazers_count']:>5} - {r.get('description', '')}")

十八、常见陷阱

  1. 忘设 timeout:请求永远卡住。
  2. raise_for_status:4xx/5xx 也会返回,容易误当成功。
  3. 循环里 requests.get 不用 Session:每次都握手,慢十倍。
  4. 下载大文件不用 stream:内存爆炸。
  5. response.text 编码不对:用 r.content.decode("utf-8") 显式解码,或先 r.encoding = "utf-8"
  6. SSL 校验关了走公网:中间人攻击风险。除非确定内网自签,别关。
  7. json 参数值含 datetime:TypeError。要么先转字符串,要么自定义 json 序列化。

十九、小结与延伸阅读

  • requests,用 get / post / put / delete
  • URL 参数用 params;body 用 jsondata
  • 永远设 timeout
  • raise_for_status() 检查 HTTP 状态;
  • 批量请求用 Session(连接池 + cookies);
  • 大文件用 stream=True + iter_content
  • 重试用 Retrytenacity
  • 异步用 httpxaiohttp

延伸阅读:

下一篇 多线程 threading 基础 我们讲怎么并发做 IO 密集型任务。

日志模块 logging 实战

print 是新手最爱的调试工具,但一到生产环境就成了灾难——没有时间戳、没法分级、没法按模块过滤、没法输出到文件……日志(logging)是任何”能长期运行的程序”的必备基础设施。Python 内置的 logging 模块功能极强,但学习曲线也陡(对新手不友好)。这一篇讲清楚:正确的日志姿势、常见的三种配置方式、生产环境的最佳实践。

一、为什么不能用 print

看这段”用 print 调试”的代码:

1
2
3
4
def do_task(user_id):
print(f"开始处理用户 {user_id}")
...
print(f"处理完成")

问题:

  • 没有时间戳;
  • 没有日志级别(哪些是错误,哪些是提示?);
  • 关不掉(生产环境不想看这些还得改代码);
  • 没法输出到文件;
  • 多进程一起打时序全乱;
  • 无法按模块过滤。

用 logging

1
2
3
4
5
6
7
import logging
logger = logging.getLogger(__name__)

def do_task(user_id):
logger.info(f"开始处理用户 {user_id}")
...
logger.info("处理完成")

同样一行,但获得所有优点。

二、五个日志级别

1
2
3
4
5
logger.debug("详细的调试信息")
logger.info("正常的运行信息")
logger.warning("警告,程序还能跑")
logger.error("错误,某个功能挂了")
logger.critical("严重错误,程序快挂了")

对应数字:DEBUG(10) < INFO(20) < WARNING(30) < ERROR(40) < CRITICAL(50)。

默认级别是 WARNING——所以直接 logger.info(...) 什么都不打印。要配置。

三、最简配置:basicConfig

新手一行代码搞定:

1
2
3
4
5
import logging

logging.basicConfig(level=logging.INFO)
logging.info("hello")
# INFO:root:hello

带时间戳:

1
2
3
4
5
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)

basicConfig 只对 root logger 生效,且只能调一次。所以只用来在脚本入口做初始化,别在库里用。

四、getLogger:正确使用日志的姿势

每个模块用自己的 logger(用 __name__),而不是直接用 logging.info

1
2
3
4
5
6
# mymodule.py
import logging
logger = logging.getLogger(__name__) # __name__ = 'mymodule'

def foo():
logger.info("foo called")

为什么

  • 日志里能看到”是哪个模块打的”;
  • 可以按模块配置级别(例如 myapp.db 打 DEBUG,其他 INFO);
  • 库不应该硬性配置日志——用户来配。

五、格式化占位符

1
format = "%(asctime)s %(levelname)-8s [%(name)s] %(message)s"

常用占位符:

占位符 含义
%(asctime)s 时间戳
%(levelname)s 级别名
%(name)s logger 名(模块名)
%(message)s 日志内容
%(filename)s 源文件
%(lineno)d 行号
%(funcName)s 函数名
%(process)d 进程 ID
%(thread)d 线程 ID

六、Handler:日志送到哪里

Logger 只是”入口”,真正把日志写出去的是 Handler

  • StreamHandler:终端;
  • FileHandler:文件;
  • RotatingFileHandler:按大小滚动;
  • TimedRotatingFileHandler:按时间滚动;
  • SysLogHandler:syslog;
  • SMTPHandler:邮件(错误告警);
  • HTTPHandler:HTTP 接口。

一个 logger 可以有多个 handler——同时输出到终端和文件很常见。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import logging

logger = logging.getLogger("myapp")
logger.setLevel(logging.DEBUG)

# 终端:INFO 及以上
console = logging.StreamHandler()
console.setLevel(logging.INFO)
console.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
logger.addHandler(console)

# 文件:DEBUG 及以上,滚动
from logging.handlers import RotatingFileHandler
file_handler = RotatingFileHandler("app.log", maxBytes=10*1024*1024, backupCount=5, encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)s [%(name)s:%(lineno)d] %(message)s"
))
logger.addHandler(file_handler)


logger.info("hello") # 终端 + 文件都有
logger.debug("debug") # 只有文件

七、异常日志

有异常时用 logger.exception——自动附加完整 traceback:

1
2
3
4
try:
do_stuff()
except Exception:
logger.exception("do_stuff 出错")

日志里会有类似:

1
2
3
4
5
ERROR do_stuff 出错
Traceback (most recent call last):
File "app.py", line 10, in do_stuff
...
ValueError: ...

logger.error(str(e)) 有用得多。

八、传参 vs 拼字符串

推荐%s + 参数而不是 f-string:

1
2
3
4
5
# ✅ 推荐
logger.info("user %s login from %s", user_id, ip)

# ❌ 不推荐(每次都格式化,即使日志级别没开)
logger.info(f"user {user_id} login from {ip}")

原因:如果 debug 日志没开启,logger.debug(msg, arg) 的格式化根本不会执行;f-string 无论如何都会先格式化。虽然差别不大,但大量 debug 日志时能省 20-30% CPU

九、字典配置

复杂配置用字典(dictConfig)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import logging.config

LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "%(asctime)s %(levelname)-8s [%(name)s] %(message)s",
},
"detailed": {
"format": "%(asctime)s %(levelname)-8s [%(name)s:%(lineno)d] %(funcName)s(): %(message)s",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": "INFO",
"formatter": "default",
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": "app.log",
"maxBytes": 10 * 1024 * 1024,
"backupCount": 5,
"encoding": "utf-8",
"level": "DEBUG",
"formatter": "detailed",
},
},
"loggers": {
"myapp": {
"level": "DEBUG",
"handlers": ["console", "file"],
"propagate": False,
},
"myapp.db": {
"level": "INFO",
},
},
"root": {
"level": "WARNING",
"handlers": ["console"],
},
}

logging.config.dictConfig(LOGGING)

字典配置的好处:

  • 全部信息一处写清;
  • 可从 JSON/YAML 加载;
  • 复杂多 logger 场景不容易乱。

十、logger 的层级

logger 名字用 . 分层:

  • myapp
  • myapp.db
  • myapp.db.query

配置 myapp 会自动作用于所有以 myapp. 开头的 logger(除非子 logger 显式覆盖)。

日志会沿着层级向上传播(propagate=True 默认),最终到 root。避免同一条日志被父子 logger 打印两次:给子 logger 设 propagate=False

十一、生产环境最佳实践

  1. 入口配置一次,模块用 getLogger(__name__)
  2. 格式化用 %s,不要 f-string
  3. DEBUG 到文件,INFO 到终端
  4. 文件按大小或时间滚动(RotatingFileHandler);
  5. 异常用 logger.exception
  6. 敏感信息不打日志(密码、令牌、身份证);
  7. 性能敏感的路径别打大量日志
  8. 多进程用 QueueHandler(避免文件写入冲突);
  9. 接错误告警(SMTPHandler、Sentry、云平台);
  10. 加请求 id / trace id(用 LoggerAdapter 或第三方 structlog)。

十二、第三方推荐

  • structlog:结构化日志(每条日志是 JSON),日志聚合平台首选;
  • loguru:极简 API,一行搞定日志;比原生 logging 好用得多;
  • rich:让终端日志变彩色、更好看;
  • Sentry / Datadog:错误监控。

loguru 的极简 API:

1
2
3
4
5
6
from loguru import logger

logger.info("hello")
logger.debug("debug 信息")
logger.add("app.log", rotation="10 MB") # 一行加文件 handler
logger.exception("出错了")

新项目非要用原生 logging,除非有历史包袱或严格要求。

十三、加自定义字段:LoggerAdapter

想在每条日志里带上 request_id、user_id 等业务字段:

1
2
3
4
5
6
7
8
9
class RequestAdapter(logging.LoggerAdapter):
def process(self, msg, kwargs):
return f"[req={self.extra['request_id']}] {msg}", kwargs


logger = logging.getLogger("myapp")
req_logger = RequestAdapter(logger, {"request_id": "abc-123"})
req_logger.info("处理请求")
# [req=abc-123] 处理请求

十四、一个完整例子:Web 服务日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# main.py
"""一个生产级的日志初始化脚本。"""

import logging
import logging.config
import sys
from pathlib import Path


def setup_logging(log_dir: Path, verbose: bool = False):
log_dir.mkdir(parents=True, exist_ok=True)

LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simple": {
"format": "%(asctime)s %(levelname)-8s %(name)s: %(message)s",
},
"detailed": {
"format": "%(asctime)s %(levelname)-8s [%(name)s:%(lineno)d] "
"%(funcName)s(): %(message)s",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": "DEBUG" if verbose else "INFO",
"formatter": "simple",
"stream": sys.stdout,
},
"app_file": {
"class": "logging.handlers.TimedRotatingFileHandler",
"filename": str(log_dir / "app.log"),
"when": "midnight",
"backupCount": 30,
"encoding": "utf-8",
"level": "DEBUG",
"formatter": "detailed",
},
"error_file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": str(log_dir / "error.log"),
"maxBytes": 20 * 1024 * 1024,
"backupCount": 10,
"encoding": "utf-8",
"level": "ERROR",
"formatter": "detailed",
},
},
"loggers": {
"myapp": {
"level": "DEBUG",
"handlers": ["console", "app_file", "error_file"],
"propagate": False,
},
"urllib3": {
"level": "WARNING", # 屏蔽 requests 的 DEBUG 噪音
},
},
"root": {
"level": "WARNING",
"handlers": ["console"],
},
}

logging.config.dictConfig(LOGGING)


if __name__ == "__main__":
setup_logging(Path("logs"), verbose=True)

logger = logging.getLogger("myapp")
logger.debug("debug 消息")
logger.info("服务启动")
try:
1 / 0
except Exception:
logger.exception("除零错误")

十五、常见陷阱

  1. logging.info() 而不是 logger.info():前者用 root logger,配置不清晰。
  2. 用 f-string 传消息:级别不开也执行格式化。用 %s + 参数。
  3. basicConfig 调多次:只有第一次生效。
  4. 多进程写同一文件:会乱序或丢日志。用 QueueHandler 或按进程分文件。
  5. DEBUG 日志上生产:文件涨飞,可能包含敏感信息。
  6. disable_existing_loggers: True(默认!):会禁用已定义的 logger。dictConfig 里推荐设 False
  7. propagate=True + 父子都有 handler:日志重复打。

十六、小结与延伸阅读

  • 每个模块用 logger = logging.getLogger(__name__)
  • 五级:DEBUG < INFO < WARNING < ERROR < CRITICAL;
  • 一个 logger 可以有多个 handler;
  • 生产环境:console + rotating file + error file;
  • 异常用 logger.exception
  • 传参用 %s + 参数
  • 复杂配置用 dictConfig
  • 新项目推荐 loguru,更简洁。

延伸阅读:

到这里模块六(中级进阶)10 篇结束! 下一篇进入 模块七:实用工具与并发,从 HTTP 请求:requests 库入门 开始。

常用标准库:itertools 与 functools

itertoolsfunctools 是 Python 里两个”看起来抽象、实际非常实用”的标准库。前者提供各种迭代器组合器,后者提供函数式编程工具。两者搭配起来,你能用几行代码解决”分组”、”分块”、”缓存”、”偏函数”这些常见问题。这一篇讲清楚它们各自最有用的 15 个 API。

一、itertools:迭代器工具箱

无限迭代器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from itertools import count, cycle, repeat

# 无限计数
for i in count(10, step=2): # 10, 12, 14, ...
if i > 20:
break
print(i)

# 无限循环
from itertools import islice
list(islice(cycle([1, 2, 3]), 8)) # [1, 2, 3, 1, 2, 3, 1, 2]

# 重复
list(repeat("x", 3)) # ['x', 'x', 'x']

# repeat 常用来配合 map
list(map(pow, [1, 2, 3], repeat(2))) # [1, 4, 9] 每个都平方

停止条件迭代器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from itertools import accumulate, chain, compress, dropwhile, takewhile, islice

# 累加(默认加法;可传 func)
list(accumulate([1, 2, 3, 4, 5])) # [1, 3, 6, 10, 15]
list(accumulate([1, 2, 3, 4], func=max)) # [1, 2, 3, 4] 滑动最大
import operator
list(accumulate([1, 2, 3, 4], operator.mul)) # [1, 2, 6, 24] 阶乘

# 拼接多个可迭代对象
list(chain([1, 2], [3, 4], [5])) # [1, 2, 3, 4, 5]
list(chain.from_iterable([[1, 2], [3, 4]])) # 展平一层

# 掩码筛选(按位)
list(compress("ABCDEF", [1, 0, 1, 0, 1, 0])) # ['A', 'C', 'E']

# 跳过直到条件为假
list(dropwhile(lambda x: x < 3, [1, 2, 3, 4, 1])) # [3, 4, 1]

# 取到条件为假为止
list(takewhile(lambda x: x < 3, [1, 2, 3, 4, 1])) # [1, 2]

# 切片
list(islice(range(100), 10, 20)) # [10..19]
list(islice(range(100), 5)) # [0..4]
list(islice(range(100), 0, None, 10)) # 每 10 个取一

组合迭代器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from itertools import product, permutations, combinations, combinations_with_replacement

# 笛卡尔积
list(product([1, 2], "ab"))
# [(1, 'a'), (1, 'b'), (2, 'a'), (2, 'b')]

list(product(range(3), repeat=2))
# 所有 2 位数组合

# 排列
list(permutations("abc"))
# [('a','b','c'), ('a','c','b'), ('b','a','c'), ...] 共 3! = 6 个

list(permutations("abc", 2)) # 长度 2

# 组合(不考虑顺序)
list(combinations("abcd", 2))
# [('a','b'), ('a','c'), ('a','d'), ('b','c'), ('b','d'), ('c','d')]

# 允许重复
list(combinations_with_replacement("abc", 2))
# [('a','a'), ('a','b'), ('a','c'), ('b','b'), ('b','c'), ('c','c')]

用途:暴力枚举、算法题、生成测试用例。

分组

1
2
3
4
5
6
7
8
9
from itertools import groupby

data = [("A", 1), ("A", 2), ("B", 3), ("B", 4), ("A", 5)]

for key, group in groupby(data, key=lambda x: x[0]):
print(key, list(group))
# A [('A', 1), ('A', 2)]
# B [('B', 3), ('B', 4)]
# A [('A', 5)] ← 注意:只按"连续"分组

注意groupby 只对相邻的相同 key 分组。要”全局分组”先 sorted

1
2
3
data.sort(key=lambda x: x[0])
for key, group in groupby(data, key=lambda x: x[0]):
print(key, list(group))

打包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from itertools import zip_longest, starmap, tee

# 以最长为准,短的补默认值
list(zip_longest([1, 2, 3], "ab", fillvalue="?"))
# [(1, 'a'), (2, 'b'), (3, '?')]

# 参数解包传给函数
list(starmap(pow, [(2, 3), (3, 2), (10, 2)]))
# [8, 9, 100] 相当于 [pow(2,3), pow(3,2), pow(10,2)]

# 分裂成 N 个独立迭代器
a, b = tee(range(5), 2)
list(a) # [0, 1, 2, 3, 4]
list(b) # [0, 1, 2, 3, 4]

注意tee 底层会缓存已消费的元素——两个迭代器差距大时内存开销大,慎用。

batched(Python 3.12+)

1
2
3
4
from itertools import batched

list(batched("ABCDEFG", 3))
# [('A', 'B', 'C'), ('D', 'E', 'F'), ('G',)]

批量分块神器,3.12 之前要自己写(见 生成器 yield 深入)。

二、functools:函数式工具

partial / partialmethod:偏函数

前面 匿名函数 lambda 与高阶函数 讲过:

1
2
3
4
5
6
7
8
9
10
from functools import partial

def send(url, method="GET", data=None):
print(url, method, data)

get = partial(send, method="GET")
post = partial(send, method="POST")

get("/api") # /api GET None
post("/api", data={"a": 1})

reduce:累积计算

1
2
3
4
5
6
7
8
9
from functools import reduce

reduce(lambda a, b: a + b, [1, 2, 3, 4]) # 10
reduce(lambda a, b: a * b, [1, 2, 3, 4], 100) # 2400,带初始值

# 用 operator 更快
import operator
reduce(operator.add, [1, 2, 3, 4]) # 10
reduce(operator.mul, [1, 2, 3, 4]) # 24

lru_cache / cache:函数缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from functools import lru_cache, cache

@lru_cache(maxsize=128)
def fib(n):
if n < 2:
return n
return fib(n-1) + fib(n-2)

fib(50) # 秒出

# Python 3.9+ 无界版
@cache
def slow(x):
...

# 缓存统计
fib.cache_info() # CacheInfo(hits=..., misses=..., maxsize=128, currsize=...)
fib.cache_clear()

要求:参数必须可哈希(list/dict/set 不行;元组/字符串/整数可以)。

wraps:保留元信息

前面 装饰器入门 讲过——写装饰器必备。

singledispatch:函数重载

Python 没有 Java 那种”同名不同参”重载,但用 singledispatch 可以按第一个参数的类型分派:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from functools import singledispatch

@singledispatch
def render(obj):
return f"未知类型:{obj}"

@render.register
def _(obj: int):
return f"整数:{obj}"

@render.register
def _(obj: str):
return f"字符串:{obj!r}"

@render.register(list)
def _(obj):
return f"列表,长度 {len(obj)}"


print(render(3)) # 整数:3
print(render("hi")) # 字符串:'hi'
print(render([1, 2, 3])) # 列表,长度 3
print(render(None)) # 未知类型:None

在类方法里用 singledispatchmethod

cached_property:类的懒加载属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from functools import cached_property

class Report:
def __init__(self, data):
self.data = data

@cached_property
def summary(self):
print("计算中...")
return sum(self.data)


r = Report([1, 2, 3])
r.summary # 计算中... → 6
r.summary # 直接返回 6,不再计算

第一次访问计算并存到实例 __dict__ 里,之后走属性查找。

total_ordering:补齐比较方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from functools import total_ordering

@total_ordering
class Version:
def __init__(self, major, minor):
self.major, self.minor = major, minor

def __eq__(self, other):
return (self.major, self.minor) == (other.major, other.minor)

def __lt__(self, other):
return (self.major, self.minor) < (other.major, other.minor)


# 自动生成 <=, >, >=, !=
Version(1, 5) < Version(2, 0) # True
Version(1, 5) >= Version(1, 5) # True

三、operator 模块

不属于 functools,但配套使用非常多:

1
2
3
4
5
6
7
8
9
10
11
from operator import add, sub, mul, itemgetter, attrgetter, methodcaller

# 排序 key
sorted(pairs, key=itemgetter(1))
sorted(users, key=attrgetter("age"))
sorted(records, key=itemgetter("category", "price")) # 多字段
sorted(names, key=methodcaller("lower"))

# 累加 / 累乘
reduce(add, [1, 2, 3])
reduce(mul, [1, 2, 3])

四、一些经典组合

分块(3.12 之前)

1
2
3
4
5
6
7
8
9
10
from itertools import islice

def chunked(iterable, size):
it = iter(iterable)
while chunk := tuple(islice(it, size)):
yield chunk


list(chunked(range(10), 3))
# [(0, 1, 2), (3, 4, 5), (6, 7, 8), (9,)]

找连续差值

1
2
3
4
5
from itertools import pairwise    # 3.10+

for a, b in pairwise([1, 3, 6, 10]):
print(b - a)
# 2 3 4

展平嵌套一层

1
2
3
4
from itertools import chain

flat = list(chain.from_iterable([[1, 2], [3, 4], [5]]))
# [1, 2, 3, 4, 5]

每个元素前面加 index

1
2
list(enumerate("abc"))                # 内置就行
list(zip(count(1), "abc")) # 用 itertools 也行

五、一个完整例子:拼接多个 CSV

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# combine.py
"""合并多个 CSV 文件,展示 itertools 的力量。"""

import csv
from pathlib import Path
from itertools import chain
from functools import reduce
from operator import iconcat


def read_rows(path: Path):
"""惰性地产出所有行。"""
with path.open(encoding="utf-8", newline="") as f:
reader = csv.reader(f)
header = next(reader, None)
for row in reader:
yield row


def combine(paths: list[Path], out: Path, header: list[str]):
"""把多个 CSV 拼成一个。"""
with out.open("w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(header)

# chain 拼接所有文件的行,惰性写出
for row in chain.from_iterable(read_rows(p) for p in paths):
writer.writerow(row)


if __name__ == "__main__":
files = sorted(Path("data").glob("part_*.csv"))
combine(files, Path("all.csv"), ["id", "name", "score"])

这个例子的关键:chain.from_iterable(generator) 让我们一行行拼接,不用把全部数据读进内存。100GB 数据也能跑。

六、性能提示

  • itertools 都是惰性的——不消费不计算;
  • 大部分是 C 实现,比手写 Python 循环快;
  • functools.lru_cache 对递归 / 幂等的重计算是”免费加速”;
  • operator.itemgetter/attrgetter 比 lambda 快 20-50%。

七、常见陷阱

  1. groupby 只按连续分组:忘了 sort 会漏。
  2. tee 内存:两个迭代器差距大时缓存变大。
  3. lru_cache 参数不可哈希:TypeError。用 tuple 化,或改用别的缓存。
  4. reduce 空序列没初始值:TypeError。永远提供 initial 或保证非空。
  5. chain(a, b) vs chain.from_iterable([a, b]):一个直接传多个,一个传”一个装着可迭代对象的可迭代对象”。
  6. permutations 数量爆炸:10 个元素的全排列有 362 万种,别乱调。

八、小结与延伸阅读

  • itertools:无限迭代(count/cycle/repeat)、组合(product/permutations/combinations)、分组(groupby)、切片(islice)、拼接(chain);
  • functoolspartial / reduce / lru_cache / cached_property / singledispatch / total_ordering / wraps
  • operatoritemgetter / attrgetter 加速排序;
  • 处理大数据、组合数学、缓存、装饰器都能派上用场。

延伸阅读:

下一篇 日志模块 logging 实战 我们讲怎么写规范的日志。

常用标准库:collections

Python 的 collections 模块提供了几个”比内置更高级”的数据结构。它们不是替代品,而是在特定场景下让代码更清晰、更快。前面几篇零散提到过 Counterdefaultdictdequenamedtuple——这一篇把 collections 里六大主角一次讲完。

一、模块概览

用途
Counter 计数专用字典
defaultdict 自动创建缺失 key 的字典
OrderedDict 有序字典(3.7+ 后普通 dict 也保序)
deque 双端队列,两头 O(1) 操作
namedtuple 带字段名的元组
ChainMap 多个字典的链式视图

二、Counter:计数专用字典

前面 字典 Dict 的使用 讲过:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from collections import Counter

words = ["a", "b", "a", "c", "b", "a"]
c = Counter(words)
print(c) # Counter({'a': 3, 'b': 2, 'c': 1})

# 直接用字符串(每个字符计数)
Counter("mississippi") # Counter({'i': 4, 's': 4, 'p': 2, 'm': 1})

# 常用方法
c.most_common(2) # [('a', 3), ('b', 2)]
c["z"] # 0,缺失 key 返回 0(不是 KeyError)
c.total() # 6(Python 3.10+)
c.update("aab") # 累加进去
c.subtract("bb") # 减

支持数学运算(元素级):

1
2
3
4
5
6
7
a = Counter(a=3, b=1)
b = Counter(a=1, b=2, c=5)

a + b # Counter({'c': 5, 'a': 4, 'b': 3})
a - b # Counter({'a': 2}) 保留正数
a & b # 交集:最小值
a | b # 并集:最大值

用途

  • 统计词频、字符频、访问量;
  • 找 Top N;
  • 集合的”多重集合”运算。

三、defaultdict:分组神器

自动创建缺失 key,无需先检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from collections import defaultdict

# 分组
groups = defaultdict(list)
for name, dept in employees:
groups[dept].append(name)

# 计数
counter = defaultdict(int)
for word in words:
counter[word] += 1

# 嵌套:dict of dict
matrix = defaultdict(lambda: defaultdict(int))
matrix["a"]["b"] += 1 # 自动初始化

参数是”如何创建缺失值”的可调用对象:

  • int → 缺省 0
  • list → 缺省空 list
  • set → 缺省空 set
  • lambda: ... → 任意逻辑

访问不存在的 key 会自动创建(如果不想触发,用 d.get(k) 而不是 d[k])。

四、OrderedDict:需要顺序还得靠它的时候

Python 3.7+ 普通 dict 已经保持插入顺序,但 OrderedDict 还有一些独有能力:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from collections import OrderedDict

od = OrderedDict()
od["a"] = 1
od["b"] = 2
od["c"] = 3

# 移到末尾(最近使用)
od.move_to_end("a") # OrderedDict([('b', 2), ('c', 3), ('a', 1)])
od.move_to_end("b", last=False) # 移到开头

# 弹出末尾/开头
od.popitem(last=True) # 弹末尾
od.popitem(last=False) # 弹开头

# 相等比较严格:顺序也要一样
OrderedDict(a=1, b=2) == OrderedDict(b=2, a=1) # False
dict(a=1, b=2) == dict(b=2, a=1) # True

典型应用:手写 LRU 缓存。

五、deque:双端队列

一个”两端都能 O(1) 增删”的容器。前面 列表 List 详解 提到过:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from collections import deque

q = deque()
q.append(1) # 右边加
q.append(2)
q.appendleft(0) # 左边加
print(q) # deque([0, 1, 2])

q.pop() # 右边弹 → 2
q.popleft() # 左边弹 → 0

# 限长队列(超过自动丢弃对端)
tail = deque(maxlen=3)
for i in range(10):
tail.append(i)
print(tail) # deque([7, 8, 9], maxlen=3) 只保留最后 3 个

# 旋转
q = deque([1, 2, 3, 4, 5])
q.rotate(2) # 向右旋转 2 位 → deque([4, 5, 1, 2, 3])
q.rotate(-1) # 向左

用途

  • 队列 / 双端队列;
  • 滑动窗口 / 保留最近 N 个;
  • BFS(广度优先搜索);
  • 循环缓冲。

六、namedtuple:给元组起名字

1
2
3
4
5
6
7
8
9
10
11
12
from collections import namedtuple

Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)

p.x, p.y # 3 4
p[0], p[1] # 也能索引
x, y = p # 解包

Point._fields # ('x', 'y')
p._replace(x=10) # 生成新元组(不可变)
p._asdict() # {'x': 3, 'y': 4}

更现代的 typing.NamedTuple 支持类型注解和方法:

1
2
3
4
5
6
7
8
from typing import NamedTuple

class Point(NamedTuple):
x: float
y: float

def distance(self, other):
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5

namedtuple vs dataclass

  • namedtuple:不可变、可解包、可索引、内存小;
  • dataclass:可变(或 frozen 可选)、可加复杂方法、更灵活。

小、只读、频繁创建 → namedtuple;有方法/可变 → dataclass。

七、ChainMap:多个字典的链式视图

想模拟”多层配置”(默认 → 用户 → 环境变量),一层层查找:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from collections import ChainMap

defaults = {"color": "red", "size": "M", "font": "Arial"}
user = {"color": "blue"}
env = {"size": "L"}

config = ChainMap(env, user, defaults) # 从前往后找
print(config["color"]) # 'blue' (在 user 里找到)
print(config["size"]) # 'L' (在 env 里找到)
print(config["font"]) # 'Arial' (fallback 到 defaults)

# 修改总是修改第一个字典
config["color"] = "green"
print(env) # {'size': 'L', 'color': 'green'}

用途:配置层叠、变量作用域模拟。

八、Counter 常见套路

Top N

1
Counter(words).most_common(10)

找出出现 1 次的元素

1
[k for k, v in Counter(items).items() if v == 1]

判断两个序列元素相同(不考虑顺序和重复)

1
Counter(a) == Counter(b)     # anagram 判断

找出多的元素

1
diff = Counter(list1) - Counter(list2)

九、defaultdict 常见套路

反向索引

1
2
3
index = defaultdict(list)
for i, tag in enumerate(tags):
index[tag].append(i)

图的邻接表

1
2
3
4
graph = defaultdict(set)
for u, v in edges:
graph[u].add(v)
graph[v].add(u)

两级分组

1
2
3
groups = defaultdict(lambda: defaultdict(list))
for record in records:
groups[record["region"]][record["type"]].append(record)

十、deque 常见套路

滑动窗口最大值(单调队列)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from collections import deque

def sliding_max(nums, k):
q = deque() # 存索引,保持值降序
result = []
for i, x in enumerate(nums):
while q and q[0] <= i - k:
q.popleft()
while q and nums[q[-1]] < x:
q.pop()
q.append(i)
if i >= k - 1:
result.append(nums[q[0]])
return result


print(sliding_max([1, 3, -1, -3, 5, 3, 6, 7], 3))
# [3, 3, 5, 5, 6, 7]

BFS

1
2
3
4
5
6
7
8
9
10
11
12
from collections import deque

def bfs(graph, start):
visited = {start}
queue = deque([start])
while queue:
node = queue.popleft()
yield node
for neighbor in graph[node]:
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)

十一、UserDict / UserList / UserString

想自定义 dict/list/str 但不想踩”直接继承内置类型”的坑:

1
2
3
4
5
6
7
8
9
10
from collections import UserDict

class LoggedDict(UserDict):
def __setitem__(self, key, value):
print(f"设置 {key}={value}")
super().__setitem__(key, value)


d = LoggedDict()
d["a"] = 1 # 设置 a=1

直接 class LoggedDict(dict) 也能用,但内置方法可能不走你的 __setitem__——比如 dict.update 就跳过。UserDict 是”纯 Python 实现”,所有操作都过你重写的方法。

十二、性能对比

  • defaultdict vs dict.setdefault:性能相近,defaultdict 稍快,代码更漂亮;
  • Counter vs 手写循环 + dict:Counter 稍慢一点,但简洁得多;
  • deque vs list:两端操作 deque 快 100 倍;随机索引 list 快;
  • namedtuple vs dataclass:namedtuple 更快、更省内存;dataclass 更灵活。

十三、一个完整例子:URL 访问统计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# stats.py
"""日志分析工具,展示 Counter / defaultdict / deque / namedtuple 的组合。"""

from collections import Counter, defaultdict, deque, namedtuple
from datetime import datetime


LogEntry = namedtuple("LogEntry", ["ts", "ip", "path", "status", "elapsed_ms"])


def parse_log(line: str) -> LogEntry | None:
"""
简化格式:
2025-09-27T10:00:00 1.2.3.4 GET /index 200 15
"""
parts = line.split()
if len(parts) != 6:
return None
return LogEntry(
ts=datetime.fromisoformat(parts[0]),
ip=parts[1],
path=parts[3],
status=int(parts[4]),
elapsed_ms=int(parts[5]),
)


def analyze(lines):
ip_counter = Counter()
path_by_status = defaultdict(Counter)
recent = deque(maxlen=100)
slow = deque(maxlen=5) # 最慢的 5 条

for line in lines:
entry = parse_log(line)
if entry is None:
continue

ip_counter[entry.ip] += 1
path_by_status[entry.status][entry.path] += 1
recent.append(entry)

# 保留最慢
slow.append(entry)
slow = deque(sorted(slow, key=lambda e: e.elapsed_ms, reverse=True)[:5], maxlen=5)

return {
"top_ips": ip_counter.most_common(10),
"errors_by_path": dict(path_by_status[500].most_common(5)),
"recent": list(recent)[-5:],
"slowest": list(slow),
}


if __name__ == "__main__":
sample = [
"2025-09-27T10:00:00 1.2.3.4 GET /a 200 12",
"2025-09-27T10:00:01 1.2.3.4 GET /b 200 20",
"2025-09-27T10:00:02 5.6.7.8 GET /a 500 150",
"2025-09-27T10:00:03 5.6.7.8 GET /c 200 500",
]
print(analyze(sample))

十四、常见陷阱

  1. defaultdict 触发副作用d[k] 会创建 key,即使你只想”查”。用 d.get(k) 只查。
  2. Counter 减法保留正数:如果想保留所有(含负),用 c.subtract(other)
  3. deque(maxlen=N):加入超出的元素时对端会被丢弃。别忘了。
  4. namedtuple 不可变:改字段用 ._replace() 返回新对象。
  5. OrderedDict 相等比较:顺序不同就 !=。
  6. ChainMap 修改:永远动第一个 dict。想改深层要指定:chain.maps[i][k] = ...

十五、小结与延伸阅读

  • Counter:计数、Top N、多重集合运算;
  • defaultdict:分组、计数、嵌套结构;
  • OrderedDict:需要 move_to_end / popitem(last) 时用;
  • deque:两端 O(1)、限长、滑动窗口、BFS;
  • namedtuple:轻量记录,比字典更结构化,比 class 更省;
  • ChainMap:多层配置查找;
  • UserDict/UserList/UserString:更安全的继承。

延伸阅读:

下一篇 常用标准库:itertools 与 functools 我们讲两个函数式工具库。

常用标准库:os、sys、pathlib

Python 标准库里有几个模块几乎每个项目都用得到:os 管系统与环境变量、sys 管 Python 解释器本身、pathlib 管路径。前面 文件读写操作 已经介绍过 pathlib 的基础。这一篇把三个模块日常最常用的功能串起来讲,让你以后写脚本时知道该找哪个

一、os 模块:跟系统打交道

环境变量

1
2
3
4
5
6
7
8
9
10
11
12
13
import os

# 读
os.environ["PATH"] # 存在则读;不存在抛 KeyError
os.environ.get("APP_ENV", "development") # 带默认值
os.getenv("APP_ENV", "development") # 同上,更常见

# 写(仅当前进程生效,退出即消失)
os.environ["APP_DEBUG"] = "1"

# 遍历
for key, value in os.environ.items():
print(key, value)

注意os.environ 的值永远是字符串。要 bool/int 自己转:

1
2
DEBUG = os.getenv("DEBUG", "0").lower() in ("1", "true", "yes")
PORT = int(os.getenv("PORT", 8080))

目录操作

1
2
3
4
5
6
7
8
os.getcwd()                          # 当前工作目录
os.chdir("/tmp") # 切换(用完记得切回)
os.listdir(".") # 列目录(返回文件名列表)
os.makedirs("a/b/c", exist_ok=True) # 递归建目录
os.mkdir("a") # 只建一层
os.rmdir("empty_dir") # 删空目录
os.rename("a.txt", "b.txt") # 重命名/移动
os.remove("file.txt") # 删文件

但现代 Python 推荐用 pathlib——见下一节。

os.path(老 API,逐步用 pathlib 取代)

1
2
3
4
5
6
7
8
9
os.path.join("a", "b", "c.txt")       # 'a/b/c.txt'
os.path.exists("file.txt")
os.path.isfile("file.txt")
os.path.isdir("dir")
os.path.abspath("relative") # → 绝对路径
os.path.dirname("/a/b/c.txt") # '/a/b'
os.path.basename("/a/b/c.txt") # 'c.txt'
os.path.splitext("c.txt") # ('c', '.txt')
os.path.getsize("file.txt") # 字节

同样功能 pathlib 全都有,代码更漂亮。

系统信息

1
2
3
4
5
os.cpu_count()                       # CPU 核心数
os.name # 'nt' (Windows) / 'posix' (Unix)
os.getpid() # 当前进程 PID
os.getppid() # 父进程 PID
os.getlogin() # 当前登录用户名

执行外部命令

1
2
3
4
5
6
os.system("ls -la")     # ❌ 老 API,不推荐(返回值只有退出码)

# ✅ 用 subprocess
import subprocess
result = subprocess.run(["ls", "-la"], capture_output=True, text=True)
print(result.stdout)

二、sys 模块:跟 Python 解释器打交道

命令行参数

1
2
3
4
5
import sys

sys.argv # 命令行参数列表,第一个是脚本名
# python script.py --name 咖飞 --age 3
# sys.argv = ['script.py', '--name', '咖飞', '--age', '3']

简单场景用 sys.argv,复杂用 argparse

1
2
3
4
5
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("input")
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()

标准输入输出错误

1
2
3
4
5
6
7
8
9
10
sys.stdin       # 标准输入
sys.stdout # 标准输出
sys.stderr # 标准错误

# 打印到 stderr
print("错误", file=sys.stderr)

# 逐行读输入
for line in sys.stdin:
print(line.rstrip())

退出

1
2
3
sys.exit()        # 正常退出
sys.exit(1) # 退出码 1(表示错误)
sys.exit("错误信息") # 打到 stderr,退出码 1

注意sys.exit() 内部抛 SystemExit 异常。所以 try / except: 会把它捕获——但 except Exception: 不会(因为 SystemExit 继承自 BaseException)。

Python 版本 / 平台

1
2
3
4
5
6
7
sys.version              # '3.13.0 (main, ...)'
sys.version_info # sys.version_info(major=3, minor=13, ...)
sys.platform # 'win32' / 'linux' / 'darwin'
sys.executable # 当前 Python 解释器路径

if sys.version_info < (3, 10):
raise RuntimeError("需要 Python 3.10+")

模块搜索路径

1
2
3
sys.path                 # 模块搜索路径列表
sys.path.append("/new/path") # 添加(不推荐,用 pip install -e . 更好)
sys.modules # 已导入的模块字典

递归深度

1
2
sys.getrecursionlimit()          # 默认 1000
sys.setrecursionlimit(5000) # 加深,慎用(栈溢出崩溃)

内存与对象

1
2
sys.getsizeof(obj)       # 对象占用字节数(浅)
sys.getrefcount(obj) # 引用计数(调试用)

三、pathlib:面向对象的路径操作

前面 文件读写操作 简单讲过,这里补充完整常用 API。

1
2
3
4
5
6
from pathlib import Path

# 构造
p = Path("data/input.txt")
p = Path.home() / "Documents" / "notes.md"
p = Path.cwd() # 当前目录

属性

1
2
3
4
5
6
7
8
p.name          # 'input.txt'
p.stem # 'input'
p.suffix # '.txt'
p.suffixes # ['.txt'](多后缀 like .tar.gz)
p.parent # PosixPath('data')
p.parents # 迭代所有上层
p.parts # ('data', 'input.txt')
p.is_absolute()

判断

1
2
3
4
p.exists()
p.is_file()
p.is_dir()
p.is_symlink()

读写(一步)

1
2
3
4
5
Path("greeting.txt").write_text("你好", encoding="utf-8")
Path("greeting.txt").read_text(encoding="utf-8")

Path("img.png").write_bytes(b"...")
Path("img.png").read_bytes()

目录

1
2
3
4
5
6
7
8
9
10
11
12
Path("dir").mkdir(parents=True, exist_ok=True)   # 递归+幂等
Path("empty_dir").rmdir() # 删空目录

# 遍历目录
for f in Path("logs").iterdir():
print(f)

for f in Path("logs").glob("*.log"): # 一层通配
print(f)

for f in Path("logs").rglob("*.log"): # 递归所有子目录
print(f)

变换

1
2
3
4
5
6
p.with_name("new.txt")
p.with_suffix(".json")
p.with_stem("newstem")
p.absolute() # → 绝对路径
p.resolve() # → 规范化(解析符号链接)
p.relative_to("/root") # 相对路径

复制/移动

pathlib 没有直接的 copy/move,用 shutil

1
2
3
4
5
6
import shutil

shutil.copy(src, dst)
shutil.copytree(src_dir, dst_dir)
shutil.move(src, dst)
shutil.rmtree(dir) # 递归删(危险)

Path.rename(target) 但不能跨设备:

1
Path("a.txt").rename("b.txt")

四、__file__ 和脚本自身路径

在一个模块里知道自己的位置:

1
2
3
4
5
6
7
from pathlib import Path

# 脚本自身
HERE = Path(__file__).resolve().parent

# 相对脚本的路径
CONFIG_PATH = HERE / "config.json"

别用相对路径,否则脚本在别的目录启动就崩。永远基于 __file__ 拼绝对路径。

五、os + pathlib 混用

有些 os 的功能 pathlib 没有:

1
2
3
4
5
6
7
8
9
# 环境变量
os.environ

# 系统调用
os.getpid()

# 权限
p.stat().st_mode # 得到权限位
os.chmod(p, 0o755) # pathlib 3.11+ 有 p.chmod

六、临时文件与目录

1
2
3
4
5
6
7
8
9
10
11
import tempfile

# 临时文件(自动删除)
with tempfile.NamedTemporaryFile("w", suffix=".txt", encoding="utf-8", delete=True) as f:
f.write("temp")
print(f.name)

# 临时目录
with tempfile.TemporaryDirectory() as d:
Path(d, "hello.txt").write_text("hi")
# 出块时目录清空

七、常用工具集

配置项统一读取

1
2
3
4
5
6
7
8
9
10
11
def env(key, default=None, cast=str):
val = os.environ.get(key, default)
if val is None:
return None
if cast == bool:
return str(val).lower() in ("1", "true", "yes")
return cast(val)


PORT = env("PORT", 8080, int)
DEBUG = env("DEBUG", False, bool)

项目根目录

1
2
3
PROJECT_ROOT = Path(__file__).resolve().parent.parent
CONFIG_DIR = PROJECT_ROOT / "config"
DATA_DIR = PROJECT_ROOT / "data"

找所有 .py

1
2
for p in Path("src").rglob("*.py"):
print(p)

统计目录大小

1
2
def total_size(root: Path) -> int:
return sum(f.stat().st_size for f in root.rglob("*") if f.is_file())

八、一个完整例子:项目文件整理脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# organize.py
"""按扩展名把一个目录里的文件归类到子目录。"""

from pathlib import Path
import shutil
import sys


CATEGORIES = {
"images": {".jpg", ".jpeg", ".png", ".gif", ".webp"},
"documents": {".pdf", ".doc", ".docx", ".xlsx", ".txt", ".md"},
"videos": {".mp4", ".mov", ".avi", ".mkv"},
"audios": {".mp3", ".wav", ".flac", ".m4a"},
"archives": {".zip", ".tar", ".gz", ".7z", ".rar"},
"code": {".py", ".js", ".ts", ".go", ".rs", ".c", ".cpp"},
}


def category(ext: str) -> str:
for cat, exts in CATEGORIES.items():
if ext.lower() in exts:
return cat
return "others"


def organize(target: Path, dry_run: bool = False):
if not target.is_dir():
print(f"{target} 不是目录", file=sys.stderr)
sys.exit(1)

for f in target.iterdir():
if not f.is_file():
continue
cat = category(f.suffix)
dest_dir = target / cat
dest_dir.mkdir(exist_ok=True)
dest = dest_dir / f.name

if dry_run:
print(f"[干跑] {f}{dest}")
else:
print(f"{f}{dest}")
shutil.move(str(f), str(dest))


if __name__ == "__main__":
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.home() / "Downloads"
dry_run = "--dry-run" in sys.argv
organize(target, dry_run=dry_run)

九、常见陷阱

  1. os.environ 值只是字符串:需要转 int/bool。
  2. os.chdir 副作用:全局改工作目录,多线程/协程时容易乱。
  3. 相对路径依赖启动目录:永远基于 __file__ 拼绝对路径。
  4. Path 拼接用 /:不是 +
  5. shutil.rmtree 是核弹:写错路径可能删掉整个磁盘。加确认或用 send2trash 库送回收站。
  6. sys.exit 抛异常:会被 try/except: 捕获,用 except Exception: 不会。
  7. Windows 路径与正斜杠:pathlib 会自动处理,但注意 str(p) 在 Windows 上会用反斜杠。

十、小结与延伸阅读

  • os:环境变量、进程、目录(旧 API);
  • sys:命令行参数、退出码、Python 版本、模块路径;
  • pathlib:面向对象的路径,优先用它取代 os.path
  • 复制/删目录用 shutil
  • 临时文件用 tempfile
  • 项目路径统一从 __file__ 出发;
  • 环境变量值都是字符串,手动转。

延伸阅读:

下一篇 常用标准库:collections 我们讲更高级的数据结构。

JSON 数据处理

JSON 是当今世界最常用的数据交换格式——API 返回、配置文件、日志、数据库字段……几乎无处不在。Python 内置的 json 模块非常够用。这一篇讲清楚 json 模块的四个核心函数、编码/解码规则、自定义序列化、以及处理”日期时间”、”Decimal”、”自定义类”这些默认不支持的类型的技巧。

一、四个核心函数

1
2
3
4
5
6
7
8
9
10
11
import json

# ============ 序列化(Python → JSON)============

json.dumps(obj) # → str
json.dump(obj, f) # 写入文件对象

# ============ 反序列化(JSON → Python)============

json.loads(s) # str → Python 对象
json.load(f) # 从文件读取

记忆:带 s 的是 str 版本,不带 s 的操作文件。

二、类型映射

Python ↔ JSON 类型对应关系:

Python JSON
dict object
list, tuple array
str string
int, float number
True / False true / false
None null

JSON 不支持的常见类型

  • datetime / date
  • Decimal
  • set / frozenset
  • bytes
  • 自定义类

以上都会引发 TypeError,需要自己转换。

三、序列化的常用参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
data = {"name": "咖飞", "age": 3, "tags": ["python", "学习"]}

# 默认:紧凑单行
json.dumps(data)
# '{"name": "\\u5496\\u98de", "age": 3, "tags": ["python", "\\u5b66\\u4e60"]}'

# 中文正常显示
json.dumps(data, ensure_ascii=False)
# '{"name": "咖飞", "age": 3, "tags": ["python", "学习"]}'

# 缩进美化
json.dumps(data, ensure_ascii=False, indent=2)
# {
# "name": "咖飞",
# "age": 3,
# "tags": ["python", "学习"]
# }

# 排序 key(用于稳定输出)
json.dumps(data, ensure_ascii=False, sort_keys=True)

# 自定义分隔符(更紧凑)
json.dumps(data, separators=(",", ":"))

几乎所有场景都建议 ensure_ascii=False——除非你的下游只能处理 ASCII。

四、读写文件

1
2
3
4
5
6
7
# 写
with open("data.json", "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)

# 读
with open("data.json", encoding="utf-8") as f:
data = json.load(f)

五、常见错误

编码错

1
2
3
4
5
json.dumps({datetime.now(): "x"})
# TypeError: keys must be str, int, float, bool or None, not datetime

json.dumps({1, 2, 3})
# TypeError: Object of type set is not JSON serializable

解码错

1
2
3
4
5
6
7
8
json.loads("{'a': 1}")
# JSONDecodeError: JSON 只允许双引号

json.loads("undefined")
# JSONDecodeError

json.loads(open("data.json").read())
# 可能因为 UTF-8 BOM 报错,用 encoding='utf-8-sig'

六、处理”不支持”的类型

方法 1:自定义 default 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from datetime import datetime, date
from decimal import Decimal


def json_default(obj):
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, Decimal):
return str(obj)
if isinstance(obj, set):
return list(obj)
raise TypeError(f"不支持的类型:{type(obj)}")


data = {
"time": datetime.now(),
"amount": Decimal("3.14"),
"tags": {"python", "json"},
}

print(json.dumps(data, default=json_default, ensure_ascii=False))
# {"time": "2025-09-26T10:12:47", "amount": "3.14", "tags": ["python", "json"]}

方法 2:继承 JSONEncoder

1
2
3
4
5
6
7
8
9
10
class MyEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, Decimal):
return str(obj)
return super().default(obj)


print(json.dumps(data, cls=MyEncoder))

两种方法功能等价,看喜好。

七、反序列化自定义处理

object_hook 让你在解析每个字典时干预:

1
2
3
4
5
6
7
8
9
10
11
12
def parse_datetime(d):
for k, v in d.items():
if isinstance(v, str) and len(v) >= 10 and v[4] == "-":
try:
d[k] = datetime.fromisoformat(v)
except ValueError:
pass
return d


json.loads('{"time": "2025-09-26T10:12:47"}', object_hook=parse_datetime)
# {'time': datetime.datetime(2025, 9, 26, 10, 12, 47)}

八、dataclass 与 JSON

如果你的数据是 dataclass,序列化很简单:

1
2
3
4
5
6
7
8
9
10
11
12
from dataclasses import dataclass, asdict
import json


@dataclass
class User:
name: str
age: int


u = User("咖飞", 3)
json.dumps(asdict(u), ensure_ascii=False)

反序列化:

1
User(**json.loads(s))

注意:字段类型不一致(比如 JSON 里 age 是字符串),要手动转。用第三方 pydantic / msgspec 能优雅解决这个。

九、pydantic 简介

pydantic 是 Python 里最强大的”JSON ↔ 类”工具。安装:pip install pydantic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from pydantic import BaseModel
from datetime import datetime


class User(BaseModel):
name: str
age: int
joined: datetime


# 自动解析 + 类型校验
u = User.model_validate_json('{"name": "咖飞", "age": 3, "joined": "2025-09-26T10:00:00"}')
print(u.joined) # datetime 对象

# 输出 JSON
print(u.model_dump_json(indent=2))

pydantic 处理 datetime、UUID、Decimal、枚举、可选字段、嵌套模型都很自然。API 项目(FastAPI)里几乎必用。

十、JSON Lines:一行一个 JSON

大数据场景常用 JSON Lines(.jsonl):每行是一个独立的 JSON,用换行分隔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 写
with open("events.jsonl", "w", encoding="utf-8") as f:
for e in events:
f.write(json.dumps(e, ensure_ascii=False) + "\n")

# 读(惰性,适合超大文件)
def read_jsonl(path):
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
yield json.loads(line)


for event in read_jsonl("events.jsonl"):
process(event)

十一、性能提示

  • json 是 Python 自带的纯 Python + C 加速实现,够快;
  • 更快的替代:orjson(Rust 写,2-5 倍快)、ujson
  • 处理超大 JSON 用 流式解析ijson 库),避免一次读进内存。

orjson 用法:

1
2
3
4
5
import orjson

# 直接返回 bytes
b = orjson.dumps({"a": 1, "time": datetime.now()})
d = orjson.loads(b)

orjson 原生支持 datetime、UUID、dataclass、numpy。

十二、安全性

不要用 eval 解析 JSON

1
2
3
4
5
# ❌ 危险
data = eval(json_str) # 攻击者可以注入代码

# ✅ 用 json
data = json.loads(json_str)

json.loads 只解析纯数据,不会执行代码。

十三、schema 校验

生产 API 需要严格的 schema:

  • jsonschema:标准 JSON Schema 校验;
  • pydantic:类型注解驱动的模型;
  • msgspec:极快的替代。

例子(pydantic):

1
2
3
4
5
6
7
8
9
10
11
class Order(BaseModel):
id: int
amount: float
status: Literal["pending", "paid", "shipped"]

# 校验通过
Order.model_validate({"id": 1, "amount": 99.9, "status": "paid"})

# 校验失败
Order.model_validate({"id": "x", "amount": 99.9, "status": "unknown"})
# ValidationError: 详细报告哪个字段错了

十四、一个完整例子:配置文件加载器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# config.py
"""一个 JSON 配置文件加载器,支持默认值、环境变量覆盖、类型转换。"""

import json
import os
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass, field, asdict


DEFAULT_CONFIG = {
"app_name": "咖飞助手",
"debug": False,
"port": 8080,
"features": {"chat": True, "search": False},
"started_at": datetime.now().isoformat(),
}


def load_config(path: Path) -> dict:
"""加载配置文件;不存在则用默认。"""
if path.exists():
config = json.loads(path.read_text(encoding="utf-8"))
else:
config = DEFAULT_CONFIG.copy()
save_config(path, config)

# 环境变量覆盖(大写 + APP_ 前缀)
for key, value in config.items():
env_key = f"APP_{key.upper()}"
if env_key in os.environ:
raw = os.environ[env_key]
if isinstance(value, bool):
config[key] = raw.lower() in ("1", "true", "yes")
elif isinstance(value, int):
config[key] = int(raw)
else:
config[key] = raw
return config


def save_config(path: Path, config: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(config, ensure_ascii=False, indent=2),
encoding="utf-8",
)


if __name__ == "__main__":
cfg = load_config(Path("config.json"))
print(cfg)

十五、常见陷阱

  1. ensure_ascii=False:中文变 \uXXXX
  2. JSON 只支持双引号:单引号 JSON 用 ast.literal_eval 或改数据。
  3. JSON 数字精度json.loads('{"x": 3.14e400}') 会转 inf;大整数没问题。
  4. NaN / Infinity:Python json.dumps(float('nan')) 输出 NaN(非标准 JSON!)。用 allow_nan=False 让它抛错。
  5. 序列化 datetime 报错:写 default 函数或 dataclass + asdict。
  6. 文件 BOM:用 encoding="utf-8-sig"
  7. JSON key 必须是字符串{1: 'x'} 序列化会把 int 转字符串(危险,反序列化后是字符串了)。

十六、小结与延伸阅读

  • 四大 API:dumps / loads / dump / load
  • 永远 ensure_ascii=False
  • 想美化用 indent=2
  • 不支持的类型用 default= 函数或 JSONEncoder 子类;
  • 反序列化自定义用 object_hook
  • 高性能用 orjson
  • 类型安全用 pydantic
  • 大数据流用 JSON Lines + 生成器。

延伸阅读:

下一篇 常用标准库:os、sys、pathlib 我们讲文件系统与运行环境的工具箱。