理解 Swift:Objective-C 的构建管道

原文地址:Manual Swift: Understanding the Swift/Objective-C Build Pipeline

Xcode 是如何将 Swift 和 Obj-C 编译到一起的?
如果你没有 xcodebuild 的话,应该要怎么做?
我们来看看“编译到一起”两种不同的方式:

  • Obj-C 使用 Swift
  • Swift 使用 Obj-C
    今天,我们将会使用 Swift 的风格来看待 Obj-C,目的是让你对这些处理过程的线索有个大致理解。改天我们再挖掘这些线索在实际过程中是如何做到的。

Obj-C 代码是如何使用其他 Obj-C 代码的?

首先,我们来看看Obj-C 代码是如何使用其他 Obj-C 代码的。显然,使用 Swift 构建的 Obj-C 建立在此之上,所以大部分繁杂的工作实际上都发生在这个处理过程中。这也意味着,一旦你搞明白了,就对 Obj-C 如何使用 Swift 理解了 90%!

逐个编译,再链接全部

总的来说,构建一个 Obj-C 代码使用其他 Obj-C 代码的可执行文件,有两步处理:

  • 编译(Compile):每个文件都会被编译成一个目标文件:A.m -> A.o

  • 链接(Link):所有的目标文件都被链接器合并成一个可执行文件:A.o, B.o, … -> MyApp

头文件承诺,编译器信任,链接器验证

编译和组合(链接)的步骤依赖于来自头文件的信息。

头文件承诺

头文件对 API 们做出承诺。就像如下代码:

1
NSString *NSTemporaryDirectory();

表示:

相信我!这儿会有个叫 NSTemporaryDirectory 的方法,如果你没有用任何参数调用它,它会返回一个 NSString *

一个这样的接口声明:

1
2
3
@interface Something: NSObject
- (BOOL)makeItSo: (NSError **)outError;
@end

表示:

听我的:当你需要的时候,会有一个名为 SomethingNSObject 类型的类。这个类的所有方法?就像这个接口声明的那样,尽管相信我吧!

编译器信任

编译器照着这些头文件说的做,并针对他们的承诺检查了它们所提供的实现文件。

假如碰到:

1
NSTemporaryDirectory(updatedTemporaryDirectoryPath);

它会大发脾气道:

你这什​​么意思,给这东西传个参数!而且会有一个返回值,使劲吹吧!难道还会有人想要有返回值?!

(编译器非常容易激动,这就是他们如何对工作所需的细节表现出的极度关注。尽管如此,这些行为并没有任何意义。)
如果所有东西都检查通过,编译器将会保持安静,完成其工作,并留下从实现代码翻译而来的目标代码后离开。
(这个经过翻译的目标代码会被嵌入额外假设,这些额外的假设基于像这些承诺:如何给函数传递参数──传到堆栈上?寄存器中?向量寄存器中?以及从哪里读出返回值?但这里牵涉到需要描述什么是 ABI,所以我们下次再讨论这些。)
目标代码完全相信,头文件所承诺的函数定义在后面确实会存在!编译器输出代码,说:“嘿,去调用这个函数吧,虽然我不知道它在哪儿,但有一些头文件承诺它会在那里,所以我只是在这里做一下信任。”
结果便是,目标代码带着一堆未定义的引用。所有这些未定义的引用都是关于,什么东西在哪里、有多少个参数,是什么类型的参数与返回值的假设。
编译器对这些头文件有很大的信心,是吗?

旁白
有多少未定义的引用?你自己看!
选择一个对象文件 A.o 并运行 nm -u A.o。输出将会是列出该文件所引用的所有未定义名称。
nm 是一个工具,它以人类可读的方式格式化对象文件所引用的名称的表格,称为符号表。(nm 是 NaMes,懂了吗?),它也可以用来过滤列表,就如这里的 -u 要求它只列出未定义的名字。

链接器验证

编译器总结说,“嘿,东西都在这儿了,一些头文件承诺的!”
链接器则会说,“把钱拿出来看看”。他们把所有的目标文件堆在一起,并处理了那些未定义的引用。如果有东西没有检查成功,他们会翻桌子,丢出错误,并拒绝继续执行:

1
2
3
4
5
6
> cc trust-me.m
Undefined symbols for architecture x86_64:
"_thisWillTotallyBeThere", referenced from:
_main in trust-me-c9e7ba.o
ld: symbol(s) not found for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

所有的目标文件都被送往链接器,链接器连接它们的外部名称并检查所有的东西是否如实被定义了。如果是的话,链接器会吐出一个可执行文件。

当然,还有更多的东西。我们没有触及模块或动态链接(frameworks!dylibs!dlopen!)。如果你觉得不够详细,可以看看Advanced Mac OS X Programming

从 Obj-C 使用 Swift

呼,这真是有够消遣的了。幸运的是,我们差不多完成了。

我们来让 Swift 变得看起来像 Obj-C

Obj-C 的编译依赖于头文件和目标文件。但Swift不需要头文件,而且你可能也从来没有碰到过 Swift 的目标文件。那 Xcode 要如何解决这个问题?
当然是给每个 Swift 文件生成单独的头文件了目标文件啦!
每个 Swift 文件都会被编译为一个目标文件和一个供 Obj-C 使用的头文件: A.swift -> A.o, A.h
这告诉了我们,运行普通的 Obj-C 构建和链接编译管道需要什么东西:

  • 编译每个.m文件的桥接头文件
  • 将所有的目标文件(Swift 和 Obj-C)连接成一个可执行文件

复数桥接头文件

因为该头文件在 Obj-C 和特定的swift文件的世界之间架起了桥梁,所以它被称为桥接头文件。
现在,你可能会遇到这种情况,“你正在添加 Obj-C 文件!到一个 Swift 的项目!你想要一个桥接头文件?”之前在 Xcode 中的提过。这也只是一个桥接头文件,而不是一堆(很多个!每个 Swift 文件一个)桥接头文件。一座桥连接一个堤岸到另一个堤岸,而单数桥接头文件则从 Swift 架往 Obj-C 大陆,复数桥接头文件则从 Obj-C 大陆通往 Swift 之地。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ===[ CallMeFromObjC.swift ]===
// 你必须 import Foundation 使其能够调用 Obj-C.
import Foundation
// 一个类若要对 Obj-C 可见则必须继承自 NSObject
// (如果你想要写一个根类,则必须在 Obj-C 上写)
public class CallMeFromObjC: NSObject {
// 公开你想在 Objc-C 上使用的 API
public var name: String
public init(name: String) {
self.name = name
}
public func speak() {
print("\(self)'s name is: \(name)")
}
}

用编译管道运行下面的粗糙调用:

1
2
3
4
5
6
7
8
9
install -d build
xcrun -sdk macosx10.12 \
swift -frontend -c -primary-file CallMeFromObjC.swift \
-sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.12.sdk/ \
-module-name Bridgette \
-emit-module-path build/Bridgette.swiftmodule \
-emit-objc-header-path build/CallMeFromObjC.h \
-enable-testing -enable-objc-interop -parse-as-library \
-o build/CallMeFromObjC.o

下面则是它生成的桥接头文件的核心代码:

1
2
3
4
5
6
7
8
SWIFT_CLASS("_TtC9Bridgette14CallMeFromObjC")
@interface CallMeFromObjC : NSObject
@property (nonatomic, copy) NSString * _Nonnull name;
- (nonnull instancetype)initWithName:(NSString * _Nonnull)name OBJC_DESIGNATED_INITIALIZER;
- (void)speak;
- (nonnull instancetype)init SWIFT_UNAVAILABLE;
@end

(为了方便起见省略了大堆顶部的定义,你可以在这个 gits 里查看完整细节。看他们如何处理警告信息也是很有趣。)

怎样导入?

编译和链接 Obj-C 用于 Swift,也意味着编译 Swift 并将其与其他 Swift 链接起来。当编译器为swift文件生成一个目标文件时,它也需要大量的信任。
在这种情况下,项目中的其他 Swift 文件允许编译器的外部定义。对,Swift 文件本身就是有效的头文件!
从其它文件导入命名到模块中在源码里是隐式的:当你在 A.swift 中编写代码时,你可以自由使用 B.swift 中定义的类型B,并且你只需认为它是可用的就行。如果这是 Obj-C,那就好像你项目中的每个 .m 文件都会自动获得一系列你项目中的每个.h文件的 #imports
尽管如此,Swift 代码并没有 #imports 来命名特定文件,以便将其编译为单个 .swift 文件。因此,不是将模块中的所有文件都在 .swift 文件列入,而是将该列表移至编译器调用:当你编译单个 Swift 文件时,编译器调用会把该模块中别的 Swift 文件都列出来。
好消息是,Xcode 正为你编写着这些编译器调用,是吧?

总结

Obj-C 编译过程是对提供了为映射步骤带出确实上下文的头文件的映射和合并。映射到目标文件;合并为可执行文件。
用 Obj-C 使用 Swift 是为了让 Swift 看起来像 Obj-C,所以普通的 Obj-C 构建管道就能发挥其魔力。为了让 Swift 看起来像 Obj-C,每个 Swift 文件都会被编译成相应的头文件和目标文件。
这是一个高层次的概览。要真正理解正在发生的事情,我们需要仔细研究 Xcode 的构建日志以了解所有这些细节。但这又是改天的工作了。