发布于
一份 diff 是关于「A 如何变成 B」的一个假设,不是一份事实陈述。算法挑的是它能找到的最短编辑脚本,这通常是最易读的解释,偶尔则是误导人的那一个。要让比较真正产出价值,就得选对粒度、清掉遮蔽真实改动的噪声,并且知道 diff 在结构上压根说不出来的那几件事。
读之前先选粒度
所有 diff 工具回答的都是同一个问题:「把 A 变成 B 所需的最小插入与删除集合是什么」,但它使用的单位会彻底改变答案。行粒度是默认值,因为版本控制就是按行工作的;对代码、配置和日志来说它是对的,因为一行本身就是有意义的单位。而对一条很长的单行值,它则会主动误导 —— 改掉压缩后 bundle 或 Base64 载荷中的一个字符,行 diff 会报告说整个文件都被替换了。
字符粒度是相反的取舍。它能找出标识符里变掉的那一位数字、主机名里换了位置的两个字母、有人从文档里粘进来的那个不可见字符。但只要内容超过几行,它产出的斑点状结果会比原文还难读。单词粒度介于两者之间,是散文、翻译和文档的正确默认值 —— 一句话换了标点重写一遍,不应该看起来像删掉了一整段。
有用的习惯是:觉得哪里不对劲就比两遍。先读行 diff 把改动的形状搞清楚,再对真正关心的那几行用字符粒度重跑一次。一个「不用重新粘贴就能切换粒度」的工具,价值高于一个算法更好的工具,因为真正的 bug 往往是在第二眼里找到的。
| 粒度 | 适合 | 会误导的地方 |
|---|---|---|
| 字符 | ID、哈希、编码值、单行配置 | 长到会变成斑点的任何内容 |
| 单词 | 散文、翻译、文档、提交信息 | 代码,因为那里标点是有意义的 |
| 行 | 源码、配置、结构化日志 | 压缩过的或单行的文件 |
| 整块或整文件 | 确认两份产物是否逐字节相同 | 对「差在哪里」只字不提 |
先消噪,再判断改动
最让人失望的那类 diff,往往被谁也没有刻意做出的改动所主导。一个在 Windows 上编辑、在 macOS 上提交的文件,会因为 CRLF 变成 LF 而每一行都算改过。用不同配置跑一遍格式化器,整个文件的缩进都会变。编辑器保存时顺手删掉了行尾空白。某个被文本编辑器重写过的文件顶部冒出了字节序标记。每一种情况里,真实改动都只有一两行,却被埋在几百行之下。
对付它的办法是「屏蔽掉已知无关的差异」,而不是硬着头皮跳过去读。在读任何东西之前,先打开忽略空白的比较,或者先统一换行符。如果工具会标出「仅空白变化」,就相信它并往下走。在版本控制里,同一件事由「评审时忽略空白」以及「把格式化器配置放进仓库、让噪声从一开始就不产生」来完成。
不过要小心一个不对称:空白并不总是噪声。它在字符串字面量内部是有意义的,在 Markdown 里两个行尾空格代表换行,在 YAML 和 Python 里缩进就是语法,在 Makefile 里制表符是强制的,在断言精确输出的测试夹具里同样如此。先屏蔽空白找到改动,再看那一行的原始字节,然后才能下「这处改动无害」的结论。
- 换行符:CRLF 与 LF 之别,通常表现为行尾的 ^M,或者「每一行都变了」。
- 文件末尾缺少换行,git 会在块尾以一行反斜杠开头的说明报出来。
- 首行的字节序标记,在多数编辑器里不可见。
- 制表符被转成空格,或因格式化器配置不同导致缩进宽度变化。
- 从文档或聊天软件粘进来的不换行空格与弯引号。
读懂统一 diff
统一格式是 git、patch、代码评审工具和多数 diff 工具的输出形式,而大家习惯一扫而过的那部分,恰恰是承载信息的那部分。每个块以形如 @@ -12,7 +12,8 @@ 的头开始。第一组是原文件中的起始行号和行数,第二组是新文件中的起始行号和行数。行数增加 1 意味着这个块净增了一行;两个行数相等则意味着只发生了修改或块内移动。
块内部,行首空格表示上下文,减号表示只存在于原文件的行,加号表示只存在于新版本的行。不存在「已修改」这个标记:一行被改动时,永远表现为一次删除紧跟一次插入 —— 这也是为什么改一个字符看起来像是两行的变动。有些工具会在收尾的 @@ 之后附上所在的函数或小节名,那是在长文件里快速定位的最好线索。
还有两种约定看起来像报错,其实不是,值得认出来。写着「No newline at end of file」的提示就是字面意思,通常值得修掉而不是无视。另外上下文行数是可配置的 —— 默认三行 —— 所以一个「缺少你想看的周边代码」的块,往往只差一个参数就能显示出来。
--- a/app.yaml 原文件
+++ b/app.yaml 新文件
@@ -12,7 +12,8 @@ server:
| | | | | |
| | | | | +-- 所在小节,用于定位
| | | | +------- 新文件:本块共 8 行
| | | +---------- 新文件:本块从第 12 行开始
| | +------------- 原文件:本块共 7 行
| +---------------- 原文件:本块从第 12 行开始
host: 0.0.0.0 行首空格 = 上下文,两个文件里都有
port: 8080
timeout: 30
- workers: 4 减号 = 只存在于原文件
+ workers: 8 加号 = 只存在于新文件
+ keepalive: 75
logging:
level: info
原文件 7 行、新文件 8 行:一行被修改(表现为先减后加),一行被新增。被移动的代码块,以及 diff 说不出的那件事
基于行的 diff 没有「移动」这个概念。如果一个四十行的函数从文件顶部挪到了底部,算法看到的是四十次删除加四十次插入,评审者看到的是八十行改动 —— 而实际上什么都没变。更糟的是反过来也一样看不见:如果那个函数不仅被挪走,内部还改了一行,这处改动就正好躺在那八十行里,而所有人都已经决定要跳读它们。
防御手段与其说是技术的,不如说是流程的。当一份 diff 很大、且被删掉的那一块和被插入的那一块看起来很像时,把两块单独抽出来直接互相比较。那第二份 diff 通常是空的,于是你可以一步确认「只是移动」;要么它里面恰好就是藏起来的那处改动 —— 也正是你需要看见的那一处。
有些工具能帮上忙。git 在评审时有移动检测,几个评审平台会把被搬运的块置灰。在工具帮不上忙的地方,真正管用的纪律是在改动本身里就把移动和编辑分开:一个提交只搬代码,另一个提交只改代码。评审者于是可以机械地核对前者,认真地阅读后者。
重新排序不等于改动
有一大类虚假差异,来自「用有序文本去表示一个无序集合」再拿去比较。两份字段相同但键序不同的 JSON 文档,语义上完全一致,文本上差异巨大。依赖列表、环境变量集合、数据库导出、权限清单 —— 在这些东西里,顺序是文件生成方式的产物,而不是数据本身的性质。
比较之前先归一。用同一个格式化器格式化两份 JSON;只要消费方不依赖键序,就把键排序。如果一个列表本质上是集合,先排序再 diff。而如果你想知道的是「哪些条目只在一边有」,行 diff 对这个问题是很差的工具 —— 集合比较会直接告诉你新增三条、移除两条,而不会把那四百行「只是挪了位置」的内容摆在你面前。
反向的错误同样存在。当顺序确实有意义时,排序会把排序 bug 藏起来:按序匹配的路由表、中间件链、后面声明胜出的 CSS 规则、迁移脚本。归一之前先确认顺序确实不承载含义;如果它承载,那么重新排序本身就是你要找的那处改动。
审阅机器生成的输出
机器生成的文件打破了让评审得以成立的那些前提。一个依赖变动会让锁文件改掉上千行;一次快照测试会重写整份夹具;代码生成器会把它碰过的一切重新排版;模型产出的内容则是一大段看起来很像那么回事、必须核对而不能只是阅读的文本。这些情况下 diff 都大到无法逐行读,而把它当成手写代码那样评审,正是未经评审的改动混进来的方式。
要换一个提问方式。对锁文件,不要读 diff:去读「哪些直接依赖换了版本」的摘要,然后核对那几条。对重新生成的文件,确认生成器版本和它的输入按预期变化了,并确认输出是可复现的 —— 自己重新生成一遍,确认它与提交版本之间的 diff 为空。对工具完成的大规模重构,比较行为而不是文本:跑测试、比构建产物、比渲染结果。
机器生成的内容还有一个特有的评审风险:它处处同样可信。手写代码是有节奏的,评审者能注意到那一行不对劲;生成的文本没有这种节奏,一个错误的常量可以极其自然地混在一百个正确的常量中间。对策是:把 diff 收窄到人真正必须核对的部分,拿真相来源而不是直觉来比对,并且把生成文件和手改内容分开提交,好让两者用不同方式阅读。
- 把生成的改动和手写的改动分到不同提交里,各用各自合适的方式评审。
- 在本地重新生成产物,再与提交版本做 diff;一份空 diff 一步回答了可复现性问题。
- 对锁文件,只评审直接依赖的变化,传递依赖交给工具去算。
- 对大规模机械重写,用测试和构建产物验证行为,而不是逐行读文本。
什么时候 diff 是错的工具
diff 比较的是表示形式。当两种表示形式不同、含义却相同时,文本 diff 回答的是你没问的那个问题。属性顺序不同的两份 XML、排版不同的两条 SQL、数字格式不同的两份 JSON、引号约定不同的两份 CSV —— 这些都需要先规范化,或者换用理解该格式的比较方式,diff 才有意义。
通用的解法是把两边都归一成一种规范形式,再去 diff 它。用同一个格式化器格式化两边,能排序的排序,浮点表示有差异的先取整或转成字符串,然后才比较。这也是跨环境比较 API 响应最可靠的做法:两边都美化输出、对象键排序,剩下的差异就是真差异。
而当问题是关于集合成员而不是顺序时 —— 哪些标识符只出现在一份导出里、哪些特性开关在预发开着而生产关着 —— 就该换用列表比较。它以「新增、移除、共有」的形式作答,这正是你真正想要的答案形状,而且它根本不在乎两份文件各自是按什么顺序写的。
要点回顾
- 有意识地选粒度:代码用行、散文用单词、对你起疑的那个标识符用字符;一旦哪里看着不对,就换一种粒度再比一遍。
- 读 diff 之前先屏蔽空白和换行符噪声,然后再去看关键那一行的原始字节 —— 因为在 YAML、Python、Markdown 和测试夹具里空白是有意义的。
- 认真读块头而不是扫过去,两个行数会立刻告诉你这一块是净增、净删还是仅有修改。
- 当一大段删除和一大段插入长得很像时,把这两块单独互相 diff,把「移动」和藏在里面的「编辑」分开。
- 比较任何「顺序只是产物」的内容之前先归一;而当问题是「两边各有哪些条目」时,用列表比较而不是文本 diff。