一. 前言:很多工具真正核心,其實只是「比對差異」 #
你有沒有遇過這些場景?設定檔改了一點點,但看半天不知道差在哪;使用者打錯指令,你想幫他猜最接近的正確名稱;兩份文字內容很像,但你想知道到底是新增、刪除還是替換;做了一個文件同步工具,想輸出像 Git 一樣的 diff 給人看。這些問題表面上看起來很多種,本質上其實都跟「比較兩段序列有多像、哪裡不同」有關。
而 Python 標準函式庫裡,就有一個很安靜但很能打的模組:difflib。它最常被拿來做幾件事:
- 比較兩段文字或兩個列表
- 產生
ndiff、context diff、unified diff - 根據相似度找出最接近的候選字串
- 輸出 HTML 版差異報表
如果你前面看過拍拍君寫的 Python argparse 實戰、Python pathlib 實戰 跟 Python prompt_toolkit 實戰,今天這篇可以把它們串起來看:argparse 幫你收參數,pathlib 幫你處理檔案路徑,difflib 幫你看懂差異,而 prompt_toolkit 可以在互動式介面裡把比對結果包得更漂亮。
這篇文章會一路帶你做五件事:理解 SequenceMatcher、學會 ndiff() 與 unified_diff()、用 get_close_matches() 做 typo 建議、用 HtmlDiff 生報表,最後做一個小型 CLI,比對兩個文字檔。difflib 不會讓你變成 Git,但很多「需要比較差異」的日常工具,它其實已經很夠用了。
二. 安裝:好消息,這次幾乎是零安裝 #
difflib 是 Python 標準函式庫的一部分,所以多數情況下你不用額外安裝任何套件,只要這樣就能開始:
import difflib
如果你想在乾淨環境裡試玩,可以用 uv 快速開一個小專案:
uv init difflib-demo
cd difflib-demo
uv venv
source .venv/bin/activate
python - <<'PY'
import difflib
print('difflib ready')
PY
拍拍君先提醒兩件事。第一,difflib 比較的是「序列」,不只是字串;也就是說,不只 str 可以比,list[str]、list[int] 甚至你自己切好的 token list 都能比。第二,difflib 的設計重點比較偏向「人類可讀的差異呈現」,不是所有場景都追求最數學上的最佳解。所以如果你要的是超大型文本的極致效能、嚴格的編輯距離演算法,或語意層級的相似度,那就要看別的工具;但如果你的需求是 CLI 顯示差異、文件版本比較、使用者輸入提示,或快速做出 patch-like 輸出,那 difflib 真的很好用。
三. 先從 SequenceMatcher 開始:理解它怎麼判斷相似
#
difflib 的核心角色之一是 SequenceMatcher。它的工作可以粗略理解成:找出兩個序列裡「最長、最像、可以對上的區塊」,再據此推算整體相似度與差異位置。
先看一個簡單例子:
from difflib import SequenceMatcher
old = ["python", "diff", "tool"]
new = ["python", "text", "diff", "tool"]
matcher = SequenceMatcher(a=old, b=new)
print(matcher.ratio())
for opcode in matcher.get_opcodes():
print(opcode)
你會看到類似這樣的結果:
0.8571428571428571
('equal', 0, 1, 0, 1)
('insert', 1, 1, 1, 2)
('equal', 1, 3, 2, 4)
這表示第一個元素 python 一樣,new 中間多插入了一個 text,後面的 diff 跟 tool 又重新對上了。
ratio() 是什麼意思?
#
ratio() 會回傳 0 到 1 之間的浮點數。越接近 1 代表越像,越接近 0 代表越不像。它很適合拿來做模糊建議、排序候選結果,或判斷某次修改是不是小改動。不過它不是語意分數。例如 delete user 跟 remove account 語意可能很近,但字面上不一定很像,所以你要記得,difflib 看的是「序列結構」,不是語意理解。
get_opcodes() 超實用
#
get_opcodes() 會把差異拆成一段一段操作,操作類型通常有:equal、replace、insert、delete。這在做自訂 diff 顯示時非常方便,例如你可能想把替換部分標紅、新增部分標綠、刪除部分加刪除線,那就可以自己遍歷 opcode。
很多時候我們不是要比一整個長字串,而是比「每一行」或「每個詞」。這時你可以先 splitlines() 或 split() 再交給 SequenceMatcher。如果你比較的是自然語言句子,拍拍君很推薦你依需求決定粒度:想看大方向變動就逐行、想看單字替換就逐詞、想看極細節拼字變化才逐字元。這樣做出來的 diff,通常比硬上逐字元更可讀。
一句話總結:SequenceMatcher 像是 difflib 的引擎,負責理解「哪些片段其實對得上」。理解它之後,後面幾個 API 會順很多。
四. ndiff() 與 unified_diff():開始輸出人類看得懂的差異
#
如果你不是想自己解 opcode,而是想直接拿到常見 diff 格式,那就可以交給 ndiff() 跟 unified_diff()。這兩個函式都很常用,但用途有點不同。
1. ndiff():教學友善、逐行可讀
#
先看例子:
import difflib
old = ["apple\n", "banana\n", "cat\n"]
new = ["apple\n", "banana split\n", "cat\n", "dog\n"]
for line in difflib.ndiff(old, new):
print(line, end='')
它會輸出帶前綴的結果: 代表沒變、- 代表舊版本有新版本沒有、+ 代表新版本新增、? 代表輔助提示,指出細節差異位置。ndiff() 很適合教學展示、給人肉眼看差異,或在 terminal 直接輸出簡單比較結果,但它不一定最適合拿去做 patch 檔。
2. unified_diff():更像 Git、patch 工具常見格式
#
如果你想要的是更標準、更接近 Git diff 的輸出,unified_diff() 通常更合適。
import difflib
old = ["apple\n", "banana\n", "cat\n"]
new = ["apple\n", "banana split\n", "cat\n", "dog\n"]
for line in difflib.unified_diff(
old,
new,
fromfile='before.txt',
tofile='after.txt',
lineterm='\n',
):
print(line, end='')
你會看到像這樣的格式:
--- before.txt
+++ after.txt
@@ -1,3 +1,4 @@
apple
-banana
+banana split
cat
+dog
這種格式的優點非常明確:大家熟悉、好讀、可以保存成 .diff 或 .patch,也很適合版本比較、審查工具、CLI 輸出。如果你要自己做一個「比較兩個設定檔差異」的小工具,拍拍君大多數時候會先選 unified_diff()。
context_diff() 呢?
#
difflib 也有 context_diff()。它輸出的也是經典格式,只是現在大家更常看到 unified diff,所以使用頻率通常沒那麼高。你可以先記住一個實務選擇:想教學、想看得更細用 ndiff();想跟 Git 世界接軌用 unified_diff();需要舊式上下文格式才用 context_diff()。
splitlines(keepends=True) 是常見小細節
#
如果你拿的是整段文字,而不是本來就帶換行的 list,記得可以這樣切:
old_lines = old_text.splitlines(keepends=True)
new_lines = new_text.splitlines(keepends=True)
為什麼要 keepends=True?因為 diff 工具很多時候希望保留每行結尾,這樣輸出格式會更穩定。如果你把換行全部吃掉,有時印出來的結果會黏在一起,看起來就很醜。
實務上常見的坑 #
- 逐字元 diff 太吵:用在長文件通常可讀性很差,先改成逐行或逐詞。
- 直接比 JSON 字串,結果很亂:先 pretty print,再逐行 diff。
- 沒有正規化就直接比較:空白、排序、大小寫都可能造成假差異,有些資料先 normalize 再比會更準。
例如你在比較設定檔前,可以先做:
normalized = text.strip().replace('\r\n', '\n')
小小的正規化,常常可以少掉一堆根本不重要的 diff 噪音。
五. get_close_matches():拿來做拼字修正和命令建議超方便
#
很多 CLI 做得貼心的地方,不是功能多,而是使用者打錯時它還願意幫忙。像是使用者輸入 pritn、pubish、statuz,你如果只回一句「未知指令」,體驗就比較硬;但 difflib.get_close_matches() 可以讓你輕鬆補上一句:「你是不是想輸入 publish?」
先看一個簡單例子:
from difflib import get_close_matches
commands = ['build', 'deploy', 'diff', 'doctor', 'publish']
word = 'pubish'
print(get_close_matches(word, commands, n=3, cutoff=0.5))
輸出通常會是:
['publish']
get_close_matches(word, possibilities, n=3, cutoff=0.6) 最常調的就是兩個:n 代表最多回傳幾個候選,cutoff 代表最低相似度門檻。門檻越高,候選更保守、不容易亂猜,但可能什麼都猜不到;門檻越低,則比較容易給建議,但有時會出現有點勉強的結果。拍拍君常見的經驗值大概是:CLI 指令用 0.5 ~ 0.7、選單項目用 0.6 ~ 0.8,而人名或自然語言則不一定適合直接用。
做成友善錯誤訊息也很簡單:
from difflib import get_close_matches
commands = ['build', 'deploy', 'diff', 'doctor', 'publish']
user_input = 'pubish'
if user_input not in commands:
suggestions = get_close_matches(user_input, commands, n=1, cutoff=0.5)
if suggestions:
print(f"未知指令:{user_input},你是不是想輸入 {suggestions[0]!r}?")
else:
print(f"未知指令:{user_input}")
這種細節很小,但整個工具的「體感品質」會差很多。它不只適合命令列,也很適合拿來做 CSV 欄位名稱比對、設定鍵名修正、API 欄位 typo 警告,或 FAQ 搜尋候選關鍵字。例如你預期欄位叫 username,結果資料裡出現 usernmae,這時就可以提示使用者。這種地方不用上大模型,difflib 就已經很好用了。
六. HtmlDiff:想做可分享的差異報表,就讓瀏覽器來看
#
有些差異很適合在 terminal 看,有些則不然。例如兩份文件都很長、非工程同事也要一起看、想把差異報告存成 HTML 附件,或想放進內部工具頁面。這時候 HtmlDiff 會很好用。
它可以把兩份逐行內容轉成 HTML 表格,左右對照,而且會標出差異。基本範例如下:
from difflib import HtmlDiff
from pathlib import Path
old_lines = Path('before.txt').read_text().splitlines()
new_lines = Path('after.txt').read_text().splitlines()
html = HtmlDiff().make_file(
old_lines,
new_lines,
fromdesc='before.txt',
todesc='after.txt',
)
Path('report.html').write_text(html, encoding='utf-8')
跑完後,直接打開 report.html 就能看結果。拍拍君覺得 HtmlDiff 最適合三種場景:內容審稿、設定檔變更審查,以及資料清理前後比對。如果原始內容很雜,先做基本清理再輸出 HTML,可讀性通常會好很多,例如把 \r\n 統一成 \n、去掉行尾多餘空白、決定要不要保留空行。否則 HTML 雖然漂亮,但整頁都在標一些沒意義的 whitespace 差異,看了也很累。
另外要注意,HtmlDiff 吐出來的是完整 HTML。如果你要把它塞進現有網頁,可以包進 iframe,或抽取中間內容再整合版型;同時也要注意 escaping 與安全性,尤其是當原始文字來自使用者輸入時,最好先確認整體處理流程是不是安全,不要直接把任何可疑內容原封不動丟進頁面。
七. 實戰:做一個比對兩個文字檔的 CLI #
前面分開看 API 很清楚,但真正落地通常會長成一個工具。這一節我們來做一個小小的 textdiff.py:接收兩個檔案路徑、可以輸出 unified 或 ndiff、也可以選擇另外寫出 HTML 報表。這種工具很適合配合 Python argparse 實戰 跟 Python pathlib 實戰 一起複習。
先看完整程式:
from __future__ import annotations
import argparse
import difflib
from pathlib import Path
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description='Compare two text files with difflib.',
)
parser.add_argument('old_file', help='path to the old file')
parser.add_argument('new_file', help='path to the new file')
parser.add_argument(
'--format',
choices=['unified', 'ndiff'],
default='unified',
help='diff output format',
)
parser.add_argument('--html', help='optional output path for HTML report')
return parser
def read_lines(path: Path) -> list[str]:
text = path.read_text(encoding='utf-8')
normalized = text.replace('\r\n', '\n')
return normalized.splitlines(keepends=True)
def make_diff(
old_lines: list[str],
new_lines: list[str],
fmt: str,
old_name: str,
new_name: str,
) -> str:
if fmt == 'ndiff':
lines = difflib.ndiff(old_lines, new_lines)
else:
lines = difflib.unified_diff(
old_lines,
new_lines,
fromfile=old_name,
tofile=new_name,
lineterm='\n',
)
return ''.join(lines)
def write_html_report(
old_lines: list[str],
new_lines: list[str],
old_name: str,
new_name: str,
output_path: Path,
) -> None:
html = difflib.HtmlDiff().make_file(
old_lines,
new_lines,
fromdesc=old_name,
todesc=new_name,
)
output_path.write_text(html, encoding='utf-8')
def main() -> None:
parser = build_parser()
args = parser.parse_args()
old_path = Path(args.old_file)
new_path = Path(args.new_file)
old_lines = read_lines(old_path)
new_lines = read_lines(new_path)
diff_text = make_diff(
old_lines,
new_lines,
args.format,
old_path.name,
new_path.name,
)
print(diff_text, end='')
if args.html:
write_html_report(
old_lines,
new_lines,
old_path.name,
new_path.name,
Path(args.html),
)
print(f'\nHTML report written to {args.html}')
if __name__ == '__main__':
main()
這個版本故意做得很樸素,但已經很實用:
argparse負責 CLI 介面Path.read_text()負責讀檔splitlines(keepends=True)保留換行資訊make_diff()集中處理輸出格式write_html_report()負責產生 HTML 報表
假設有兩個檔案:before.txt 與 after.txt,可以這樣跑:
python textdiff.py before.txt after.txt
如果想看 ndiff:
python textdiff.py before.txt after.txt --format ndiff
如果想額外輸出 HTML:
python textdiff.py before.txt after.txt --html report.html
再往前走一步,你還可以加上 --ignore-case、用 pathlib 遞迴比較整個資料夾、用 rich 把新增刪除做彩色輸出、用 watchdog 監看檔案自動重跑 diff,或用 prompt_toolkit 做互動式檔案選擇器。這些主角都不同,但底層那顆「比較差異」的小引擎,很多時候還是 difflib。
拍拍君也順便潑個冷水:如果你要處理超大檔案、binary file、語意層級的等價判斷,或嚴格 patch 套用流程,那就不要硬把所有事都丟給 difflib。工具不是越萬能越好,而是知道它擅長哪一段。對 difflib 來說,它最擅長的就是:用很低的成本,幫你把文字差異整理成對人有用的資訊。
結語:別小看標準函式庫裡這些「安靜但很能打」的模組 #
difflib 很少像 FastAPI、Pydantic 或 Textual 那樣一登場就很華麗。它比較像工具箱裡那把你平常沒特別炫耀,但每次拿出來都會想說「啊,還好 Python 內建有這個」的工具。
今天我們一路看了幾件事:
SequenceMatcher怎麼理解相似片段ndiff()與unified_diff()怎麼輸出差異get_close_matches()怎麼拿來做 typo 建議HtmlDiff怎麼變成可分享的報表- 最後怎麼把它們包成一個小 CLI
如果你正在做文件工具、設定檔管理工具、CLI 體驗優化,或自動化比對流程,那拍拍君真的很推薦把 difflib 放進你的腦中備用清單。它不一定是最強、最快、最潮的方案,但在很多「我只是想把差異看懂」的時刻,它會非常省時間。
下次你想寫 typo suggestion、config diff、patch 輸出,或版本比較報告時,先別急著找第三方套件,標準函式庫可能已經幫你準備好了。