Skip to content

第 16 章 · 类型注解与 PEP 8 编码规范 ​

这一章要解决什么:给函数写上"输入什么类型、返回什么类型",让工具在运行前就发现错误。 要打掉的关键坑:注解在运行时完全不强制 —— 它不拦你,它是写给 IDE、mypy 和同事看的。

本章你需要完成的代码:demos/ch16-typing.py

16.1 类型注解:不拦你,但能救你 ​

python
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,但传字符串照样能跑进函数体:

text
=== ① 基本类型注解 ===
  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
python
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+) ​

python
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)
text
=== ② 容器类型 ===
  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"写进签名 ​

python
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
text
=== ③ 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], 意思是"这里可能没有值"。它的价值不在工具,而在逼调用方判空:

python
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:函数也是类型 ​

python
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 正好符合:

text
=== ④ 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  ← 元组也行
python
def mean_any(values: Sequence[float]) -> float:   # 比 list[float] 更通用:元组也能传
    return sum(values) / len(values)

怎么选:函数只读这些元素 → 用 Sequence(调用方更自由);要修改(append)→ 只能写 list。

16.6 get_type_hints():框架靠它做魔法 ​

python
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:把检查交给机器 ​

bash
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 速查:格式上别自创风格 ​

text
  缩进        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,提示别从外面用)。

② 导入分组,组内按字母序:

python
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:。

④ 注释解释"为什么":

python
i = i + 1        # ❌ i 加 1 —— 把代码翻译了一遍,毫无信息
i = i + 1        # ✅ 跳过表头行

不必背规范:装好 ruff + black,让机器管格式,你只管逻辑。 但要能读懂规范 —— 因为你要读别人的代码,也要让 AI 生成的代码符合你项目的风格。

16.9 docstring:函数的说明书(Google 风格) ​

本课程统一用 Google 风格(VS Code 装 autoDocstring 插件可自动生成):

python
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 完整示例与真实输出 ​

bash
cd code && python demos/ch16-typing.py    # 示例 ⑤ 节会打印四条「AI 项目更需要注解」的理由

exercises/ex04-16-typing 把注解读出来验证,关键一行是:

text
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]):

python
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})"
参考答案
python
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 处不规范
python
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):

python
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 项目第一大错误来源。

python
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 协作:让大模型改你的代码时,带注解的函数签名是最好的提示词。

一句话总结:注解是"给未来的自己和工具看的合同" —— 它不改变程序行为, 但决定了你的项目能不能在三个月后还被安全地修改。


下一章:第 17 章 · 第一个 NumPy 数组