Xcodo Version 12.5.1

iOS 14.2

Protocol Oriented Programming

之前分享过C++动态多态的完成,在C++中经过虚函数表的办法完成运行时多态。在Swift中承继目标的动态多态也是类似的完成,经过V-Table的办法完成运行时多态。

在Swift中咱们一般经过协议进行行为的抽象,这也是我经常提起的面向协议编程。经过定义Protocol的办法来束缚类型遵从某一些规则和完成一些办法,然后在运行时调用各类型自己完成版别的办法来到达运行时多态。


Protocol Witness Table

如下咱们定义了一个Drawable的协议,里边只需一个draw办法。

protocol Drawable {
  func draw()
}

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

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办法。

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的履行。

let point: Drawable = Point(
    x: 3, y: 3
)
point.draw() // Point draw

对应汇编如下:

Swift Protocol 探究

这儿咱们只需重视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 探究

所以在Swift中Protocol经过查表的办法来完成运行时多态,而这个表的姓名Protocol Witness Table(简称PWT)。

Swift Protocol 探究


Existential Container

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

let shapes: [Drawable] = [point, line]
func drawShape(_ shape: Drawable) {
    shape.draw()
}

咱们知道数组寄存的元素所占的空间巨细是固定的,但咱们寄存的实在类型巨细却是不相同的,这个状况编译器会怎样处理呢?首要咱们看下Drawable类型的巨细:

MemoryLayout<Drawable>.size // 40 bytes

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

Swift Protocol 探究

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

  • 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上的创立、复制和毁掉

Swift Protocol 探究

这是官方给出的VWT的结构图,很好的阐明了是用来办理目标的生命周期, 而VWT则是经过Existential Container中的MetaType间接获取的,而终究获取的VWT在内存结构上跟官方给出的有些许不同,但作用都是共同的,大约长这姿态:

Swift Protocol 探究

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

  • copy

    • 用于在复制Existential Container时,决议ValueBuffer的内容复制的办法地址。
    • 假如在目标直接寄存在ValueBuffer上,则改copy办法为__swift_memcpy,将内容直接复制到新的ValueBuffer上。
    • 假如在目标寄存在堆上,则改copy办法为initializeBufferWithCopyOfBuffer,将新复制的指针寄存ValueBuffer上。
  • flag

    • 这个flag值决议了目标怎样寄存在ValueBuffer上的标志,在目标类型巨细不超越24bytes时为0x7,而超越24bytes时则为0x20007。

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

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则运用同一个引证。

Swift Protocol 探究

然后以Line类型为例:

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也是运用同一个引证。

Swift Protocol 探究

从复制办法能够看出,苹果为了功能考虑,避免复制时频繁创立新的目标,所以才把堆目标变成一个Class结构,但相同不丢失struct结构Copy-on-Write的特性,会在修改内容时经过isKnownUniquelyReferenced办法判别引证数来决议是否发生复制。

到这儿咱们能够考虑下Swift中的Any是怎样完成?是否会创立一个AnyContainer的中心类型


汇编剖析流程

经过上面的介绍大家基本能够了解Swift中Protocol完成的原理,下面从汇编角度去看看详细的完成细节(毕竟代码不会骗咱们),从三行代码来剖析详细的汇编逻辑:

let point = Point(x: 3, y: 3) // 创立Point目标
let shape: Drawable = point // 转成Drawable目标
shape.draw() // 调用draw办法
  • 创立Point目标

Swift Protocol 探究

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

  • 创立Drawable目标,并获取实在目标的地址

Swift Protocol 探究

这儿我停在了跳转指令上,这儿是跳转到__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中获取实在目标地址的一个通用函数,详细看看怎样做的:

Swift Protocol 探究

首要看到x0寄存了Existential Container的地址,x1MetaData的地址,经过MetaData地址加偏移找到了FullMetaData地址,终究找到VWT地址。从VWT地址加偏移找到了flag的值(便是上面提到决议ValueBuffer存储办法的flag),终究比较第0x11位是否为0知道了目标是直接寄存在ValueBuffer上,所以Existential Container的地址便是目标地址,就直接返回了x0

  • 调用draw办法

Swift Protocol 探究

这儿终究经过PWT调用了对应的函数,并把实在目标经过x20进行传参(Existential Container的地址便是目标地址)。

  • 毁掉Drawable目标

Swift Protocol 探究

终究经过__swift_destroy_boxed_opaque_existential_1函数进行Existential Container的内存开释。这个函数跟__swift_project_boxed_opaque_existential_1函数的逻辑很类似,都是获取VWT对应的flag值,发现目标是在ValueBuffer上直接回收栈内存就好了。这个调用逻辑也就结束了。

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

Swift Protocol 探究

关于经过_swift_project_boxed_opaque_existential_1获取目标地址的流程图如下:

Swift Protocol 探究

关于经过_swift_project_boxed_opaque_existential_1毁掉目标地址的流程图如下:

Swift Protocol 探究

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

and w10 flag, #0x20000

cbnz w0, xxxxxx

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

tbnz w0, 0x11

能够看到苹果在功能方面很契合寻求极致(狗头

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

let line = Line(
    x1: 2, y1: 2,
  x2: 6, y2: 6
) // 创立Line目标
let shape: Drawable = line // 转成Drawable目标
shape.draw() // 调用draw办法
  • 创立Drawable目标,并获取实在目标的地址

Swift Protocol 探究

经过汇编能够发现Line初始化完后会调用swift_allocObject函数创立一个堆目标,这也是Line在堆上的副本。

Swift Protocol 探究

经过控制台打印相关寄存器和内存的信息,x0便是Existential Container的地址,榜首个8字节便是指向堆上Line目标的指针,在堆上的Line目标是一个Class结构。

经过PWT调用对应的办法时需求传入实在目标,所以这儿仍是经过__swift_project_boxed_opaque_existential_1这个通用函数去获取

Swift Protocol 探究

这儿相同仍是经过VWT去获取flag值,发现是0x20007,然后判别第0x11位不为0,跑到下面部分去读取指针地址,从而获取到实在目标的地址,并且再偏移16个字节跳过Class结构中的TypeRefCount字段。

  • 毁掉Drawable目标

Swift Protocol 探究

__swift_destroy_boxed_opaque_existential_1函数相同是获取VWT的flag值后,知道需求去回收在堆上的Line目标

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

Swift Protocol 探究

关于经过_swift_project_boxed_opaque_existential_1获取目标地址的流程图如下:

Swift Protocol 探究

关于经过_swift_project_boxed_opaque_existential_1毁掉目标地址的流程图如下:

Swift Protocol 探究

终究再来看看复制一个Protocol目标的流程:

let point = Point(x: 3, y: 3) // 创立Point目标
let shape1: Drawable = point // 转成Drawable目标
let shape2: Drawable = shape1 // 复制Drawable目标

Swift Protocol 探究

这儿x0便是Existential Container的地址,经过init with copy of Drawable函数来复制。

Swift Protocol 探究

进入函数后能够发现是找存储在VWT上的榜首函数地址去履行复制的,由于Point是在栈上,所以对应的copy函数便是__swift_memcpy,对应的Line是需求放在堆上的,所以对应Line的VWT的copy函数便是initializeBufferWithCopyOfBuffer,这个大家自己去验证好了。

Copy调用的流程图如下:

Swift Protocol 探究


Generic Type

上面咱们了解到Protocol的作业原理,接下来咱们来看看Protocol合作泛型时编译器会怎样处理,剖析一下比如:

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并调用,看下详细的汇编代码:

Swift Protocol 探究

Swift Protocol 探究

这儿发现跟前面的汇编代码不太一样,这儿并没有生成Existential Container。首要原因是泛型函数归于编译期多态,编译时假如编译器有满足信息能推导出实在类型,那就会进行代码优化,直接调用对应类型的PWT函数,所以经过泛型的办法能够削减内存的开销和削减调用的指令,但同时泛型特化或许会伴随代码体积的增大的风险。

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

Swift Protocol 探究

为了能看到优化作用,把mainadd函数都标记了@inline(never),让断点能收效,终究生成的汇编代码只需简略的几条指令:

Swift Protocol 探究


References

Whole-Module Optimization

swift witness table

Understanding Swift Performance