进阶篇 / 第 13 章
第 13 章 · 类型注解与代码规范
typing 各模块、结构化子类型 Protocol、dataclass 与类型化 NamedTuple,再讲怎么用 mypy / pyright 做静态检查,最后落到工程规范:PEP 8、ruff、black、docstring 写法与项目配置。学完本章,你不仅代码"长得像样",还能让编辑器给出智能补全、让 CI 在合并前拦下类型错误。类型注解是什么,不是什么
先澄清三个常见误解:
- 注解不会改变运行时行为。
def f(x: int) -> int: return x与def f(x): return x生成的字节码完全一样,Python 解释器不检查注解是否被遵守。 - 注解不会自动转换。
def f(x: int)传入"abc"不会报错,x依然是字符串。 - 注解不是为了别的语言。它主要是给人看(文档 + 可读性)、给工具看(mypy / IDE / pydantic / 序列化库)。
那为什么现代 Python 项目几乎都写注解?因为它换来的东西太值了:
| 收益 | 说明 | 谁在用 |
|---|---|---|
| 静态检查 | 不运行代码就能发现类型错误 | mypy、pyright、basedpyright |
| IDE 智能补全 | 输入 obj. 只列出真实存在的属性 | VS Code / PyCharm / Jedi |
| 跳转定义 | 点方法名直达实现 | IDE 通用 |
| 运行时校验 | 从注解生成校验器 | pydantic、dataclasses、beartype、typeguard |
| 序列化 / ORM | 注解即字段定义 | SQLAlchemy、Pydantic、dataclasses |
| 文档 | Sphinx / mkdocstrings 直接读取 | 文档工具链 |
1.1 从最小例子开始
def add(a: int, b: int) -> int:
"""两数相加。"""
return a + b
print(add(1, 2)) # 3
print(add(1, "2")) # TypeError: can only concatenate str (not "int") to str
注意:类型错误仍会在运行到那行时才爆出来,因为运算本身要求类型匹配。真正的「静默」错误例如 a: int = "5" 完全不会报错:
a: int = "5" # 运行时完全合法,a 是 str
print(type(a)) # <class 'str'>
: int 怎么还能传字符串」——因为注解是承诺,不是检查。要真的检查,请看 s7 的 beartype / typeguard 或第 17–18 章的 pydantic。1.2 为什么现在应该用 3.10+ 的写法
Python 3.9 起可用内置泛型 list[str],3.10 起可用 X | Y,3.12 起支持 type 语句(本教程基于 3.12)。老的 typing.List[str]、typing.Optional[str]、typing.Union[str, int] 仍可用但已过时:
| 能力 | 旧写法(typing) | 新写法(推荐) | 起始版本 |
|---|---|---|---|
| 列表 | List[str] | list[str] | 3.9 |
| 字典 | Dict[str, int] | dict[str, int] | 3.9 |
| 元组 | Tuple[int, ...] | tuple[int, ...] | 3.9 |
| 可选 | Optional[str] | str | None | 3.10 |
| 联合 | Union[A, B] | A | B | 3.10 |
| 类型别名 | X = TypeVar("X") 约束写法 | type X = ... | 3.12 |
| 自身引用 | "Tree" 字符串 | from __future__ import annotations | 3.7+ |
# 旧写法,仍然可用但不推荐
from typing import List, Dict, Optional, Union
def old(xs: List[str], meta: Optional[Dict[str, int]]) -> Union[int, str]:
return len(xs) or str(meta)
# 新写法(Python 3.12 推荐)
def new(xs: list[str], meta: dict[str, int] | None = None) -> int | str:
return len(xs) or str(meta)
print(old(["a"], None), new(["a"], None))
输出:
1 1
from __future__ import annotations(PEP 563 精神)让所有注解变成字符串延迟求值,因此可以前向引用尚未定义的类,且几乎零开销。本教程所有示例都在文件顶部加这一行。3.12 中它已是默认倾向写法。基础注解语法全表
2.1 变量注解
count: int = 0 # 类型 + 赋值
name: str # 只声明类型,不赋值(仅存在于 __annotations__)
items: list[str] = [] # 空的必须给默认值,或先声明再赋值
mapping: dict[str, float] = {} # key/value 都标注
maybe: int | None = None # 可空
matrix: list[list[int]] = [[1, 2], [3, 4]]
print(count, name is None, items, mapping, maybe, matrix)
print(type(None) is type(None))
输出:
0 True [] {} None [[1, 2], [3, 4]]
True
class Bad:
items: list = [] # 这是类属性,所有实例共享同一个列表!
count: int = 0
a, b = Bad(), Bad()
a.items.append(1)
print(b.items) # [1] —— b 也变了
正确做法是用 __init__ 里 self.items: list = [](实例属性),或用 @dataclass + field(default_factory=list)。2.2 函数注解
from typing import Sequence, Callable, Iterable
def mean(xs: Sequence[float]) -> float:
"""参数接受任何序列,返回浮点均值。"""
if not xs:
raise ValueError("空序列无均值")
return sum(xs) / len(xs)
def retry(times: int = 3, delay: float = 1.0) -> Callable[[Callable], Callable]:
"""返回装饰器:为函数调用加失败重试。"""
def deco(fn: Callable) -> Callable:
import time
def wrapper(*args, **kwargs):
last = None
for _ in range(times):
try:
return fn(*args, **kwargs)
except Exception as e:
last = e
time.sleep(delay)
raise last
wrapper.__name__ = fn.__name__
wrapper.__doc__ = fn.__doc__
return wrapper
return deco
def iter_even(n: int) -> Iterable[int]:
for i in range(n):
if i % 2 == 0:
yield i
print(f"{mean([1, 2, 3]):.2f}")
print(list(iter_even(7)))
输出:
2.00
[0, 2, 4, 6]
list[str] = 「我只会往里加、不换整个列表」;Sequence[str] = 「可以接收 list/tuple/str,只读遍历」;Iterable[str] = 「只要能迭代一次即可」。参数位置放宽(用 Sequence)比收紧(用 list)更友好,这是「注解是承诺」原则的体现。2.3 *args / **kwargs 的注解
# 位置参数的注解写在 * 之后,** 之前的名字作为元组类型
def add_all(first: int, /, *rest: int, factor: int = 1, **tags: str) -> int:
total = first + sum(rest)
return total * factor
print(add_all(1, 2, 3, factor=10, tag_a="x"))
输出:
60
若接受任意关键字:
from typing import Any
def log_event(name: str, **payload: Any) -> None:
print(f"[{name}] {payload}")
log_event("login", user="alice", ip="10.0.0.1")
输出:
[login] {'user': 'alice', 'ip': '10.0.0.1'}
typing 模块核心工具
3.1 Any / cast / NoReturn
from typing import Any, NoReturn, TypeVar, cast
T = TypeVar("T") # 类型变量:泛型占位符
def first_or(xs: list[T], default: T | None = None) -> T | None:
return xs[0] if xs else default
def parse_int(s: str) -> int | NoReturn: # NoReturn = 永不返回
s = s.strip()
if s.isdigit():
return int(s)
raise ValueError(f"不是整数: {s}")
def dump(obj: Any) -> str: # Any = 放弃检查,慎用
import json
return json.dumps(obj, ensure_ascii=False)
print(first_or([1.5, 2.0])) # 1.5
print(parse_int(" 42 ")) # 42
print(dump({"a": 1})) # {"a": 1}
try:
parse_int("abc")
except ValueError as e:
print("失败:", e)
# cast:告诉检查器「我知道它是 X」,不做运行时转换
value: object = "42"
print(cast(int, value) + 1) # 运行时是 str + int -> TypeError
cast 不是类型转换函数。上面最后一行会抛 TypeError: can only concatenate str (not "int") to str,因为 cast 只在静态层面「撒谎」,运行时原样返回。cast 的正确用途是「我已经在别处校验过了,但类型检查器不知道」。3.2 Literal / Final / ClassVar / Annotated
from typing import Literal, Final, ClassVar, Annotated
# Literal:只能取字面量那几个值
type Direction = Literal["north", "south"]
def move(d: Direction, step: int = 1) -> str:
return f"{d}*{step}"
print(move("north", 2)) # north*2
try:
move("up") # 静态检查会报错,运行时正常
except Exception:
pass
# Final:常量,赋值后不可改(检查器层面)
TIMEOUT: Final[int] = 30
# ClassVar:类变量,不参与实例 __init__
class Config:
ENV: ClassVar[str] = "prod"
debug: bool = False
print(TIMEOUT, Config.ENV, Config().ENV, Config.debug)
# Annotated:给类型附加元数据(校验、序列化、单位等)
Port = Annotated[int, "取值范围 1-65535"]
def serve(port: Port) -> str:
return f"listening on :{port}"
print(serve(8080))
输出:
north*2
30 prod prod False
listening on :8080
Annotated[T, ...] 是现代类型系统的「元数据携带者」:beartype 用它做运行时校验,pydantic 用它加验证规则与 JSON Schema 描述,FastAPI 用它生成接口文档。这是「一个注解,多方复用」的关键机制。3.3 TypeVar 与泛型
from typing import TypeVar, Generic, Sequence
T = TypeVar("T")
K = TypeVar("K")
V = TypeVar("V")
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:
if not self._items:
raise IndexError("栈空")
return self._items.pop()
def peek(self) -> T | None:
return self._items[-1] if self._items else None
s: Stack[int] = Stack()
s.push(1); s.push(2)
print(s.pop(), s.peek())
s.push("错误类型") # 静态检查会报错;运行时不报错
# 通用函数泛型
def first_item(data: Sequence[T]) -> T:
if not data:
raise ValueError("空")
return data[0]
print(first_item([1, 2]), first_item(["a", "b"]))
输出:
2 1
1 a
3.4 ParamSpec 与 Concatenate(进阶可选)
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P") # 代表「一整组参数签名」
R = TypeVar("R")
def with_logging(fn: Callable[P, R]) -> Callable[P, R]:
"""给任意函数加日志,签名完全保持。"""
import functools
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"调用 {fn.__name__}{args}{kwargs}")
return fn(*args, **kwargs)
return wrapper
@with_logging
def add(a: int, b: int) -> int:
return a + b
print(add(1, 2))
print(add.__name__) # 名字被 wraps 保留
输出:
调用 add(1, 2){}
3
add
Protocol:结构化子类型
Python 是鸭子类型语言:只要有 quack() 就能当鸭子。但鸭子类型没有名字,注解写不了。结构化子类型(structural subtyping)由 Protocol 引入:只要方法签名匹配,就算实现了这个「接口」,且不需要显式继承。
protocol_demo.py
from typing import Protocol, runtime_checkable, Iterable
@runtime_checkable
class Readable(Protocol):
"""任何能给我一个字符串的就算数。"""
def read(self, n: int = -1) -> str: ...
@runtime_checkable
class HasName(Protocol):
name: str # 属性也可以
def dump(obj: Readable) -> str:
return obj.read().strip().upper()
class FileLike:
def __init__(self, text: str) -> None:
self._text = text
def read(self, n: int = -1) -> str:
return self._text if n < 0 else self._text[:n]
class Response: # 和 FileLike 毫无继承关系
def read(self, n: int = -1) -> str:
return "HTTP/1.1 200 OK\nContent-Type: text/html"
print(dump(FileLike(" hello ")))
print(dump(Response()))
# runtime_checkable 让你能在运行时 isinstance(只检查方法名存在)
print(isinstance(FileLike("x"), Readable)) # True
print(isinstance(42, Readable)) # False
class Product:
name = "键盘"
print(isinstance(Product(), HasName)) # True(结构匹配)
输出:
HELLO
HTTP/1.1 200 OK
CONTENT-TYPE: TEXT/HTML
True
False
True
| 能力 | 说明 | 是否推荐 |
|---|---|---|
class P(Protocol) | 接口类,不能实例化 | ✓ 核心用法 |
@runtime_checkable | 允许 isinstance(只查方法名,不查签名) | △ 谨慎用,误报率高 |
| 显式继承 Protocol | class Impl(P),变成名义子类型 | △ 一般不需要 |
| 协议继承 | class Readable2(Readable, Protocol) 组合多个协议 | ✓ 常用 |
| 泛型协议 | class Container(Protocol[T]) | ✓ 容器抽象常用 |
泛型 Protocol:容器抽象
from typing import Protocol, TypeVar, Iterator, runtime_checkable
T_co = TypeVar("T_co", covariant=True) # 协变:只出现在返回位置
@runtime_checkable
class Iterable_(Protocol[T_co]):
def __iter__(self) -> Iterator[T_co]: ...
@runtime_checkable
class Sized(Protocol):
def __len__(self) -> int: ...
def longest(items: Iterable_["Sized"]) -> Sized:
return max(items, key=len) # 要求元素有 __len__
class Bag:
def __init__(self, items: list) -> None:
self.items = items
def __len__(self) -> int:
return len(self.items)
print(longest([[1], [1, 2, 3], [1, 2]]).items) # [1, 2, 3]
print(isinstance([1, 2], Iterable_)) # True
print(isinstance("abc", Iterable_)) # True(str 也是 Iterable)
输出:
[1, 2, 3]
True
True
dataclass 与类型化数据结构
第 12 章讲了 dataclass,本章从类型角度补齐它的注解用法,并引入它的两个「表亲」:TypedDict 与 NamedTuple。
5.1 dataclass:注解即构造函数签名
typed_dataclass.py
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from decimal import Decimal
from typing import ClassVar
class Status(Enum):
DRAFT = "draft"
ACTIVE = "active"
@dataclass(frozen=True, slots=True, order=True)
class Product:
sku: str
name: str
price: Decimal
stock: int = 0
status: Status = Status.DRAFT
tags: list[str] = field(default_factory=list)
KIND: ClassVar[str] = "product" # 不进 __init__
def __post_init__(self) -> None:
if self.price < 0:
raise ValueError("价格不能为负")
if self.stock < 0:
raise ValueError("库存不能为负")
@property
def is_available(self) -> bool:
return self.stock > 0 and self.status is Status.ACTIVE
def discounted(self, percent: Decimal) -> Product:
return Product(self.sku, self.name, self.price * (1 - percent), self.stock)
p1 = Product("KB-001", "机械键盘", Decimal("299.00"), stock=12)
p2 = Product("MS-002", "鼠标", Decimal("99.50"), status=Status.ACTIVE)
print(p1, p1.KIND, Product.KIND)
print(p1.is_available, p2.is_available)
print(p1 < p2) # order=True -> 可比较
print(p1.discounted(Decimal("0.1")))
print(hash(p1) == hash(p1)) # frozen -> 可哈希
try:
Product("X", "坏", Decimal("-1"))
except ValueError as e:
print("创建失败:", e)
输出:
Product(sku='KB-001', name='机械键盘', price=Decimal('299.00'), stock=12, status=<Status.DRAFT: 'draft'>, tags=[])
product product
False True
False
Product(sku='KB-001', name='机械键盘', price=Decimal('269.10'), stock=12, status=<Status.DRAFT: 'draft'>, tags=[])
True
创建失败: 价格不能为负
frozen=True 时不要在 __post_init__ 里给实例赋新属性——会抛 FrozenInstanceError。需要「可变计算字段」时用 field(init=False, default=..., compare=False) 预声明,或者干脆放弃 frozen 改用普通 dataclass + 外部缓存。5.2 TypedDict:描述 dict 的形状
from typing import TypedDict, Required, NotRequired
class UserDict(TypedDict):
name: Required[str] # 必填
age: NotRequired[int] # 可选(Python 3.11+ 内置)
email: str # total=False 关闭时也可选
def register(u: UserDict) -> str:
return f"{u['name']}({u.get('age', '未知年龄')})"
print(register({"name": "张三", "age": 18}))
print(register({"name": "李四"}))
print(register({"name": "王五", "email": "w@x.com"}))
# 运行时它就是普通 dict
u: UserDict = {"name": "赵六", "age": 22}
print(type(u), u["name"], sorted(u.keys()))
输出:
张三(18)
李四(未知年龄)
王五(未知年龄)
<class 'dict'> 赵六 ['age', 'name']
TypedDict 只是给 dict 加了类型约束,运行时零成本、无校验——写错键名仍会正常运行,只是静态检查会报错。它比 dict[str, Any] 信息量大得多,是描述 API 返回体(尤其是 JSON)的首选。5.3 NamedTuple:轻量不可变记录
from typing import NamedTuple
class Point(NamedTuple):
x: int
y: int
label: str = "原点"
p = Point(1, 2)
print(p, p.x, p[1], len(p))
print(p._asdict()) # 转 dict
q = p._replace(y=9) # 复制并改字段
print(q)
print(p + (1,)) # 当元组拼接
# 与默认值配合良好,适合表示「函数返回多值」
def divmod_int(a: int, b: int) -> tuple[int, int]:
if b == 0:
raise ZeroDivisionError
return a // b, a % b
r = divmod_int(17, 5)
print(r, type(r))
输出:
Point(x=1, y=2, label='原点') 1 2 3
{'x': 1, 'y': 2, 'label': '原点'}
Point(x=1, y=9, label='原点')
(1, 2, '原点')
(3, 2) <class 'tuple'>
| 需求 | 选择 | 是否可变 | 额外能力 |
|---|---|---|---|
| 普通业务对象,要方法、要校验 | @dataclass | 默认可变,可 frozen | 方法、property、继承 |
| 极简记录,追求性能/紧凑 | NamedTuple | 不可变 | tuple 全部能力 + 字段名 |
| 描述 dict 的键值形状 | TypedDict | dict 本身可变 | 键名静态检查 |
| 必须来自其他数据源(API/文件) | pydantic.BaseModel | 可变 | 运行时校验 + 序列化 |
| 固定的一组常量 | Enum | 不可变 | 唯一性、可比较 |
5.4 用属性访问代替魔法字典
当一个函数要返回很多字段时,裸元组会让人「第 3 个值到底是啥」。用 NamedTuple 或 dataclass 返回,代码可读性立刻提升:
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Stats:
count: int
total: float
mean: float
minimum: float
maximum: float
def compute(xs: list[float]) -> Stats:
return Stats(
count=len(xs),
total=round(sum(xs), 2),
mean=round(sum(xs) / len(xs), 2),
minimum=min(xs),
maximum=max(xs),
)
s = compute([3, 1, 4, 1, 5, 9, 2, 6])
print(s)
print(f"均值 {s.mean},极差 {s.maximum - s.minimum:.1f}")
输出:
Stats(count=8, total=31.0, mean=3.88, minimum=1, maximum=9)
均值 3.88,极差 8.0
静态类型检查:mypy 与 pyright
注解本身不检查任何东西,必须配一个检查器(type checker)。两大主流:
| 工具 | 开发方 | 特点 | 适合 |
|---|---|---|---|
| mypy | Python 官方生态 / Jukka Lehtosalo | 历史最久、插件多、渐进式友好 | 存量项目、CI 集成 |
| pyright | Microsoft | 速度快、类型推断更准、语言服务器完备 | VS Code 编辑器、新项目 |
| basedpyright | pyright 社区增强分支 | 更严格、默认规则更合理、诊断更友好 | 追求严格的新项目 |
| ty | Astral(ruff 同厂) | 极快,新一代检查器 | CI 快速反馈 |
6.1 安装与最小配置
# 安装
pip install mypy # 或 pip install pyright / basedpyright
# pyproject.toml
# [tool.mypy]
# python_version = "3.12"
# strict = true # 最严格模式,强烈推荐新项目开启
# warn_unused_configs = true
# disallow_any_generics = true
# no_implicit_reexport = false
# exclude = ["^build/", "^\\.venv/"]
# 运行
mypy src/
mypy --strict src/
6.2 一个能被 mypy 抓出错误的例子
# demo_types.py —— 故意埋了 4 个类型错误
def greet(name: str) -> str:
return "你好," + name
age = "30" # 错误 1:推断为 str,却当作数字用
result = greet(42) # 错误 2:给 str 参数传 int
items: list[int] = [1, 2]
items.append("三") # 错误 3:list[int] 里塞 str
def get_total(prices: list[float]) -> float:
return sum(prices) / len(prices) # 错误 4:prices 可能为空(如果开了 strict 的部分检查)
def first(xs: list[str]) -> str:
return xs[0] # 错误 5:可能 IndexError(strict 下会提示需 NoReturn 处理)
mypy 输出(大致):
demo_types.py:4: error: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment]
demo_types.py:5: error: Argument 1 to "greet" has incompatible type "int"; expected "str" [arg-type]
demo_types.py:7: error: Argument 1 to "append" of "list" has incompatible type "str"; expected "int" [arg-type]
demo_types.py:10: error: Unsupported operand types for / ("float" and "int") [operator]
demo_types.py:13: error: Returning Any from function declared to return "str" [no-any-return]
greet(42) 抓不到,因为没注解时是 Any)。「注解 + 检查器」是一套组合拳,缺一不可。6.3 渐进式严格化策略
存量项目不可能一天切成 --strict。推荐路径:
- 第 1 步:只开基础检查,
mypy src/,把存量包的报错用# type: ignore或exclude隔离。 - 第 2 步:新代码强制注解(CI 里对变更文件跑 strict)。
- 第 3 步:逐包把
# type: ignore消掉,加disallow_untyped_defs。 - 第 4 步:全局开
strict = true,此时覆盖率应已在 90%+。
# config.py
# import untyped
# untyped = ["legacy/", "third_party_vendor/"]
# tool: mypy
# [mypy-legacy.*]
# disallow_untyped_defs = false
# ignore_errors = true
6.4 CI 中集成
# .github/workflows/ci.yml(片段)
# name: type-check
# on: [push, pull_request]
# jobs:
# check:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-python@v5
# with: { python-version: "3.12" }
# - run: pip install -r requirements-dev.txt
# - run: ruff check .
# - run: mypy --strict src
# - run: pytest -q
6.5 什么时候注解反而添乱
- 写胶水脚本、一次性代码:
# type: ignore反而比注解更省事。 - 大量
Any流(动态 dict / JSON):写了注解等于没写,还给人虚假的安全感。 - 热循环里的纯数值计算:
int/float注解在纯 Python 里没有运行时成本,但会牺牲一点「写起来爽」的手感。 - 与运行时校验混用时要小心:注解声明
int,实际还需要beartype之类的工具在入口强制,否则承诺是空的。
从注解到运行时校验
注解是静态承诺。要在运行时强制它,需要专门的库。beartype 零配置、性能好,适合函数入口;pydantic 功能最全,适合数据模型。
beartype_demo.py
from beartype import beartype # pip install beartype
@beartype
def divide(a: int, b: int) -> float:
"""被 beartype 装饰后,参数类型会被运行时强制。"""
return a / b
print(divide(7, 2)) # 3.5
try:
divide("8", 2) # 函数体都没执行就被拦下
except Exception as e:
print(type(e).__name__, ":", e)
输出:
3.5
BeartypeCallHintParamViolation: Function divide() parameter a must be of type int, not str
pydantic_demo.py(预告第 18 章)
from pydantic import BaseModel, Field, field_validator
class UserIn(BaseModel):
"""从 JSON 反序列化的输入模型:注解即校验规则。"""
name: str = Field(min_length=1, max_length=50, description="用户名")
age: int = Field(ge=0, le=150)
email: str | None = None
@field_validator("email")
@classmethod
def check_email(cls, v: str | None) -> str | None:
if v and "@" not in v:
raise ValueError("邮箱格式不正确")
return v
u = UserIn(name="张三", age=18, email="z@x.com")
print(u.model_dump())
try:
UserIn(name="", age=200)
except Exception as e:
print("校验失败:", str(e).splitlines()[0])
print(UserIn.model_json_schema()["properties"]["name"])
输出:
{'name': '张三', 'age': 18, 'email': 'z@x.com'}
1 validation error for UserIn
{'maxLength': 50, 'minLength': 1, 'type': 'string', 'title': 'Name', 'description': '用户名'}
UserIn 同时是类型声明、运行时校验器、JSON Schema 源、以及 OpenAPI 文档源。第 18 章讲 FastAPI 时会完整展开。PEP 8 与代码风格规范
PEP 8(Python Enhancement Proposal 8)是 Python 官方风格指南。核心原则只有一句:一致性优先于个人偏好。团队里的风格统一比「哪种更好」重要得多。
8.1 硬性规则速查(缩进、行长、命名)
| 项目 | 规则 | 说明 |
|---|---|---|
| 缩进 | 4 个空格 | 严禁混用 Tab,PEP 8 只允许空格 |
| 行宽 | 不超过 79(现代项目常放宽到 88–100) | 黑色格式化器默认 88 |
| 续行 | 对齐括号 / 缩进 4 / 垂直对齐 | 括号内换行优于反斜杠 |
| 函数/类名 | snake_case | def calculate_total() |
| 常量 | UPPER_SNAKE_CASE | MAX_RETRIES = 3 |
| 类名 | CapWords | class UserRepository |
| 变量/函数 | snake_case | 含下划线表示「内部」 |
| 包/模块 | short_lower_case | 避免下划线,尽量短 |
| 私有成员 | _leading_underscore | Python 没有真私有,只是约定 |
| 类内强私有 | __double_underscore | 触发名字改写(name mangling) |
| 公开成员 | public | 不带下划线即「公开 API」 |
| 导入 | 标准库 / 第三方 / 本地,三组空行分隔 | 组内按字母序 |
| 运算符两侧 | 两侧各一个空格(除括号紧邻时) | a = b + c,f(x) 不加 |
8.2 好代码与坏代码对照
# ❌ 反例集合
import os,sys,json
from typing import*
def process(a,b,c=1):
d={}
for x in a:
if x['type']=='user' and x.get('active',True)==True:
d[x['id']]=x
return json.dumps(d)
class user_account:
def __init__(self,ID,name):
self.ID=ID;self.name=name
# ✅ 正例
"""用户账户处理工具。
提供按类型过滤并序列化账户的函数。
"""
import json
from typing import Any
def process(records: list[dict[str, Any]], threshold: int = 1) -> str:
"""筛选出活跃用户并序列化为 JSON。
Args:
records: 原始记录列表。
threshold: 保留数量上限。
Returns:
JSON 字符串。
"""
filtered: dict[int, dict[str, Any]] = {}
for record in records:
is_user = record["type"] == "user"
is_active = record.get("active", True)
if is_user and is_active:
filtered[record["id"]] = record
if len(filtered) >= threshold:
break
return json.dumps(filtered, ensure_ascii=False, indent=2)
class UserAccount:
"""用户账户。"""
def __init__(self, user_id: int, name: str) -> None:
self.user_id = user_id
self.name = name
8.3 空白与换行的心理线
# ✅ 函数内部逻辑用空行分组,让阅读者看到「步骤」
def process_payment(order, gateway):
validate_order(order) # 第一步:校验
amount = calculate_amount(order)
if amount <= 0: # 第二步:分支处理
raise ValueError("金额异常")
receipt = gateway.charge(order.user_id, amount)
audit.log(receipt) # 第三步:记录
return receipt
# ❌ 反例:全挤在一起,没有视觉分组
def process_payment(order, gateway):
validate_order(order)
amount = calculate_amount(order)
if amount <= 0:
raise ValueError("金额异常")
receipt = gateway.charge(order.user_id, amount)
audit.log(receipt)
return receipt
格式化与静态检查工具链
现代 Python 项目的风格工具已经收敛到两个主角:ruff(lint + 部分 format)与 black(纯格式化)。
9.1 工具分工
| 工具 | 类型 | 能做什么 | 不能做什么 |
|---|---|---|---|
| black | 格式化器 | 自动排版,风格统一,争论终结者 | 不查错、不管 import 顺序(默认) |
| ruff format | 格式化器 | black 的更快替代,兼容 99% | 同 black |
| ruff check | Linter | 未使用导入、bug 模式、简化建议、安全检查 | 不改代码风格(除非 --fix) |
| flake8 | Linter | 传统方案,已基本被 ruff 取代 | 速度慢 |
| isort | 导入排序 | 整理 import 顺序 | ruff 内置同类规则 |
| pylint | Linter | 非常严格的风格与设计检查 | 噪音多,配置复杂 |
| mypy / pyright | 类型检查器 | 类型错误 | 不管风格 |
9.2 一份开箱即用的 pyproject.toml
[project]
name = "myapp"
version = "0.1.0"
requires-python = ">=3.12"
[dependency-groups]
dev = ["ruff>=0.6", "mypy>=1.11", "pytest>=8", "beartype>=0.20"]
[tool.ruff]
line-length = 88
target-version = "py312"
src = ["src"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "SIM", "RUF", "ANN", "D"]
ignore = ["D203", "D213", "ANN401"] # ANN401: 允许 *args: Any
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101", "D100", "D103"] # 测试里允许 assert、免文档字符串
[tool.ruff.lint.pydocstyle]
convention = "google" # 文档字符串风格统一
[tool.black]
line-length = 88
target-version = ["py312"]
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
disallow_untyped_defs = true
pretty = true
说明:
Epycodestyle 错误,FPyflakes,Iisort 规则UPpyupgrade:自动提示「List→list」「Union→|」等现代化写法ANN强制注解,D强制 docstring,Bbugbear(可变默认参数等)SIM可简化代码,RUFruff 特有规则
9.3 常用命令
# 格式化:把代码排成统一风格
ruff format . # 或 black .
# 静态检查 + 自动修复
ruff check . --fix # 修能修的(未使用导入等)
ruff check . # 只报告不修改(适合 CI)
# 类型检查
mypy --strict src
pyright src
# 全部一起跑(本地 & CI 保持一致)
pre-commit run --all-files
# .pre-commit-config.yaml
# repos:
# - repo: https://github.com/astral-sh/ruff-pre-commit
# rev: v0.6.9
# hooks:
# - id: ruff # 修 import 顺序、未使用变量
# args: [--fix]
# - id: ruff-format # 统一格式
# - repo: https://github.com/pre-commit/mirrors-mypy
# rev: v1.11.2
# hooks:
# - id: mypy
# additional_dependencies: [types-requests]
ruff check --fix 有时会改动代码逻辑边界(如重写可迭代为生成器表达式),修复后务必 review diff。③ 格式化工具会让「代码行数变多/变少」,行数不能作为代码量指标。Docstring:写给三个月后的自己
Docstring 是模块/类/函数的三引号字符串,遵循特定格式后可以被 Sphinx、IDE、help() 读取。
10.1 三种风格对比
| 风格 | 示例 | 谁在用 |
|---|---|---|
Args: / Returns: / Raises: | Google 内部、多数中文教程 | |
| Numpy | Parameters 表格区 | 科学计算、数据分析库 |
| reST / Sphinx | :param x: :returns: | 传统 Python 库、Read the Docs |
[tool.ruff.lint.pydocstyle] 里设置 convention 让工具强制。本教程全文采用 Google 风格。10.2 完整模板
"""把用户数据导出为多种格式的小工具。
模块级 docstring 用三引号,概述一句话,说明用途。
可以有多行段落。
"""
import csv
import io
def export_users(users: list[dict], fmt: str = "csv") -> str:
"""把用户列表导出为指定格式的字符串。
Args:
users: 用户字典列表,每个字典需含 name 与 age 键。
fmt: 输出格式,支持 "csv" 与 "tsv"。
Returns:
格式化后的字符串。
Raises:
ValueError: 当 fmt 不是受支持的格式时。
Example:
>>> export_users([{"name": "张三", "age": 18}], "csv")
'name,age\\n张三,18'
"""
if fmt not in ("csv", "tsv"):
raise ValueError(f"不支持的格式: {fmt}")
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=["name", "age"], delimiter="\t" if fmt == "tsv" else ",")
writer.writeheader()
writer.writerows(users)
return buf.getvalue()
class UserStore:
"""内存中的用户存储。
Attributes:
_data: 内部字典,按 id 索引用户。
"""
def __init__(self) -> None:
self._data: dict[int, dict] = {}
def add(self, uid: int, **fields: object) -> dict:
"""新增或更新一个用户。
Args:
uid: 用户唯一标识。
**fields: 用户字段,如 name、age。
Returns:
存储后的完整用户字典。
"""
self._data[uid] = {"id": uid, **fields}
return self._data[uid]
if __name__ == "__main__":
print(export_users([{"name": "张三", "age": 18}]))
print(UserStore().add(1, name="李四", age=20))
输出:
name,age
张三,18
{'id': 1, 'name': '李四', 'age': 20}
10.3 什么该写、什么不必写
| 对象 | 要不要写 | 理由 |
|---|---|---|
| 模块 | 要(简短) | 告诉读者这个模块解决什么问题 |
| 公共类 | 要 | 说明职责、不变量、典型用法 |
| 公共函数 | 要 | Args / Returns / Raises 三件套 |
| 魔术方法 | 要 | 说明返回什么语义 |
| 私有函数(下划线开头) | 可选 | 逻辑一眼能懂就不必凑字数 |
| getter/setter | 不必重复 | 注解 + 属性名已经说清 |
| 测试函数 | 通常不写 | 函数名 test_xxx_when_yyy 已表达意图 |
help() 读取并出现在文档里,写错就会误导人。10.4 类型注解与 docstring 的分工
from typing import Optional
def parse_config(text: str, *, strict: bool = True) -> Optional[dict[str, str]]:
"""解析 "k=v" 形式的配置文本。
Args:
text: 配置文本,每行一个键值对。
strict: 为 True 时遇到无法解析的行直接抛异常;
为 False 时跳过并继续。
Returns:
解析得到的字典;strict 为 False 且全部行都无效时返回 None。
"""
result: dict[str, str] = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
if strict:
raise ValueError(f"无效行: {line}")
return None
k, v = line.split("=", 1)
result[k.strip()] = v.strip()
return result or None
print(parse_config("a=1\n# c\nb=2"))
print(parse_config("bad", strict=False))
输出:
{'a': '1', 'b': '2'}
None
Optional[dict[str, str]],但「strict=False 时返回 None」这类语义必须写在 docstring 里,注解表达不了。综合工程示例:一个类型良好的项目
把本章所有内容拼成一个可运行的小项目,展示「类型 + 规范」如何协同。
inventory.py
"""库存管理核心模块。
演示:Protocol 依赖注入 + dataclass 数据模型 + Enum 状态
+ 完整注解 + Google 风格 docstring。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from decimal import Decimal
from enum import Enum
from typing import Any, Iterable, Iterator, Literal, Protocol
# ——— 依赖抽象(Protocol)———
class Clock(Protocol):
"""时间来源抽象,便于测试时注入假时钟。"""
def now_iso(self) -> str: ...
class Storage(Protocol):
"""持久化抽象:任何能存/取记录的都可注入。"""
def save(self, record: dict[str, Any]) -> None: ...
def load_all(self) -> list[dict[str, Any]]: ...
# ——— 具体实现 ———
class SystemClock:
"""使用真实系统时间的时钟。"""
def now_iso(self) -> str:
from datetime import datetime
return datetime.now().isoformat(timespec="seconds")
class MemoryStorage:
"""内存存储,仅用于演示与测试。"""
def __init__(self) -> None:
self._rows: list[dict[str, Any]] = []
def save(self, record: dict[str, Any]) -> None:
self._rows.append(record)
def load_all(self) -> list[dict[str, Any]]:
return list(self._rows)
# ——— 领域模型 ———
class Category(Literal["food", "book", "tool"]):
"""商品分类字面量。"""
class ItemState(Enum):
IN_STOCK = "in_stock"
LOW = "low"
OUT = "out"
@dataclass(frozen=True, slots=True)
class Item:
"""一个库存条目(不可变值对象)。"""
sku: str
name: str
price: Decimal
qty: int
category: Category = "tool"
low_threshold: int = field(default=5, compare=False)
def __post_init__(self) -> None:
if self.price < 0:
raise ValueError(f"{self.sku}: 价格不能为负")
if self.qty < 0:
raise ValueError(f"{self.sku}: 数量不能为负")
@property
def state(self) -> ItemState:
"""根据数量推导库存状态。"""
if self.qty == 0:
return ItemState.OUT
if self.qty <= self.low_threshold:
return ItemState.LOW
return ItemState.IN_STOCK
@property
def total_value(self) -> Decimal:
"""库存总价值。"""
return self.price * self.qty
def to_dict(self) -> dict[str, Any]:
"""转为可 JSON 序列化的字典。"""
return {
"sku": self.sku,
"name": self.name,
"price": str(self.price),
"qty": self.qty,
"category": self.category,
"state": self.state.value,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Item":
"""从字典还原,非法数据抛 ValueError。"""
try:
return cls(
sku=str(data["sku"]),
name=str(data["name"]),
price=Decimal(str(data["price"])),
qty=int(data["qty"]),
category=data.get("category", "tool"),
)
except (KeyError, ValueError) as e:
raise ValueError(f"非法商品数据: {data}") from e
# ——— 服务层 ———
class InventoryService:
"""库存服务:管理条目并写入存储。"""
def __init__(self, storage: Storage, clock: Clock) -> None:
self._storage = storage
self._clock = clock
self._items: dict[str, Item] = {}
self._log: list[str] = []
def __iter__(self) -> Iterator[Item]:
"""支持 for item in service。"""
return iter(self._items.values())
def add(self, item: Item) -> None:
"""添加或覆盖一个条目。
Args:
item: 商品条目。
"""
self._items[item.sku] = item
self._storage.save({"op": "add", "at": self._clock.now_iso(), **item.to_dict()})
self._log.append(f"[{self._clock.now_iso()}] add {item.sku}")
def find_by_category(self, category: Category) -> list[Item]:
"""按分类筛选。"""
return [i for i in self._items.values() if i.category == category]
def low_stock(self) -> list[Item]:
"""返回所有低库存条目。"""
return [i for i in self._items.values() if i.state is not ItemState.IN_STOCK]
def total_value(self) -> Decimal:
"""总库存价值。"""
return sum((i.total_value for i in self._items.values()), Decimal("0"))
def export_json(self) -> str:
"""导出为 JSON 字符串。"""
return json.dumps([i.to_dict() for i in self], ensure_ascii=False, indent=2)
def import_json(self, text: str) -> int:
"""从 JSON 导入,返回成功条数。"""
ok = 0
for row in json.loads(text):
self.add(Item.from_dict(row))
ok += 1
return ok
class FakeClock:
"""测试用假时钟:时间由外部控制,逐秒递增。"""
def __init__(self, start: int = 0) -> None:
self.t = start
def now_iso(self) -> str:
self.t += 1
return f"2024-01-01T00:00:{self.t:02d}"
def main() -> None:
"""演示入口。"""
storage, clock = MemoryStorage(), FakeClock()
svc = InventoryService(storage, clock)
items: Iterable[Item] = [
Item("F001", "苹果", Decimal("5.50"), 20, "food", low_threshold=10),
Item("B001", "Python 入门", Decimal("79.00"), 3, "book", low_threshold=5),
Item("T001", "螺丝刀", Decimal("15.80"), 0, "tool", low_threshold=2),
]
for it in items:
svc.add(it)
print("总价值:", svc.total_value())
print("食品类:", [i.name for i in svc.find_by_category("food")])
for i in svc.low_stock():
print(f" 缺货/低库存: {i.name} ({i.state.value})")
blob = svc.export_json()
print(blob[:120], "...")
svc2 = InventoryService(MemoryStorage(), FakeClock())
print("重新导入条数:", svc2.import_json(blob))
print("数量一致:", svc2.total_value() == svc.total_value())
if __name__ == "__main__":
main()
输出:
总价值: 346.40
食品类: ['苹果']
缺货/低库存: Python 入门 (low)
缺货/低库存: 螺丝刀 (out)
[
{
"sku": "F001",
"name": "苹果",
"price": "5.50",
"qty": 20,
"category": "food",
"state": "in_stock"
},
...
重新导入条数: 3
数量一致: True
工程规范清单与常见反模式
12.1 项目风格落地清单
| 环节 | 工具 | 执行时机 |
|---|---|---|
| 格式化 | ruff format(或 black) | 保存时 / pre-commit |
| 静态检查 | ruff check --fix | 保存时 / pre-commit |
| 类型检查 | mypy --strict | pre-commit / CI |
| 测试 | pytest | pre-commit / CI |
| 文档 | mkdocstrings + ruff pydocstyle | CI 构建 |
12.2 反模式清单
- 注解撒谎:写
-> int实返回 str。注解是承诺,写错比不写更糟。 - 滥用 Any 逃避思考:把参数全标
Any等于关掉类型系统,还给人虚假安全感。 - 类型别名当全局变量用:
UserId = str在 3.12 应用type UserId = str,两者在类型层面含义不同(3.12 的type是真正的惰性别名)。 - 过度注解样板代码:给三行 getter 加三行注解没人感谢你。
- 手改格式对抗工具:任何人手工排版去「对抗」black,下次提交全被重排,徒增 diff 噪音。
- docstring 与代码不同步:改逻辑不改注释,比没注释更糟。
- 类名缩写:
class UIMgr不好;写class UIManager或class Config。 - 用
get_前缀命名所有方法:Python 不用get_x(),直接x()或x属性。 - 魔法数字与魔法字符串裸奔:抽成常量或
Enum,让「为什么是这个值」有处可查。 - 导入通配符:
from x import *让静态检查完全失效,且易冲突。
12.3 什么时候可以不管规范
规范服务于协作,不服务于教条。以下情况可以放宽:一次性脚本、Jupyter 探索性分析、教学示例(为了突出概念可暂略类型)。但一旦代码进入仓库、超过一周后你自己还要读它,就该按规范走。
本章小结
- 类型注解不改变运行时行为,它的价值在于文档、IDE 补全、静态检查、运行时校验与序列化;现代项目应统一使用
list[str]、X | None(3.10+)写法,并在文件顶部加from __future__ import annotations支持前向引用。 typing核心:Any(放弃检查,慎用)、cast(静态撒谎,非运行时转换)、NoReturn(永不返回)、Literal(字面量约束)、Final(常量)、ClassVar(类变量)、Annotated(携带元数据供 beartype / pydantic / FastAPI 复用)。- 泛型用
TypeVar+Generic[T];装饰器保持签名用ParamSpec;协变返回位置用T_co。 Protocol提供结构化子类型:只要方法匹配就算实现,无需继承。它是依赖倒置与「组合优于继承」的类型化表达,配合@runtime_checkable可做有限的 isinstance 检查。- 参数注解要「放宽」:接收方用
Sequence/Iterable,返回方才用具体的list。 dataclass(可写方法、可继承)、NamedTuple(紧凑不可变)、TypedDict(描述 dict 形状)、Enum(有限取值)、pydantic.BaseModel(需运行时校验时)各有分工。- 类型检查器:mypy(生态最全)、pyright(更快更准)、basedpyright(更严格)、ty(极快)。存量项目用「分步严格化」渐进推进,CI 中对变更文件强制 strict。
- 注解只是承诺,需要
beartype(轻量运行时强制)或pydantic(全功能)才能真正校验。 - PEP 8 核心是一致性:4 空格、snake_case 函数、CapWords 类、UPPER 常量、公开 API 不带下划线、续行优先用括号、import 分三组、空行表达逻辑段落。
- 工具链现代标准是 ruff(check + format)+ mypy + pytest,全部挂在 pre-commit 与 CI 上;不要再引入 flake8/pylint/autopep8 的混搭组合。
- docstring 统一用 Google 风格:模块说用途,函数写 Args / Returns / Raises / Example,类型归注解、语义归 docstring。
练习题
-
为第 5 章的数据结构补全注解:写一个
Stack[T]与Queue[T]泛型类,要求:泛型参数T;push/pop/peek/__len__/__iter__全部带正确注解;空栈pop抛IndexError;写 Google 风格 docstring;能通过mypy --strict且通过ruff check。参考答案要点T = TypeVar("T");class Stack(Generic[T]);内部self._items: list[T] = [];pop(self) -> T先判空抛错再return self._items.pop()(这一步同时让 mypy 确信非空);peek(self) -> T | None。docstring 的Args:段要列出T的语义约束。 -
Protocol 化你的依赖:写一个
HttpClient(Protocol)(方法get(url) -> str、post(url, data: dict) -> str),实现RequestsClient(用标准库urllib模拟)与FakeClient(返回固定数据)。再写一个Downloader类只依赖 Protocol,演示如何用 FakeClient 写测试而不需要真实网络。参考答案要点class HttpClient(Protocol): ...只声明方法签名;Downloader.__init__(self, client: HttpClient);测试时Downloader(FakeClient({...}));无需任何 mock 库。注意 FakeClient 不需要继承 HttpClient,这正是结构化子类型的意义。 -
用 dataclass 重构第 12 章的 Order:把
Order改成@dataclass(frozen=True, slots=True),包含no: str、items: list[LineItem]、state: OrderState、created_at: datetime,要求:total是 property;状态迁移写成方法并校验合法性;提供to_json_dict()。要求通过 mypy strict。参考答案要点frozen=True下状态变化需要object.__setattr__或dataclasses.replace(self, state=OrderState.PAID);created_at用field(default_factory=datetime.now);items用field(default_factory=list);非法迁移在pay()里显式if self.state is not OrderState.NEW: raise RuntimeError。 -
开启严格模式并消灭所有报错:把本章的
inventory.py放进一个目录,加入pyproject.toml(mypy strict + ruff ANN/D 规则),运行mypy --strict .与ruff check .,把所有告警修干净。记录你遇到的三类典型报错及修法。参考答案要点常见三类:① 缺返回类型注解 → 补-> None;② 可空值未收窄 → 用if x is None: raise或assert;③Returning Any→ 给dict[str, Any]显式转换或收窄来源。Annotated与Final可减少一批 ANN 报错。 -
写一套 docstring 并生成文档:为
inventory.py的每个公开类与函数写 Google 风格 docstring(含Args/Returns/Raises与至少 2 个Example),配置[tool.ruff.lint.pydocstyle] convention = "google",确保ruff check通过D规则。然后用help()或 mkdocstrings 验证能被正确读取。参考答案要点D 规则会强制:模块 docstring(D100)、公共类(D101)、公共方法(D102)、__init__除外(D107)。Example用>>>行内 doctest 格式,最好写成可直接被pytest --doctest-modules执行的例子。命令行python -c "import inventory; help(inventory.InventoryService)"验证。 -
运行时校验对比实验:分别用纯注解、
@beartype、@pydantic.validate_call三种方式给同一个函数def calc(x: int, y: int) -> float加校验,各传一次错误类型参数,比较报错信息与性能(用timeit跑 10 万次)。参考答案要点纯注解无运行时检查(不报错);beartype 报BeartypeCallHintParamViolation,开销极小(一次 isinstance);pydantic 功能最全但开销最大(要构造并校验模型),适合边界处而非热循环。结论:热路径用注解+静态检查,入口/边界用 beartype 或 pydantic。 -
重构反模式代码:拿一段含
from typing import *、魔法字符串、get_命名、混合 Tab/空格、无 docstring 的代码,把它整理为规范代码。要求列出「改了哪些地方、为什么改」的对照表,并在 pyproject 中启用对应 ruff 规则证明。参考答案要点对照表应涵盖:导入改为显式且分组(F401 未使用导入会报错)、魔法字符串提为Enum(S105类规则)、get_total()改total()、补全所有注解(ANN)、补 docstring(D)、统一引号与空行(E/W)。最后ruff check .应 0 error。 -
配置 pre-commit 门禁:写
.pre-commit-config.yaml,串起 ruff(--fix)、ruff-format、mypy、trailing-whitespace、end-of-file-fixer、check-yaml 五类钩子;在代码里故意制造 3 个问题(未使用导入、格式错、类型错),运行pre-commit run --all-files验证它们被自动修复或拦截,并写出修复前后的git diff。参考答案要点安装pip install pre-commit && pre-commit install;注意 ruff-format 与 check-yaml 可能冲突顺序(先修再格式化);某些钩子(如 mypy)需要additional_dependencies才能导入项目依赖;pre-commit run --all-files --show-diff-on-failure便于排查。