8ca0a721ae4412ec3d176f97dcdf77d4
Swift 开源初步 - Swift 标准库源码导读 (一)

在很多很多年前,当我还是一个青涩的大学生时,我曾经陪一个安卓的大神面试了几个求职的安卓开发者 (其实我并不会安卓,就是去给大神端茶和凑数打酱油的),大神上来的问题就是:

  1. 你 clone 过安卓源码吗?
  2. 你编译过安卓系统吗?
  3. 你看安卓开源代码时有什么心得体会吗?

一般来说,在这华丽三连击下,一般都是 KO + 退场。

这一幕在我幼小的心灵里留下了深刻的印象,自从那以后我就一直在想,我们到底有没有必要去仔细看看我们每天手中的工具。在之前有一段与安卓各种坑打交道的时间 (其实后来我还是会一点安卓的),一旦遇到想不通的地方,就会跑去看相应部分的源码是如何实现的,会不会是系统里的问题。彼时的 iOS 开发并没有这个条件,几乎所有东西都是封闭的,很多开发者甚至会通过逆向的方法来检判具体实现。在 Swift 开源的今天,看源码的条件可是比之前好上了很多,即使不是为了针对某个特定的 bug,就算对于自我提高来说,闲暇的时候看看每天使用的东西的源码也是很不错的选择。

Swift 语言本身使用了大量的 C++ 实现,对于普通的 iOS 开发者或者是编译原理没学太深的同学来说估计比较吃力。但是,我们每天所接触最为频繁的 Swift 标准库几乎全是 Swift 写的,这就给大家阅读源码带来了极大的便利。另外,和很多其他语言不同,Swift 将很多基础的操作都定义在了标准库,而非语言特性中 (比如 Bool 取反的 ! 操作,是一个标准库中的函数而非语言定义),这使得相对与其他语言,阅读 Swift 标准库实现更加有趣,也具有更大的意义。

在这一系列文章里,我会“带领”大家架设 Swift 的本地编译环境,熟悉为 Swift 开源贡献代码的流程,并挑选一些标准库中有意思和有代表性的部分和大家一同探讨。笔者自己也水平有限,还处于爬地上摸索的阶段,所以整个系列更多的是期待能够抛砖引玉。文中可能难免也会出现各种错误,如有发现还望指出。

开发环境和项目结构

如果你在阅读本文的话,相信 Xcode 最新版和 Homebrew 什么的都没问题了。首次编译 Swift 和相关的一系列工具链是很容易的,基本上照着 Swift 主 repo 的说明就能完成编译了。主要步骤:

# 安装 cmake 和 ninja
brew install cmake ninja

mkdir swift-source
cd swift-source

# 将 Swift 主项目 clone 到本地
git clone https://github.com/apple/swift.git

# 使用 update-checkout 脚本更新所有 repo
./swift/utils/update-checkout --clone

./swift/utils/build-script --release-debuginfo

Swift 默认使用 ninja 进行编译任务的管理,你暂时可以把它想像成一个加强版的 Makefile。

在 clone Swift 的主 repo 之后,你就可以使用 utils 下的各种便利工具了,比如 update-checkout 将会拉取像是 llbuildcorelibs-foundation 等一系列周边 repo。我们会在后面稍微解释一下其他这些 repo 的作用。最后,使用 build-script 就可以编译包括 Swift 项目了,默认情况下,大部分的周边项目也会被编译。根据机器的性能,当前版本下整个 fresh 编译的过程耗时大概在 20~40 分钟。

编译的最后是生成文档,如果你没有安装 Sphinx 的话,可能会提示错误,这显然是不太能忍受的。你可以通过 easy_install -U Sphinx 来安装这个工具,让编译脚本完全正确退出。注意你最好使用系统自带的 Python 2 来做这件事,如果你的环境中有多个 Python 环境的话,可能要多费点儿事进行切换 (请不要问我是怎么知道的)。

编译模式

编译脚本中的 --release-debuginfo 选项表示使用 RelWithDebInfo 模式来进行编译。其他的选项还有像是 --debug--release。对于 Debug 和 Release 想必大家都很清楚了:Debug 版本保留了全部的调试信息,而放弃了速度的优化;而 Release 版本追求了最大的性能优化,抛弃了调试信息。RelWithDebInfo 则“结合”了两者的优点,为了迅速的执行,代码经过了优化,但是同时在编译期间也生成了调试用的数据和部分调试行,这让 debugger 有机会 catch 到出问题的代码究竟是哪个部分。不过,在实际开发中,这种有限的调试信息往往只能作为参考,如果确实需要寻找问题或者加挂 debugger,我们还是需要使用 Debug 模式来编译。

不过,对整个项目进行 Debug 模式编译是相当耗费时间,也没有必要的。build-script 为我们提供了只针对某个部分开启 Debug 编译的选项,比如:

./swift/utils/build-script --release-debuginfo --debug-swift-stdlib

将只对标准库开启 Debug 模式,而保持其他部分使用 RelWithDebInfo。这不仅可以节省大量编译时间,也可以大大加速测试,是我们针对某个部分进行调试时最常用的手段。

这些模式都有对应的简写形式:--release-debuginfo-r--release-R--debug-d

我们在本系列文章中基本不会去挂载 lldb 进行调试,所以最常用的编译形式 (我们在下文提到编译时),如果没有特别指出,是指:

./swift/utils/build-script -r

项目结构

Swift 标准库的源代码大部分存放在 swift/stdlib/public 中,这也是我们之后最主要会涉及的位置。其中 core 文件夹下包括标准库的包括 StringArray 等主要类型;SDK 文件夹下是针对对应平台的代码,和 Foundation 或者 UIKit 等框架相关的一些代码,像是 IndexPathURL 或者 JSONEncoder 等就在这里。你可能已经注意到和 swift 文件夹平级的位置还有一个 swift-corelibs-foundation 项目,其中也包括了几乎同样的代码。swift-corelibs-foundation 的目的是为非 Apple 平台提供一套可以移植的 Foundation API,而在 SDK 下的代码的目的是为 Apple 平台的对应 SDK 提供 Swift Style 的支持,所以两者的目的不尽相同。这也是在 Linux 下的 Swift 和 Mac 下的 Swift 有时候 Foundation 语法略有不同的原因。

在 macOS 下,用 ninja 进行编译后的产品会被放在 build/Ninja-RelWithDebInfoAssert/swift-macosx-x86_64 下,你可以在 bin 中找到 swift 命令行工具和编译器的 binary (swiftc)。我们在之后会将这个文件夹简称为”编译结果文件夹“ 或者 ${build_dir}

源码中有一些 gyb 文件 (在这篇专栏文章中我也提到过一些 .gyb 和代码生成的事情),它们是一些模板文件,用来生成重复代码 (比如 Int8Int16Int32 等,接口一致的类型)。我们可以通过 utils 下的 gyb.py 文件将这些模板填充为实际的 Swift 源代码,不过上面使用的 build-script 实际已经帮助我们完成过这件事了。在编辑结果文件夹中对应源码的文件夹的 {cpu_core_count} 下可以找到生成的文件。比如 ${build_dir}/stdlib/public/core/8/ 下的文件对应的是源码文件夹 swift/stdlib/public 下的 gyb 文件。源码文件夹下已经是 .swift 的文件将保持在原位置不动,编译期间 make 规则将把不同位置的源文件进行编译。

更新项目

再次使用 ./swift/utils/update-checkout 就可以将包括 Swift 自身在内的所有项目更新到最新的 master。

Xcode

在上面的命令中,你也可以使用添加 --xcode 或者 -x 来使用 Xcode 作为编译工具。这么做会将编译结果文件夹变为类似 Xcode-RelWithDebInfoAssert 的名字,另外,还会为我们生成 Swift.xcodeproj 项目文件,让我们可以用 Xcode 进行开发。不过因为 Scheme 配置是基于 cmake 编译规则的,所以代码高亮和提示什么的应该是想都不用想的。一番挣扎后,还是乖乖打开一个 VS Code 这样的文本编辑器才是上策。

如果你是 Vim 党,在 utils 下的 vim 文件夹中有一套 Vim 的配置,可以考虑导入。不过如果你日常也是用 Vim 来写 Swift 的话,相信你应该已经会有更完整的配置了。

测试

Swift 项目的测试主要位于 swift/testswift/validation-test 下,前者是一般性的测试,后者包含了功能验证和回归测试。想要为 Swift 标准库添加一个新类型并被承认和 merge 并不是很容易的事情,需要经过预先讨论,提出 proposal,社区发表意见讨论,最后 Core Team 拍板接受。基本只有在添加新类型的情况下,我们会需要添加新的测试文件,其他时候我们更多地只需要针对感兴趣的类型找到找到已有测试文件,并向其中添加测试即可。

可以使用下面的命令运行所有测试:

# -r 编译并运行 test
./swift/utils/build-script -r -t

# -r 编译并运行 validation test
./swift/utils/build-script -r -T

笔者写作本文时,test suite 下有 4300 来个测试,validation suite 下有 10000 多个。在我的开发环境下光是测试两者加起来大概就要跑接近 20 分钟。对于一般的针对标准库中某一个或几个类型的小修改来说,每次全跑并不必要,而且时间过长。

只要运行过一次测试,测试套件将会被生成并放在编译结果文件夹的对应 testvalidation-test 文件夹下。好消息是此时我们就可以指定只运行某一套或者某一个测试。utils/lit 下的 lit.py 工具可以让我们进行部分测试,比如如果我们只希望运行标准库的测试:

./llvm/utils/lit/lit.py -sv build/Ninja-RelWithDebInfoAssert/swift-macosx-x86_64/test-macosx-x86_64/stdlib
top Created with Sketch.