Python 代码质量与设计
适用场景
- 编写或审查虽然能工作但难以理解、扩展或维护的 Python 代码。
- 将从 Java 或 C++ 直接翻译过来的代码替换为更适合 Python 语言的构造。
- 选择抽象层次、评估耦合度或在新需求出现后进行重构。
- 识别设计模式的意图,但不要将其名称变成架构上的强制义务。
- 本范围关注代码本身的推理,既不规定部署环境也不规定框架。
理念
寻找能够解决当前问题的最小构造,而不是想象中最通用的设计。 当 Python 已经提供了某些结构时,保留设计模式的意图意味着去除重复结构是一种改进,而不是缺乏 sophistication。 好的抽象能在不擦除客户端所需信息的前提下降低变更成本。 基于 Bruce Eckel 的《Thinking in Python》以及语料库 a8feb860 的操作性综合。 尊重项目实际支持的 Python 版本:语料库近期目标版本不构成隐式版本升级的依据。
规则
可读的语法和可预测的命名
- 让缩进匹配实际的块结构;使用四个空格,不要混用制表符和空格。
- 块头部以冒号结束;避免使用分号和人为压缩代码的多条语句。
- 对于长语句,使用圆括号、方括号或花括号内的续行,而不是难以跟随的分隔。
- 不要混淆块与作用域:
if或for中的赋值可能在块之后仍然可见。 - 确保在使用名称前的每条路径上都执行了赋值;仅靠缩进不能保证它的绑定。
- 对变量、函数、方法和文件使用
snake_case;类使用PascalCase;全大写表示常量。 - 保留将类命名为函数风格的例外,仅用于实际上这样使用的对象;不要将其作为通用风格。
- 不要从大写名称推断运行时保护:它向读者传递一种意图。
- 保留项目一致的惯例并使用其已有的 linter,而不是创建任意的局部规则。
引用、值和可变性
- 将赋值理解为将名称绑定到对象;它不会复制对象。
- 明确区分重新赋值与修改:重新赋值名称会保留其之前的别名不变,而修改对象则可能影响所有别名。
- 对值相等使用
==;将is保留用于有意的身份比较,特别是is None和其他单例。 - 不要使用字符串或小整数的身份作为值测试:共享优化不提供该契约。
- 列表的切片或浅拷贝会重新创建外层结构,但不会重新创建嵌套元素;检查哪些对象仍然共享。
- 仅在需要复制所包含对象时才使用深拷贝;赋值无法提供这种隔离。
- 不要机械地将
x += y替换为x = x + y:对于列表,前者原地修改,后者生成新列表。 - 对于不可变数字,理解增强赋值是将名称重新绑定到结果,而不是修改共享的数字。
- 在不需演进的情况下优先使用不可变数据,但不要混淆容器的不可变性与内容的不变性。
数字与真值
- 区分
/(整数操作数时产生浮点数)与//(向负无穷舍入的整数除法)。 - 在移植时测试负数情况:整数商不是向零截断,余数
%的符号遵循除数。 - 不要假设 Python 整数具有固定宽度;它们不会像其他语言中的有界整数那样溢出。
- 将精确整数运算与浮点运算分开;不要将一个领域的保证引入另一个领域。
- 在
round()和格式化时考虑偶舍入(银行家舍入);不要假设总是远离零舍入。 - 在字面量中使用下划线分隔符使大数可读,且不影响其值。
- 使用
+= 1和-= 1进行增减;Python 没有++或--运算符。 - 布尔值也是整数:对条件求和可以计数真值情况,但
True并非独立于int的数字类型。 - 当问题仅关乎容器是否为空时,优先使用
if items:而非长度测试。 - 记住对象的真值可由
__bool__()定义,否则由__len__()定义。 and和or会短路并返回一个操作数,不一定是布尔值;不要仅根据名称来注释其结果。- 仅当所有假值都应被替换时才使用
x or default;如需保留零或空文本,用显式的None测试。 - 对于掩码,区分位运算符与逻辑运算符;
~x不会反转一个固定宽度的字。 - 不要期望
@对内置数字执行乘积运算:矩阵乘法取决于类型,与装饰器语法不同。
字符串与插值
- 字符串不可变:如果需要重用转换结果,请保留它。
- 选择限制转义的引号;当换行符是文本一部分时使用三引号。
- 对字面反斜杠使用原始字符串,注意此类字面量不能以单独反斜杠结尾。
- 使用内置的搜索、分割、替换和连接操作,除非有特殊需要,否则避免字符操作循环。
- 不要使用
strip、lstrip或rstrip来移除精确前缀:它们的参数表示字符集合,而非不可分的子串。 - 如需移除精确的前导或尾随子串,在目标版本支持时使用
removeprefix()和removesuffix()。 +不会隐式地将数字转换为字符串;请使用显式转换或格式化字符串。- 对于生成普通文本,优先使用 f-string;其中的表达式会被求值并立即渲染结果。
- 使用格式规范来指定精度、宽度和对齐;
!r显示对诊断有用的表示,=标记表达式。 - 不要仅仅因为旧版
%或str.format()存在就重写它们:它们仍然有效,迁移应旨在改善相关代码。 - 仅当目标版本支持且消费者需要在组装前区分字面量和值时,才考虑使用模板字符串(t-string)。
- t-string 生成结构化对象而非最终字符串;消费者必须根据其领域实际地引用、转义或拒绝插值。
- 不要仅基于插值语法来承诺安全性,也不要将说明性的字符拒绝重新用作通用保护。
- 模板消费者不能假设文本与值严格交替:迭代可能省略空字面量。
抽象、依赖与内聚
- 在添加新类之前,检查函数、字典、模块或语言协议是否足够。
- 仅因托管一个方法而存在的类不能凭这种组织方式证明其合理性。
- 根据实际需求命名变更轴;同一位置的重复变更是比预期灵活性更有用的证据。
- 将变化的部分与稳定的部分分开;在没有变化时不要构建变化机制。
- 将惯用法、具体方案和通用模式视为可能的阶段,而不是必须攀爬的阶梯。
- 对每个抽象,识别它擦除的细节,并确认没有合法客户端需要恢复这些细节。
- 同时检查隐藏在接口背后的工作:复制、排序或间接访问在实际数据规模下可能成为主导因素。
- 通过一个部分知道的内容来评估耦合:父类细节、具体类、接口、调用签名或简单值。
- 不要混淆类型化依赖与无依赖:
Protocol避免声明式继承,但其成员仍然是契约。 - 当客户端只需要一个操作时,优先使用函数签名;不要创建没有用途的多方法接口。
- 模式通常只是移动依赖而非消除它;找到仍然必须命名具体类的部分。
- 在接受具体依赖时,确保它被有意识地放置在易于变更的组装点。
- 不要深入到间接获得的对象的内部细节;通过显式接口请求所需服务。
- 将服务于同一主题的元素分组到一个组件中;将可以独立演进的想法分开。
- 优先使用组合来变更协作者,而不继承其实现细节;如果真正的可替代性存在,则保留继承。
- 检查子类型是否满足基类型客户端的期望,而不仅仅是方法名称。
- 提取出于相同原因表达相同意图的重复;偶然的相似性不足以作为理由。
- 使常见情况简单、罕见情况可能、接口一致;行为的例外会增加记忆成本。
- 如果移除一个层既不消除必要行为,也不移除有用的隔离,则移除它;在两个足够好的方案中选简单的。
- 尽可能使计算纯函数化:相同输入产生相同结果,且没有对外部世界的可观察变化。
沿扩展的两个轴重构
- 区分添加类型与添加操作;设计往往偏向一方而牺牲另一方。
- 枚举开放家族所有子类的 match 需要维护列表,并可能静默忽略新类型。
- 如果操作仅按精确类分组,则使用
type(obj)作为索引的字典可以避免这种枚举。 - 不要将此替换视为纯语法:子类会拥有自己的键,这与父类模式不同。
- 对于在首次出现时创建的组,
defaultdict(list)提供一个新列表;字典注解不会创建此行为。 - 将统一操作保留为读取相关属性的普通函数;如果算法不按类型变化,分发不增加价值。
- 当每个类型自然地携带其响应且操作较少时,将方法保留在小型、受控的层次结构中。
- 考虑使用
functools.singledispatch来添加按类型变化的操作,而无需修改外部层次结构或将新操作分散到每个类中。 - 使用
singledispatch时,实现根据第一个参数选择,并可以根据 MRO 继承;它不是精确类表。 - 默认响应必须对未注册类型真正有效;否则抛出
NotImplementedError使遗漏可见。 - 不要从任意模块添加方法:这会迫使读者在类定义之外寻找其行为。
- 通过更改的位置和保留的数据来度量子重构,而不仅仅是移除的行数。
使用模式词汇而不强加目录
- 首先描述问题:创建、接口适配、算法选择、通知、树或状态恢复;名称随后而来。
- 当函数足以胜任时,将 Strategy 和 Command 简化为传递或存储函数;链可以是一组按顺序尝试的函数。
- 认识共享模块、类表或迭代协议可能已经实现了 Singleton、Factory 或 Iterator 的意图。
- 区分 Adapter(转换接口)、Façade(简化子系统)和 Proxy(控制对另一个对象的访问)。
- 区分 State(根据状态改变行为)与状态机(根据输入选择转换)。
- 区分从外部注入协作者与在注册表中查找协作者;两者都移除了控制,但暴露依赖的方式不同。
- 将架构、持久化、消息传递和云相关的名称保持在所描述的意图层次:目录中的存在并不确立实现配方。
- 不要从仅仅提到 Circuit Breaker、Saga、Repository 或 Event Sourcing 就推断出部署、恢复或一致性建议。
流程
- 确定目标 Python 版本、已有惯例和检查项;记录需要保留的行为。
- 陈述具体需求以及观察到的变更轴;列出今天受影响的组件。
- 检查涉及的共享引用、真值、数值运算和字符串转换。
- 编写最小解决方案,然后比较所提议的抽象:擦除的细节、隐藏的工作和移动的依赖。
- 分别测试添加类型和添加操作;记录每种变体所需的修改。
- 分阶段重构,同时保留行为测试以及输入和输出数据的计数。
- 移除已不再需要的层,然后在项目支持的版本上运行测试和检查。
陷阱
- 程序可能在新类型丢失的情况下产生看似合理的结果:检查数据保留,而不仅仅是输出呈现。
- 回退分支或默认实现可能隐藏被遗忘的扩展;如果没有正确的中性响应,优先显式失败。
- 在拥有几个真实案例之前进行泛化会创造没有证据的选择;拒绝投机性的框架。
- 简短的抽象可能隐藏高成本或擦除不可少的细节;仅凭行数不足以判断其质量。
- 语料库中的最新语法和 API 不一定在旧项目中可用;不要为了采用它们而提高项目的最低 Python 版本。
验证
- 每个新类或接口保护一个已命名的需求;没有模式仅因其名称而存在。
- 条件路径在使用前绑定其变量;身份、修改和相等不被混淆。
- 零、空值、
None、负数以及嵌套数据在区分重要时已被测试。 - 新的子类不会静默消失,并且精确类或继承区分已被确认。
…
[已截断以适配翻译]