AI 技术博客
返回首页
Python 基础 21 分钟阅读

代码风格审查:lint/format/type 三件套实战

Python代码风格ruffblackmypy

引言

这是第十章「阶段测验与查漏补缺」的第 5 篇。前四篇把知识测了、把坑总结了,这一篇解决一个更现实的问题:你的代码能跑,但「能跑」与「能维护」之间隔着什么? 课程前九章教的都是做出正确输出的代码;而放进真实工程,读者就不再只有解释器,还有三个月后的你自己和同事。本篇介绍 Python 生态里约定俗成的工程三件套:lint(静态检查)、format(格式化)、type check(类型检查),工具分别是 ruff、black、mypy。其中 mypy 在第四章《类型系统与注解》里已经安装并跑过基础示例,本文直接复用;ruff 与 black 需要先安装。本文的推进方式是审一份「故意写差但真实可运行」的成绩单脚本,走一遍从报错到全绿的完整过程,所有命令行输出均为本机 Python 3.12.10 实测结果(ruff 0.16.5、black 26.5.1、mypy 2.3.1)。

概念与原理

lint / format / type 的分工

三个工具回答三个不同的问题,别混为一谈:

  • lint(ruff):回答「代码里有没有确定的错误气味」——未使用的导入与变量、语法层面的可疑代码、违反行宽等风格约定、import 顺序混乱。lint 的规则可以裁剪,默认规则集聚焦「几乎一定是错的」:F 类(pyflakes,未定义名字、未使用变量/导入)、部分 E 类(pycodestyle 的子集 E4/E7/E9)、I 类(isort,import 排序)。行长度 E501 这类「纯风格」规则默认关闭,需要显式打开——这正是工程上可以按团队约定取舍的部分。
  • format(black):回答「代码长什么样」——缩进、换行、引号、逗号、空格。black 号称「不可争论的格式化器」(The Uncompromising Code Formatter):它不给选项,你接受它的审美,换来整个代码库风格统一、diff 干净。改完格式的程序逻辑一行不变。
  • type check(mypy):回答「类型标注与使用是否自洽」——在不运行程序的前提下静态分析,利用第四章讲过的 PEP 484 注解推导出错误:把 int 当 str 用、函数声明返回 float 却返回了 dict,这些 bug 在运行之前就被抓住。类型检查是三者中唯一「不懂类型标注就无从谈起」的,所以依赖前八章的注解基础。

为什么按 lint → format → type 的顺序推进

顺序有讲究:lint 先清理死代码和命名,让「每一个名字都有意义」,type 检查才有干净的推理环境——一个没被使用却写了错误类型的函数,浪费检查时间还误导读者。format 放在最后统一「排版」,避免 lint 修复与手改把缩进搅乱。三个工具各司其职、互不替代:lint 不保证格式统一,formatter 不查逻辑错误,mypy 不懂代码风格。

操作与实现

操作与实现

下面这套流程不是”为了工具而工具”,它的背后是一条可重复的工程流水线:先让机器把能自动判断的问题全部找出来(lint),再让格式统一(format),最后用类型把”意图”写进代码(type)。三个阶段各自解决不同层级的风险:lint 抓的是”大概率是 bug”的低级错误,format 解决的是”每个人都按自己的审美写”的协作噪音,类型则把”这个函数到底接收什么、返回什么”的契约从人脑搬到代码里。三者配合,代码审查(code review)时人就可以把注意力集中在真正的逻辑问题上,而不是缩进和命名。

把这段流程嵌入日常工作还有一个顺序上的讲究:先修 lint,再跑 format,最后做类型标注。因为 lint 和 format 都会改写代码(自动修复会删除未使用的 import、调整空行),类型标注建立在”代码已经稳定”的基础上更不容易被推翻;反过来先写类型再改结构,类型标注往往要跟着返工。工具的输出要用”差异视角”来读——关注它新增的每个提示背后对应的代码习惯,而不是把工具的告警当成需要逐一消灭的清单。工具不是裁判,是协作伙伴:它负责提醒,你负责判断是否真的有问题。

第一步:拿到「故意写差」的样例

先安装工具(mypy 第四章已装,此处只需补两个):

pip install ruff black

然后创建 report_bad.py。这份代码能运行、输出也正确,但问题成堆:os 导入了没用、unused = 1 定义了没用、函数名 getAvg 违反 snake_case 惯例、参数与局部变量用单字母、没有 docstring、没有类型注解、还有一行 164 个字符的超长行:

import os

def getAvg(a):
    unused = 1
    t = 0
    for x in a:
        t = t + x
    return t / len(a)

def show(data):
    print('平均分:')
    for k, v in data.items():
        print(f'  {k}: {v:.1f}')

r = {k: getAvg(v) for k, v in {'zhang': [88, 92, 79, 85, 90, 77, 81, 86], 'li': [95, 87, 91, 78, 89, 92, 84, 88], 'wang': [76, 80, 85, 93, 79, 82, 88, 90]}.items()}
show(r)

先确认它能跑:

python report_bad.py
平均分:
  zhang: 84.8
  li: 88.0
  wang: 84.1

运行正常——所以这类问题绝不会靠「跑一遍」暴露,必须靠静态工具。这就是 lint 存在的理由。

第二步:ruff 检查,认识默认规则

ruff check report_bad.py
I001 [*] Import block is un-sorted or un-formatted
 --> report_bad.py:1:1
  |
1 | import os
  | ^^^^^^^^^
2 |
3 | def getAvg(a):
  |
help: Organize imports
  |
2 |
3 +
4 | def getAvg(a):
  |

F401 [*] `os` imported but unused
 --> report_bad.py:1:8
  |
1 | import os
  |        ^^
2 |
3 | def getAvg(a):
  |
help: Remove unused import: `os`
  |
  - import os
1 |
  |

F841 Local variable `unused` is assigned to but never used
 --> report_bad.py:4:5
  |
3 | def getAvg(a):
4 |     unused = 1
  |     ^^^^^^
5 |     t = 0
6 |     for x in a:
  |
help: Remove assignment to unused variable `unused`

Found 3 errors.
[*] 2 fixable with the `--fix` option (1 hidden fix can be enabled with the `--unsafe-fixes` option).

三处报错都是「几乎可以断定是错误」的:import os 没用到(F401,连带 import 块格式 I001)、unused = 1 赋了值没人用(F841)。注意方括号里的 [*] 标记——带这个标记的错误可以自动修复。

第三步:打开 E501,行长度显形

默认规则不含行长度。把 E501 显式加进来,超长的那一行立刻现形。为了节约版面,这里用 --output-format concise 换成简洁一行式输出(默认的带上下文的格式见上一步):

ruff check --output-format concise --extend-select E501 report_bad.py
report_bad.py:1:1: I001 [*] Import block is un-sorted or un-formatted
report_bad.py:1:8: F401 [*] `os` imported but unused
report_bad.py:4:5: F841 Local variable `unused` is assigned to but never used
report_bad.py:15:89: E501 Line too long (164 > 88)
Found 4 errors.
[*] 2 fixable with the `--fix` option (1 hidden fix can be enabled with the `--unsafe-fixes` option).

E501 说第 15 行第 89 列开始超长(164 > 88)。PEP 8 建议每行不超过 79 个字符,ruff 的默认阈值是 88(配合 black 的 88 列排版)。这一行正是那个塞满三个学生成绩的字典推导式——本质问题是「一行里装了两个层面的事」:数据字面量 + 计算逻辑。

第四步:自动修复的边界

先跑自动修复,看看工具能替我们做多少:

ruff check --extend-select E501 --fix --unsafe-fixes report_bad.py
E501 Line too long (164 > 88)
  --> report_bad.py:13:89
   |
11 | …
12 | …
13 | … 77, 81, 86], 'li': [95, 87, 91, 78, 89, 92, 84, 88], 'wang': [76, 80, 85, 93, 79, 82, 88, 90]}.items()}
   |                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
14 | …
   |

Found 3 errors (2 fixed, 1 remaining).
ruff check --output-format concise --extend-select E501 report_bad.py
report_bad.py:13:89: E501 Line too long (164 > 88)
Found 1 error.

两个事实值得记住:第一,默认的 --fix 只修「安全」的——未使用的 import 直接删掉没问题(--unsafe-fixes 进一步删掉 unused = 1 这种赋值语句,因为删除赋值理论上可能改变行为)。第二,E501 永远无法自动修复——一行塞太长该拆成几行、怎么拆,是语义判断,工具不会替你写代码。剩下这件必须人工完成。

第五步:手动修复

人工修复要动手的不止 E501:改函数名 getAvg → average(蛇形命名)、a/t → 有语义的名字、加 docstring、把数据字面量拆成多行独立变量、去掉多余的 import。修复后的 report_fixed.py:

def average(scores):
    """计算列表的平均值。"""
    total = 0
    for score in scores:
        total += score
    return total / len(scores)


def show_report(result):
    """打印每个学生的平均分。"""
    print("平均分:")
    for name, avg in result.items():
        print(f"  {name}: {avg:.1f}")


students = {
    "zhang": [88, 92, 79, 85, 90, 77, 81, 86],
    "li": [95, 87, 91, 78, 89, 92, 84, 88],
    "wang": [76, 80, 85, 93, 79, 82, 88, 90],
}
result = {name: average(scores) for name, scores in students.items()}
show_report(result)

再查一遍,全绿:

ruff check --extend-select E501 report_fixed.py
All checks passed!
python report_fixed.py
平均分:
  zhang: 84.8
  li: 88.0
  wang: 84.1

输出与改前逐字一致——这一次修复没有改变任何行为,只改变了代码的「质地」。这是 lint/format 修复与功能修改的分界线。

第六步:black 格式化

lint 管「有没有错」,format 管「好不好看」。black 的特点是零配置、少废话。它也能接受一份压缩过头的文件,一次排好:

students = {'zhang': [88, 92, 79, 85, 90, 77, 81, 86], 'li': [95, 87, 91, 78, 89, 92, 84, 88]}
def avg(a):
 return sum(a) / len(a)
print({k: round(avg(v), 1) for k, v in students.items()})
black format_demo.py
reformatted format_demo.py

All done! ✨ 🍰 ✨
1 file reformatted.
cat format_demo.py
students = {
    "zhang": [88, 92, 79, 85, 90, 77, 81, 86],
    "li": [95, 87, 91, 78, 89, 92, 84, 88],
}


def avg(a):
    return sum(a) / len(a)


print({k: round(avg(v), 1) for k, v in students.items()})

注意几个自动发生的变化:单行字典按 88 列折行、函数定义前后各空两行、缩进统一为 4 空格、字符串引号统一为双引号。black --check 可以只报「有没有需要改的」而不实际改写,适合放进 CI 流水线;再格式化一遍它会回答 1 file would be left unchanged——格式是幂等的。

第七步:mypy 类型检查

最后补上类型维度。给脚本加注解时故意留两处错——这正好演示 mypy 抓的是什么。注意第 7 行的原地写回:name2scores[name] = average(...) 往「成绩列表字典」里塞了 float;第 8 行声明返回 dict[str, float] 却原样返回了 dict[str, list[float]];第 15 行 list[int] 的成绩表被当作 list[float] 传入:

def average(scores: list[float]) -> float:
    return sum(scores) / len(scores)


def compute_averages(name2scores: dict[str, list[float]]) -> dict[str, float]:
    for name in name2scores:
        name2scores[name] = average(name2scores[name])
    return name2scores


students = {
    "zhang": [88, 92, 79, 85],
    "li": [95, 87, 91, 78],
}
result = compute_averages(students)
print(result)
mypy mypy_demo.py
mypy_demo.py:7: error: Incompatible types in assignment (expression has type "float", target has type "list[float]")  [assignment]
mypy_demo.py:8: error: Incompatible return value type (got "dict[str, list[float]]", expected "dict[str, float]")  [return-value]
mypy_demo.py:15: error: Argument 1 to "compute_averages" has incompatible type "dict[str, list[int]]"; expected "dict[str, list[float]]"  [arg-type]
Found 3 errors in 1 file (checked 1 source file)

三处错误三种典型:赋值类型不匹配、返回值类型不匹配、实参类型不匹配。修复的思路不是绕开检查,而是把代码改对——第 2 章的教训在这里复用:list[int] 换成 list[float] 语义时,要么显式注解 students 为 dict[str, list[float]],要么在入口转换。同时把「原地写回」改成「构建新字典」——这与第六章拷贝章节的引用语义一脉相承:

def average(scores: list[float]) -> float:
    """计算列表平均值。"""
    return sum(scores) / len(scores)


def compute_averages(name2scores: dict[str, list[float]]) -> dict[str, float]:
    """把每个名字的成绩算成平均分,返回新字典。"""
    result: dict[str, float] = {}
    for name, scores in name2scores.items():
        result[name] = average(scores)
    return result


students: dict[str, list[float]] = {
    "zhang": [88, 92, 79, 85],
    "li": [95, 87, 91, 78],
}
print(compute_averages(students))
mypy mypy_fixed.py
Success: no issues found in 1 source file
python mypy_fixed.py
{'zhang': 86.0, 'li': 87.75}

至此三件套走完一遍:ruff 清结构、black 统排版、mypy 验类型,三者输出全绿,程序行为从头到尾没变过。

易错点与陷阱

  1. 把 lint 输出当「诗朗诵」,逐条手工改。带 [*] 的错误先交给 --fix,只有剩下的人工处理;但 --fix 与 --unsafe-fixes 的边界要心里有数——「安全修复」不改变行为,「不安全修复」可能改变行为,跑完一定要再跑一遍测试(第九章的 unittest 正好用上)。
  2. 以为「mypy 过了」等于「程序对了」。mypy 不运行你的代码,它只检查类型自洽;逻辑写反、边界算错它一概不管。类型通过 + 运行正确,两个证据缺一不可。
  3. 为了过检而绕检。把 students 注解成 dict[str, list[float]] 却仍传 list[int] 数据、或者到处 # type: ignore,属于自欺欺人;正确的姿势是让数据与标注对齐,或明确做类型转换。
  4. 工具与解释器版本错位。Windows 上机器里常有多套 Python,pip install ruff 装进去的解释器要和 python report_bad.py 用的是同一个(用 python -m pip 而非裸 pip 更保险),否则会出现「命令找不到」或「修了半天不是这份代码」。

小结

本章实操把「能跑」提升到「可维护」:ruff 负责 lint,默认规则抓住未用导入/变量等确定的错误,--extend-select E501 打开行长度检查,--fix 处理安全修复、--unsafe-fixes 扩大覆盖、语义类问题留给人;black 是零配置格式化器,专注 88 列折行、引号与空行统一,幂等且可进 CI;mypy 静态验证类型标注自洽,抓赋值、返回、实参三类典型错配,需要代码真正改对而非绕检。三者顺序 lint → format → type,最终目标是:改动经过三件套全绿,而程序行为零变化——这正是工程级代码的入场券(下一篇进入性能初体验)。

练习与思考题

  1. 把 report_bad.py 复制一份,只允许用 ruff check --extend-select E501 --fix --unsafe-fixes 自动修复,不许手工改。观察最终还剩哪些错误,并解释为什么这类错误只能人改。
  2. 给 report_fixed.py 的 average 函数补上 list[float] 注解,然后故意把一个成绩写成字符串 "88",运行 mypy,记录报错信息并修复。
  3. 搜索 ruff 的规则文档,找出负责「函数名必须 snake_case」的规则编号(提示:N 类命名规则),并用 --select 把它单独跑在 report_fixed.py 上验证。
  4. 思考题:black --check 放进 CI 的意义是什么?如果两个同事的本地 black 版本不同,会发生什么?结合 black 官方「版本间输出稳定」的承诺讨论。