API & ABI 版本控制¶
标题:API & ABI Versioning…
日期:2017/10/18
作者:Mathieu Ropert
链接:https://www.youtube.com/watch?v=Ia3IDPjA-d0
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
好的。大家好。今天早上怎么样?你们好吗?嗯,很棒。很棒。到目前为止,你们享受这次大会吗?
我也是。好的。感谢大家来到这里。我将要和大家谈谈 API、ABI 版本控制,以及基本上当您做出任何类型的更改时,如何在您的代码中处理其带来的影响。
通常,当您更改某些内容,当您发布一个新的提交时,您应该问自己的问题是:这会带来什么影响?
如果我推送这个更改,我会不会为某人搞砸什么东西?
我真的非常感谢 Titus 昨天为我做了一个非常棒的开场白,因为他说基本上你无法预知。他说,基本思想是,有太多可能出错的事情是你无法预测的,有太多你无法预见的用例。他特别强调了两个部分,尤其是 Semver(语义化版本)显然不是一个选项,以及想要处理二进制兼容性简直是疯了。
猜猜我今天讲什么?Semver!二进制兼容性!太棒了。好吧。
不开玩笑了,这次演讲的核心更多的是向你们解释,当你们在代码中进行更改时,应该预料到会产生什么样的影响,因为我认为这里有空间来判断人们可以从你将要在代码中更改的东西中期待什么。
所以,我们将研究如果您选择更改某些内容,对 API 的影响。我们将研究对 ABI 的影响,也就是二进制兼容性。然后我们将看看如何尝试对这些影响进行分类,比如,它是会破坏性的还是没问题的?当然,还有如何与您的开发者沟通,告诉他们您更改了某些东西,以及他们应该确切地期待什么。
对于那些今天没有听我上一个演讲的人,大家好。我叫 Mathieu。我来自法国,在 Murex 工作。我从事内部框架的开发工作。我也参与一些尚未发布的开源项目,很遗憾。因此,我必须问自己很多关于生命周期以及如何长期维护事物的问题。您可以通过您选择的社交媒体关注我或联系我。
库的生命周期¶
那么,我们来谈谈一个库的生命周期。假设您想发布一些东西。您想发布一个库。您想创造一些东西。您想把它公之于众。一些您写的漂亮代码。关于这会带来什么影响,我应该害怕什么,您可以问自己上百万个问题。既然有上百万个,我就只尝试回答那些我认为在这种情况下真正相关的问题。
第一个问题是,您的所有用户都会在同一个代码仓库(repo)中吗?
当然,如果所有使用您代码的人都在同一个 Git 仓库里,那情况就完全不同了;另一种情况是,您只是 GitHub 上,比如说,数十亿个仓库中的一个,人们会把您的代码拉到他们的代码库中使用。在第一种情况下,如果您破坏了某些东西,您会立即知道。您可能连拉取请求(pull request)都过不了。这将是显而易见的。在第二种情况下,就不那么明显了。所以,当然,在第一种情况下,版本控制不是强制性的。你会立刻看到你破坏了某些东西。不过,我仍然认为,认为在推送前不必担心是不够的。因为我认为,在拉取请求阶段才注意到你为别人破坏了东西有点太晚了。相反,我认为我们应该更主动、更有预见性,在推送修改之前就去思考这将改变什么,会影响到哪些人。
第二个问题是,您是否会在您的库中永远、永远地破坏向后兼容性?
完全不破坏兼容性并不是一个常见的用例。但我知道你们中的一些人可能有某些限制,或者可能有这样的意愿,说:“我永远不会破坏向后兼容性。” 我可能会每次都提供一个新的命名空间,但我放在那里的所有东西,在 10 年、20 年、30 年后仍然能用。再次提醒,即使你废弃了某个东西,如果你说,“好的,这个已经弃用了”,然后在 20 年后你移除了它,技术上讲,你还是破坏了向后兼容性。所以当我说“永不破坏”,就是指真正的永不。
所以,当然,即使你很少这样做,哪怕是十年一次,你也必须有办法区分破坏性变更和非破坏性变更。
哦,这一个非常重要。您的用户是否需要在生产环境中热插拔(hot swap)您的库?
同样,我认为这里有多种用可例。一方面,我们有昨天谷歌展示的美好事物,即 “live at head” 的理念,基本上你总是重新编译所有东西。也许你有一些云应用,或者你使用 Docker,我不知道。但也可能不是。也许你有一个安装在服务器上、安装在机器上的库。它确实位于依赖链的底层,你不能允许自己破坏二进制兼容性。我的意思是,想象一下你是一个维护者,比如 OpenSSL 或者 C 运行时。你能对人们说,“嗯,对不起,我破坏了 API,我破坏了 ABI,请重新编译所有依赖它的东西” 吗?我以前在家里用 Gantu 系统。如果有人曾经尝试过递归地重新编译 LibC 的所有依赖,嗯,我知道这是不可行的。这是不合理的。所以如果那是你的用例,你就必须担心二进制兼容性。你将不得不担心 ABI。当然,如果你是一个只有头文件(header-only)的库,而且我知道这现在很流行,你就没有那个选项。你永远不可能给人们提供一个二进制文件来热插拔,因为,根据定义,你没有任何二进制文件。
所以基本上,当你要改变某些东西时,重要的是要尝试进行分类。它会破坏还是不会破坏我用户的流程?如果你想要二进制兼容性,你不仅要监控 API 的变化,还要监控 ABI 的变化,我稍后会解释。当然,如果你在没有事先通知的情况下破坏了某些东西,人们会非常生气。
因为当你思考这个问题时,至少当我想的时候,我认为版本控制是关于沟通的。这是维护者和用户之间的沟通。你想告诉他们,“好了,这个变了。这是你需要做的。” “我们改变了东西。这里有一些很棒的新功能。” “别担心,我们没有破坏任何东西。” 但这一切都关乎沟通。
在接下来的演讲中,我需要提醒一下,我将要谈论的是我认为的合理使用。因为,就像 Titus 昨天说的,有些人可能会对你的代码有不合理的期望,不合理的保证。他们可能期望行号永远不会改变,或者符号地址保持不变,或者你甚至可以……我不知道,auto
类型的真实类型永远都是一样的,不管你在幕后做了什么。或者,比如你私有成员的布局。嗯,任何事。好吧,这不是这次演讲的内容。我将尝试专注于你可以定义的合理方面。如果你有其他用例,我很乐意稍后和你讨论。但这不是这次演讲的内容,因为那是一个完全不同的世界。好的。
API 变更¶
让我们从最简单的开始,API。因为我想你们大多数人至少对什么是 API 和契约背后的思想有一点了解。
简单总结一下,API 是用户和维护者之间的契约。它是这样的:“如果你提供给我这个,如果你遵守那个,如果你调用这个,我将反过来给你这个和那个。” 如果你不遵守我的要求,那么,我不能保证任何事情。我不知道你会得到什么,而且它很可能不会工作。另一方面,既然你作为维护者做出了保证,如果人们遵守规则,你就必须给他们一些回报,而且你必须给他们你所同意的东西。这是一种相互约束的契约,就像你在法律或任何其他领域可以看到的一样。
我们通常把它分为两部分。一部分是前置条件(precondition),这是调用你的人必须遵守、必须遵循的。这是他们手头上的事,是他们必须尊重的。另一部分是,如果他们尊重了前置条件,你同意去做的事,即后置条件(postcondition)。
一个很好的契约例子,是你在 cppreference.com
上可以找到的。在这个例子中,是 std::swap
。当你查看这个契约时,首先你有契约的名称,也就是你必须调用的函数名,swap
。然后是它的位置,你需要包含哪个头文件。你已经可以看到,技术上讲,C++11 破坏了 API 的兼容性,因为头文件在发布中改变了。所以从技术上讲,人们无法在他们认为可以找到的地方找到它。
接着是签名,比如参数的类型是什么,返回值的类型是什么,有哪些不同的替代形式。还有额外的前置条件要求,比如类型要求。例如,在 swap
的情况下,第一个重载要求类型是“可移动赋值”和“可移动构造”的。在第二个重载中,它只需要是“可交换的”。如果你不遵守,swap
,嗯,我不能保证 swap
会做什么,标准库也不能。你就没辙了。当然,之后是后置条件,也就是,如果你遵守我之前陈述的所有规则,我将交换 t1
和 t2
这两个成员。
好的。那么用 C++ 的术语来说,这意味着什么?我想把它分为两个方面。
内部(Internal):在左边,是我称之为内部的东西。我所说的内部是指编译器能看到的东西,是目前语言的一部分。名称、签名、你的声明位置。任何这些,如果你破坏了,编译器会告诉用户。它会告诉他们,“好了,类型不匹配”,或者“我找不到那个函数”,或者“名称不是我想的那样”。这是最简单的部分,因为它是类型系统的一部分。
外部(External):第二部分是我称之为外部的,因为它今天还不是语言的一部分。这是你期望的东西,是你保证的东西,但不是编译器可以检查的东西。这些东西你只能以某种方式记录在文档中,而且人们必须遵守,否则它就不会工作。
好了,就像我说的,由于并非所有 API 的部分都是类型系统的一部分,有些变更比其他的更危险。如果你通过编译器可以检查的方式更改了 API,嗯,人们会生气,因为他们的代码编译不过,但至少他们会得到一些反馈。他们会得到一些东西。它编译不过。他们必须打开你的文档或头文件,然后说,“好吧,为什么它坏了?” “为什么找不到函数,或者为什么类型不一样了?” 很有可能,他们会发现他们必须调整代码,因为你确实发布了一个破坏性变更。
另一方面,如果你改变了右侧的部分,很有可能编译器什么都不会告诉他们。所以如果你不宣传这一点,人们会遇到一些 неприятные сюрпризы (nasty surprises)。
在接下来的演讲中,我将尝试根据对 API 的影响类型来对您在代码中所做的更改进行分类。为了和我稍后要讲的 Semver 有所衔接,我想说你可以做三种类型的更改:
破坏性变更(Breaking changes):我破坏了一个契约。人们必须修改他们的代码。如果他们不改,事情就会变得糟糕。如果幸运的话,代码不会编译。如果不幸,情况会比那更糟。
非破坏性影响(Non-breaking impact):你添加了一些东西。你提供了新的机会。但对于现有用户来说,这应该是透明的。他们不需要做任何事情来适应。
无影响变更(No impact):当然,还有最后一种变更。就像你对代码做了些改动,但 API 根本没有受到影响。
让我们详细看看。
无影响变更¶
先看最简单的。无变更。没有影响的变更。嗯,这很简单。如果你没有改变任何契约,那么,你也就没有改变 API。所以基本上,如果你修复了一个 bug,如果你做了一些性能调优,或者如果你在内部重构了你的代码,因为你改变了实现,但所有可观察和保证的行为都是相同的,那么,这是一个无影响的变更。
所以基本上,用 C++ 的术语来说,这意味着:
没有名称或签名发生变化。
所有已定义的行为都和之前一样。
这一点很重要:它包括了特定的保证。比如,如果你额外保证说,“我的函数保证在线性时间内工作”,或者你说,“我的函数不会使任何迭代器失效”,你就不能改变这一点。如果你没有改变 API 中的任何东西,但却用了个更差的算法,比如说,那就不是“无变更”。这里是有影响的。
非破坏性变更¶
那么,什么是非破坏性变更呢?嗯,任何你添加的东西。任何你添加的、不触及之前已存在的东西。比如,新的函数、新的命名空间、新的变量、新的结构体成员、新的类型,等等。这不会改变(破坏)API。之前存在的一切仍然在那里。我在“新重载”上打了个星号,因为就像我们之前说的,如果有些人试图获取你函数的地址,如果你添加一个重载,他们可能会遇到一些意外。但除了这种情况,一个新的重载应该是安全的。
你也可以放宽一个现有的契约。基本上,我的意思是,你改变了一个契约,但只是为了更好或者增加了新的选项。比如,如果你的函数过去需要两个参数,你现在添加了第三个,但是第三个参数的默认值导致了和以前完全相同的行为,你就没有破坏 API。人们仍然可以像以前一样调用你的代码,并期望得到相同的结果。
如果你添加了新的结构体成员,就像我说的,这对 API 没有影响。所有其他的成员仍然可以在结构体中找到。没有名称改变。
如果你放宽了前置条件,如果你接受了比以前更多的值,这是可以的。例如,如果你的函数过去说,“不,我只想要正整数”,而现在你可以给我任何整数,嗯,你只是定义了一个以前未定义的行为,这完全可以接受。
你也可以收紧后置条件。比如,如果你过去说,“我只会返回一个 1 到 20 之间的数字”,而现在你说“我只会返回一个 1 到 10 之间的数字”。1 到 10 在 1 到 20 之间,所以这仍然是可以的。你没有违背你的承诺。
当然,你也可以收紧保证。例如,在过去,你向人们保证你的算法将以 O(n log n) 运行,而现在它只以 O(n) 运行,这只会更好。所以人们会对此表示赞同。对我来说,这不是一个破坏性变更,我想对你也不是。
当然,就像我们之前说的,基本上定义任何类型的未定义行为都不是一个破坏性变更。人们不应该依赖未定义行为。我想这一点已经被说了很多次,甚至在这个同一个房间里。我不需要再强调了。
破坏性变更¶
那么什么是破坏性变更?嗯,基本上,其他的一切。
例如,如果你更改了签名,比如你更改了参数类型,或者你更改了返回类型,或者你更改了顺序,嗯,它就行不通了。一些例外,或者可能有例外,如果你有一个新的、使用带有隐式转换的兼容类型,那可能没问题。但要小心,因为有很多转换规则,如果你已经有几个重载,然后你改变了其中一个,要确保所有以前的重载仍然匹配同样的东西。例如,如果你把字符串、指针、布尔值和整数混在一起,有时候,如果你改变了其中一个,重载选择的结果可能不是你认为的那样,对于每种类型,它们过去的行为可能都不同,人们会遇到 неприятные сюрпризы。我确实遇到过一些问题,当你传递一个 bool
值,第一个被选择的转换是,“哦,好的,你说了 false
,那意味着你的意思是 nullptr
,对吧?” 所以那意味着它是一个字符串。不。嗯,我看到有些人在笑,我猜我不是唯一一个遇到这种事的人。
当然,重命名。如果你重命名了某个东西,它就无法编译,所以这显然是破坏性变更。
最后一个可能不那么明显,但仍然是:如果你把某个东西从一个头文件移动到另一个头文件,如果人们必须包含别的东西才能找到你的代码,嗯,很明显,你破坏了 API,因为它无法编译。
如果你收紧了一个契约,如果你增加了比以前更多的限制,当然,这是一个破坏性变更。与我之前说的相反,如果你过去接受所有整数,而现在你只接受正整数,很多人,可能有一半的人,会不高兴,因为他们过去能那么做,现在不行了。
另一方面,当然,如果你放宽了后置条件,也是同样的问题。如果在过去,你向人们保证你不会使任何迭代器失效,而现在你这么做了,你会接到一些非常愤怒的人打来的电话。
那太邪恶了。基本上,那太邪恶了。因为如果你收紧一个契约会发生什么?嗯,基本上,你改变了你的 API,但如果这是你做的唯一改变,编译器仍然会编译通过。对于人们来说,这完全是不可观察的,直到他们运行你的代码,如果幸运的话是在测试中,如果不幸的话是在生产中,他们会非常非常不高兴。不,说真的,别那么做。如果这是你唯一要改变的事情,就不要收紧一个契约。
再举一个例子,以确保我讲到位了。让我们看一些变化。
过去,我有一个函数,本应是用来排序一个 vector<int>
的。我在上面记录了契约。我是个很不错的人。我告诉他们,“好的,我排序一个整数向量,我向你保证我会在 O(n log n) 时间内完成。” 实现细节是 std::sort
。很好。
现在,我改变了 API。我说,“好的,我仍然排序你的整数向量,但我改变了后置条件。现在,复杂度是 O(n!)。” 谢谢。因为在内部,我用了 bogo_sort
(猴子排序)。你们熟悉猴子排序吗?是的。是的,有趣的算法。为了增加你们的知识,它基本上是,你检查它是否排好序了,如果没排好,你就把所有东西都打乱,然后再试一次,看看这次是否可以。顺便说一下,这是一个优化版本,因为如果它已经排好序了,它不会阻塞。所以,从技术上讲,这是优化版的猴子排序。是的,但你懂我的意思。你一直是个好人,你知道,你改变了契约并记录了它。这很好。你说,“嘿,我没有给你惊喜。契约已经记录了。我改变了复杂度,好吧,但我告诉你了,你为什么不高兴?”
嗯,你破坏了 API,但代码仍然能编译。即使是世界上意图最好的人,如果你说你破坏了 API,他们也不会总是去看你的变更日志。大多数时候,他们会注意到你破坏了 API,是因为他们的代码编译不过了。所以,如果你做了像这样的糟糕改动,而人们没有得到编译器错误,他们中至少有一些人会把这个推到生产环境中,期望着,“呼,这个改动没有影响到我。” 然后,过了一段时间,你会接到一些非常愤怒的电话。如果不是律师的话。
所以,再说一次,如果你必须破坏 API,就彻底破坏它。别只破坏一半。别半途而废。放手去做。破坏类型,破坏名称,破坏你能破坏的一切,这样编译器就会阻止人们,他们就必须花时间去弄清楚你到底改变了什么。不要只改变编译器看不到的东西。那太危险了。
ABI 变更¶
好的,好的,好的。我们现在来谈谈 ABI 的变化。与二进制文件的兼容性问题。好的。
那么,这里有多少人知道 ABI?我看看。大概一半的人。好的。所以,我想现在是时候我花点时间来刷新一下你们的记忆了。
ABI 是应用程序二进制接口(Application Binary Interface)。它是你的二进制文件相互交谈的方式。它真正定义了程序在你的计算机中是如何工作的。有趣的是,它不属于标准的一部分。我在这里放了个小星号,因为有一个非常非常小的特定部分是在标准中定义的。但它的大部分内容根本不是由 C++ 标准定义的。它取决于你的平台、你的编译器,很多东西,但不是标准。这当然让事情变得棘手。
同样,我想把它分成两部分。
基础设施部分:左边的部分是我称之为基础设施的部分。这是所有来自你选择的平台、CPU、操作系统、编译器等这类东西的 ABI 规则。这部分我不会谈,或者可能不会谈太多。因为这是你们开发者、API 维护者不需要关心的部分。这基本上是使用你代码的人的选择。他们有责任确保,如果两个二进制文件要相互通信,它们必须使用相同的基础设施。那不是你的问题。它是个问题,但不是你的问题。
代码派生部分:既然我没有无限的时间,我将专注于第二部分,也就是从你的代码中派生出来的东西。你能在你的代码中改变什么,可能会对 ABI 产生影响?
这主要涉及改变符号名称(Symbol Names)、二进制类型表示(Binary Type Representation) 和 虚函数表(VTable)。
名称修饰 (Name Mangling)¶
第一个可能是最广为人知的。它叫名称修饰(mangling)。名称修饰的思想是,你用于 C++ 的链接器(linker)仍然与 C 平台紧密相连。这意味着它只能理解由下划线、字母和数字组成的名称。仅此而已。但问题是,在 C++ 中,一个名称不仅仅是一个名称。它是名称加上所有参数的类型,再加上命名空间。正如你所见,像冒号或星号之类的东西,或者任何你能在签名中找到的东西,都不符合那个标准。
所以在 C++ 中,我们有所谓的名称修饰,基本上就是你给我名称,你给我签名,我把它们混合匹配,最后我输出一个与 C 名称兼容的 ID,这样你的链接器、你的平台就能用它工作了。这个算法,同样,没有在标准中定义。它通常由编译器定义。大多数编译器会尽量友好,在同一个平台上有相同的修饰规则,但这甚至也不是一个保证。
所以,例如,这里,我为同一个函数 foo
写了两个重载。第一个接受 int
,第二个接受 double
,正如你所见,最终出来的确切名称有点不同。
/* 生成代码,仔细甄别 */
// What the compiler sees
void foo(int); // Becomes something like: _Z3fooi
void foo(double); // Becomes something like: _Z3food
那么,我们能从中推断出的第一件事是,如果我改变了类型,我改变了签名,我就改变了函数名末尾那个漂亮的 i
和 d
,所以我改变了一个符号名称。我想我们所有人,在我们的职业生涯中至少都遇到过一次,如果你在库中改变了名称,然后你运行一个二进制文件,你会在 Windows 上得到一个漂亮的弹窗,在 Unix 上得到一条漂亮的错误消息,告诉你**“找不到那个符号”**。所以,基本上,即使你没有重命名一个函数,如果你只是改变了参数类型,你也改变了那个神奇的 ID,它就不再是二进制兼容的了。
有趣的是,你不仅仅需要关心你的公共函数。你不仅需要关心你的 API 函数。因为很自然地会想到,“好吧,我只需要关心,我是否改变了 API?” 但不仅仅是那样。那也可能是实现细节。例如,如果我做了一些内联,因为这是调用工具包所做的,我有一个函数 foo
,它是我的 API 的一部分,然后在内部,作为一个实现细节,它调用了一个在 details
命名空间中的函数,这个命名空间通常由维护者保留,不是公共 API。
/* 生成代码,仔细甄别 */
// Old version
namespace details { void bar() { /* ... */ } }
void foo() { details::bar(); }
// New version
namespace details { void bar(int) { /* ... */ } }
void foo() { details::bar(42); }
所以,技术上讲,如果我从左到右做了这个改变,我没有破坏 API。函数 foo
仍然在那里,它仍然编译得很好,定义的行为也仍然一样。但是这里有一个符号不一样了。bar
过去不接受参数,现在它接受一个。最终的魔术名称不一样了。那么会发生什么?嗯,我得到了一个链接错误,或者我得到了一个加载器错误,而从外部看,API 没有改变。但我还是改变了符号。所以你不仅要关心 API 方法,API 符号,你必须关心你系统中任何导出的符号。
我想这就是……James 在哪里?在那里。我想这就是 Windows 做得对的地方。因为在 DLL 中,就像昨天展示的那样,默认是私有的。你必须明确声明那个符号将被导出。所以很容易查看你的 DLL,或者查看你的导出文件,或者只是在你的配置上运行一个 grep
,来知道你需要关心哪些符号,特别是如果你必须手动导出它们,因为那样你就知道你破坏了 ABI,因为你重命名了符号。
在 Unix 上,另一方面,默认是任何不是 static
或在匿名命名空间内的东西都会成为一个公共符号。要确定你是否改变了对外部世界可见的东西,从而破坏了代码,就困难得多。通常,我建议你们,特别是如果你们想要可移植的代码,是使用 CMake 可以为你们生成的导出宏,你们也可以手动做,它在 Windows 上展开为 DLL_EXPORT/IMPORT
,在 Unix 上展开为无,因为你可以很容易地 grep
它然后说,“好吧,在 diff 中,这些行中有没有改变?” 因为如果变了,你可能就破坏了 ABI,即使 API 仍然是相同的。明白了吗?很好。
虚函数表 (VTable)¶
现在我们来谈谈虚函数表(VTable)。VTable 是你的编译器如何恢复指向你的类的函数指针的布局,因为我们都知道,当你有一个虚方法时,是在运行时代码才会去思考实践中要调用哪个方法,这通常是通过一个函数指针表来完成的。这,同样,没有在标准中定义,这取决于你的编译器,通常你可以期望它在一次又一次的编译中保持不变,只要你没有改变任何东西。
但是因为它只是一个指针表,一些偏移量,编译器,代码会盲目地跳转到那里,如果你在里面添加一个方法,你很可能会改变某个地方的一些偏移量,事情就不会顺利。特别是如果一个 API 的两端在两个库中对 VTable 布局有不同的看法。比如,如果第一个库认为有四个方法,第二个库认为有五个,而你想要调用的那个特定的是第三个,而它们对哪个是第三个没有达成一致,你就会遇到一些 неприятные сюрпризы。
所以基本上,如果你重新排序虚方法,或者你添加一个,你很可能会改变大小或一个偏移量,事情就会变得很糟糕。
结构体布局 (Struct Layout)¶
既然我们谈论的是二进制表示,最后一个可能是最广为人知的,因为我们在 C 语言中就见过,那就是结构体的布局。因为,嗯,你的编译器看不到,嗯,编译器看得到,但你的机器看不到你漂亮的结构体布局。你的机器只看到偏移量和大小,基本上,以及一些非常基本的汇编类型。所以,如果一个结构体的大小改变了,一个成员的大小改变了,或者一个成员的偏移量改变了,你就会遇到一些问题。有趣的是,这取决于你的平台。根据你平台的规则,它可能会从一种方式完全变成另一种方式。例如,在 ARM 和 x86 之间,或者在 x86 和 Spark 之间,你没有相同的规则。所以,在一种情况下,它甚至可能看起来能工作。然后,你把同样的更改和同样的发布二进制文件推到另一个平台上,结果就不那么好了。
基本上,思想是你拿出你在 C++ 中漂亮的结构体,然后你的编译器把它翻译成一个二进制布局。例如,我的 int
,那是在我的机器上,所以是 x86 64 位。在我的机器上,我首先有一个 4 字节的整数,然后是一个 bool
,一个指向 char
的指针,然后是一个 double
。
/* 生成代码,仔细甄别 */
struct MyData {
int m1;
bool m2;
char* m3;
double m4;
};
好的,这是它在我的机器上的样子。第一个,编译器把它放在我结构体的最开始,因为它可以。然后我有一个布尔值紧随其后。然后我有一个指针,我机器上的 ABI 说每个指针都应该按其大小对齐,这意味着它应该从一个作为其自身大小倍数的偏移量开始。所以例如这里,下一个可用的位会是地址 5,但据我所知 5 不是 8 的倍数。所以编译器会添加一些**填充(padding)**以便能够遵守规则。
当然,如果我在末尾添加一个成员,嗯,大小就变了。如果我在 m3
和 m4
之间添加一个成员,同样,因为对齐规则,它会把所有东西都推后。所以通常如果你改变了结构体中的任何东西,比如你改变了大小,你改变了顺序,或者你改变了偏移量,它就不会是二进制兼容的。
甚至不止于此,如果你改变了一个成员的可见性(visibility),它也可能不兼容,因为这就回到了我说的标准中唯一谈论 ABI 的部分。编译器有两种方式可以将你漂亮的结构体布局翻译成二进制。有 C 兼容的方式,还有,嗯,我们称之为新的 C++ 方式。它们没有相同的规则和相同的保证。C 的方式说所有东西都应该以相同的顺序排列,与你有的任何平台规则对齐。C++ 的方式说,而且它只有在你至少有两个不同可见性的成员时才会激活,例如 public
和 private
。然后编译器说,“好的,你不是想做 C 兼容,我有更多的自由来重新排序成员,如果我想要的话。” 所以你更难知道 ABI 是否会破坏。简单的规则就是说,“好的,我改变了结构体中的任何东西,任何类型,任何大小,任何顺序,它就不再是二进制兼容的了。”
当然,这里我谈论的是你通过 API 交换的类型,公共类型,任何一个 API 两端都必须看到的类型。因为如果它只在你代码内部,嗯,你可以随心所欲地改变它。你的二进制文件只是一个单元,他总是会和自己达成一致,希望如此。但至少当你有两个人谈论一种结构体,一个公共结构体时,他们必须在布局上达成一致。如果他们不一致,坏事就会发生。如果幸运的话,它会很早就失败。如果不幸,它会混淆你金融结构体中的支付和获取成员,你的客户每次买东西都会得到钱,而不是付钱给你。
如何整合这一切并进行版本控制?¶
我想昨天对 Semver 有一个很好的介绍,但还是,你们所有人都熟悉 Semver 吗?或者我需要……如果你知道 Semver,能举手吗?Semver?仍然,好吧,也许一半的人不知道 Semver,所以我想一个快速的回顾可能会很有趣。
基本上,它是一个版本控制方案,我想是六七年前由 GitHub 的某个人创建的,它是一个用来表达 API 变更影响的正式约定。它由三个数字组成,X.Y.Z,X 是主版本号(Major),Y 是次版本号(Minor),Z 是修订号(Patch)。
主版本号 (X):当您进行不兼容的 API 更改时。任何破坏性变更都应该是主版本发布。
次版本号 (Y):当您以向后兼容的方式添加功能时。所以,有变化,但不是影响性的。
修订号 (Z):当您进行向后兼容的错误修复时。API 没有变化,升级是安全的,甚至降级(如果你认为必须的话)也应该是可以的。
那么,我们该如何处理呢?嗯,我的建议是,在我们可能进入一个“live at head”的新世界之前,如果那一天真的到来,我们应该遵循某种约定,而且,在那之前,我没有找到更好的。也许有,也许没有,但至少我们有东西,我们可以就某事达成一致。
维护变更日志 (Changelog)。 仅仅告诉人们,“好了,版本变了”是不够的,因为如果你只告诉人们版本变了,嗯,通常这意味着,“好吧,做好准备,你可能会有问题。” 我认为这不够。现在,应该是,“好的,我改了些东西,拜托,拜托,去看看变更日志,看看这对你是否有影响,然后调整你的代码。”
记录你的契约。 当然,既然我们说 API 中有一半的东西不属于类型系统,你就必须记录下来,因为它们是协议,如果你必须就某事达成一致,它必须写在某个地方,必须有一个单一的真相来源,人们可以用它来谈论某事,所以请记录下来,任何你想要的,它可以是你项目中的一个 markdown 文件,可以是一个 wiki,可以是 Doxygen,可以只是注释,任何你想要的,但要记录它们。
不要做任何不可见的破坏性变更。 如果你改变了 API 中的某些东西,选择一个编译器会看到的东西,选择一个会触发编译错误的东西。不要做那个
bogo_sort
的事情,我知道那是个夸张的例子,但更险恶的事情可能会发生,而且在过去已经发生过无数次了,因为人们改变了 API,而他们只改变了类型系统看不到的部分。
如何包含 ABI?¶
那么,如何包含 ABI 呢?嗯,第一个选项是:不要。
我的意思是,我完全同意最近展示的观点。如果你能避免它,那就容易多了。正如你所见,正如许多人之前说过的,ABI 很复杂。它有很多规则,它们依赖于平台,甚至在某些情况下,它可能在你的机器上工作,但在服务器机器上不工作,因为它不是同一个 CPU 或同一个操作系统或其他什么。它很危险,不容易处理,如果你是 header-only,这甚至不是一个选择,你不需要这样做。所以,最简单的改变,如果你可以要求你的客户总是重新编译,就那么做。那样容易得多,你会睡得更好。
但是,有时候你不能,就像我说的。有时候你维护一个底层库,你的客户会打电话给你说,“好吧,你的二进制文件里有一个安全问题,我明天之前需要一个二进制修复。” 而且,“我需要一个不需要我重新构建所有基于你产品的东西的二进制修复。” 如果你有那种情况,你就必须找到一个方法。那就是 ABI,二进制兼容性。
所以,我当时的建议是,如果你想在发布库时考虑二进制兼容性,你应该调整 Semver 来谈论它,因为 Semver 只关心 API,因为 API 是,嗯,一个 C++ 的关注点。所以,基本上,我建议:
主版本号 (MAJOR): 如果你破坏了 API 或 ABI,你说这是一个主版本变更。人们不能期望可以热插拔二进制文件,人们不能期望他们的代码能编译,他们必须做些什么。
次版本号 (MINOR): 如果你只做了向后兼容的 API 变更(并且 ABI 兼容),这是一个次版本发布。你只是改变了一些东西,但人们可以安全地进行二进制升级,他们可以简单地重新编译而不用改变他们的代码,它会工作的。
修订号 (PATCH): 当然,还有修订号,也就是我没有改变任何属于契约一部分的东西,我只是修复了安全问题,我只是提高了性能,我只是做了内部重构,没有符号改变,ABI 或 API 中没有任何改变,这只是一个补丁,去做吧,它是安全的。
关于依赖关系呢?这是二进制兼容性变得棘手的部分,就是,嗯,如果你改变了一个依赖项的主版本,嗯,大多数时候,那意味着你的 ABI 改变了,因为构建你的人将不得不重新构建,并可能调整代码。也许你,也许你在你的公开类型中暴露的库也改变了它的 API,所以人们将不得不适应。我会说这很可能,会破坏你的 API,并且当然也可以破坏你的 ABI。如果你依赖项之一有一个新的 ABI,嗯,你的客户很可能也会知道,因为他们会得到一些错误。当然,如果你改变了一个私有依赖项的主版本,那也很可能对你的 ABI 是一个破坏性变更。因为你的二进制文件,如果在生产环境中只热插拔你的二进制文件而不动别的,它就不会工作,因为预期的依赖项不一样了。这是个棘手的话题,我希望我有更多时间来谈论它,但就像大多数人说的,通常这涉及到包管理器或一些更重型的武器。
我能做的比这更多吗?我能做的不仅仅是向人们宣传我改变了某些东西吗?因为变更日志很好,文档很好,提升版本号来提醒人们阅读文档也很好。你能做得更多吗?是的,当然。你可以更进一步。你可以为人们提供迁移脚本,比如一个 Clang 脚本,或者其他任何东西,甚至可能是一个 sed
脚本。我不认为 sed
就足够了,但任何一种脚本,也许人们可以用它来直接升级,那会很好,因为问题是如果你过于频繁地破坏 API,嗯,你知道,我们开发者,我们很懒。我们不喜欢做我们能避免的工作,所以如果我们能避免,我们就不升级。所以如果你为人们提供可以帮助他们无痛切换到新版本的脚本,他们会感谢你的。他们做的越多,你收到的关于旧版本的支持请求就越少。
未来展望¶
未来,我没剩多少时间了,所以我会快速过一下。基本上,有两件事可能会影响这次演讲。
契约 (Contracts):因为我们可能最终会有一些东西,至少能把更多左手边的、API 的外部部分,放入编译器中。我们可能会有一些选项来告诉用户,“好的,那部分变了。” 也许你在改变契约时就不必破坏任何东西了。你也许可以依靠编译器来告诉用户,他们现在不能期望同样的东西了。
模块 (Modules):当然,如果模块来了,人们可能会开始分发,不是头文件,而是只有二进制模块加上也许是库。这可能会改变你分发软件的方式或你记录东西的方式。例如,如果你只有模块而没有头文件了,也许他们永远没有机会看到头文件里的代码,所以没有文档,什么都没有。他们将不得不找到另一个来源来检测某些东西已经改变了。
小测验¶
好了,现在是时候醒醒,看看你们是否都跟上了。因为,就像 Bjarne 告诉我们的,如果你告诉学生会有考试,他们就只会为考试而学习,所以我事先没有告诉你们。
好的,我们开始。基本上,每当我有一个变更,你告诉我,这是不是一个破坏性变更?我破坏了 API 吗?我破坏了 ABI 吗?我改变了它们中的任何一个吗?我什么都没改变吗?
1. 在结构体中添加一个新成员
/* 生成代码,仔细甄别 */
// Before
struct A { int m1; };
// After
struct A { int m1; int m2; };
我破坏了什么?我破坏了 API 吗?是的,基本上我破坏了。我破坏了 ABI 吗?也破坏了,是的,很好,我把所有东西都破坏了。嗯,这种事常有。一个新成员,你把所有东西都破坏了。
2. 将参数类型从 int
改为 long
/* 生成代码,仔细甄别 */
// Before
void foo(int);
// After
void foo(long);
我破坏了 API 吗?也许。也许。嗯,我会说没有,因为技术上我只是接受了更多的值,它是向后兼容的。我改变了 API,但我不认为我破坏了它。但我破坏了 ABI,因为它对我的方法来说不是同一个二进制签名。
3. 在结构体中交换两个成员的顺序
/* 生成代码,仔细甄别 */
// Before
struct A { int m1; bool m2; };
// After
struct A { bool m2; int m1; };
API 改变了吗?是的。不,不,答案是没有。不,这只是名称,编译器不关心名称的顺序。幸运的是,C++ 不是一种偏移量会对编译器产生影响的语言。但我改变了顺序,所以 ABI 完全被破坏了。
听众/James插话:这实际上是一个破坏性的 API 变更,因为例如,你可以对一个
A
类型的实例进行聚合初始化,所以现在你破坏了所有那些聚合初始化器。
啊,是的。好问题。你说不,Eccle(可能是对之前某次演讲的引用)。也许那是我上一个演讲的内容。我没有看清细则。我没有看清契约。我的错。
4. 重新排序两个非虚成员函数
/* 生成代码,仔细甄别 */
// Before
struct A { void f1(); void f2(); };
// After
struct A { void f2(); void f1(); };
嗯,这个,是的,是的,它没有改变任何东西,没有破坏任何东西。我只是重新排序了两个成员(函数)。没问题。
听众插话:如果,比如,你为了别的什么东西存储了这个成员函数的地址呢?
演讲者回答:那不是问题。它们不存储在结构体内部。它们只是名字。那没有影响。不,因为成员函数,它们的地址不依赖于你的对象。它只是一个第一个参数是
this
的函数。它不是 VTable。
5. 改变函数逻辑
/* 生成代码,仔细甄别 */
// Before
int f(int a, int b) { return a + b; }
// After
int f(int a, int b) { return std::max(a, b); }
这个是我最喜欢的。之前我的函数返回和,现在它返回最大值。邪恶。是的,完全正确。邪恶。这是一个不可见的破坏性 API 变更。你到底想干嘛?你是在 troll 我吗?那不是 troll 我。那让我生气。会有人为此付出代价的。
6. 重新排序两个虚函数
/* 生成代码,仔细甄别 */
// Before
struct A { virtual void f1(); virtual void f2(); };
// After
struct A { virtual void f2(); virtual void f1(); };
这是你提到的那个。在这种情况下,我改变了 VTable,所以我破坏了 ABI。
7. 在结构体中间添加成员(特定于平台)
/* 生成代码,仔细甄别 */
// Before
struct A { bool m1; char* m2; };
// After
struct A { bool m1; short m3; char* m2; };
这个有点棘手,而且它只在我的机器上有效,所以答案会取决于你的机器。答案是,在我的机器上,它能工作。因为,你知道,如果我在一个布尔值后面有两个字节,x86 64 位上的对齐规则会说,对于所有之前的成员,结构体的布局仍然是相同的。所以我可能没有改变 ABI,但你注意到那个星号了吗?我不会依赖它。我想明天或今天可能有一个演讲,是关于你可以做什么来避免在这种情况下改变 ABI。那可能会救你,但大多数时候,它只会来咬你。所以你没从我这里听到这个。
8. 添加带默认值的参数
/* 生成代码,仔细甄别 */
// Before
void f(int a);
// After
void f(int a, bool b = false);
这个。破坏性的。我破坏了什么?我破坏了任何东西吗?是的,我破坏了 ABI。API 仍然,嗯,向后兼容,因为我有一个默认参数,但 ABI 不一样了。多了一个参数。不是同一个名字。
9. 重命名函数
/* 生成代码,仔细甄别 */
// Before
void foo();
// After
void bar();
嗯,简单。我重命名了函数。嗯,显然,什么都不会工作了。我不知道人们会期望什么。
10. 重命名结构体成员
/* 生成代码,仔细甄别 */
// Before
struct A { int member1; };
// After
struct A { int member2; };
我破坏了什么?是的,完全正确。我破坏了 API。名字不一样了,但偏移量是一样的,而且既然 ABI 看不到名字,它们看到的是偏移量和大小,那就没问题。
11. 重命名一个内部实现函数
/* 生成代码,仔细甄别 */
// Before
namespace details { void bar_impl(); }
void foo() { details::bar_impl(); }
// After
namespace details { void new_bar_impl(); }
void foo() { details::new_bar_impl(); }
这个可能有点难读,但基本上,它和我之前展示的那个非常相似。API 是相同的,但我重命名了一个必须导出的内部实现函数,所以我又破坏了 ABI。
结论¶
好的。有人告诉我,用一句名言来结束演讲是个好主意,所以你可能听过这个的某个变体,那就是:
“没有哪个系统是通过破坏向后兼容性而成功的。”
对此,我将加上我个人的引言:
“特别是如果你没有事先警告人们。”
记住,版本控制是关于维护者和用户之间的沟通,所以和他们交谈。告诉他们你什么时候破坏了他们的东西。如果你必须破坏某些东西,就彻底地破坏它。不要悄悄地破坏它。谢谢。真的要和他们交谈。告诉他们你改变了什么。谢谢。
问答环节¶
我们大概有五分钟或者更多的时间提问,所以如果你想,这里有两个麦克风。是的?
问题 1:这个开着吗?好的。我就重复一下。两个评论。GCC 也有一个方法来指定你想要导出哪些符号,哪些不导出。它是一个 pragma visibility 什么的。
回答:对不起,我没听清。GCC 有一个方法来指定你想要从库中导出哪些符号。是的。是的。你有两种方法可以做到。在 Windows 上,James 今天向我们展示了有导出列表。在 Windows 上,在 Unix 上,编译器上有 F-visibility 设置,可以告诉它你应该改变行为来导出。手动使用它相当痛苦,但我认为你可以使用 CMake 或者也许一个构建系统来为你做这个并改变默认值。但默认是所有东西都是可见的,你必须使用 -fvisibility
开关来改变它。
问题 2:你能回到那个你将 Semver 映射到 C++ 变更的幻灯片吗?就是你说,如果你破坏了这个,就改变 Semver 的这一部分。就是关于 Semver 的那张,对吧?好的。对我来说,作为一个你的库的用户,似乎最后两个是无法区分的。它没有破坏任何东西。事实上,甚至不清楚你为什么要……你说没有变化。你为什么要增加修订号? 回答:嗯,技术上讲,就像我说的,这是因为你可能要做一些内部重构。所以对用户没有影响,但你可能还是想推送那些代码,因为我们所有维护者有时候都想重构、重新改变我们自己的代码。这对任何人都没有影响。 追问:但这和第二个,非破坏性变更,是一样的。 回答:不,因为如果你想,技术上讲你可以回滚。我不推荐那样做,但有时候人们,他们想要降级。对于一个修订版本,技术上讲降级是安全的。通常你不会那么做,但,你知道,有时候你推送一个重构,结果却带来了 bug。人们可能想知道,如果他们因为某种原因必须这样做,回到上一个版本是否安全。特别是,这是大规模版本控制的问题之一,就是你可能有两个来自不同地方的钻石依赖,它们想要两个不同的版本。你可能因为某种原因不得不选择最旧的那个。有了这个,你就可以说,“好的,这是可能的。” 这不理想。我不会那么说。但有时候你想说,“好的,我可以降级,因为最新的版本里有一个关键 bug 还没有修复。” 这种情况如今越来越频繁,因为我们有更快的发布周期,但它仍然可能发生。谢谢。
问题 3:第二个评论。有时候当你减少函数的运行时间,也可能是破坏性变更。例如,如果一个函数比较两个字符串,它保证运行时间是 O(n)。如果你优化它,在找到不匹配时提前返回,这对于安全来说可能是破坏性变更,因为黑客可以进行计时攻击。 回答:啊,嗯,我猜那要看情况。你过去是否保证过你的函数会花费至少一些时间?如果你保证了,嗯,是的,你破坏了 API。但如果你没有做任何保证,嗯,人们把他们的东西建立在实现细节上。而正如我们被无数次告知的,是的,嗯,你不应该那么做。非常感谢。