F5e14921eaf8bf786d8f6d7a36e4e9a1
简单又不简单的 Bool - Swift 标准库源码导读 (二)

上一篇文章中,我们搭建了 Swift 标准库的开发环境。从这篇文章开始,我们来尝试读读看 Swift 标准库中的一些实现。

当然从简单的开始,比如 Bool 类型就是一个很好的 starting point。现在版本的 Bool.swift 代码,加上各种标注和注释,也不过区区 300 多行。你完全可以打开上面的链接,花几分钟阅读一下实现,然后再回到本文来看看我们能有什么样的发现。

标准库中常用的 Attribute

很多时候,我们很快就会被开头的几行震慑住:

@_fixed_layout
public struct Bool {
  @_versioned
  internal var _value: Builtin.Int1

  @_inlineable
  @_transparent
  public init() {
    let zero: Int8 = 0
    self._value = Builtin.trunc_Int8_Int1(zero._value)
  }

@_fixed_layout@_versioned@_inlineable@_transparent! 全是一些我们平常写 Swift 没有见过的私有标注...而且如果你完成了前一篇文章中的练习的话,你可能已经注意到了在其他源文件中也充斥着类似的标注。

这些标注大都和 Swift ABI 相关,它们为编译器最终完成 ABI 稳定提供了提示,同时也对性能进行优化。当然,如果你只是想不求甚解地了解一下 Swift 标准库长什么样的话,你大可忽视掉这些标注,并跳到本文的下一节去。不过机会难得,结合 ABI 的一些话题,我还是想对这些标注到底都干了些什么进行一点说明。

什么是 Swift ABI,什么是 ABI 稳定

想要说清楚这几个标注的意义,我们需要从 ABI 说起。

API (Application Programming Interface) 的概念我们都很熟悉了,在很大程度上,API 稳定指的是源码级别的兼容,也就是:

  • 新版本的编译器应该可以不做修改地编译旧版本的代码

在经过 Swift 3 的阵痛后,从 Swift 4 开始已经达成了源码兼容。不过,Swift Core Team 一直以来最为关心的是尽快做到 ABI 稳定。在运行时,Swift 二进制程序需要通过一套规则 (比如如何调用方法,在哪里能找到需要的类型信息,内存中的数据的布局是怎样等) 来和其他框架进行交互。这套规则定义就是 ABI (Application Binary Interface)。直观来说,ABI 稳定意味着这两件事情:

  1. 去年的 app 可以使用今年的框架
  2. 明年的 app 也可以使用今年的框架

ABI 稳定对于 Apple 自身非常重要,只有达到 ABI 稳定,Swift 才可能被大范围应用在 Apple 自己的软件框架中。对于我们第三方开发者来说,在没有 ABI 稳定的现在,只要我们在项目中使用了 Swift 代码,我们就需要把同样版本的 Swift 运行时和标准库等打包到最后的项目里。而一旦 ABI 稳定达成,就不再会有此限制,我们将可以直接使用系统自带的 Swift 运行时和标准库运行任意版本的 Swift 代码。

一旦宣称 ABI 稳定,这时候的 ABI 将成为 Swift 编译器规范,后续的编译器开发可以对规范进行添加,但是也必须确保不破坏已有的规范,这将会使这门语言的进化受到很大束缚 (其实有时候也未必是坏事)。这也是 Swift 团队一再推迟宣称 ABI 稳定的时间点的原因,因为必须确保 everything is correct and prepared。

Resilient 类型

ABI 一个很重要的方面就是计算和获取内存布局。当框架中的一个类型 T 的成员改变时 (比如添加一个新的存储变量),它的实例在内存中的布局一般也会改变 (比如该成员的 offset 地址增加)。如果 app 里是直接通过 offset 地址来访问框架中类型的某个成员变量的话,当框架中该类型被改变时,除非 app 重新获取新的地址 (也就是重新编译和链接框架),原来的代码将会错误地访问到其他变量或者甚至崩溃。这时候,T 就被叫做易碎 (fragile) 类型。

这让我们无法添加/删除/甚至对属性重新排序,但是框架必须要进化,这些事情难免发生。为了避免这种 fragile 的情况,我们在编译时就不能直接把属性偏移量写到 app 里,也就是说,我们不能将某个类型的内存布局直接暴露出去。在编译期间,编译器会把最新的偏移量信息写到和类型绑定的 metadata 中。在传递这个类型的时候,则使用非透明的布局 (Opaque Layout),使用者要访问某个成员时,不再是直接通过偏移量直接进行内存地址读取,而是先访问存储了类型中各种信息的 metadata 并找到想要访问的成员的最新地址,然后再进行内存访问。这时候,我们在源码层级就可以任意地增删重排成员变量了。此时,就算换了新的编译器版本,只要 metadata 访问的算法和定义不变,旧版本的 app 依然可以使用新版本的框架。这种类型被称为弹性 (resilient) 类型。

根据类型不同,Type metadata 存储的内容也不尽相同。在这里可以找到一些文档说明。

@_fixed_layout

有了上面的背景知识,我们就可以回到 Bool.swift 的源码了。Resilient 类型显然会带来性能上的额外开销,像是 Bool 这样的基础中的基础,我们显然是希望它越快越好。这里 @_fixed_layout 的作用是,告诉编译器这个 struct 的布局也应当被作为编译时考虑的部分。换句话说,这是开发者对编译器所做的保证,保证这个结构体中不会再增加或减少存储变量,也不会改变已有的变量的顺序。由此一来,外部在遇到这样的类型时,就可以放心大胆地直接用内存偏移量进行成员访问,而不必再经由 metadata 了。

@_inlineable 或 @_transparent

@_fixed_layout 解决了存储属性的性能和 ABI 问题。对于函数来说,最常见的优化方法大概就是将它内联 (inline) 了。inline 相当于将函数的具体实现完全暴露在接口中,编译期间,当 app 对框架中的某个方法进行调用时,编译器如果发现方法可以内联,就将具体实现“复制“过来,替换掉原有的方法调用。这可以减少模块间的方法调用的次数,加速运行时的性能。

@_inlineable 做的就是这件事情,为了在 Debug 时保留调用栈的信息,它只在 -O 的时候生效。它告诉编译器,在需要的时候可以将所声明的函数进行内联。@_transparent@_inlineable 类似,不过它在 -Onone 时也会生效,并在编译时更早的阶段就被内联插入到目标位置。这样编译器就能在检查数据流 (比如 if else) 错误的时候找到可能出现的内联错误。

@_transparent 实际上是比 @_inlineable 更强的存在,所有的 @_transparent 声明其实也隐式包含了 @_inlineable。内联的声明非常有用,Swift 公开文档中还存在 @inline(__always)@inline(never),用来”强制“内联和非内联某个函数。

内联会给 ABI 稳定带来很多”陷阱“。最显而易见的是,被内联的方法的实现中的符号都会被外界”看到“。而很多时候这些符号并不是可用的,比如 Boolinit 中对 self._value 赋值的部分,_value 的 access permission 是 internal,这是无法被别的 module 看到的。

@_versioned

@_versioned 的主要目的就是解决上面的问题。在进行编译时,当遇到 @_versioned 的标签时,编译器将会“暂时”在内部将其作为 public 进行处理,这样,在 inline 的时候,即使某个 API 是 internal 或者 private 的,我们也依然可以对它进行调用了。

在 ABI 稳定方面,@_versioned 的意义也十分重要。我们可以安全地将某个以前就被标记为 @_versioned 的方法公开为 public,但是由于实际上这个方法会被外部 module inline 调用,所以我们不能轻易删除或者更改它的调用规则。

关于这几个私有 attribute 的总结

top Created with Sketch.