深入 Swift Runtime 之 Protocol 方法派发的实现原理

作为 iOS 开发者,我们都非常熟悉 Objective-C 的消息派发机制,围绕着这个机制也诞生了一系列“黑(miàn)魔(shì)法(tí)”。而到了 Swift 这边我们似乎还都在吃 OC 的老本,继续用着 @objc 来搞事。其实 Swift 在方法派发这件事上也做了很多底层的改变和优化,本文将以 Protocol 的视角来剖析一下 Swift 中的方法派发机制。

回顾一下 objc_msgSend

Objective-C 中的万物皆对象,不管是具体的类(如 NSObject *)还是 id,都具有类似的内存结构,即:

union isa_t {
    isa_t() { }
    isa_t(uintptr_t value) : bits(value) { }

    Class cls;
    uintptr_t bits;
#if defined(ISA_BITFIELD)
    struct {
        ISA_BITFIELD;  // defined in isa.h
    };
#endif
};

struct objc_object {
private:
    isa_t isa;

    // ivars...
};

而常规的方法调用(除 objc_direct 外)无一例外会走 objc_msgSend 的消息派发流程。因此在 Objective-C 中,是否为 protocol 不会决定方法派发的方式,只会影响编译时的一系列静态检查。

关于 Objective-C 的消息派发机制本文就不过度展开了,有兴趣大家可以自己研究一下。

C++ 的虚方法及 vtable

在回到 Swift 之前我们再来看看另一个语言 C++ 是如何做方法派发的,这对理解 Swift 的机制也会有一定帮助和参考意义。

C++ 对象的内存布局相比 Objective-C 对象的更加灵活,根据基类和成员方法的不同会有多种形态。当一个类为“标准布局类型”(StandardLayoutType)时,其内存布局与 C 中 struct 的内存布局一致。而当我们给类增加一个虚方法时,其内存布局就会发生变化,即在最前面增加了 vtable 指针。

与 Objective-C 有一定区别的是,vtable 指针的赋值时机和 OC 对象 isa 的赋值时机不同。由于 OC 对象的构造是显式二段式,也就是 allocinit 可以分开进行,一个对象在 alloc 之后就已经具有类型信息了(即 isa 已被初始化)。而 C++ 对象的构造是空间分配和初始化一体的,并且 C++ 支持 placement new 特性来将一个对象初始化到一个已经分配好的内存空间上,因此 C++ 在构造器中对 vtable 指针进行赋值。

我们可以反编译下面的这个代码片段来验证这个过程:

class A {
public:
    virtual void foo() { }
};

int main() {
    A *a = new A;
    return 0;
}

反编译结果如下:

main:
    push rbp
    mov  rbp, rsp
    sub  rsp, 32
    mov  dword ptr [rbp - 4], 0

    ; A *a = new A;
    mov  edi, 8
    call operator new(unsigned long)
    mov  rdi, rax
    mov  qword pttr [rbp - 24], rax
    call A::A() ; [base object constructor]

    ; ...

可以看到对象构造的两段过程,operator new 仅分配空间,接下来直接调用构造器:

A::A():
    push   rbp
    mov    rbp, rsp
    movabs rax, offset vtable for A
    add    rax, 16
    mov    qword ptr [rbp - 8], rdi
    mov    rcx, qword ptr [rbp - 8]
    mov    qword ptr [rcx], rax
    pop    rbp
    ret

此时 rdi 寄存器为 this 指针(即对象的地址),vtable 指针经过偏移后被赋给对象的首部空间。

接下来来看方法派发的实现,我们直接反编译下面这段代码:

class A {
public:
    virtual void foo() = 0;
};

class B {
public:
    virtual void bar() = 0;
};

class C : public A, public B {
public:
    virtual void foo() { }
    virtual void bar() { }
};

int main() {
    C *c = new C;
    c->bar();  // 主要关注这里
    return 0;
}

反编译结果如下:

main:
    push    rbp
    mov     rbp, rsp
    sub     rsp, 32
    mov     dword ptr [rbp - 4], 0
    mov     edi, 16
    call    operator new(unsigned long)
    mov     rdi, rax
    mov     qword ptr [rbp - 24], rax
    call    C::C() [base object constructor]
    mov     rax, qword ptr [rbp - 24]
    mov     qword ptr [rbp - 16], rax
    mov     rcx, qword ptr [rbp - 16] ; rcx 现在是 c 的指针
    mov     rdx, qword ptr [rcx]      ; rdx 现在是 vtable 指针
    mov     rdi, rcx
    call    qword ptr [rdx + 8]       ; 调用 (vtable + 8) 地址指向的函数
    xor     eax, eax
    add     rsp, 32
    pop     rbp
    ret

可以看到一次查表的过程,由于类 C 中包含虚方法,方法调用不能采用静态派发方式(为了实现多态),但与 Objective-C 不同的是这个查表过程非常简单,其实就是个数组访问的操作,因此它的性能也会比 Objective-C 的消息派发(查 cache / 遍历 method list)快上很多倍。

但假如这里我们稍微变换一下代码:

int main() {
    B *b = new C;  // 变量类型从 C 改成了基类 B
    b->bar();
    return 0;
}

再看反编译结果:

main:
    push    rbp
    mov     rbp, rsp
    sub     rsp, 32
    mov     dword ptr [rbp - 4], 0
    mov     edi, 16
    call    operator new(unsigned long)
    mov     rdi, rax
    mov     qword ptr [rbp - 24], rax
    call    C::C() [base object constructor]
    xor     ecx, ecx
    mov     eax, ecx
    mov     rdx, qword ptr [rbp - 24]
    cmp     rdx, 0
    mov     qword ptr [rbp - 32], rax
    je      .LBB0_2                   ; 这里有个判空保护,我们直接忽略这行
    mov     rax, qword ptr [rbp - 24]
    add     rax, 8                    ; this 指针被偏移了 8,赋给 rax(记变量 b)
    mov     qword ptr [rbp - 32], rax
.LBB0_2:
    mov     rax, qword ptr [rbp - 32]
    mov     qword ptr [rbp - 16], rax
    mov     rax, qword ptr [rbp - 16] ; rax 现在等于变量 b 的值
    mov     rcx, qword ptr [rax]      ; 到这里就回到与上个例子相同的查表逻辑
    mov     rdi, rax
    call    qword ptr [rcx]
    xor     eax, eax
    add     rsp, 32
    pop     rbp
    ret

改变了一下变量的类型,汇编代码竟然有这么大的变化。其实到后面大家就会发现 C++ 与 Swift 类似,会根据代码上下文的不同来改变生成的代码。原因也很简单,我们先来看 C++ 这个现象背后的原理。首先看一下类型 C 的构造器汇编代码:

C::C() [base object constructor]:
    push    rbp
    mov     rbp, rsp
    sub     rsp, 16
    mov     qword ptr [rbp - 8], rdi
    mov     rax, qword ptr [rbp - 8]
    mov     rcx, rax
    mov     rdi, rcx
    mov     qword ptr [rbp - 16], rax
    call    A::A() [base object constructor]  ; 调用 A 的构造器
    mov     rax, qword ptr [rbp - 16]
    add     rax, 8
    mov     rdi, rax
    call    B::B() [base object constructor]  ; 调用 B 的构造器
    movabs  rax, offset vtable for C
    mov     rcx, rax
    add     rcx, 48
    add     rax, 16
    mov     rdx, qword ptr [rbp - 16]
    mov     qword ptr [rdx], rax
    mov     qword ptr [rdx + 8], rcx
    add     rsp, 16
    pop     rbp
    ret

由于每个类的构造器都会为那个类型设置 vtable 指针,因此类 C 的对象中会存在 2 个 vtable 指针(第一个被 C 自己的 vtable 指针覆盖了)!最开始的那里例子中,我们调用的是 C::bar 方法,由于接收类型是 C,因此查的也是 C 的 vtable,其内容如下:

vtable for C:
    .quad   0
    .quad   typeinfo for C
    .quad   C::foo()
    .quad   C::bar()
    .quad   -8
    .quad   typeinfo for C
    .quad   non-virtual thunk to C::bar()

C 继承了 AB,因此 vtable for C 是会向下兼容类 A 的 vtable 的(即把 C* 的值赋给 A* 变量与 C* 的值赋给 C* 变量是相同的)。这个特性可以使得一个固定方法在 vtable 中的 index 是固定的。比如调用 C::bar,查找 vtable 下标就是 1,不管其真实类型是什么。

当我们将 C* 的值赋给 B* 的变量时,由于两者的 vtable 结构不兼容(很显然,vtable for Cvtable for B 的第一个函数指针不同),我们就不能不做任何处理地把原地址赋值过去,而是要偏移到兼容 vtable for B 的第二个 vtable 地址。这时当我们调用 B::bar 的时候,根据 vtable 内容,就会调用到 non-virtual thunk to C::bar() 函数中。

这里画了一张图,方便大家理解:

thunk 到底是什么?

看到这里你可能会发现了,这咋 vtable for B 的内容和 vtable for C 的还不一样呢?虽然我们的目的都是调用 C::bar,但是还记得上文说的对象指针偏移吗?由于 C* 赋给 B* 变量时指针发生了偏移,C::bar 拿到的 this 指针就不正确了。所以我们这里不能直接调用 C::bar,而是要先调用一个“跳板”函数,这就是所谓的 thunk。大家记住这个东西,后面我们分析 Swift 时还会遇到哦。 我们来看一下这个 thunk 所做的事情:

non-virtual thunk to C::bar():
    push    rbp
    mov     rbp, rsp
    mov     qword ptr [rbp - 8], rdi
    mov     rax, qword ptr [rbp - 8]
    add     rax, -8
    mov     rdi, rax             ; 其实只是对 this 做了一次偏移
    pop     rbp
    jmp     C::bar()             ; 尾递归风格调用,不会产生新栈帧

所以 thunk 基本上就是对入参(通常是 this)进行一些调整,然后调用真正的函数继续执行。

Back to Swift

上面我们简单回顾了一下 Objective-C 和 C++ 的方法派发机制,不是很完整,但相信大家能够大致理解其中的思路了。有了这些前置知识之后再来分析 Swift 的方法派发就会容易得多。

但是在正式开始之前我们还是要再认识两个中间语言:SIL、LLVM IR。不像 C++,Swift 的反编译结果比较晦涩,原因一方面是 Swift ABI 结构相比 C++ 会更复杂一些;另一方面是 SIL 的存在会对 Swift 源码做一次去抽象(desugar、lowering),之后生成的 LLVM IR 又会对类 C 的操作做一次优化,最后得到的代码就比较难看出原貌了。因此本文分析 Swift 的时候不去看反汇编代码,而主要去看 LLVM IR,因为在这一层仍然能保留很多内存布局的信息,各种操作相比机器码也更易于理解。关于 SIL 和 LLVM IR,本文不过多展开,大家可以在阅读文章时找到对应的文档自行参考。

通过静态派发初识 SIL、LLVM IR

第一个例子我们先来看看 Swift 中的静态派发,考虑下面的代码:

protocol SomeRegularProtocol {
    func methodA(_ x: Int) -> Int
}

struct SomeImpl: SomeRegularProtocol {

    let data: Int

    func methodA(_ x: Int) -> Int {
        return data + x
    }

}

let impl = SomeImpl(data: 1024)
impl.methodA(42)

由于 struct 不能继承,使用时均是值类型,因此调用的方法地址一定是确定的,我们来分别看看 SIL 和 LLVM IR:

// SIL
// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  alloc_global @$s4main4implAA8SomeImplVvp        // id: %2
  %3 = global_addr @$s4main4implAA8SomeImplVvp : $*SomeImpl // users: %10, %9
  %4 = metatype $@thin SomeImpl.Type              // user: %8
  %5 = integer_literal $Builtin.Int64, 1024       // user: %6
  %6 = struct $Int (%5 : $Builtin.Int64)          // user: %8
  // function_ref SomeImpl.init(data:)
  %7 = function_ref @$s4main8SomeImplV4dataACSi_tcfC : $@convention(method) (Int, @thin SomeImpl.Type) -> SomeImpl // user: %8
  %8 = apply %7(%6, %4) : $@convention(method) (Int, @thin SomeImpl.Type) -> SomeImpl // user: %9
  store %8 to %3 : $*SomeImpl                     // id: %9
  %10 = load %3 : $*SomeImpl                      // user: %14
  %11 = integer_literal $Builtin.Int64, 42        // user: %12
  %12 = struct $Int (%11 : $Builtin.Int64)        // user: %14
  // >>>> 这里要调用方法了 <<<<
  // function_ref SomeImpl.methodA(_:)
  %13 = function_ref @$s4main8SomeImplV7methodAyS2iF : $@convention(method) (Int, SomeImpl) -> Int // user: %14
  %14 = apply %13(%12, %10) : $@convention(method) (Int, SomeImpl) -> Int
  %15 = integer_literal $Builtin.Int32, 0         // user: %16
  %16 = struct $Int32 (%15 : $Builtin.Int32)      // user: %17
  return %16 : $Int32                             // id: %17
} // end sil function 'main'

从 SIL 上就能看出对 SomeImpl.methodA(_:) 的调用就是直接拿到对应方法的函数指针,然后直接调用过去。这里提一下 @convention(method) 这个标注,它会指定一个方法的调用规约(Calling Convention),这里 method 其实与常规的 System V ABI 差不多,只不过规定了第一个参数是 self 指针。但需要注意的是,SIL 里体现的并不一定是真实的操作,到 LLVM IR 这一层之前仍然会有很多优化,我们来看看上面代码到 LLVM IR 后的结果:

define i32 @main(i32 %0, i8** %1) #0 {
entry:
  %2 = bitcast i8** %1 to i8*
  %3 = call swiftcc i64 @"$s4main8SomeImplV4dataACSi_tcfC"(i64 1024)
  store i64 %3, i64* getelementptr inbounds (%T4main8SomeImplV, %T4main8SomeImplV* @"$s4main4implAA8SomeImplVvp", i32 0, i32 0, i32 0), align 8
  %4 = load i64, i64* getelementptr inbounds (%T4main8SomeImplV, %T4main8SomeImplV* @"$s4main4implAA8SomeImplVvp", i32 0, i32 0, i32 0), align 8
  // 直接调用对应方法,但注意入参
  %5 = call swiftcc i64 @"$s4main8SomeImplV7methodAyS2iF"(i64 42, i64 %4)
  ret i32 0
}

可以看到 LLVM IR 的代码比 SIL 简单了很多,很多无用操作会被优化掉。同时我们会发现 SomeImpl.methodA(_:) 方法的入参发生了变化,第二个参数是一个 Int,这也是编译期做的优化,因为这个方法并不需要完整的结构体内容,仅传入需要的数据可以减少拷贝开销。

Protocol 方法调用的实现原理

上面的代码里我们声明了一个 protocol,在正常使用结构体的时候我们没有看到任何动态派发的过程,那我们用 protocol 的方式调用一下看看,首先修改一下代码:

// ...
func useRegularProtocol(_ p: SomeRegularProtocol) {
    let _ = p.methodA(42)
}

let impl = SomeImpl(data: 1024)
useRegularProtocol(impl)

然后我们来看一下 SIL:

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  alloc_global @$s4main4implAA8SomeImplVvp        // id: %2
  %3 = global_addr @$s4main4implAA8SomeImplVvp : $*SomeImpl // users: %11, %9
  %4 = metatype $@thin SomeImpl.Type              // user: %8
  %5 = integer_literal $Builtin.Int64, 1024       // user: %6
  %6 = struct $Int (%5 : $Builtin.Int64)          // user: %8
  // function_ref SomeImpl.init(data:)
  %7 = function_ref @$s4main8SomeImplV4dataACSi_tcfC : $@convention(method) (Int, @thin SomeImpl.Type) -> SomeImpl // user: %8
  %8 = apply %7(%6, %4) : $@convention(method) (Int, @thin SomeImpl.Type) -> SomeImpl // user: %9
  store %8 to %3 : $*SomeImpl                     // id: %9

  // 以上都没什么区别
  // 这里开辟了一个存放 protocol 变量的空间
  %10 = alloc_stack $SomeRegularProtocol          // users: %17, %16, %15, %12
  %11 = load %3 : $*SomeImpl                      // user: %13
  // 做了一个奇怪操作,一会着重看一下
  %12 = init_existential_addr %10 : $*SomeRegularProtocol, $SomeImpl // user: %13
  store %11 to %12 : $*SomeImpl                   // id: %13
  // function_ref useRegularProtocol(_:)
  // 直接调用我们的 useRegularProtocol 方法
  %14 = function_ref @$s4main18useRegularProtocolyyAA04SomecD0_pF : $@convention(thin) (@in_guaranteed SomeRegularProtocol) -> () // user: %15
  %15 = apply %14(%10) : $@convention(thin) (@in_guaranteed SomeRegularProtocol) -> ()
  destroy_addr %10 : $*SomeRegularProtocol        // id: %16
  dealloc_stack %10 : $*SomeRegularProtocol       // id: %17
  %18 = integer_literal $Builtin.Int32, 0         // user: %19
  %19 = struct $Int32 (%18 : $Builtin.Int32)      // user: %20
  return %19 : $Int32                             // id: %20
} // end sil function 'main'

上面我们遇到了一个新指令:init_existential_addr。不知道是什么,我们查一下 SIL 文档:

Partially initializes the memory referenced by %0 with an existential container prepared to contain a value of type $T. The result of the instruction is an address referencing the storage for the contained value, which remains uninitialized. The contained value must be store-d or copy_addr-ed to in order for the existential value to be fully initialized. If the existential container needs to be destroyed while the contained value is uninitialized, deinit_existential_addr must be used to do so. A fully initialized existential container can be destroyed with destroy_addr as usual. It is undefined behavior to destroy_addr a partially-initialized existential container.

Existential Container

这里又出现了一个新概念:Existential Container。这个概念大家如果熟悉 Swift 的话应该或多或少听说过,简单来讲它就是一个存放任意类型的变量,你可以把它理解为 Any。为了做到这一点,Swift 在运行时需要知道这个变量的存储数据(结构体、类本身)类型一系列特征值。这三要素缺一不可,存储数据好理解,大家可能会问为什么需要类型信息呢?因为 Swift 的 struct 是非常朴素的,它在内存布局中完全无法体现类型信息,如果不是编译时插入了什么上下文信息,我们在运行时拿到一个指向 struct 的地址是不能解析出它的类型名等信息的。而后面的一系列特征值则是我们通常讲的 witness tables,分为 protocol witness tablevalue witness table。他们在 protocol 方法派发过程中起到了至关重要的作用。

Swift 的 ABI 稳定后 Existential Container 的内存布局不会发生变化,可以用 C 代码表示为:

struct OpaqueExistentialContainer {
  void *fixedSizeBuffer[3];
  Metadata *type;
  WitnessTable *witnessTables[NUM_WITNESS_TABLES];
};

其中 fixedSizeBuffer 存放了 struct 的数据,如果 3 * 8 = 24 个字节放不下会转移到堆内存去,这个机制与 C++ 的 std::string 实现很像,属于 SBO 优化。

另外 Existential Container 还有一个变种版本,如果确定一个 protocol 一定是 class 类型时,它的内存布局会变成:

struct ClassExistentialContainer {
  HeapObject *value;
  WitnessTable *witnessTables[NUM_WITNESS_TABLES];
};

因为 class 对象的内存布局里一定有 Metadata 信息。

到这里其实我们就可以看一下上面那段代码转成 LLVM IR 的结果了(代码很长,你忍一下):

define i32 @main(i32 %0, i8** %1) #0 {
entry:
  // 这里已经为 protocol 变量分配栈空间了
  %2 = alloca %T4main19SomeRegularProtocolP, align 8
  %3 = bitcast i8** %1 to i8*
  %4 = call swiftcc i64 @"$s4main8SomeImplV4dataACSi_tcfC"(i64 1024)
  store i64 %4, i64* getelementptr inbounds (%T4main8SomeImplV, %T4main8SomeImplV* @"$s4main4implAA8SomeImplVvp", i32 0, i32 0, i32 0), align 8

  // 开始为 Existential Container 赋值
  %5 = bitcast %T4main19SomeRegularProtocolP* %2 to i8*
  call void @llvm.lifetime.start.p0i8(i64 40, i8* %5)
  // 注意:%6 是 SomeImpl.data 的值,这里没有立即使用
  %6 = load i64, i64* getelementptr inbounds (%T4main8SomeImplV, %T4main8SomeImplV* @"$s4main4implAA8SomeImplVvp", i32 0, i32 0, i32 0), align 8
  // %7 是指向 type 的地址,紧接着的 store 指令为其赋了 SomeImpl 的 Metadata
  %7 = getelementptr inbounds %T4main19SomeRegularProtocolP, %T4main19SomeRegularProtocolP* %2, i32 0, i32 1
  store %swift.type* bitcast (i64* getelementptr inbounds (<{ i8**, i64, <{ i32, i32, i32, i32, i32, i32, i32 }>*, i32, [4 x i8] }>, <{ i8**, i64, <{ i32, i32, i32, i32, i32, i32, i32 }>*, i32, [4 x i8] }>* @"$s4main8SomeImplVMf", i32 0, i32 1) to %swift.type*), %swift.type** %7, align 8
  // %8 是 witnessTables 指向的地址,紧接着的 store 指令为其赋值
  %8 = getelementptr inbounds %T4main19SomeRegularProtocolP, %T4main19SomeRegularProtocolP* %2, i32 0, i32 2
  store i8** getelementptr inbounds ([2 x i8*], [2 x i8*]* @"$s4main8SomeImplVAA0B15RegularProtocolAAWP", i32 0, i32 0), i8*** %8, align 8

  // 开始为 fixedSizeBuffer 赋值,由于空间足够,这里采用 inline 模式
  %10 = getelementptr inbounds %T4main19SomeRegularProtocolP, %T4main19SomeRegularProtocolP* %2, i32 0, i32 0
  %11 = bitcast [24 x i8]* %10 to %T4main8SomeImplV*
  %.data = getelementptr inbounds %T4main8SomeImplV, %T4main8SomeImplV* %11, i32 0, i32 0
  %.data._value = getelementptr inbounds %TSi, %TSi* %.data, i32 0, i32 0
  store i64 %6, i64* %.data._value, align 8
  // 赋值完毕

  // 直接调用 useRegularProtocol 方法
  call swiftcc void @"$s4main18useRegularProtocolyyAA04SomecD0_pF"(%T4main19SomeRegularProtocolP* noalias nocapture dereferenceable(40) %2)
  %12 = bitcast %T4main19SomeRegularProtocolP* %2 to %__opaque_existential_type_1*
  call void @__swift_destroy_boxed_opaque_existential_1(%__opaque_existential_type_1* %12) #3
  %13 = bitcast %T4main19SomeRegularProtocolP* %2 to i8*
  call void @llvm.lifetime.end.p0i8(i64 40, i8* %13)
  ret i32 0
}

上面的代码就是 init_existential_addr 所表示的操作了,基本上跟我们的预想一样。到这里我们会发现,Swift 并不会像 C++ 那样,由于一个类定义了虚方法(实现 protocol 方法也算虚方法了),就为它立即生成一个 vtable 并伴随对象生命周期。Swift 将这个过程 defer 到 casting 的时机了,也就是说只有当我们把 SomeImpl 当作 protocolAny 去用的时候才会生成对应的 witness tables,否则这部分开销就不需要。并且生成的 witness tables 不像 C++ 的 vtable 包含了所有的方法,而只会包含对应 protocol 需要的方法。如果你去 demangle pwt 的符号名,会得到 protocol witness table for main.SomeImpl : main.SomeRegularProtocol in main,也就是说这个 pwt 就是 X 类型实现 Y 协议专用的。

Value Witness Table

除了 pwt 以外还存在着 vwt,前者与 protocol 相关,而后者则与任何 Existential Container 都有关。想象一下,当一个 Any 变量退出作用域时会发生什么。

对于引用类型而言,退出作用域需要减少引用计数;而对于 struct 而言这里就要分情况讨论一下,有引用类型成员的 struct 需要对这些成员做引用计数减少,没有引用类型成员的 struct 则只需要释放内存空间即可。一个 Any 变量如何确定自己执行释放、拷贝等操作时应该做什么,这就取决于 Value Withness Table

这里我们用一个简单的例子展示一下 vwt 的使用场景,就不过多展开了:

struct SomeImpl: SomeRegularProtocol {
    let obj = NSObject()
}

func test() {
    let p: SomeRegularProtocol = SomeImpl(data: 42)
}

IR 如下:

define hidden swiftcc void @"$s4main4testyyF"() #0 {
entry:
  %0 = alloca %T4main19SomeRegularProtocolP, align 8
  %1 = bitcast %T4main19SomeRegularProtocolP* %0 to i8*
  // 此处省略 10 多行...
  %10 = bitcast %T4main19SomeRegularProtocolP* %0 to %__opaque_existential_type_1*
  call void @__swift_destroy_boxed_opaque_existential_1(%__opaque_existential_type_1* %10) #2
  %11 = bitcast %T4main19SomeRegularProtocolP* %0 to i8*
  call void @llvm.lifetime.end.p0i8(i64 40, i8* %11)
  ret void
}

可以看到编译器在作用域退出时生成了 __swift_destroy_boxed_opaque_existential_1 调用,这个函数实际也是编译器合成的一个行数,我们可以看到它对应的代码:

; Function Attrs: noinline nounwind
define linkonce_odr hidden void @__swift_destroy_boxed_opaque_existential_1(%__opaque_existential_type_1* %0) #9 {
entry:
  // %1 是类型的 Metadata
  %1 = getelementptr inbounds %__opaque_existential_type_1, %__opaque_existential_type_1* %0, i32 0, i32 1
  %2 = load %swift.type*, %swift.type** %1, align 8
  %3 = getelementptr inbounds %__opaque_existential_type_1, %__opaque_existential_type_1* %0, i32 0, i32 0
  %4 = bitcast %swift.type* %2 to i8***
  %5 = getelementptr inbounds i8**, i8*** %4, i64 -1
  // 从 Metadata 里取出 vwt
  %.valueWitnesses = load i8**, i8*** %5, align 8, !invariant.load !48, !dereferenceable !49
  %6 = bitcast i8** %.valueWitnesses to %swift.vwtable*
  %7 = getelementptr inbounds %swift.vwtable, %swift.vwtable* %6, i32 0, i32 10
  // 从 vwt 中取出 flags 字段(详见下文)
  %flags = load i32, i32* %7, align 8, !invariant.load !48
  %8 = and i32 %flags, 131072
  %flags.isInline = icmp eq i32 %8, 0
  br i1 %flags.isInline, label %inline, label %outline

// 对象的存储数据以 inline 形式存放在 existential container 的 buffer 里
inline:                                           ; preds = %entry
  %9 = bitcast [24 x i8]* %3 to %swift.opaque*
  %10 = bitcast %swift.type* %2 to i8***
  %11 = getelementptr inbounds i8**, i8*** %10, i64 -1
  %.valueWitnesses1 = load i8**, i8*** %11, align 8, !invariant.load !48, !dereferenceable !49
  // 从 vwt 中取出 destroy 操作的函数地址
  %12 = getelementptr inbounds i8*, i8** %.valueWitnesses1, i32 1
  %13 = load i8*, i8** %12, align 8, !invariant.load !48
  %destroy = bitcast i8* %13 to void (%swift.opaque*, %swift.type*)*
  // 调用 destroy 函数
  call void %destroy(%swift.opaque* noalias %9, %swift.type* %2) #2
  ret void

// 对象以 outline 形式分配在堆上,直接走引用计数 release 流程
outline:                                          ; preds = %entry
  %14 = bitcast [24 x i8]* %3 to %swift.refcounted**
  %15 = load %swift.refcounted*, %swift.refcounted** %14, align 8
  call void @swift_release(%swift.refcounted* %15) #2
  ret void
}

上面我们看到的 vwt 的数据结构在 Swift ABI 源码中均有体现,大家可以主要参考这个文件:Metadata.h:334

顺便我们再看一下上面这个例子 vwt 里的函数吧,SomeImpl 的 vwt 内容如下:

@"$s4main8SomeImplVWV" = internal constant %swift.vwtable {
 i8* bitcast (%swift.opaque* ([24 x i8]*, [24 x i8]*, %swift.type*)* @"$s4main8SomeImplVwCP" to i8*),
 i8* bitcast (void (%swift.opaque*, %swift.type*)* @"$s4main8SomeImplVwxx" to i8*),
 i8* bitcast (%swift.opaque* (%swift.opaque*, %swift.opaque*, %swift.type*)* @"$s4main8SomeImplVwcp" to i8*),
 i8* bitcast (%swift.opaque* (%swift.opaque*, %swift.opaque*, %swift.type*)* @"$s4main8SomeImplVwca" to i8*),
 i8* bitcast (i8* (i8*, i8*, %swift.type*)* @__swift_memcpy16_8 to i8*),
 i8* bitcast (%swift.opaque* (%swift.opaque*, %swift.opaque*, %swift.type*)* @"$s4main8SomeImplVwta" to i8*),
 i8* bitcast (i32 (%swift.opaque*, i32, %swift.type*)* @"$s4main8SomeImplVwet" to i8*),
 i8* bitcast (void (%swift.opaque*, i32, i32, %swift.type*)* @"$s4main8SomeImplVwst" to i8*),
 i64 16, i64 16, i32 65543, i32 2147483647
}, align 8

其中 destroy 对应的函数是 s4main8SomeImplVwxx,其内容如下:

; Function Attrs: nounwind
define internal void @"$s4main8SomeImplVwxx"(%swift.opaque* noalias %object, %swift.type* %SomeImpl) #10 {
entry:
  %0 = bitcast %swift.opaque* %object to %T4main8SomeImplV*
  %.obj = getelementptr inbounds %T4main8SomeImplV, %T4main8SomeImplV* %0, i32 0, i32 1
  %toDestroy = load %TSo8NSObjectC*, %TSo8NSObjectC** %.obj, align 8
  %1 = bitcast %TSo8NSObjectC* %toDestroy to i8*
  call void @llvm.objc.release(i8* %1)
  ret void
}

可以说是非常浅显易懂了。

thunk?

大家一定在调用栈中见过 protocol witness for X in conformance Y 这个奇怪方法,为什么有了 pwt 还需要一个跳板函数来调用真实的函数呢?

可以回想一下上文中提到的 C++ thunk 和 SIL 优化。回到 “通过静态派发初识 SIL、LLVM IR” 这一小节,Swift 的 struct 方法会有个成员内联的优化,也就是说方法参数中不会传入完整的 struct 对象,而只会传入所需要使用的成员。然而 protocol 调用时并不了解这一细节,调用方仍然需要将完整的 self 指针作为一个参数传入。这里编译器有两个选择:

  1. 生成两份方法代码,分别适配直接调用和 protocol 调用
  2. 生成跳板函数,在跳板函数中提取成员后调用原方法

显然第二种方法更为高效。

实际上 thunk 函数的作用还有很多,大家开发 OC 日常可能会用到的 Method Swizzling 也会用到类似 thunk 的技术,如果有兴趣也可以深入研究一下,还是非常有意思的。

总结

本文从 Objective-C、C++ 切入,以 SIL、LLVM IR 为“抓手”详细分析了 Swift Protocol 的方法派发原理,从中我们可以发现语言之间的很多共性,例如通过 thunk 来适配不同的调用方,通过函数表实现多态等。现代的很多编程语言实际上存在非常多相似的实现思路,比如 Golang 的 interface{} 也是用了跟 Swift Existential Container 一样的结构,只不过 Golang 称之为 fat pointer。通过这种方法能极大地减少堆内存的分配开销,使抽象的成本更低。

同时我们也注意到了,相比 Objective-C 这门古老的语言,Swift 等现代语言会把更多的优化和底层实现放在编译期进行,减少运行时所需的信息,这也是为什么大家感觉在 Swift 里做反射这类操作更难了。其实不是 Swift 不存储元信息,只是 Apple 不再希望开发者通过运行时的特性来完成业务逻辑,开发者越依赖运行时信息,未来底层的优化机会就会越少。同理,G 家的 Flutter 也不允许开发者在 Dart 里使用反射,因为会影响 tree shaking 优化。Swift ABI 现在已经稳定,我们仍然可以在开发阶段借助运行时信息来做一些调试用途的事情。在实现业务逻辑上,还是应该优先选择编译期的解决方案。

深入 Swift Runtime 之 TrailingObjects

Swift Runtime 底层结构和行为大量使用 C++ 开发,因此如果想要深入分析 Runtime 的原理或者 ABI 的内存布局等就需要对这部分 C++ 代码有所了解。Swift 的 C++ 源码中使用了很多“黑魔法”,尤其是模板,可能对 C++ 新手来说会比较劝退。本文就带大家深入浅出地分析其源码中的一个模板结构 TrailingObjects,揭开 Swift Runtime 的冰山一角。

简单聊聊 Swift ABI

据 Apple 称,自 Swift 5.0 起,其 ABI 在 Apple 平台上就稳定了,这意味着 Swift 5.0 的编译产物在内存布局、runtime 调用上就不会发生变化了,也意味着你可以更换任意一个 ABI 兼容的标准库(libSwiftCore.dylib)。因此在新的 iOS 版本中,app 就可以不将额外的 Swift Runtime 内嵌到 bundle 中了,从而节省了一部分包体积。

ABI 的稳定也意味着我们可以直接更换编译好的可执行二进制产物的 Swift Runtime,并通过自己编译的 runtime 来调试其内部数据结构和行为。亦或者直接将 Swift Runtime 的头文件引入自己的工程中来调用(毕竟内存布局是一样的,所有的结构可以完全匹配上)。

如何调试 Runtime

自己编译好 Swift 工具链后可以得到可调试的 libSwiftCore.dylib, 但直接 link 它的话在运行时还是会加在系统中的动态库,因为产物默认的 LC_ID_DYLIB 是系统路径,这里为了方便我们可以直接通过 install_name_tool -change 更换可执行的 LC_LOAD_DYLIB command,让它加载我们的 runtime。

otool

更改好后我们在 runtime 代码里打一个断点,发现调试器可以成功断在里面并且可以随意访问变量和步进执行。

Xcode Breakpoints

认识第一个 Runtime 结构

Swift Runtime 中的数据结构很多,有运行时产生的,也有编译链接时产生的。我们这里来看一个编译时产生的重要结构:Metadata。它表示一个类型的元数据,runtime 的方方面面都依赖这个信息。在 Swift 中的表示为 T.Type,即 T.self 的返回值类型。我们可以通过反编译下面这个简单的代码片段来看一下它在运行时是怎么获取的:

func takeType<T>(_ t: T.Type) { }

takeType(Foo.self)  // Line to decompile

反编译结果如下:

Hopper

可以看到 takeType 的第一个参数为一个指针,指向了 _$s4test3FooVMf 这个符号加偏移量 8 的地址。这个符号 demangle 之后的符号名为 full type metadata for test.Foo,内容如下:

Hopper

其 + 8 后的地址为另一个符号,但内容 hopper 无法解析。

之前我们说过,Metadata 结构对应的 Swift 类型为 T.Type,那么这个 _$s4test3FooVMf + 8 的地址必然存放了与 Metadata 布局相同的内容。

我们来看一下 Metadata 在 C++ 代码中的定义(已简化,完整代码链接也贴在了文章最后):

template <typename Runtime> struct TargetMetadata;
using Metadata = TargetMetadata<InProcess>;

template <typename Runtime>
struct TargetMetadata {
  using StoredPointer = typename Runtime::StoredPointer;

  /// The basic header type.
  typedef TargetTypeMetadataHeader<Runtime> HeaderType;

  constexpr TargetMetadata()
    : Kind(static_cast<StoredPointer>(MetadataKind::Class)) {}
  constexpr TargetMetadata(MetadataKind Kind)
    : Kind(static_cast<StoredPointer>(Kind)) {}

private:
  /// The kind. Only valid for non-class metadata; getKind() must be used to get
  /// the kind value.
  StoredPointer Kind;
public:
  /// Get the metadata kind.
  MetadataKind getKind() const {
    return getEnumeratedMetadataKind(Kind);
  }
  
  /// Set the metadata kind.
  void setKind(MetadataKind kind) {
    Kind = static_cast<StoredPointer>(kind);
  }
};

通过类型的定义可以看到,这个 MetadataTargetMetadata<InProcess> 的 type-alias,为了方便我们后续都用 Metadata 代指后者。其存储字段只有一个 StoredPointer 类型的 Kind 变量。StoredPointer 定义在 Runtime 泛型中,代入 InProcess 可以看到定义(已简化):

struct InProcess {
  static constexpr size_t PointerSize = sizeof(uintptr_t);
  using StoredPointer = uintptr_t;
  using StoredSignedPointer = uintptr_t;
  using StoredSize = size_t;
  using StoredPointerDifference = ptrdiff_t;
};

我们再来看下 MetadataKind 的定义:

const unsigned MetadataKindIsNonHeap = 0x200;

enum class MetadataKind : uint32_t {
#define METADATAKIND(name, value) name = value,
#define ABSTRACTMETADATAKIND(name, start, end)                                 \
  name##_Start = start, name##_End = end,
  // content of: #include "MetadataKind.def"
  
  #ifndef NOMINALTYPEMETADATAKIND
	#define NOMINALTYPEMETADATAKIND(Name, Value) METADATAKIND(Name, Value)
	#endif
  
  /// A class type.
  NOMINALTYPEMETADATAKIND(Class, 0)

  /// A struct type.
  NOMINALTYPEMETADATAKIND(Struct, 0 | MetadataKindIsNonHeap)

  /// An enum type.
  /// If we add reference enums, that needs to go here.
  NOMINALTYPEMETADATAKIND(Enum, 1 | MetadataKindIsNonHeap)
  
  // ...
};

刚才反编译看到的那段未知数据在内存布局上就与这个 Kind 对应,按值匹配即为 Struct。到这里大家可能就会好奇了,为什么 _$s4test3FooVMf + 8 才是 Metadata 呢,那 _$s4test3FooVMf 对应的是什么呢?这里先不展开,相信大家按照本文的思路也能自己找到它的定义。

Metadata 子类?

到这里我们已经正式将一个类型横跨 Swift - 二进制 - C++ 进行了对接,接下来我们就来继续分析其他的结构。通过 Metadata 的定义我们应该能猜到,它不可能承载所有的信息,那么不同类型的详细信息都在哪里呢?这就要引出它的“子类”们了。以本文分析的 struct 为例,其真实 Metadata 类型是 StructMetadata (aka. TargetStructMetadata<InProcess>),结构定义及类型层次如下:

template <typename Runtime>
struct TargetValueMetadata : public TargetMetadata<Runtime> {
  using StoredPointer = typename Runtime::StoredPointer;
  TargetValueMetadata(MetadataKind Kind,
                      const TargetTypeContextDescriptor<Runtime> *description)
      : TargetMetadata<Runtime>(Kind), Description(description) {}

  /// An out-of-line description of the type.
  TargetSignedPointer<Runtime, const TargetValueTypeDescriptor<Runtime> * __ptrauth_swift_type_descriptor> Description;
};

template <typename Runtime>
struct TargetStructMetadata : public TargetValueMetadata<Runtime> {
  using StoredPointer = typename Runtime::StoredPointer;
  using TargetValueMetadata<Runtime>::TargetValueMetadata;

  const TargetStructDescriptor<Runtime> *getDescription() const {
    return llvm::cast<TargetStructDescriptor<Runtime>>(this->Description);
  }

  /// Get a pointer to the field offset vector, if present, or null.
  const uint32_t *getFieldOffsets() const {
    auto offset = getDescription()->FieldOffsetVectorOffset;
    if (offset == 0)
      return nullptr;
    auto asWords = reinterpret_cast<const void * const*>(this);
    return reinterpret_cast<const uint32_t *>(asWords + offset);
  }
};

可以看到 StructMetadata 本质上与 ValueMetadata 内存布局一致,仅当类型不同时有不同的运行时行为。ValueMetadata 在公共的 Kind 字段下又增加了 Description 字段,通过类型分析也是一个指针。这个字段指向了一个外联的描述结构,这个结构也是本文要重点分析的。

TargetStructDescriptor

先看定义:

template <typename Runtime>
class TargetStructDescriptor final
    : public TargetValueTypeDescriptor<Runtime>,
      public TrailingGenericContextObjects<TargetStructDescriptor<Runtime>,
                            TargetTypeGenericContextDescriptorHeader,
                            /*additional trailing objects*/
                            TargetForeignMetadataInitialization<Runtime>,
                            TargetSingletonMetadataInitialization<Runtime>,
                            TargetCanonicalSpecializedMetadatasListCount<Runtime>,
                            TargetCanonicalSpecializedMetadatasListEntry<Runtime>,
                            TargetCanonicalSpecializedMetadatasCachingOnceToken<Runtime>> {
    // ...
};

说实话,刚开始看到这个类型我也懵了一下,但实际并没有看上去那么复杂。我们来一点一点分析。

首先第一个父类 TargetValueTypeDescriptor,它其实是一个很简单的类型,提供了一些公共字段和行为,继承关系如下:

TargetValueTypeDescriptor
`-- TargetTypeContextDescriptor
    `-- TargetContextDescriptor

比较麻烦的是它的第二个父类 TrailingGenericContextObjects<&@#?%^@*>,这个类型的模板参数很多且雷同,一看就是个 variadic template,我们还是直接看定义:

template<class Self,
         template <typename> class TargetGenericContextHeaderType =
           TargetGenericContextDescriptorHeader,
         typename... FollowingTrailingObjects>
class TrailingGenericContextObjects;

template<class Runtime,
         template <typename> class TargetSelf,
         template <typename> class TargetGenericContextHeaderType,
         typename... FollowingTrailingObjects>
class TrailingGenericContextObjects<TargetSelf<Runtime>,
                                    TargetGenericContextHeaderType,
                                    FollowingTrailingObjects...> :
  protected swift::ABI::TrailingObjects<TargetSelf<Runtime>,
      TargetGenericContextHeaderType<Runtime>,
      GenericParamDescriptor,
      TargetGenericRequirementDescriptor<Runtime>,
      FollowingTrailingObjects...>
{ /* ... */ };

通过定义可知,TrailingGenericContextObjects 在使用时有两个定参(SelfTargetGenericContextHeaderType)和一个不定参模板参数(FollowingTrailingObjects...),下面有一个偏特化定义,主要作用是萃取 Runtime 参数以便后用。这个偏特化的定义直接继承了 TrailingObjects,即我们接下来要重点分析的类型,实现如下:

template <typename BaseTy, typename... TrailingTys>
class TrailingObjects : private trailing_objects_internal::TrailingObjectsImpl<
                            trailing_objects_internal::AlignmentCalcHelper<
                                TrailingTys...>::Alignment,
                            BaseTy, TrailingObjects<BaseTy, TrailingTys...>,
                            BaseTy, TrailingTys...> {
  // ...
private:
  // These two methods are the base of the recursion for this method.
  static const BaseTy *
  getTrailingObjectsImpl(const BaseTy *Obj,
                         TrailingObjectsBase::OverloadToken<BaseTy>) {
    return Obj;
  }

  static BaseTy *
  getTrailingObjectsImpl(BaseTy *Obj,
                         TrailingObjectsBase::OverloadToken<BaseTy>) {
    return Obj;
  }
  // ...
public:
  /// Returns a pointer to the trailing object array of the given type
  /// (which must be one of those specified in the class template). The
  /// array may have zero or more elements in it.
  template <typename T> const T *getTrailingObjects() const {
    // Forwards to an impl function with overloads, since member
    // function templates can't be specialized.
    return this->getTrailingObjectsImpl(
        static_cast<const BaseTy *>(this),
        TrailingObjectsBase::OverloadToken<T>());
  }
  // ...
};

通过模板参数的声明我们可以大致猜想到,这个类型除了第一个模板参数(BaseTy),其他不定参模板参数的角色都是相同的,对应到 TrailingGenericContextObjects 类型上,除了第一个 TargetSelf<Runtime> 参数,其他参数 TrailingObjects 并不实际感知。那么就可以得出结论,TrailingGenericContextObjects 实际上就是为它的参数追加上一部分额外参数(与 Swift 泛型相关),传递给 TrailingObjects 使用,充当了一层装饰器。这里我们可以先忽略它的作用。

TrailingObjects

接下来我们继续分析 TrailingObjects

通过前面的代码,我们可以看到这个类型定义了一些方法,但没有任何存储字段,并且可以看到它对接受的模板参数进行了一些类型计算,又传给了父类作为模板参数。其父类是 TrailingObjects 的核心实现,我们也一块来看一下定义:

template <int Align, typename BaseTy, typename TopTrailingObj, typename PrevTy,
          typename... MoreTys>
class TrailingObjectsImpl {
  // The main template definition is never used -- the two
  // specializations cover all possibilities.
};

template <int Align, typename BaseTy, typename TopTrailingObj, typename PrevTy,
          typename NextTy, typename... MoreTys>
class TrailingObjectsImpl<Align, BaseTy, TopTrailingObj, PrevTy, NextTy,
                          MoreTys...>
    : public TrailingObjectsImpl<Align, BaseTy, TopTrailingObj, NextTy,
                                 MoreTys...> {
  // ...
};

/*
  trailing_objects_internal::TrailingObjectsImpl<
    trailing_objects_internal::AlignmentCalcHelper<TrailingTys...>::Alignment,
    BaseTy,
    TrailingObjects<BaseTy, TrailingTys...>,
    BaseTy,
    TrailingTys...
  >
*/

这个类型继承了它自己,因此这是一个递归模板,是处理不定参模板的一种手段。在这种模式下,每一层类型都可以获取到参数列表中的一项,然后把剩下的参数传给父类,那么它的父类就可以处理下一项,然后继续把剩下的参数传给父类...

很像 Lisp 和 Haskell 等 FP 语言处理列表的方式,有兴趣的同学可以看看这篇文章:Learn You a Haskell for Great Good! - Recursion

但是自己继承自己如果没有终止条件就会死循环,因此一定存在一个(偏)特化用于终止递归:

// The base case of the TrailingObjectsImpl inheritance recursion,
// when there's no more trailing types.
template <int Align, typename BaseTy, typename TopTrailingObj, typename PrevTy>
class TrailingObjectsImpl<Align, BaseTy, TopTrailingObj, PrevTy>
    : public TrailingObjectsAligner<Align> {
  // ...
};

/*
  matching TrailingObjectsImpl<Align, BaseTy, TopTrailingObj, NextTy, MoreTys...>
    with MoreTys = []
*/

整个模板递归展开的过程大家可以使用 cppinsights 这个工具来查看,我这里已经做好了一个可编译的版本可以直接打开:https://cppinsights.io/s/8de0f488

接下来我们来看递归的每层类型为最终的类型添加了什么内容:

class TrailingObjectsImpl<Align, BaseTy, TopTrailingObj, /* 顶层完整类型 */
    PrevTy, /* 上一层处理的模板参数 */
    NextTy, /* 当前层处理的模板参数 */
    MoreTys... /* 下一层处理的模板参数列表 */> /* : ... */ {
protected:
  static const NextTy *
  getTrailingObjectsImpl(const BaseTy *Obj,
                         TrailingObjectsBase::OverloadToken<NextTy>) {
    auto *Ptr = TopTrailingObj::getTrailingObjectsImpl(
                    Obj, TrailingObjectsBase::OverloadToken<PrevTy>()) +
                TopTrailingObj::callNumTrailingObjects(
                    Obj, TrailingObjectsBase::OverloadToken<PrevTy>());

    if (requiresRealignment())
      return reinterpret_cast<const NextTy *>(
          llvm::alignAddr(Ptr, llvm::Align(alignof(NextTy))));
    else
      return reinterpret_cast<const NextTy *>(Ptr);
  }
};

可以看到每一层都会有一个 getTrailingObjectsImpl 方法的重载,这个就是 TrailingObjectsImpl 最核心的方法,可以看到它能够返回当前层所处理的类型。同时他接受一个比较特殊的参数 OverloadToken,之所以存在这个参数是因为 C++ 不支持仅返回值不同的函数重载,也不支持模板函数的(偏)特化,因此这个参数是用来帮助编译器确定我们需要的重载版本的,函数体内并不需要关心其内容。其他语言中也有类似的技术,比如 Rust 的 PhantomData

继续来看函数的实现,它调用了 TopTrailingObjgetTrailingObjectsImpl 方法并传入了 PrevTy 构造的 OverloadToken。这里 TopTrailingObj 表示一个完整类型,可以像外界一样获取到整个类型层次中的所有成员。很明显这里其实也是一个递归调用,PrevTy 向前追溯是 BaseTy,所以我们看看返回 BaseTy 的重载版本,它位于 TrailingObjects 这个类型:

static const BaseTy *
getTrailingObjectsImpl(const BaseTy *Obj,
                       TrailingObjectsBase::OverloadToken<BaseTy>) {
  return Obj;
}

callNumTrailingObjects 则是直接调用到 TopTrailingObj 中的实现了,因为每层类型中并没有增加重载,它的实现如下:

template <typename T>
  static size_t callNumTrailingObjects(const BaseTy *Obj,
                                       TrailingObjectsBase::OverloadToken<T>) {
    return Obj->numTrailingObjects(TrailingObjectsBase::OverloadToken<T>());
  }

这里是用到了 BaseTy 这个参数,在 TargetStructDescriptor 的使用场景中 BaseTyTargetStructDescriptor<Runtime>,而 TargetStructDescriptor 继承自 TrailingObjects,这种手法叫 CRTP,即父类通过模板参数感知子类,从而实现编译期多态。在这个例子中,callNumTrailingObjects 调用到 TargetStructDescriptor 中的 numTrailingObjects 实现,实现不同类型的自定义行为:

class TargetStructDescriptor {
  // ...
  size_t numTrailingObjects(OverloadToken<ForeignMetadataInitialization>) const{
    return this->hasForeignMetadataInitialization() ? 1 : 0;
  }

  size_t numTrailingObjects(OverloadToken<SingletonMetadataInitialization>) const{
    return this->hasSingletonMetadataInitialization() ? 1 : 0;
  }

  size_t numTrailingObjects(OverloadToken<MetadataListCount>) const {
    return this->hasCanonicicalMetadataPrespecializations() ?
      1
      : 0;
  }

  size_t numTrailingObjects(OverloadToken<MetadataListEntry>) const {
    return this->hasCanonicicalMetadataPrespecializations() ?
      this->template getTrailingObjects<MetadataListCount>()->count
      : 0;
  }
  // ...
};

用法分析 & 小结

到这里我们就可以分析一下 TrailingObjects 的作用了。首先整个 TrailingObjects 继承链中没有任何存储字段,因此当一个类型继承它是不会更改内存布局。然后来看一下 getTrailingObjects 方法的逻辑,假设对于 TrailingObjects<Base, A, B> 调用 getTrailingObjects<B>(),其调用过程为:

getTrailingObjects<B>()
= getTrailingObjectsImpl<B>()
= getTrailingObjectsImpl(A) + callNumTrailingObjects(A)
= (A*)(getTrailingObjectsImpl(Base) + callNumTrailingObjects(Base)) + callNumTrailingObjects(A)

可以得出 TrailingObjects 作用的结构内存布局为:

+------+------------+------------+
| Base | A * num(A) | B * num(B) |
+------+------------+------------+

这与 Swift ABI 约定的 Metadata 在二进制中的布局一致,通过 TrailingObjects 我们可以自由地声明类型的内存映射,在顺序和数量上的调整更加灵活。相比于简单的指针偏移计算,它能够提供更强的类型安全性,并且能够复用大部分计算逻辑,减少代码错误。

当然这种结构也有一些不大不小的缺点,比如会让代码看起来更晦涩,不太容易看出类型的真实结构;另一方面,受制于 C++ 编译器的优化能力,模板 + 非尾递归的代码不能被优化为简单的表达式计算(尽管它在运行时做的确实就是一个简单的表达式计算),在运行效率上可能有一定开销,至少方法调用是真实存在的:

bt

总结

本文通过对 TrailingObjects 的分析,用具体例子介绍了自己在 Swift Runtime 的调试和源码阅读上的思路。相比 Objective-C Runtime,Swift Runtime 在复杂度和抽象程度上会更上一层,不过好在调试相对更容易。

希望本文对大家可以起到抛砖引玉的作用。

相关链接

  1. ABI Stability and More
  2. TrailingObjects.h
  3. Metadata.h
  4. Partial template specialization - cppreference.com
  5. C++ Compiler as a Brainfuck Interpreter
  6. 从源码解析 Swift 弱引用