通过设计保障稳定性

2025-05-08

我最近看到了来自 OneHappyFellow1 的一条推文:

我认为我弄清楚了动态类型语言编程让我感到压力的地方:总是无法确定以特定方式使用库是否会正常工作,以及次要版本升级是否会破坏你的代码。 — One Happy Fellow (@onehappyfellow) May 5, 2025

我发现这条推文很有趣,因为我使用最多的语言——Clojure——既是动态的,又在生态系统方面拥有非常强大的稳定性声誉。在深入探讨这究竟是为什么之前,请允许我提出一些证据来证明这种信念。

Clojure 库是否稳定?

我在 Clojurians Slack 中搜索了“stability”一词,在第一页的 20 个帖子中,有 8 个帖子都在称赞 Clojure 带来的稳定性。这个 Slack 是 Clojurians 的主要论坛,其中包括关于各种库、错误、修复等的讨论,因此人们理所当然地会期望稳定性方面的_抱怨_占据讨论的主导地位。我的搜索显然不是随机抽样,但它应该让你了解社区对稳定性的欣赏和庆祝程度。

作为进一步的证据,请考虑以下两张图表,它们来自 A History of Clojure,详细说明了 Clojure 和 Scala 的新代码引入和保留情况。

Clojure codebase—Introduction and retention of code Clojure codebase—Introduction and retention of code Scala codebase—Introduction and retention of code Scala codebase—Introduction and retention of code

虽然这不一定转化为_库_的稳定性,但有理由假设 Clojure 维护者的态度会渗透到社区中。而且这种假设是正确的。

让我们看一下各种流行库的代码保留情况。我凭直觉选择了以下库,并遵循了三个标准:所有库都有超过 500 个 star 并且都在积极使用。我可以很容易地选择更多。

xforms codebase—Introduction and retention of code xforms codebase—Introduction and retention of code Component codebase—Introduction and retention of code Component codebase—Introduction and retention of code Instaparse codebase—Introduction and retention of code Instaparse codebase—Introduction and retention of code core.match codebase—Introduction and retention of code core.match codebase—Introduction and retention of code

显然,库作者在这方面遵循了 Clojure 维护者的脚步。

我要给出的最后一个证据是轶事性的,但具有启发意义。我最近向我的容错库 Fusebox 推送了一个更新。此更新是由于重试实用程序中的一个怪癖而产生的。每当抛出异常时,该异常都会被重试实用程序的元数据(例如,发生了多少次重试)包装,然后重新抛出。

Fusebox 的用户 Martin Kavalar 最近要求仅在实际发生重试时才包装异常。这不仅是一个合理的要求,而且这可能从一开始就应该是这种行为。我甚至会将其称为一个 bug。

但是,这是一个人们已经处理过的 bug。他们已经编写了他们的代码来处理这个 bug。换句话说,如果我做“正确”的事情并“修复”这个 bug,我就会破坏_其他人的_代码。

我告诉了 Martin 这一点,他毫不犹豫地同意我们需要找到一个不会破坏当前用户代码的解决方案。这_不是_软件工程师之间正常的互动——软件工程师以他们对最细微的细节进行漫长而冗长的辩论而臭名昭著。然而,这在 Clojure 社区中是完全可以预料的。

在给出了 Clojure 库在实践中往往非常稳定的证据之后,问题就变成了:“这怎么可能?” 从表面上看,OneHappyFellow 的推理是有道理的:静态类型会在发生重大更改时通知你,因此它们使升级过程更加容易。这个谜题的答案包含两个部分。

Clojure 有什么不同?

简而言之,Clojure 通过约定俗成,是现存最静态的动态语言。

在他的 Twitter 帖子中,OneHappyFellow 提出了几个较小的观点:动态语言序列化 被破坏了,而 monkey patching 使 跨进程传递对象 成为一场噩梦。

考虑一个典型的 JavaScript 程序。它由什么组成?对象、对象以及更多的对象。这些对象的成员必须经过内省或推断。更糟糕的是,monkeypatch 这些对象是很正常的,因此对象成员可能会(也可能不会)随着时间的推移而改变。

现在,考虑一个典型的 Clojure 程序。它由什么组成?命名空间。这些命名空间包含函数和数据。函数可能是动态生成的(通过宏),但是“monkeypatch”一个命名空间是_极其_罕见的。2 如果你想知道一个命名空间中有哪些函数可用,你只需阅读源文件即可。

更重要的是,你_永远不会_序列化一个命名空间。这甚至没有意义。相反,你序列化数据,并且_所有_ Clojure 数据都可以开箱即用进行序列化。事实上,它以与你在源代码中编写它的完全相同的方式进行序列化,格式称为 Extensible Data Notation (EDN)。EDN 甚至允许你创建自定义标签,从而允许你使用自定义构造函数来创建数据(例如,用于日期时间或红黑树)。

Clojure 数据还有另一个奇特的属性,使其更具弹性以应对变化:它是不可变的。一旦你获得了一个 hashmap,你就可以确信_没有人_会在你不知情的情况下对其进行调整。这是不可能的。对 assocupdate 的调用会返回一个_新的_ hashmap,而不是更新现有的 hashmap。3 这意味着当你将数据传递给另一个进程或通过网络传递时,你_知道_接收者将看到你所看到的完全一样的东西。

最后,我想请你注意在动态语言中如何命名对象成员。通常(意味着,据我所知总是),它们是简单的、未加修饰的符号,例如 .name 表示一个字段,或者 .doSomething() 表示一个函数。当例如你想将两个名称附加到某个事物时,这自然会导致歧义。以 user 对象为例。你从 user.name 开始,但随后意识到你需要组织名称。此时有多个选项,但一个选项是将 user.name 重命名为 user.username 以适应新字段 user.orgName。问题是,这只是创建了一个重大更改。

相反,Clojure 字段通常会获得一个命名空间元素。(这个概念与我们用来容纳函数的命名空间相关,但又有所不同。)因此,尽管 user.name 在 JavaScript 中很常见,但在 Clojure 中它看起来像这样 {:user/name "OneHappyFellow"}。这允许其他类型的“名称”无缝集成,而不会造成破坏:

{:user/name "OneHappyFellow"
 :organization/name "OCaml Bois"}

OneHappyFellow 的 最后一个观点 切入得更深,需要提出另一个问题。

对我来说,最糟糕的情况是当我重构一些代码,结果发现我的方式从根本上与我正在使用的一些库不兼容。 — One Happy Fellow (@onehappyfellow) May 5, 2025

为什么库更改会破坏程序?

好吧,这很复杂,但首先,让我们列出库进行更改的所有原因:

在这些更改中,我认为我们原则上至少可以同意,安全修复和 Bug 修复应该是_非破坏性_的更改。安全修复基本上永远不应触发重大更改,而 Bug 修复_可能_会破坏某些东西,但它们通常是次要的,或者被标记为 wontfix。

这使得“增强”成为了真正的问题。

“增强”本身并不意味着破坏。毕竟,向对象添加新方法不会破坏任何东西。那么,哪些“增强”_会_破坏某些东西?嗯:

不难看出,该列表中唯一具有任何有效性的项目是最后一个。在这里,我们遇到了本文中的第一个残酷真相。

所有这些随意的重命名都在_扼杀_我们。

所有这些对静态类型的呼吁?所有坚持认为静态类型“解决”此问题的说法?这让情况变得更糟。静态类型“解决”此问题的方式与扫帚解决你将酒杯扔到厨房地板上的问题的方式相同。当然,类型使它更易于忍受,但它们并没有解决问题。你为什么要先在家中乱扔酒杯?

我们为什么要一直重命名所有东西?

一旦你注意到这种趋势,就再也无法视而不见。我们从数据库中获取记录,然后我们做的第一件事是什么?重命名它的字段。然后,我们通过几个转换步骤运行它,这些步骤不可避免地会再次重命名它们。然后,我们将其作为 JSON 放在网络上,当然,这要求我们再次重命名它们。然后,我们将它们加载到我们的 SPA 中,嗯,我们从网络上获得的名称肯定行不通。最好再重命名一次。

这太疯狂了,但这正是我们创造的世界。

信不信由你,这是 Clojure 程序如此稳定的一个主要原因。我们不会那样做。我们不会在我们的库中重命名东西,当我们从某个地方提取数据时,我们会尽最大努力_不_重命名东西。

但是,重命名并不是“导致破坏的因素”列表中唯一的因素。更改方法签名呢?为了解决这个问题,我们需要进一步对事物进行分类。哪些类型的更改会触发更改方法签名?

在这些项目中,其中两个不应引起任何问题。向函数添加可选输入显然是非破坏性的。4 如果你突然需要更少的输入——例如,你弄清楚了如何动态地解决一些分区问题——那就不必是一个重大更改。这种情况可能会变成一个重大更改(例如,如果你的函数曾经接受三个参数,而现在只接受两个参数),但是,至少在原则上,如果你以某种方式构造你的代码,你的用户就不需要处理此更改。

返回比以前更多的数据有点微妙。原则上,它不应该是一个重大更改。但是,如果一个函数返回一个新字段,然后该数据直接写入到一个没有该字段列的数据库中,你可能会收到一个错误(取决于数据库)。一般来说,如果你养成在通过网络传输数据之前选择你想要的数据的习惯,你就可以避免任何此类问题。

你要_始终_避免的两件事是需要更多输入和返回更少输出。_这_会破坏用户代码。如果你避免这样做,你将_永远不会_破坏用户代码。

问题出现了:如果你找到了一个更好的做事方式,需要更多输入并返回更少输出怎么办?答案很简单:为此创建一个_新_函数。新函数_不会_破坏用户代码。新函数只是新的功能。它们很棒!你甚至不必编辑旧代码。你可以直接从头开始重写它!

再一次,类型在这种情况下是一个_很棒的_促成因素。“哦,我不必担心需要向这个函数提供更多数据。类型检查器会提醒用户。” 这是真的,但这并不能改变你根本不需要向用户提出这种需求的事实。

为什么 Clojure 库是稳定的?

简而言之,Clojure 生态系统异常稳定,因为我们避免破坏东西

我们不重命名命名空间。我们不重命名函数。我们不重命名关键字。我们既不增加我们需求的数据,也不减少我们发出的数据。如果我们想到一个更好的做事方式,我们会创建一个新函数、一个新的命名空间,甚至是一个全新的库。

值得注意的是,这些活动并非免费的。当你意识到你要长期携带一些代码块时,你的心态会发生转变。你变得更加慎重,更加了解权衡。你学会了观察限制增长的模式和促进增长的模式。例如,在其生命周期中,Clojure 社区已经从接受函数中的参数列表和命名参数转变为接受单个 hashmap。这是因为单个 hashmap 更容易随着时间的推移而增长。

这些原则在开发人员社区中已经广为人知。你在你的 API 中多久重命名一次 URL 路径?从不!你在你的 API 中多久重命名一次键?从不!你是否曾经决定只返回更少的数据?不!每当你想做任何这些事情时,你会做什么?你创建一个_新名称_(通常是 v2)。

我们为什么要这样做?因为我们知道这给我们的客户带来了痛苦,我们想帮助他们避免这种痛苦。然而,不知何故,在与其他开发人员打交道时,规则发生了巨大变化。突然间,引起痛苦是可以接受的了。

请注意,这本身不是静态与动态的问题。_任何_语言中的_任何_库都可以轻松地采用这些原则。但是,我经常看到静态类型爱好者大声宣扬类型检查器的优势,说:“当我升级一个库时,我知道它会正常工作。” 这说得通。至少你知道你的代码可以编译,你不会不知道发生了什么变化以及在哪里。然而,未说出的是为了使升级工作所做的工作。

再看看 Scala 代码保留图。该图中有多少个悬崖代表着用户的_大量_工作?如果相反,我们选择不破坏任何东西,那么可以避免多少工作?

1: OneHappyFellow 是一个很棒的关注对象。 2: 我唯一一次看到这样做是在我自己出于……某种原因这样做的时候。如果你对自己做这样的事情,你很难责怪一个库导致后续的错误。 3: 它通过 高效的方式 通过与新结构共享旧结构的元素来实现这一点。 4: 巧合的是,这正是我最终解决 fusebox 中 Martin 问题的解决方案。我向函数签名添加了一个 可选的 ::retry/exception

感谢 OneHappyFellowSlim JimmyMatthew Boston 对本文的贡献。 特别感谢 Eugene Pakhomov 对本文的投入并提供 Clojure 库的堆栈图。