Swift 中的库演进

Swift 5.0 在 Apple 平台上引入了稳定的二进制接口。这意味着使用 Swift 5.0 编译器构建的应用程序可以使用操作系统内置的 Swift 运行时和标准库,并且现有应用程序将与未来操作系统版本中新的 Swift 运行时版本保持兼容。

Swift 5.1 发布了与二进制稳定性相关的两项新功能,这些功能支持可以分发和与他人共享的二进制框架

模块稳定性目前需要库演进支持;通常,在构建用于分发的二进制框架时,您将同时启用这两个功能。

有关二进制稳定性、模块稳定性和库演进支持如何协同工作的更多详细信息,请参阅本博客上名为 ABI 稳定性和更多 的早期文章。

何时启用库演进支持

库演进支持默认是关闭的。始终一起构建和分发的框架,例如 Swift Package Manager 包或应用程序内部的二进制框架,不应使用库演进支持构建。

库演进支持仅应在框架与其客户端分开构建和更新时使用。在这种情况下,针对旧版本框架构建的客户端可以在不重新编译的情况下与新版本的框架一起运行。

如果您计划发布将以这种方式使用的框架,请务必至少从第一个版本开始启用库演进,或者最好在开发和测试周期的早期就启用。启用库演进支持会更改框架的性能特征,并引入与枚举的 switch 详尽性相关的源代码不兼容的语言更改。此外,为框架启用库演进支持本身就是一个二进制不兼容的更改,因为在没有库演进的情况下构建的框架不提供任何二进制兼容性保证。

启用库演进支持

Xcode

当使用 Xcode 为 Apple 平台开发时,在框架的目标中设置 BUILD_LIBRARY_FOR_DISTRIBUTION 构建设置。此设置同时启用库演进和模块稳定性。请确保在 Debug 和 Release 构建中都使用此设置。

BUILD_LIBRARY_FOR_DISTRIBUTION Xcode 构建设置和相关的 .xcframework 支持在 WWDC 2019 的题为 Swift 中的二进制框架 的演讲中介绍。

直接调用编译器

如果您直接从命令行或其他构建系统调用 swiftc,则可以传递 -enable-library-evolution-emit-module-interface 标志。例如

$ swiftc Tack.swift Barn.swift Hay.swift \
    -module-name Horse \
    -emit-module -emit-library -emit-module-interface \
    -enable-library-evolution

上述调用将生成一个名为 Horse.swiftinterface 的模块接口文件和一个共享库 libHorse.dylib (macOS) 或 libHorse.so (Linux)。

库演进模型

库演进允许您对框架进行某些更改,而不会破坏二进制兼容性。如果新版本保持与旧版本的源代码兼容和二进制兼容,我们称框架的更改是弹性的。

在我们详细说明哪些类型的更改是弹性更改之前,我们需要介绍 ABI 公共声明 的概念。这是一个可以从另一个 Swift 模块引用的声明。以下是一些示例

如果我们需要明确关注非 ABI 公共声明的行为,则使用术语 ABI 私有。ABI 私有声明是那些声明为 privatefileprivateinternal 且没有 @usableFromInline 属性的声明。

@frozen 属性 也与库演进相关联。此属性更改 ABI 公共结构体或枚举的二进制接口,以公开更多实现细节。通过限制未来哪些类型的更改可以是弹性的,可以牺牲一些灵活性以换取额外的性能。

介绍完这些之后,让我们继续描述框架作者可以引入的一些常见弹性更改,以及要避免的非弹性更改。

弹性更改的示例

非弹性更改的示例

有关哪些更改是弹性更改或非弹性更改的更详尽说明,请参阅 Swift 编译器源代码存储库中题为 LibraryEvolution.rst 的文档。

选择性退出库演进

现在,我们将详细讨论 @frozen@inlinable 属性。

库演进通过在编译后的客户端代码和框架之间引入抽象级别,从而牺牲性能以换取灵活性。在大多数情况下,允许未来的灵活性是正确的默认设置。但是,有时您的框架会定义非常简单的数据类型,这些数据类型根本无法以任何合理的方式演进。

例如,一个用于二维图形的库可能会定义一个 struct,用于表示二维空间中的点,表示为两个名为 xyDouble 类型的存储属性。此结构体的存储属性布局在未来不太可能更改。

在这些情况下,对于开发者来说,向编译器传达声明在库的未来版本中不会演进可能是有利的。作为回报,当客户端与这些声明交互时,编译器可能会生成更高效的代码。

应谨慎使用这些属性。但是,它们在某些情况下仍然非常有价值,因此接下来我们将详细研究这些属性中的每一个。

内联函数

@inlinable 属性是库开发者承诺函数当前定义在使用库的未来版本时将保持正确的承诺。此承诺允许编译器在构建客户端代码时查看函数体。请注意,尽管名称如此,但并不保证会发生内联;编译器可以选择在客户端内部发出函数的专门的外部副本,或者继续调用在框架中找到的原始版本。

可能需要使用此属性的一个示例是完全根据协议要求实现的泛型算法。假设协议发布的不变量没有更改,则将泛型算法内联到客户端应用程序中始终是正确的。库的未来版本可能会用更高效的实现替换泛型算法,但已内联到客户端应用程序中的现有版本应继续工作。

编译器对 @inlinable 函数体强制执行了一个重要的限制;它们只能引用其他 ABI 公共声明。回想一下,ABI 公共声明是 public@usableFromInline 的声明。@usableFromInline 属性的存在是为了可以为内联代码定义辅助函数,但这些辅助函数不能作为公共接口的一部分直接调用。要理解为什么存在此限制,请考虑如果 @inlinable 函数可以引用 private 函数或类型会发生什么。这些私有函数和类型现在将成为框架二进制接口的一部分,从而阻碍未来的演进。

从二进制兼容性的角度来看,@usableFromInline 声明实际上与 public 声明相同,这就是为什么我们总是谈论ABI 公开声明的概念,它涵盖了两者。一旦发布,@usableFromInline 声明绝不能被移除,或者对其接口进行任何不兼容的更改。

内联函数在 Swift Evolution 提案 SE-0193 跨模块内联和特化 中有更详细的描述。

冻结结构体

@frozen 属性可以应用于结构体,以向客户端公开其存储属性布局。@frozen 结构体的存储属性的添加、移除或重新排序都是二进制不兼容的更改。作为失去灵活性的回报,编译器能够跨模块边界对冻结结构体执行某些优化。

编译器对 @frozen 结构体施加了两个语言限制

请记住,@frozen 仅表示存储属性成员的集合不会更改。它没有对其他类型的结构体成员施加任何限制。添加和重新排序方法以及计算型属性是完全可以的。但是,不要将任何计算型属性更改为存储型属性,反之亦然;并请记住,属性包装器和 lazy 属性在底层实现为存储属性。

最后的注意事项是,实际上在结构体上添加或移除 @frozen 都是二进制不兼容的更改;结构体必须“天生冻结”,或者永远保持弹性!

关于冻结结构体的更多详细信息可以在 Swift Evolution 提案 SE-0260 用于稳定 ABI 的库演进 中找到。

冻结枚举

枚举也可以是 @frozen 的,这承诺不会添加、移除或重新排序枚举用例。(请注意,虽然“移除”在列表中,但从 ABI 公开的枚举中移除用例会破坏二进制兼容性,即使枚举不是 @frozen 的,因为所有用例都是 ABI 公开的。)

与冻结结构体一样,编译器可以跨模块边界更有效地操作冻结枚举值。在枚举上添加或移除 @frozen 是二进制不兼容的。

如果冻结枚举的所有用例都被 switch 语句覆盖,则认为对冻结枚举的 switch 是穷尽的,而对非冻结枚举的 switch 语句必须始终提供 default 或 @unknown 用例。这是启用库演进支持引入的唯一源码不兼容性。

switch 穷尽性的行为在 Swift Evolution 提案 SE-0192 非穷尽枚举 中有详细说明。

平台支持

目前,Swift 编译器仅保证在 Apple 平台上的不同编译器版本之间实现二进制兼容性。这意味着在 Linux 和其他平台上,使用不同版本的 Swift 编译器构建的应用程序和库不一定能在运行时正确链接或运行。

但是,稳定的模块接口和库演进可以在 Swift 支持的所有平台上使用。因此,在非 Apple 平台上,您仍然可以使用同一库的多个版本,而无需重新编译客户端应用程序,前提是所有二进制文件都是使用相同版本的 Swift 编译器构建的。

正如在 ABI 稳定性及更多 中提到的,随着 Swift 在 Linux、Windows 和其他平台上的开发日趋成熟,Swift 核心团队将评估在这些平台上稳定 ABI。这将解除对混合和匹配使用不同编译器版本构建的产物的限制。

Objective-C 互操作性

以下内容仅适用于 Apple 平台。

如果您的框架定义了一个 open 类,则客户端代码中的子类定义必须执行运行时初始化,以应对基类的弹性更改,例如添加新的存储属性或插入超类。此初始化由 Swift 运行时在幕后处理。

但是,如果一个类需要运行时初始化,则只有在新平台版本上运行时,它才对 Objective-C 运行时可见。这在实践中的结果是,在旧平台上,某些功能(例如构建在 NSClassFromString() 之上的功能)对于需要运行时初始化的类将无法按预期工作。此外,除非部署目标设置为足够新的平台版本,否则需要运行时初始化的类不会出现在 Swift 编译器生成的 Objective-C 头文件中。

以下操作系统版本中提供了必要的 Objective-C 运行时功能

除非您确定框架的类不会以前述方式与动态 Objective-C 功能结合使用,否则最安全的选择是将上述平台版本作为框架和客户端代码的最低部署目标。

与 -enable-testing 的交互

-enable-testing 编译器标志以特殊模式构建框架,允许其他模块使用 @testable 属性导入该框架。@testable import 使框架中所有 internal 声明对导入模块可见。这通常用于希望测试框架公共 API 之外的代码的单元测试。

-enable-library-evolution 编译器标志与 -enable-testing 结合使用时也受支持,实际上,构建用于测试的框架目标推荐的方式是同时传递这两个标志。但是,重要的是要注意,生成的框架仅在公共 API 的更改方面具有弹性。这意味着通常导入框架的客户端在二进制方面与为测试构建的新版本保持兼容。但是,实际使用 @testable import 的代码(例如框架自身的单元测试)绕过了访问控制,并且必然依赖于它所针对构建的特定框架版本的非弹性实现细节。因此,测试应始终与框架一起构建。

库演进的实现

对于本文的其余部分,我们将深入探讨编译器实现的细节。理解这些细节不是使用库演进功能的必要条件。这些内容仅对 Swift 编译器贡献者或任何对幕后工作原理感到好奇的人感兴趣。

弹性边界

对于给定的单个语言构造,Swift 编译器可能会根据上下文和可用的静态信息量生成不同的代码模式。使用支持库演进的框架与不使用支持库演进的框架之间的主要区别在于,对于某些语言构造,在使用库演进支持的情况下,编译器在生成代码时会更加保守。

一个重要的概念是弹性边界。在单个框架内部,编译器始终完全了解框架的类型和函数。框架内部没有弹性边界,因为框架的所有源文件都假定一起编译。

但是,在构建客户端应用程序时,编译器必须注意仅做出静态假设,以确保即使在框架的未来版本中这些假设仍然成立。跨弹性边界可用的编译时信息的范围受到有意限制,并且某些决策必须推迟到运行时,以便实现库演进支持提供的灵活性。

结构体和枚举

如果结构体或枚举未声明为 @frozen,则其内存布局在弹性边界之外是不透明的。这包括值的大小和对齐方式,以及在移动、复制和销毁此类型的值时是否必须执行额外的工作(例如,更新引用计数)。

当生成跨弹性边界与弹性结构体或枚举交互的代码时,编译器将始终间接操作该值,传递类型元数据以描述该值的内存布局。这类似于未特化的泛型函数如何操作泛型参数类型的值,这是 2017 年 LLVM 开发者会议题为 实现 Swift 泛型 的演讲中详细讨论的主题。

实现的一个重要特性是,弹性结构体或枚举与非弹性结构体或枚举具有相同的内存布局;在值级别上没有 装箱 或间接寻址。相反,操作这些值的代码必须采取额外的步骤来计算字段偏移量或在函数之间传递值作为参数。这确保了虽然库演进支持可能会增加代码大小,但它不会影响数据的 缓存局部性

属性

Swift 中的属性有许多不同的类型:存储属性、计算型属性、带有观察器的存储属性,以及一些更特殊的变体,例如 lazy@NSManaged

回想一下,从库演进的角度来看,所有属性都公开了一个由访问器函数组成的统一接口。每个属性都有一个 getter 函数。如果属性是可变的,它还将具有 setter 和modify 协程。modify 协程允许在某些用法中生成更高效的代码,例如将属性作为 inout 参数传递。如今,它的存在是一个实现细节,但 向语言添加 modify 访问器的提案 目前正在通过 Swift 演进过程。

编译器通常总是使用访问器函数来跨弹性边界访问属性。这保证了对属性底层实现的更改是弹性的。

当然,例外情况是 @frozen 结构体中的存储属性。虽然访问器函数仍然生成,并且在某些上下文中(例如在发出协议见证表时)使用,但编译器能够在可能的情况下发出对存储属性的直接访问。

协议

当框架发布协议时,客户端代码可以声明符合此协议的类型。编译器生成一个称为协议见证表的函数指针表,以描述每个协议一致性。在泛型参数上调用协议需求需要从协议见证表中加载正确的函数指针。由于协议需求可以重新排序,并且可以添加具有默认实现的新协议需求,因此协议见证表的布局在弹性边界之外必须完全不透明。

这通过两个步骤完成。首先,对于每个协议需求,二进制框架导出一个名为分发桩的特殊函数。分发桩是框架本身的一部分,因此它可以直接硬编码协议需求在见证表中的偏移量。如果协议的声明被更改以重新排序需求,则见证表中条目的顺序会更改,但分发桩的符号名称保持不变。由于客户端代码通过分发桩调用所有协议方法,因此可以保持与框架未来版本的二进制兼容性。

最后,为了应对添加新的协议需求,协议见证表需要运行时实例化。编译器不是直接在客户端代码中发出见证表,而是发出一致性的符号描述。实例化过程将协议需求按正确的顺序放置,并填充缺失的条目以指向其默认实现,从而生成一个格式良好的见证表,该表可以传递给分发桩。

与结构体和枚举不同,协议没有定义选择退出机制来发布协议的确切布局并绕过调度 thunk 的使用。这是因为在实践中开销可以忽略不计。

如果您一直特别关注,您可能会(正确地)猜到,就像其他弹性功能一样,如果一致性是在与协议相同的框架中定义的,则编译器不会使用运行时实例化或调度 thunk。

Swift 中的类提供了大量功能,这主要是继承的结果。一个类可以继承自另一个 Swift 超类或 Objective-C 超类;当从 Swift 超类继承时,超类可能在同一模块中,或在另一个模块中,无论是否使用库演进支持构建。

类的方法可以动态调度,从而允许在子类中重写它们。从 Objective-C 类继承的 Swift 类也可以重写 Objective-C 方法。类可以通过将方法声明为 final 来选择退出动态调度。整个类也可以设为 final。最后但并非最不重要的一点是,可以使用 @objc 属性将类的方法发布到 Objective-C。这里有很多内容,与弹性的相互作用可能很复杂。

这里的关键要点是,对弹性类上的 Swift 原生方法的方法调度是通过调用调度 thunk 来执行的;与协议一样,这允许重新排序类上的方法和添加新方法,而不会干扰调用者。这种机制还允许*超类*添加或删除方法,而不会干扰子类。

当然,@objc 方法使用完全不同的方法调度策略,涉及调用 Objective-C objc_msgSend() 运行时函数,该函数通过哈希表查找而具有弹性。

开发历史

库演进背后的大部分功能已经在编译器之前的版本中逐步测试和推出,从 Swift 3.0 版本开始。

在 Swift 4.0 之前,标准库以特殊模式构建,使用未公开的 -sil-serialize-all 编译器标志启用。此标志早于 @inlinable 属性的实现,并且本质上等同于将所有函数声明为可内联的。没有显式属性可以选择加入每个函数的行为;我们始终在标准库上启用该标志,并在其他任何地方禁用它。

Swift 4.0 引入了可内联函数的实验性实现,当时拼写为 @_inlineable,并且删除了特殊的 -sil-serialize-all 标志。为了简化过渡,我们只是将所有标准库函数标记为 @_inlineable,因此起初,这些更改几乎没有功能效果。

在 Swift 4.1 和 4.2 中,我们开始了对标准库的全面审核,以确定哪些应该和不应该成为 @_inlineable。Swift 4.2 最终推出了 @inlinable 作为官方支持的属性,表明可内联函数的实现已达到所需的完善程度和正确性。

到 Swift 5.0 版本发布时,标准库审核已完成,可内联代码已精简到绝对最小值,确保标准库可以向未来演进。

我们还继续充实弹性结构体和枚举的实现,引入了另一个实验性属性 @_fixed_layout,该属性后来变为 @frozen。标准库现在是 ABI 稳定的,但实现这一目标所需的工具之一 @_fixed_layout 属性仍然不是官方语言功能。

Swift 5.1 最终引入了 @frozen,作为实验性 @_fixed_layout 的替代品,同时保持与 Swift 5.0 标准库的 ABI 兼容性。随着 @frozen 的引入,库演进现在已准备好用于通用用途。

有问题吗?

请随时在 Swift 论坛上关于此帖子的相关主题中发布问题。

参考

以下列表收集了本文档前面找到的各种链接