第 16 章 · 类型注解与 PEP 8 编码规范
这一章要解决什么:给函数写上"输入什么类型、返回什么类型",让工具在运行前就发现错误。 要打掉的关键坑:注解在运行时完全不强制 —— 它不拦你,它是写给 IDE、
mypy和同事看的。
本章你需要完成的代码:demos/ch16-typing.py
16.1 类型注解:不拦你,但能救你
def calc_soh(cap_now: float, cap_rated: float = 2.5) -> float:
"""由容量算 SOH。"""
return cap_now / cap_rated
print(" calc_soh(2.30) =", round(calc_soh(2.30), 4))
try:
print(" ", calc_soh("2.30")) # type: ignore[arg-type]
except TypeError as exc:
print(" TypeError:", exc)上面写了 cap_now: float,但传字符串照样能跑进函数体:
=== ① 基本类型注解 ===
calc_soh(2.30) = 0.92
TypeError: unsupported operand type(s) for /: 'str' and 'float'
→ 所以要用 mypy 在「运行前」检查:`pip install mypy && mypy ch16_typing.py`报错来自计算本身,不是注解 —— "2.30" / 2.5 才报 TypeError。 注解的作用是让 mypy 在不运行代码的情况下告诉你"这里类型不对": 运行时的 Python 完全不检查注解,只有 mypy / IDE 在"写完就报"。 注解不影响性能、也不改变行为 —— 加它的成本几乎为零,收益是"提前发现错误"。
16.2 基本类型怎么写
| 位置 | 语法 | 说明 |
|---|---|---|
| 参数 | cap_now: float | 冒号 + 类型 |
| 带默认值 | cap_rated: float = 2.5 | 类型在前,= 在后 |
| 返回值 | -> float | 箭头;没有返回值写 -> None |
def calc_soh(cap_now: float, cap_rated: float = 2.5) -> float: ...
def is_eol(soh: float, thr: float = 0.80) -> bool: ...
def cell_label(cell_id: str, batch: str) -> str: ...
def count_rows(rows: list) -> int: ...
def log_note(msg: str) -> None: ... # 只打印、不返回东西-> None 不是"没写",而是"明确说了没有返回值" —— 调用方一眼就知道 "这个函数是靠副作用干活的"(打印、写文件、改对象)。
16.3 容器类型:用内置泛型(Python 3.9+)
def soh_by_cell(rows: dict[str, float]) -> dict[str, dict[str, float]]:
"""嵌套类型也能表达清楚:cell_id -> {'soh': ..., 'fade': ...}"""
return {cid: {"soh": s, "fade": round(1 - s, 4)} for cid, s in rows.items()}
def total_cycles(matrix: list[list[int]]) -> int:
return sum(sum(row) for row in matrix)=== ② 容器类型 ===
mean([2.5, 2.4]) → 2.45
soh_by_cell({'C001': 0.95}) → {'C001': {'soh': 0.95, 'fade': 0.05}}
total_cycles([[1, 2], [3]]) → 6| 写法 | 含义 | 旧写法(3.9 之前) |
|---|---|---|
list[float] | 元素是 float 的列表 | List[float] |
dict[str, float] | 键 str、值 float 的字典 | Dict[str, float] |
list[list[int]] | 嵌套:二维 | List[List[int]] |
本课程统一用新写法(Python 3.9+ 把内置类型直接当泛型用,不用 from typing import List)。 list[float] 里"元素是什么"的信息,正是 IDE 补全和 mypy 检查的依据 —— 写成裸 list 就什么都检查不出来。
16.4 Optional 与 Union:把"可能是 None"写进签名
from typing import Optional, Union
def find_cell(cells: list[dict], cell_id: str) -> Optional[dict]:
"""找不到返回 None —— 把「可能没有」写进签名,调用方就必须处理。"""
for c in cells:
if c["cell_id"] == cell_id:
return c
return None
def parse_capacity(raw: Union[str, float, None]) -> Optional[float]:
"""接受多种输入类型(读 CSV 时常见:有时是字符串,有时已是数字)。"""
if raw is None or raw == "":
return None
try:
return float(raw)
except (TypeError, ValueError):
return None=== ③ Optional 与 Union ===
find_cell(..., 'C002') → {'cell_id': 'C002', 'soh': 0.79}
find_cell(..., 'C999') → None ← 必须判 None 再用
parse_capacity('2.30') = 2.3 parse_capacity('') = None
parse_capacity('abc') = None Python 3.10+ 可写 `str | float | None`Optional[T] 是全书最有价值的一个注解:它等于 Union[T, None], 意思是"这里可能没有值"。它的价值不在工具,而在逼调用方判空:
found = find_cell(cells, "C999")
if found is not None: # ← 因为签名说了可能为 None,你才会想到判它
print(f"soh = {found['soh']}")没有这个注解,你会很自然地写 found["soh"],然后喜提 TypeError: 'NoneType' object is not subscriptable —— 正是第 13 章那个 "用 None 表示失败就必须处处判空"的老问题。注解把这件容易忘的事变成了签名的一部分。 三种等价写法:Optional[float](语义最清楚)、float | None(Python 3.10+,更短)、 Union[float, None](通用形式)。
16.5 Callable 与 Sequence:函数也是类型
from typing import Callable, Sequence
def apply_all(caps: list[float], transform: Callable[[float], float]) -> list[float]:
return [transform(c) for c in caps]Callable[[float], float] 读作:接受一个 float、返回一个 float 的可调用对象。 所以 lambda c: c * 1000 正好符合:
=== ④ Callable:函数也能当类型 ===
apply_all([2.5, 2.3], lambda c: c * 1000) = [2500.0, 2300.0]
Sequence 比 list 更宽松(元组、列表都能传):
mean_any([1, 2, 3]) = 2.0
mean_any((1, 2, 3)) = 2.0 ← 元组也行def mean_any(values: Sequence[float]) -> float: # 比 list[float] 更通用:元组也能传
return sum(values) / len(values)怎么选:函数只读这些元素 → 用 Sequence(调用方更自由);要修改(append)→ 只能写 list。
16.6 get_type_hints():框架靠它做魔法
from typing import get_type_hints
print(f" get_type_hints(calc_soh) = {get_type_hints(calc_soh)}")
# {'cap_now': <class 'float'>, 'cap_rated': <class 'float'>, 'return': <class 'float'>}注解不是装饰品,它在运行时也读得出来 —— 这是很多框架的底层机制: Pydantic 按字段类型自动校验数据,FastAPI 用它生成参数校验和交互式 API 文档 (def get_cell(cell_id: str, limit: int = 10) -> dict: 里的参数会自动变成 URL 查询参数 并被类型校验),typer 用它把函数变成命令行参数。 所以你写注解的收益是双份的:给自己(IDE / mypy)+ 给框架(自动校验、自动文档)。
16.7 mypy 与 ruff:把检查交给机器
pip install mypy ruff
mypy demos/ch16-typing.py # 类型检查
ruff check . # 代码检查(含 PEP 8);--fix 自动修| 工具 | 管什么 | 类比 |
|---|---|---|
mypy | 类型对不对 | 编译器 |
ruff | 风格、可疑写法、未使用的导入 | 严格的同事 |
black / ruff format | 排版(缩进、行长、空格) | 不用再争论格式 |
为什么值得装:mypy 能在你没运行代码的情况下告诉你 "第 27 行你把 str 传给了要 float 的参数"。AI 项目动辄几千行、一次训练几小时 —— 越早发现越省钱。 但注解写错了运行时不会报,所以"我加了注解"不等于"类型一定对", 工具必须真的跑起来。
16.8 PEP 8 速查:格式上别自创风格
缩进 4 个空格,绝不用 Tab
行长 不超过 79~99 字符(本项目按 99)
命名 变量/函数 snake_case 类 PascalCase 常量 UPPER_CASE
空格 a = b + c(运算符两侧一个空格);f(a, b)(逗号后一个空格)
空行 顶层函数/类之间 2 个空行,方法之间 1 个
导入 标准库 → 第三方 → 本项目,各组之间空一行,按字母序
比较 is None / is not None(不要 == None);布尔判断直接 if flag:
字符串 统一用双引号(或统一单引号),别混
注释 # 后有空格;注释解释「为什么」,不解释「是什么」逐条看最常用的四条:
① 命名:变量 / 函数 / 模块用 snake_case(cap_now、calc_soh), 类用 PascalCase(BatteryCell),常量用 UPPER_CASE(RATED_CAP、EOL_SOH), 前导下划线表示"内部名字"(_cache,提示别从外面用)。
② 导入分组,组内按字母序:
import json # ① 标准库
from pathlib import Path
import numpy as np # ② 第三方(空一行)
from common.plots import PALETTE # ③ 本项目(再空一行)为什么按字母序:找 import 时不用一行行看,合并代码时冲突也更少。
③ 比较用 is None,不要 == None:None 全局只有一个实例(第 03 章); 布尔值也别写 if flag == True:,直接 if flag:。
④ 注释解释"为什么":
i = i + 1 # ❌ i 加 1 —— 把代码翻译了一遍,毫无信息
i = i + 1 # ✅ 跳过表头行不必背规范:装好 ruff + black,让机器管格式,你只管逻辑。 但要能读懂规范 —— 因为你要读别人的代码,也要让 AI 生成的代码符合你项目的风格。
16.9 docstring:函数的说明书(Google 风格)
本课程统一用 Google 风格(VS Code 装 autoDocstring 插件可自动生成):
def calc_soh(cap_now: float, cap_rated: float = 2.5) -> float:
"""用一句话说清这个函数做什么。
Args:
cap_now: 当前容量(Ah)
cap_rated: 额定容量(Ah),默认 2.5
Returns:
SOH,0.80 ~ 1.00 之间的 float
"""真实输出:本文件里 calc_soh 的摘要:由容量算 SOH。
| 段 | 写什么 | 什么时候必须写 |
|---|---|---|
| 第一行摘要 | 一句话说清做什么 | 永远 |
Args: | 每个参数的含义 + 单位 | 参数不是一眼能懂的 |
Returns: / Raises: | 返回什么 + 取值范围 / 抛什么异常 | 有返回值 / 会 raise 时 |
查看方式:help(calc_soh)、calc_soh.__doc__,或 IDE 里悬停。 第一行摘要最重要:help() 和大部分工具只显示它,所以它必须能独立表达"这个函数干什么"(第 14 章讲过,help() 显示的就是 docstring)。 ⚠ 别写没信息量的 docstring:"""算 SOH。""" 不如不写 —— 告诉别人单位、范围、异常才是真正的价值。
16.10 完整示例与真实输出
cd code && python demos/ch16-typing.py # 示例 ⑤ 节会打印四条「AI 项目更需要注解」的理由exercises/ex04-16-typing 把注解读出来验证,关键一行是:
find_cell 的注解:{'rows': list[dict], 'cell_id': <class 'str'>, 'return': typing.Optional[dict]}
找不到电芯,已按 None 分支处理(这就是 Optional 的价值)常见坑
- 以为注解会"拦住"错误的类型:它不会 ——
calc_soh("2.30")照样进函数体,报错来自"2.30" / 2.5本身;注解要配mypy才有威力。 list[float]写成了list:裸list等于"一个列表,里面随便什么",工具检查不出元素类型。- 该写
Optional的地方没写:函数会return None却没在签名里说,调用方就会忘判空,然后在别处报TypeError: 'NoneType' object is not subscriptable。 == None:用is None/is not None(PEP 8,也更快更准)。- 一行放两个 import:
import os, sys能跑,但 PEP 8 要求一行一个(diff 更清晰)。 - docstring 只写"算 SOH":没写单位、范围、异常 —— 等于没写。
- 注解写错了却不跑
mypy:注解本身不会报错,工具不跑就等于没写;建议加到 CI 或提交前钩子里。
本章练习
练习 1:给三个函数补全类型注解
给下面三个函数补上参数和返回值注解,再用 get_type_hints() 读出来验证 (find_cell 的返回应是 typing.Optional[dict]):
def calc_soh(cap_now, cap_rated=2.5):
return cap_now / cap_rated
def find_cell(rows, cell_id):
for r in rows:
if r["cell_id"] == cell_id:
return r
return None
def describe(cell_id, soh, thr=0.80):
tag = "待退役" if soh < thr else "健康"
return f"电芯 {cell_id}:SOH {soh:.4f}({tag})"参考答案
from typing import Optional, get_type_hints
def calc_soh(cap_now: float, cap_rated: float = 2.5) -> float:
"""由容量算 SOH。"""
return cap_now / cap_rated
def find_cell(rows: list[dict], cell_id: str) -> Optional[dict]:
"""在列表里找一支电芯,找不到返回 None。"""
for r in rows:
if r["cell_id"] == cell_id:
return r
return None
def describe(cell_id: str, soh: float, thr: float = 0.80) -> str:
"""拼一句人类可读的说明。"""
tag = "待退役" if soh < thr else "健康"
return f"电芯 {cell_id}:SOH {soh:.4f}({tag})"
for fn in (calc_soh, find_cell, describe):
print(f"{fn.__name__} 的注解:{get_type_hints(fn)}")
# calc_soh 的注解:{'cap_now': <class 'float'>, 'cap_rated': <class 'float'>, 'return': <class 'float'>}
# find_cell 的注解:{'rows': list[dict], 'cell_id': <class 'str'>, 'return': typing.Optional[dict]}四个要点:① 标量用 float / str / bool;② 可能为 None 用 Optional[dict] (Python 3.10+ 可写 dict | None);③ 容器用内置泛型 list[dict]; ④ get_type_hints() 把字符串形式的注解还原成真正的类型对象 —— 框架靠它工作。
完整练习在 exercises/ex04-16-typing。
练习 2:找出这段代码的 5 处不规范
import os, sys
from pathlib import Path
import numpy as np
RATED=2.5
class battery_cell:
def __init__(Self,CellId,cap_now=None):
Self.CellId=CellId
Self.cap_now = RATED if cap_now==None else cap_now
def SOH(self):
return self.cap_now/RATED参考答案
① import os, sys 一行两个(该拆开,而且这两个都没用到,属于死导入)。 ② 导入顺序不对:标准库 → 第三方 → 本项目,组间空一行;numpy 混进标准库了。 ③ RATED=2.5 缺空格,应写 RATED = 2.5。 ④ 类名用了 snake_case(类要 PascalCase → BatteryCell),参数 CellId、Self 也要改 snake_case(self 是必须叫 self 的惯例)。 ⑤ cap_now == None 应写 is None(PEP 8 明确要求)。
修正后(顺带补上注解和 docstring):
RATED_CAP = 2.5
class BatteryCell:
"""一支电池电芯。"""
def __init__(self, cell_id: str, cap_now: float | None = None):
self.cell_id = cell_id
self.cap_now = RATED_CAP if cap_now is None else cap_now
def soh(self) -> float:
"""健康度 = 当前容量 / 额定容量。"""
return self.cap_now / RATED_CAP一条经验:这些都不是"能不能跑"的问题 —— 原代码完全能跑。 PEP 8 的价值是"让别人和三个月后的你读起来不费劲"。装个 ruff 就能自动发现这 5 处。
练习 3:为什么 AI 项目比普通脚本更需要类型注解
先自己写 3 条理由,再对照参考答案。
参考答案
① 张量形状(shape)是最大的错误来源。 def make_windows(series: list[float], win: int) -> list[list[float]] —— 看到 list[list[float]] 就知道返回的是二维结构(样本 × 窗口)。 形状错误((N, T, C) 还是 (T, N, C)?)是 AI 里最常见的 bug。
② 数据集与模型的接口靠类型约定。 Dataset.__getitem__ -> tuple[np.ndarray, int] 把"一个样本长什么样"写清楚了,DataLoader 和 collate_fn 才有依据。 ③ 重构时敢动代码:改了签名,mypy 会把所有调用处标出来;没有注解你只能 grep 函数名再一个个读。④ IDE 靠注解做补全和跳转 —— 几千行的项目里这是"每天省一小时"。
成本视角:注解是一次性成本,收益却发生在每次改代码、每次别人读代码的时候。 AI 项目的迭代次数远多于普通脚本,这笔投资的回报最高。
面向 AI 开发:类型注解在 AI 项目里的四个具体用途
① 把"张量形状"写进签名 —— AI 项目第一大错误来源。
def make_windows(series: list[float], win: int) -> list[list[float]]:
"""把一维序列切成滑动窗口:返回 [样本数][窗口长度]。"""
def normalize(x: np.ndarray, mean: float, std: float) -> np.ndarray:
"""按 z 分数标准化(第 32 章),返回同形状数组。"""注解表达不了具体的 N/T,但能表达"有几个维度",配上命名和 docstring 就够用了。
② Dataset.__getitem__ 的返回类型 = 数据接口的契约。def __getitem__(self, idx: int) -> tuple[np.ndarray, int] 写清楚之后, 别人写 collate_fn 有依据,你自己重构也不会把返回顺序搞反。 ③ 配置对象用带注解的 @dataclass(第 15 章):mypy 能发现"你把学习率写成了字符串"。 ④ 重构安全 + 与 AI 协作:让大模型改你的代码时,带注解的函数签名是最好的提示词。
一句话总结:注解是"给未来的自己和工具看的合同" —— 它不改变程序行为, 但决定了你的项目能不能在三个月后还被安全地修改。