welltypedwitch

Home Blog

重新思考 Breaking Changes:一种更愚蠢的方案

_ 2025年3月26日 _

现代编译器面临的一个主要问题是它们没有时间概念。当编译器运行在一个代码库上时,它会认为代码库一直处于当前状态。更新一个依赖项,实际上只是下载了代码到你的硬盘,而不会以任何方式修改调用位置。因此产生的任何错误都被认为是程序本身固有的,而不是由更新引起的暂时性烦恼。

实际上,这意味着我们实际上不允许直接更改函数的类型。Non-breaking changes之所以存在,唯一的原因是,某些语法在之前版本的函数中可以正常工作,而恰好在新版本中也适用。例如,如果一个函数之前接受 Int 类型的参数,现在修改为接受 Int | null 类型的参数,这通常不是一个 breaking change,因为任何 Int 类型的值都隐式地具有 Int | null 类型,所以任何之前调用它并传入 Int 的代码,例如 f(5),仍然可以工作,因为 5 也可以被看作是 Int | null 类型的值。但是,如果我们使用 Maybe Int,我们将会破坏调用位置,因为之前的语法表达式 f(5) 现在会产生类型错误。

不幸的是,union types 会破坏 parametricity,并且嵌套行为很差1,所以你很可能更喜欢使用 Maybe。 如果你想允许将 Int 类型的值改为 Maybe Int 而不破坏调用者,你可能会想象添加某种隐式强制转换,在需要 Maybe Int 时自动将 Int 类型的参数包装在 Just 中。这可能会给类型推断带来问题,并且可能会导致很多麻烦,因为现在 Nothing 在技术上可以表示 NothingJust NothingJust (Just Nothing) 等。

但是如果我们后退一步,我们会发现整个方法真的很愚蠢!我们根本不在乎是否可以在 Int 上调用 f。我们所关心的只是我们现有的代码保持其行为。但是因为我们的编译器太天真了,所以我们必须继续支持以前支持的精确语法。

数据库很久以前就解决了这个问题

那么,我们如何在不必永远支持调用它们的精确方式的情况下,防止旧的调用位置中断?迁移

在上面的例子中,我们实际上不想要从 IntMaybe Int 的自动强制转换,它插入一个隐式的 Some。我们只希望函数类型更改的调用位置从 IntMaybe Int 应用一个迁移,插入一个 Some

这里有几种方法可以处理这些细节。

方案 1:自动类型迁移

使用类似 Haskell 的语法,可以直接在类型上声明迁移,如下所示:

dataMaybea
=Nothing
|Justa
migration(a-->Maybea)argument=[e|Some$(argument)|]

像这样的迁移声明会声明一个 (typed!) 宏,它将任何 a 类型的参数表达式转换为 Maybe a 类型的表达式。现在,每当一个函数将其一个参数的类型从 Int 更改为 Maybe Int 时,编译器会自动将这个宏应用到每个调用位置,这将保证使它们再次编译(尽管验证迁移在语义上是否正确的负担仍然在程序员身上)。重要的是,这也可以在依赖项更新后发生。

方案 2:迁移文件

并非每个 API 更改都只是以一种普遍适用、无上下文的方式更改其参数。例如,假设我们曾经有一个函数 f :: Int -> String,但是我们后来意识到,一个 int 永远不够,所以我们将它泛化为 f :: Map String Int -> String,其中 f (fromList [("default", x)]) 现在的功能与之前的 f x 相同。我们可以采取的一种方法是在发布版本中添加一个迁移文件,以更改 f 的类型。

-- blah.atria
f::MapStringInt->String
f=...
-- migration/1.2.3/blah.atria.migration
migrationfx=[e|f(fromList[("default",$x)])]

这个迁移仍然是一个(类型化的)宏,编译器可以在将依赖项从先前版本升级到 1.2.3 时自动将其应用于任何匹配的调用位置。

我们不必止步于函数调用

如果我们真的关心处理和自动迁移受更改影响的代码(这些更改本应是非 breaking 的),我们可以做更多的事情!向模块添加函数是一个纯粹的附加、向后兼容的更改,但是在具有非限定导入的语言中,如果另一个同名函数已经在作用域中,则会导致歧义错误!通过迁移,我们可以让编译器检测到这样的情况,并从受影响模块的导入列表中隐藏新函数。

类似地,Rust 存在一个颇具争议的问题,即添加 trait 的新 instance 有时会破坏现有代码,因为编译器知道作用域中只有一个 trait 的 instance。如果 Rust 编译器更智能,它可以自动修复这样的问题,方法是在任何以前明确的代码中插入类型注释!

这甚至不是一个革命性的想法。随着 language server 的兴起,当前语言还不支持像这样的自动代码迁移,这实在令人惊讶。

  1. 想象一下,你有一个函数 lookup :: key -> Map key value -> value | null。这是一个很自然的类型,除了如果 value 被实例化为 _ | null 类型的形式,它会完全中断,因为你将无法区分 null 作为 map 中的值,以及 null 作为查找失败的标记值。如果没有 parametricity,你总是需要单独考虑所有可能的实例化,并且无法排除像这样的细微的极端情况。

27 Powered by Bear ʕ•ᴥ•ʔ