语义冲突

2011 年 8 月 4 日

那些听到我和我的同事谈论 功能分支 的人知道我们并不是这种模式的忠实粉丝。我们反对的一个重要部分是观察到分支很容易,但合并很困难。我们偶尔听到的一个论点是,现代的 版本控制工具 使合并足够容易,以至于功能分支是值得的。

当然,现代工具在合并方面比我年轻的时候做得要好得多。这种能力的一个很好的例子是合并重命名,它可以正确地合并我更改了 lorem.rb 的部分内容,而 Jez 将其重命名为 ipsum.rb 的情况。

这一切都很好,但它只解决了文本冲突,并没有帮助解决语义冲突。我所说的语义冲突是指 Jez 和我所做的更改,这些更改可以在文本级别安全地合并,但会导致程序的行为不同。

最简单的例子是重命名函数。假设我认为方法 clcBl 如果称为 calculateBill 会更容易使用。使用现代重构工具,这很简单:只需按 Shift+F6,输入新名称,然后工具就会更改所有调用者。但是,如果 Jez 在他的功能分支上添加了更多对该方法的调用,问题就出现了。当两者合并时,文本合并将正常工作,但程序将不会以相同的方式运行。

方法重命名是一个简单的例子,在静态类型语言中也很容易找到,因为它将无法编译。但是,有很多更微妙的语义冲突,它们不会那么干净地合并。假设我正在查看 calculateBill 方法,并意识到除了计算账单之外,它还将会计条目发送到会计系统。我不喜欢这种副作用,所以我将其提取到一个单独的 notifyAccounting 方法中。然后,我可以找到所有 calculateBill 的调用者,并添加对 notifyAccounting 的调用。但是 Jez 在他的分支中不知道这一点。

因此,这里的第一点是,无论你的工具有多强大,它都只能保护你免受文本冲突 [1]。特别令人讨厌的是,语义冲突更难发现,也更难修复。

我们无法自动解决语义冲突。也许有一天工具能够解决其中的一些问题,但我怀疑一些棘手的问题将永远伴随着我们——至少在计算机能够读懂我们的思想并自动推断出我们的意图之前。但是,有一些策略可以帮助我们有效地处理它们。

第一个是 自测试代码。测试实际上是在探测我们的代码,以查看它们对代码语义的看法是否与代码的实际行为一致。如果 Jez 期望代码调用发生某些事情,并且为此进行了测试,那么当他集成时,这些测试就会中断。当然,这不是一个完美的解决方案。测试永远不可能完美,但它们在实践中捕获了许多语义冲突。它们也不能帮助你修复冲突,一旦你发现了它,但找到它就是战斗的一大部分。

另一种有帮助的技术是更频繁地合并。如果 Jez 在几个小时内而不是几天内发现我的更改,那么他的困难就会少得多。这样,他就不再基于旧语义构建大量代码。这就是为什么我们如此热衷于 持续集成 的原因。

似乎有两组人支持工具使功能分支变得可以忍受的观点。一组是“企业级”VCS 的供应商。我们并不关心他们。另一组是 DVCS(分布式版本控制系统)的粉丝。我对后一组人有点担心。人们经常试图通过他们如何使功能分支变得容易来为 DVCS 辩护。但这忽略了语义冲突的问题 [2]。使用 DVCS 有很多充分的理由,因此没有理由将一个好工具与一种有问题的技术联系起来。

注释

1: 而且,如果我们更改了完全相同的文本,合并工具通常也无能为力,除非你使用类似 git rerere 的东西。但这个问题远小于语义冲突。

2: 如果你的功能构建速度很快,在几天内完成,那么你遇到的语义冲突就会更少(如果不到一天,那么它实际上与 CI 相同)。但是,我们很少看到这么短的功能分支。