如何在 Python 中发现重复代码¶
重复代码会带来实际问题。在一处副本中修复的 bug,往往在其他副本中继续存在。每次改动都要在多处同步修改。代码审查者也会浪费时间反复阅读已经看过的逻辑。如今 AI 助手大量参与代码编写,近乎相同的代码块在代码库中出现的速度,远比人工复制粘贴快得多。
本指南将说明什么样的代码算作"重复",哪些工具可以在 Python 中检测重复代码,以及如何在本地和 CI 中运行检测。
快速上手¶
如需立即扫描项目,运行:
这会在不安装任何依赖的情况下运行 pyscn 的克隆检测。命令完成后会打开一份 HTML 报告,列出所有重复代码组,并附有相似度评分和文件位置。
什么算"重复"?四种克隆类型¶
大多数开发者对重复代码的印象是字面意义上的复制粘贴。在研究领域,重复代码被称为代码克隆,分为四种类型。这一区别很重要,因为大多数工具只能捕获前一两种。
下面用同一个函数的四个变体来说明。
Type-1:完全相同的代码。 复制粘贴,仅空白和注释有所不同:
def calculate_order_total(items, discount_rate):
subtotal = 0.0
for item in items:
price = item["price"]
quantity = item["quantity"]
if quantity <= 0:
continue
subtotal += price * quantity
if discount_rate > 0:
subtotal = subtotal * (1 - discount_rate)
tax = subtotal * 0.1
total = subtotal + tax
return round(total, 2)
如果这段代码被粘贴到另一个文件(也许加了一条注释),就是 Type-1 克隆。这是最容易检测、也最容易修复的类型:提取为共享函数即可。(规则:duplicate-code-identical)
Type-2:重命名标识符。 结构完全相同,只有名称发生了变化:
def compute_cart_amount(products, rebate):
amount = 0.0
for product in products:
cost = product["price"]
count = product["quantity"]
if count <= 0:
continue
amount += cost * count
if rebate > 0:
amount = amount * (1 - rebate)
levy = amount * 0.1
result = amount + levy
return round(result, 2)
基于行的工具会忽略这种情况,因为没有任何两行在文本上完全相同。但如果将语法树的名称归一化后再比较,这两个函数的结构完全一致。(规则:duplicate-code-renamed)
Type-3:修改后的副本。 有人复制了该函数,随后增加或删除了几条语句:
def calculate_quote_total(items, discount_rate, shipping=0.0):
subtotal = 0.0
for item in items:
price = item["price"]
quantity = item["quantity"]
if quantity <= 0:
continue
subtotal += price * quantity
if discount_rate > 0:
subtotal = subtotal * (1 - discount_rate)
subtotal += shipping # <- 新增
tax = subtotal * 0.1
total = subtotal + tax
return round(total, 2)
这是实际代码库中最常见的克隆类型。有人复制了一个函数,为新场景做了调整,然后继续开发。检测这类克隆需要度量两棵语法树之间的距离(树编辑距离),而不只是判断是否匹配。(规则:duplicate-code-modified)
Type-4:行为相同,实现不同。 代码从头重写,但计算结果相同:
def total_for_order(items, discount_rate):
valid_items = []
for item in items:
if item["quantity"] > 0:
valid_items.append(item)
subtotal = sum(
item["price"] * item["quantity"]
for item in valid_items
)
if discount_rate > 0:
subtotal = subtotal * (1 - discount_rate)
total_with_tax = subtotal * 1.1
return round(total_with_tax, 2)
文本匹配或树匹配都无法将这段代码与原始版本关联起来,但通过比较控制流结构可以发现。(规则:duplicate-code-semantic)
检测 Python 重复代码的工具¶
以下是主要工具及各自的检测能力:
| 工具 | 检测范围 | 说明 |
|---|---|---|
pylint(R0801) |
Type-1 | 基于行的相似度检查,随 pylint 一同提供。能捕获复制粘贴,重命名后失效。 |
| jscpd | Type-1,部分 Type-2 | 基于 token,支持 150 多种语言。适合需要一个检测器覆盖多语言仓库的场景。 |
| SonarQube | Type-1,部分 Type-2 | 完整平台,含仪表盘和历史记录。配置和托管成本较高。 |
| PMD CPD | Type-1,Type-2 | 经典的复制粘贴检测器,需要 JVM。 |
| pyscn | Type-1 至 Type-4 | Python 专用。Type 1-2 使用 AST 哈希,Type-3 使用树编辑距离(APTED),Type-4 使用控制流比较。 |
一个常见的误解:ruff 不检测重复代码。 Ruff 是 lint 工具和格式化工具,负责检查单行和语句的写法,这与跨文件比较函数是两件完全不同的事。两类工具相辅相成,而非互相竞争。
演练:使用 pyscn 检测克隆¶
将上面四个变体分散到两个文件 orders.py 和 invoices.py 中,并将原始版本粘贴到两个文件里,然后运行:
pyscn 会解析所有 Python 文件,提取代码片段,并两两比较。对于大型代码库,它使用 LSH 加速,分析速度超过 100,000 行/秒。终端会显示摘要:
📊 Analysis Summary:
Health Score: 80/100 (Grade: B)
📈 Detailed Scores:
Duplication: 0/100 ❌ (10.0% duplication, 1 groups)
HTML 报告会将五个片段归入同一个克隆组,并对每对代码进行分类和评分:
| 代码对 | 分类 | 相似度 |
|---|---|---|
| 两个文件中的完全相同副本 | Type-1 | 1.00 |
| 原始版本与修改副本 | Type-2 | 0.85 |
| 原始版本与重写版本 | Type-4 | 0.94 |
注意最后一行。重写后的 total_for_order 是任何基于文本的工具都无法与原始版本关联的变体,pyscn 通过控制流结构检测到了 0.94 的相似度。
调整阈值¶
--clone-threshold 参数(默认值 0.65)设置报告一对代码所需的最低相似度:
如需固化设置,创建 .pyscn.toml 文件(或在 pyproject.toml 中使用 [tool.pyscn]):
默认会跳过非常短的函数(min_lines)。低于某个大小,相似度就失去了意义,因为每个两行的 getter 看起来都差不多。所有选项(包括单独开关各克隆类型)详见配置参考。
在 CI 中自动化检测¶
pyscn check 是 analyze 的 CI 版本,不生成报告,只输出通过/失败的退出码:
作为 GitHub Actions 步骤:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pipx run pyscn check --select clones .
当新增重复代码超出阈值时,任务失败。这正是关键所在:需要手动触发的检测,迟早会被遗忘而停止执行。完整工作流详见 CI/CD 集成,如需在 Pull Request 上自动发布审查评论,请参阅 Pyscn Bot。
如何处理检测结果¶
并非所有克隆都需要消除。建议按以下优先级处理:
- 生产代码中的 Type-1 和 Type-2 克隆。 提取为共享函数。由于副本几乎完全相同,修复方式机械化,风险极低。
- Type-3 克隆。 仔细查看各副本之间的差异。如果差异体现在数据上,提取一个接受参数的函数;如果差异体现在行为上,这些副本可能是有意分化的。有时两处调用确实需要独立演进,强行合并反而会耦合本该独立的模块。
- Type-4 克隆。 将其视为信号,而非必须立即处理的任务。同一逻辑的两套独立实现,往往意味着两位开发者互不知情。选择其中一套保留,或记录说明两者共存的原因。
- 测试代码中的克隆。 对这类情况可以宽松一些。测试代码注重显式表达,而非 DRY 原则,适当的重复有助于每个测试用例保持独立可读性。
实践中,设置一个较严格的阈值以保持报告简短,修复排名最靠前的克隆组,再重新运行,效果往往好于一次性清理 40 组克隆的大规模重构。
常见问题¶
ruff 能检测重复代码吗? 不能。Ruff 是 lint 工具和格式化工具,没有克隆检测规则。发现重复代码需要跨文件比较代码片段,这超出了 linter 的职责范围。请用 ruff 做风格和正确性检查,用克隆检测工具处理重复问题,两者配合使用效果更佳。
多少重复是可以接受的? 没有统一的标准。粗略来说,维护良好的代码库重复行占比通常低于 5%,超过 15% 一般意味着存在系统性的复制粘贴开发习惯。趋势比数字本身更重要。每个版本发布后重复率持续上升,才是真正需要警惕的信号。
能跨多个仓库检测重复吗?
可以。将分析器指向包含多个仓库检出目录的父目录即可:pyscn analyze --select clones repo-a/ repo-b/。所有在扫描范围内的片段都会两两比较,跨仓库克隆与普通克隆一样会出现在报告中。
为什么我的重复代码片段没有被报告?
最可能的原因是片段低于最小大小限制(配置中的 min_lines / min_nodes)。检测器故意跳过极短的片段。在五行的情况下,代码库中的大半代码都会彼此相似。如需比较短片段,请在 .pyscn.toml 中降低限制值。