Swift 的崛起:操作系统的新成员

发表时间: 2022-10-20 13:16

链接:https://belkadan.com/blog/2022/10/Swift-in-the-OS/

作者:Jordan Rose

译者:弯月

最近,有人在Swift论坛上抱怨说,将Swift放入操作系统只会让开发人员更头疼。我想通过本文讲述我在苹果从事Swift工作的一段经历,并让大家明白让Swift进入操作系统是无奈之举,而且也是必然的选择。


操作系统的依赖性



我曾在以前的文章中讨论过,在选择静态连接库和动态连接库时,怎样找到恰当的平衡点。对于操作系统自带的库,动态链接是一把双刃剑:构建和测试时用到的库有可能到了实际运行时就发生了变化,因此你必须更加小心你所依赖的库。

下面,我们来对比一下两个极端:一个极端是不使用宿主操作系统的任何东西;而另一个是完全依赖。

  • 曾几何时,个人计算机需要将整个计算机的控制权移交给进程,而进程则可以“为所欲为”。即便是操作系统为进程提供了“库函数”,这些库函数也要由进程加载,或者事先放在内存中的某个位置供进程使用,但程序可以完全不使用这些函数,这样这个程序本身就像一个操作系统一样,完全没有动态依赖的关系。

  • 如今,即使是“静态链接”的程序也不可能“负责”整台机器。操作系统内核始终在运行,提供基本的操作系统服务,并公开某种“系统调用”接口,而程序可以通过该接口请求某些特权操作(例如读取文件或分配更多内存页面)。允许程序以这种方式运行的现代操作系统之一就是Linux,程序的唯一依赖项就是系统调用。

  • 如今,最常见的编程方式都会依赖操作系统附带的一些库,比如C标准库及其最小的运行时。现如今的苹果操作系统称此为“最小系统API边界”:各个操作系统版本之间的系统调用接口并不稳定,因此你“需要”使用C/POSIX标准库及其扩展来执行基本的原始操作。Windows与之类似,只不过它的“稳定”接口是C标准库下面的库。

  • 我认为,任何程序都不可能处于另一个极端,即所有行为都来自依赖关系,除了某些简单的示例。但从某种角度来看,任何解释程序都可以看作如此。shell脚本不会直接执行任何机器指令,它的行为皆来自脚本的指示。

因此,我们可以看到程序或多或少都会依赖于宿主操作系统,尤其是苹果正在全力以赴采用“库”模型。


苹果的模型(Swift之前)


在Swift之前,几乎所有的苹果公共API都是用 C 或 Objective-C 编写的,经过编译后作为原生代码提供,而不是某种字节码(如 JVM 或 CIL)。新版OS包括现有库的二进制兼容版本,是先前版本的API的超集(理论上)。因此,旧的应用仍可以运行,而新的应用则可以利用新功能。Objective-C 中的“弱链接”以及建立在此之上的基于名称的调度甚至允许新应用利用新的API(有一定的条件),而且还可以在旧操作系统上运行。最终这被正式确定为availability模型,但这是后话了。

此模型的主要缺点是,新API与新版操作系统紧密相连。假设你有一个应用在OS v7中运行,则无法使用OS v9中的新API。苹果当然希望每个人都迅速迁移到新的操作系统上,但并不是每个人都会照做,原因有很多,比如新版操作系统不支持他们的计算机,或者是新版操作系统与他们使用的应用不兼容,等等。因此,应用开发人员不得不耐心等待,直到有足够多的人使用OS v9,这样才能在不损失收入和用户好感的前提下停止对OS v7 和 v8 的支持。另一方面,苹果更改现有库的行为很可能会导致已有应用无法正常工作,因此即便现有的一些行为存在很多错误,也很难修改。

稍后我们再讨论替代方案,这里先申明一个基本要点:Swift的设计目的之一就是不改变此模型。库仍将编译成原生代码,新版仍将与旧版本二进制兼容。我们接受了这些设计约束,以及与Objective-C的紧密互操作,同时不依赖于JIT。


Swift“测试版” 1..<5


从Swift 1 到 Swift 5 发生了巨大的变化,不仅API的设计改变了,ARC函数约定也变化了,而且还添加了优化,并确定了Swift的通用实践和惯用模式。“ABI的稳定性”(使用两个不同版本的 Swift 编译出的代码之间的互操作性)一直是困扰我们的难题。为什么这一点很重要,而许多其他语言似乎并不在乎?因为这是苹果基于操作系统的库分发模型的前提:为Swift 5编译的应用也可以在基于Swift 6的操作系统上运行,而使用 Swift 6 编译的应用仍然能够“向后部署”到基于 Swift 5的操作系统上。缺乏这个基础,苹果就无法在自己的公共API中使用 Swift。

所以,这一直是Swift的目标。开发人员也抱怨说:每个使用Swift的人都必须将库和应用绑定到一起,这会导致应用的规模膨胀。此外,闭源库的开发人员必须针对每个新版本的Swift发布一个单独的版本,因为它们不一定是二进制兼容的。但与此同时,ABI 稳定性意味着你不能修改任何东西!

我们终于在Swift 5中实现了这一点,大多数功能运行良好,我们已经解决了运行早期版本的Swift旧应用的问题,而且我们还尽可能地消除了对源代码的破坏。实际上,来自苹果内部的压力也不容忽视:这个项目已经进行了多年,但仍然存在各种限制。


过渡


如果将Swift放入操作系统,首先我们必须弄清楚如何不破坏

  • 现有的应用,它们已嵌入了Swift的早期版本;

  • 现有项目,他们希望继续支持iOS的早期版本。

第一个问题已通过修改Swift 的 ABI得到了解决,这样Swift 5就不会与Swift 1~4中的任何东西发生冲突。在新旧Swift需要交互的少数几个地方(Objective-C),Swift 5类型的元数据的标记方式与 Swift 1~4 不同,因此旧版Swift会将新类视为奇怪的 Objective-C 类。虽然实际上有很多细节问题要解决,但这个方法是非常简单的。

第二个问题不能以同样的方式解决,我们希望新的应用能够与系统Swift API进行交互。因此,我们不得不继续允许Swift库与应用程序捆绑发布,同时还要避免新版操作系统上拥有两个Swift副本。当然,我们不希望应用的嵌入式 Swift 5 库取代之后的操作系统Swift 5.1。这将对来年即将推出的操作系统造成部分破坏,因为它依赖于Swift 5.1。

最终我们使用了一个名为“rpath”(runtime search path,运行时搜索路径)的功能,它允许可执行文件通过搜索一系列目录来查找其动态库。我们指定了其搜索顺序:首先搜索/usr/lib/swift/,然后是应用程序包,这样就可以保证应用使用操作系统版的Swift(如果存在的话),否则再退而求其次使用嵌入式版本。

我想澄清一点:这是一种向后部署OS库的技巧,它只能用于部署第一个版本。你不能使用相同的rpath技巧部署第二个版本,因为第一个版本已经存在于磁盘上。去年在部署Swift并发时就使用了相同的技术,然后第一个版本中出现了一个严重的错误,结果这个错误无法在向后部署库中“修复”,因为即使进行向后部署,有问题的操作系统依然会包含libswiftConcurrency 1.0。而这反过来又会造成支持的不连续性。

错误会不断出现,然后在操作系统升级时得到修复,因此只要抓住正确的时机就可以修复这些错误,但重点是这不是一个完美的解决方案,而且对于新的API来说,它会破坏availability模型。


我们失去了什么


我曾在以前的文章中讨论过,在选择静态连

如今,Swift已进入操作系统!应用不再需要嵌入Swift!由于Swift是和Objective-C运行时一起发布的,因此我们可以进行一系列的性能提升!最后,苹果还可以将Swift API作为操作系统的一部分发布。

很快,Swift的外部贡献者就会发现苹果的框架工程师每年都在干什么:

  • 简而言之,你永远不能删除任何东西,只能弃用,因为一旦删除,一些现有的应用就可能无法启动或丢失用户数据。

  • Swift的发布周期与操作系统的发布周期紧密相关。

  • 重点在于,新的API只存在于新的操作系统中。

Go没有最后一个问题,Rust也没有,就连Swift 1~4也没有,而且都是出于相同的原因:这些语言提供了标准库,供应用程序使用。它们不是操作系统的一部分,因此拥有稳定的ABI,而且支持的库也不会有太多变化。

苹果正在设法实现这些目标。但目前我们只能权衡取舍,因此在Swift进入操作系统之后的几年里,苹果以及Swift团队提出了一些技术,用于支持一些常见的向后部署:

  • 给特定函数打上一个“尚不支持”的标签,这样就可以将它们复制到客户端,而不必通过符号名称引用,就像在头文件中定义的 C 静态函数一样。不过,这种方法只适用于函数、方法等不需要恒等性的东西。如果全局变量也使用相同的实现,就会导致出现多个全局副本。此外,只有当实现可与API过去的版本和未来的版本兼容时,才能使用这种方法。

  • 有些苹果库没有与操作系统打包发布,你可以选择将它们嵌入到应用中,就像第三方依赖项。ResearchKit就是其中最早的一个库,在Swift成为操作系统的一部分之前就推出了。

  • 在某些情况下,Swift团队会提供一个“兼容性”库,该库只链接到主应用(因此只有一个),为的是让旧的操作系统能够支持新的功能。为此,有时我们会在运行时中加入“定制点”,这样就可以知道什么地方发生了变化,而有时我们会使用一些依赖于旧运行时或操作系统的实现细节的技巧。

然而,这些方法都不完美。我们没有标准机制,可以将“根据需要向后部署”添加到标准库和操作系统的其他Swift库中,而且向后移植可能会导致修改的难度加大。如果新功能是一个通用函数,依赖于去年尚不存在的协议,该怎么办?如果虽然协议存在,但你想与新功能一起使用的一些类型直到今年才采用,该怎么办?

Swift的要求之一就是能够用于定义操作系统库,而这势必会导致很多工作更加困难。因此,人们不禁会觉得:成为一名操作系统库开发人员比成为一名第三方库开发人员更难。但我们应该明白,这只是一种权衡,而且偏向某一方面也是非常合理的,尤其是考虑到苹果的做法,几乎每年都会出现一些负面影响。


替代方案


苹果本可以努力改善开发人员的处境,而且即便事到如今,仍有机会弥补。但苹果会这样做吗?也许会,也许不会。

删除重复库

如果你的手机上有15款不同的应用,但它们都使用了同一个开源库,则使用方式必然有所不同。也许是各自拥有不同的构建设置,也许是做了局部的改动。但如果它们都使用同一个闭源库,而且这是一个动态库,那么理论上,苹果可以删除磁盘上的这些重复库,从而为最终用户节省一些空间。虽然代码签名会让删除工作变得更加棘手,但我认为也不是不可能,因为如果我没记错的话,应用商店已经重新签名了应用包中的所有库。重复的Swift兼容性库绝对可以删除,我甚至记得曾经讨论过这个问题,所以可能已经有人在这么做了。但即便删除重复库,也不会改变应用商店中下载文件的大小,对于第一方库来说,只有在向后部署时才能删除重复库,因此苹果对于这种方法并没有兴趣。

此外,我们无法删除macOS上的重复库,因为其上的应用并非由操作系统全权管理,但macOS也是苹果平台,你可能压根不关心每个应用的大小。

明确支持“polyfill”

“Polyfill”是一个Web开发领域的术语,指的是将功能向后移植到早期的浏览器,从而掩盖浏览器之间的差异。在JavaScript中,这很容易实现,因为你可以在安装自己的实现之前检查特性是否存在,但在Objective-C中就会很笨拙,而在 Swift 中根本做不到。

在Objective-C和Swift中,更常见的做法是添加如下方法:

func backportFoo() -> String {

if #available(iOS 16, *) {

return foo()

}

// Fallback implementation

return "foo: \(self)"

}

当你打包的是类型而不是操作时,这会变得更加棘手,但通常采用协议的形式依然可以实现这种方法,甚至用基类的方式,将一种实现推给操作系统,另一种实现用于向后移植,也是可行的。我们希望Swift明确支持这一点,但设计这样的功能非常棘手,而且还要考虑到这样一个问题:“如果有两个人同时这样做会怎么样?”人们采用自己的方式来实现这个功能时,至少我们非常清楚它仅仅是一种用户行为,因为从语言来看,这种方式与非向后移植API没有任何关系。

明确支持向后部署

与第三方“polyfill”方法,或显式包装函数、包装类型或抽象协议不同,Swift可以降低标准库(以及作为操作系统的一部分的其他Swift库)支持向后部署新功能的难度,我们只需根据需要将新功能与应用打包到一起,并根据需要加载它们。但这种方法很棘手,因为这意味着,任何客户端代码都必须支持两种不同的工作方式。这绝对不是一件易事。

实际上,Android经常这么干,至少它就是这样使用Java API的。但Android更容易设置,因为它的应用和库使用中间格式(而不是原生代码),此外Java 没有扩展,因此修改现有类型的方法更少。可以说,二者是“脱糖”的。

如果Swift没有进入操作系统,那么ABI稳定性……

最后一种可能性是,苹果本可以努力确保Swift的ABI稳定性,但他们却将Swift放入了操作系统中。苹果希望所有闭源 Swift 库都可以嵌入到每个想要使用它们的应用程序中,也许上述我们提到的删除重复库的策略对此有所帮助。但苹果希望能够将这些库作为操作系统的一部分进行更新。这样,他们就能够进行系统范围的 UI 调整和重新设计,同时无需强迫每个人提前发布应用程序的新版本,而且事后面临的难题也更少。或许你对此有不同的看法,但苹果永远不会放弃。


总结

将Swift放入操作系统对应用开发人员来说是一个很糟糕的权衡吗?也许吧,如果你不关心代码大小的话。但苹果将无法使用Swift编写系统库,而这绝不是苹果所能接受的选择。

另外,Windows上的Swift仍然是一个年轻的项目,而且它没有太关注操作系统库。但Windows上的第三方闭源库远超Linux。因此,在不久的将来,也许我们将拥有一个具有ABI稳定性、且Swift没有进入操作系统的平台。