Swift Protocol 探究

Xcodo Version 12.5.1

iOS 14.2

Protocol Oriented Programming

之前分享过C++动态多态的实现,在C++中通过虚函数表的方式实现运行时多态。在Swift中继承对象的动态多态也是类似的实现,通过V-Table的方式实现运行时多态。

在Swift中我们一般通过协议进行行为的抽象,这也是我经常提起的面向协议编程。通过定义Protocol的形式来约束类型遵循某一些规则和实现一些方法,然后在运行时调用各类型自己实现版本的方法来达到运行时多态。


Protocol Witness Table

如下我们定义了一个Drawable的协议,里面只有一个draw方法。

swift 复制代码
protocol Drawable {
    func draw()
}

然后我们定义了PointLine类型并遵守Drawable协议

swift 复制代码
struct Point: Drawable {
    let x: Int
    let y: Int

    func draw() {
        print("Point draw")
    }
}

struct Line: Drawable {
    let x1, y1: Int
    let x2, y2: Int
    
    func draw() {
        print("Line draw")
    }
}

构造PointLine的实例并将类型声明为Drawable,最后调用draw方法。

less 复制代码
let point: Drawable = Point(
    x: 3, y: 3
)
let line: Drawable = Line(
    x1: 2, y1: 2,
    x2: 6, y2: 6
)

point.draw() // Point draw
line.draw() // Line draw

可以看到在运行时根据真实的类型去调用了对应类型实现版本的draw方法。那对于Protocol的运行时多态又是如何实现的呢?是否又是通过Table的方式实现运行时查找和调用?这个不妨从汇编角度去看看编译器是如何做的。

首先我们只看point的执行。

arduino 复制代码
let point: Drawable = Point(
    x: 3, y: 3
)
point.draw() // Point draw

对应汇编如下:

这里我们只要关注bl这个函数调整指令,但是这里有好几个bl相关的指令,但是运行时多态肯定不是跳转写死的地址,所以我们只需关注blr x10的指令。然后我们打印一下当前上下文使用到的寄存器的信息,发现x8存放的是一个 protocol witness table for Point的东西,而x10是通过x8+offset获取了protocol witness for Drawable.draw() -> () in conformance Point的函数地址,最终通过blr x10的指令跳转并执行。

这里还打印x20的内容,发现存放的是指向Point的指针,并作为隐式参数(self)传递到draw方法中执行。

所以在Swift中Protocol通过查表的方式来实现运行时多态,而这个表的名字Protocol Witness Table(简称PWT)。


Existential Container

现在我们思考一个问题,平时我们会把Protocol当做一种类型来使用,例如作为数组元素类型的声明或者是函数的形参类型声明:

swift 复制代码
let shapes: [Drawable] = [point, line]

func drawShape(_ shape: Drawable) {
    shape.draw()
}

我们知道数组存放的元素所占的空间大小是固定的,但我们存放的真实类型大小却是不相同的,这个情况编译器会怎么处理呢?首先我们看下Drawable类型的大小:

arduino 复制代码
MemoryLayout<Drawable>.size // 40 bytes

发现Drawable类型的大小是40字节,显然编译器处理后存放在数组中的元素并非原来的元素,而是一个中间类型,并且这个中间类型用来处理所有Protocol作为类型时的包装,苹果官方把这个中间类型叫做Existential Container,大概长这样子:

首先来了解下每个字段的含义和作用:

  • ValueBuffer

    • 用于存储真实类型对象的容器。如果类型大小不超过24bytes,则直接存储在ValueBuffer上;如果类型大于24bytes,则把对象存放到堆上,并将指针存储在ValueBuffer上。
  • MetaData

    • 用于存储真实类型元信息的指针。通过MetaData信息可以知道真实类型对象应该直接存储在ValueBuffer上还是存放到堆上;并且通过MetaData找到真实类型的Value Witness Table, 用于管理对象在ValueBuffer 上的生命周期。
  • ProtocolWitnessTable

    • 用于存放真实类型对应协议表的地址。

通过Existential Container就可以让实现了Protocol的不同大小的类型变成相同大小的中间类型,让Protocol类型可以做到存储和传参。

Value Witness Table

既然刚才提到了不同类型数据存放在Existential ContainerValue Buffer上的方式不一样,那么就需要一个东西去管理这块内存的创建方式,在Swift中这个东西叫做Value Witness Table (简称VWT),用来管理对象在Value Buffer上的创建、拷贝和销毁

这是官方给出的VWT的结构图,很好的说明了是用来管理对象的生命周期, 而VWT则是通过Existential Container 中的MetaType间接获取的,而最后获取的VWT在内存结构上跟官方给出的有些许不同,但作用都是一致的,大概长这样子:

对于内存中的VWT结构主要关心两个字段:

  • copy

    • 用于在拷贝Existential Container 时,决定ValueBuffer的内容拷贝的方法地址。
    • 如果在对象直接存放在ValueBuffer上,则改copy方法为__swift_memcpy,将内容直接拷贝到新的ValueBuffer上。
    • 如果在对象存放在堆上,则改copy方法为initializeBufferWithCopyOfBuffer,将新拷贝的指针存放ValueBuffer上。
  • flag

    • 这个flag值决定了对象如何存放在ValueBuffer上的标志,在对象类型大小不超过24bytes时为0x7,而超过24bytes时则为0x20007。

然后我们看看这两种情况的Existential Container 的结构图,以Point类型为例:

ini 复制代码
let point: Drawable = Point(x: 3, y: 3)
let point2: Drawable = point

因为Point大小不超过24bytes,而且是个struct结构,所以Existential Container 创建时Point的x和y直接存放在ValueBuffer 上。如果这时候拷贝了Existential Container ,对于新的Existential Container 的**ValueBuffer**通过MetaData找到VWT后获取copy方法后把Point内容拷贝过去,而VWT里存放的copy方法的地址是__swift_memcpy的地址,对于MetaData和PWT则使用同一个引用。

然后以Line类型为例:

yaml 复制代码
let line: Drawable = Line(
    x1: 2, y1: 2,
    x2: 6, y2: 6
)
let line2: Drawable = line

由于Line类型超过24bytes,即使是struct结构编译器还是会在堆上创建一个Class结构的Line对象,和其他Class结构一样会多两个字段TypeRefCount,最后才是存放Line的内容,而ValueBuffer存放的是指向这个堆对象的指针。如果这时候拷贝Existential Container ,VWT里存放的copy方法的地址是initializeBufferWithCopyOfBuffer的地址,只是简单的将堆对象引用加一,然后把新指针存放到新的ValueBuffer上,对于MetaData和PWT也是使用同一个引用。

从拷贝方式可以看出,苹果为了性能考虑,避免拷贝时频繁创建新的对象,所以才把堆对象变成一个Class结构,但同样不丢失struct结构Copy-on-Write的特性,会在修改内容时通过isKnownUniquelyReferenced方法判断引用数来决定是否产生拷贝。

到这里我们可以思考下Swift中的Any是怎么实现?是否会创建一个AnyContainer的中间类型


汇编分析流程

通过上面的介绍大家基本能够了解Swift中Protocol实现的原理,下面从汇编角度去看看具体的实现细节(毕竟代码不会骗我们),从三行代码来分析具体的汇编逻辑:

arduino 复制代码
let point = Point(x: 3, y: 3) // 创建Point对象
let shape: Drawable = point // 转成Drawable对象
shape.draw() // 调用draw方法
  • 创建Point对象

这里看到的是Point初始化逻辑,x0x1是内容,而x10则是MetaData的地址。

  • 创建Drawable对象,并获取真实对象的地址

这里我停在了跳转命令上,这里是跳转到__swift_project_boxed_opaque_existential_1的函数地址,因为是写死的地址,表明这是个通用的函数,所有的Protocol类型都会调用这个函数。

跳转前从控制台打印下上面使用过的寄存器信息,可以发现MetaData和PWT的地址,sp存的是当前栈底指针,x0x1可能用于传参,而x0的地址跟sp的地址很接近,那也是在栈上。

打印一下x0地址里40bytes的内容,可以发现就是Existential Container 的内容,前24bytes存了Point的内容(第三个8字节有值是因为内存未初始化),然后是MetaData的地址,最后是PWT的地址。

简单说明下__swift_project_boxed_opaque_existential_1的作用是,从Existential Container 中获取真实对象地址的一个通用函数,具体看看怎么做的:

首先看到x0存放了Existential Container 的地址,x1MetaData的地址,通过MetaData地址加偏移找到了FullMetaData地址,最终找到VWT地址。从VWT地址加偏移找到了flag的值(就是上面说到决定ValueBuffer存储方式的flag),最后比较第0x11位是否为0知道了对象是直接存放在ValueBuffer上,所以Existential Container 的地址就是对象地址,就直接返回了x0

  • 调用draw方法

这里最终通过PWT调用了对应的函数,并把真实对象通过x20进行传参(Existential Container的地址就是对象地址)。

  • 销毁Drawable对象

最后通过__swift_destroy_boxed_opaque_existential_1函数进行Existential Container 的内存释放。这个函数跟__swift_project_boxed_opaque_existential_1函数的逻辑很相似,都是获取VWT对应的flag值,发现对象是在ValueBuffer上直接回收栈内存就好了。这个调用逻辑也就结束了。

把整个调用变成一个流程图如下:

对于通过_swift_project_boxed_opaque_existential_1获取对象地址的流程图如下:

对于通过_swift_project_boxed_opaque_existential_1销毁对象地址的流程图如下:

之前用Xcode12.2是通过两个汇编指令判断Flag,先与操作再判断是否为0:

and w10 flag, #0x20000

cbnz w0, xxxxxx

到了Xcode12.5.1就变成一个指令,判断某一位是否为0:

tbnz w0, 0x11

可以看到苹果在性能方面很符合追求极致(狗头

接着我们来看看Line类型的汇编流程,看跟Point的差异点,也是对应三行代码逻辑:

less 复制代码
let line = Line(
    x1: 2, y1: 2,
    x2: 6, y2: 6
) // 创建Line对象
let shape: Drawable = line // 转成Drawable对象
shape.draw() // 调用draw方法
  • 创建Drawable对象,并获取真实对象的地址

通过汇编可以发现Line初始化完后会调用swift_allocObject函数创建一个堆对象,这也是Line在堆上的副本。

通过控制台打印相关寄存器和内存的信息,x0就是Existential Container 的地址,第一个8字节就是指向堆上Line对象的指针,在堆上的Line对象是一个Class结构。

通过PWT调用对应的方法时需要传入真实对象,所以这里还是通过__swift_project_boxed_opaque_existential_1这个通用函数去获取

这里同样还是通过VWT去获取flag值,发现是0x20007,然后判断第0x11位不为0,跑到下面部分去读取指针地址,从而获取到真实对象的地址,并且再偏移16个字节跳过Class结构中的TypeRefCount字段。

  • 销毁Drawable对象

__swift_destroy_boxed_opaque_existential_1函数同样是获取VWT的flag值后,知道需要去回收在堆上的Line对象

把整个调用变成一个流程图如下:

对于通过_swift_project_boxed_opaque_existential_1获取对象地址的流程图如下:

对于通过_swift_project_boxed_opaque_existential_1销毁对象地址的流程图如下:

最后再来看看拷贝一个Protocol对象的流程:

csharp 复制代码
let point = Point(x: 3, y: 3) // 创建Point对象
let shape1: Drawable = point // 转成Drawable对象
let shape2: Drawable = shape1 // 拷贝Drawable对象

这里x0就是Existential Container 的地址,通过init with copy of Drawable函数来拷贝。

进入函数后可以发现是找存储在VWT上的第一函数地址去执行拷贝的,因为Point是在栈上,所以对应的copy函数就是__swift_memcpy,对应的Line是需要放在堆上的,所以对应Line的VWT的copy函数就是initializeBufferWithCopyOfBuffer,这个大家自己去验证好了。

Copy调用的流程图如下:


Generic Type

上面我们了解到Protocol的工作原理,接下来我们来看看Protocol配合泛型时编译器会怎么处理,分析一下例子:

swift 复制代码
protocol Drawable {
    func draw()
}

struct Point: Drawable {
    let x: Int
    let y: Int

    func draw() {
        add()
    }
    
    @inline(never)
    func add() {
        let z = x + y
        print(z)
    }
}

func drawShape<T: Drawable>(_ shape: T) {
    shape.draw()
}

@inline(never)
func main() {
    let point = Point(x: 3, y: 3)
    drawShape(point)
}

这里我们声明了一个带泛型的drawShape<T: Drawable>函数,创建Point并调用,看下具体的汇编代码:

这里发现跟前面的汇编代码不太一样,这里并没有生成Existential Container。主要原因是泛型函数属于编译期多态,编译时如果编译器有足够信息能推导出真实类型,那就会进行代码优化,直接调用对应类型的PWT函数,所以通过泛型的方式可以减少内存的开销和减少调用的指令,但同时泛型特化可能会伴随代码体积的增大的风险。

当然编译器还能对代码进行更进一步的优化,当我们把编译优化改-Osize时会有更好的收益,例如函数内联:

为了能看到优化效果,把mainadd函数都标记了@inline(never),让断点能生效,最后生成的汇编代码只有简单的几条指令:


References

Whole-Module Optimization

swift witness table

Understanding Swift Performance

相关推荐
/**书香门第*/2 小时前
Laya ios接入goole广告,搭建环境 1
ios
wakangda9 小时前
React Native 集成 iOS 原生功能
react native·ios·cocoa
crasowas1 天前
iOS - 超好用的隐私清单修复脚本(持续更新)
ios·app store
ii_best1 天前
ios按键精灵脚本开发:ios悬浮窗命令
ios
Code&Ocean1 天前
iOS从Matter的设备认证证书中获取VID和PID
ios·matter·chip
/**书香门第*/1 天前
Laya ios接入goole广告,开始接入 2
ios
恋猫de小郭2 天前
什么?Flutter 可能会被 SwiftUI/ArkUI 化?全新的 Flutter Roadmap
flutter·ios·swiftui
网安墨雨2 天前
iOS应用网络安全之HTTPS
web安全·ios·https
福大大架构师每日一题2 天前
37.1 prometheus管理接口源码讲解
ios·iphone·prometheus
二流小码农3 天前
鸿蒙开发:简单了解属性动画
android·ios·harmonyos