先回答『diff 为什么不聪明』
「我明明只是把第 3 行的代码挪到第 9 行,为什么 git diff 把两段都标红了?」——这句吐槽背后,是 diff 算法根本不打算懂『移动』,它只求一件事:
用最少的编辑步骤(删除、插入),把旧文本变成新文本。
一旦理解了它的目标函数,绝大多数『反直觉』输出都有了解释。
网格模型:所有的 diff 都是一条路径
把旧文本放在横轴、新文本放在纵轴:
新文本 y
↑
… ○──○──● (对角线 = 字符相同,免费)
…
- 向右一步 → 删除旧文本一个字符;
- 向上一步 → 插入新文本一个字符;
- 对角线(两端字符相同)→ 免费,不算步数。
从起点到终点,每一条走法对应一种『把旧文本变到新文本』的编辑方式。diff 要的就是『总步数最少的走法』——也就是尽可能多走免费对角线,让改动集中到必须变的地方。
Myers:用斜线编号逼近最短路径
Myers 算法用每条网格斜线(diagonal,编号 k)上「走了几步」来追踪进度,逐轮扩展 k 值:
- 每轮尝试走到所有可及的斜线;
- 优先扩展能到达的『最大同行步数』,也就是把免费对角线走到头;
- 当某一步到达终点,这条路径就是编辑步数最短的解。
它的精妙在于:同样优劣时,Myers 倾向于统一:少插入或少删除较集中,因而 diff 输出通常相对紧凑、可读。这也是 git diff、GNU diff 等默认实现它的原因。
(细节上 Myers 走的是「d 步数」层推进而非严格 k;这里用「尽可能多吃免费对角线」的口径讲透直觉即可。)
行级 vs 字符级:取舍
| 维度 | 行级 diff | 字符级 diff |
|---|---|---|
| 观察粒度 | 行增删 | 行内单词/字符 |
| 适用 | 代码评审、配置对比 | 长行内参数改动 |
| 噪音 | 低 | 高(大文件满屏标红) |
| JSON 这类数据 | 会误判为整行改 | 语义化更好(用专门的 JSON diff) |
建议:先看行级抓住结构,再对关心的行开字符级高亮。数据处理场景尽量用结构化 diff。
读懂一次真实 diff
假设某个配置把 timeout 从 30 提到 60,并挪了注释位置,git diff --no-index a.txt b.txt 输出可能是:
--- timeout: 30
+++ timeout: 60
@@ -1,5 +1,5 @@
server { listen 80 }
- # TODO 调优
- timeout: 30
+ timeout: 60
@@ -1,5 +1,5 @@:上下文块的范围;-行旧文本独有,+行新文本独有;- 相同行保持不动——算法在组件周围尽量保住了不变上下文。
别急着以为「注释被删了」:那是因为最短路径里,删注释与调整时间在「编辑距离」视角下比『保留不动 + 移动位置』这个假想操作更划算。
练习:让直觉落地
拿两段带小改动的 C 代码/配置,用在线文本差异工具(或本地 diff -u)跑一遍:
- 观察
@@上下文范围怎么定; - 故意把一大块内容上下移动,看它如何变成「全删 + 全加」;
- 再用 JSON 差异工具比较两份 JSON——对比行级 diff 与专业 JSON diff 在「识别数组位移」上的差别。
当你能预判「这段大概率会被 diff 标成全删全加」,说明你已经读懂了它的编辑距离视角。
小结
diff 不笨,只是它的目标函数是最少编辑步数 + 多吃免费对角线,严谨、确定、可复现。读懂它,你就能从一遍遍翻 commit 的疑惑中走出来:不是内容变了,而是编辑路径变了。