协议的基本语法
定义协议
在Swift开发中通过关键字Protocol来声明一个协议。
//定义一个协议
protocol OSProtocolOne {
}
协议中可以声明方法。
protocol OSProtocolOne {
//声明方法
func func1()
}
协议中也可以定义属性,但必须是(get)、( get set)类型的,并且必须是变量即只能用var修饰。
protocol OSProtocolOne {
func func1()
var age: Int { get }
}
注意⚠️:协议中允许定义构造函数,如果是类遵循协议,构造函数需要使用关键字request修饰,或者该累使用final修饰。
遵循协议
Swfit中class、struct、enum都可以遵循协议,如果遵循多个协议,可以使用逗号隔开,也可以分成多个Extention来书写。 遵循协议后必须实现协议中定义的方法和属性。
class OSComputer: OSProtocolOne {
func func1() {
print(#function)
}
var age: Int = 0
}
extension OSComputer: OSProtocolTwo {
}
和OC交互
如果OC类需要遵循协议,需要在协议前面增加关键字@objc,如果想使用OC的optionl的属性,需要在方法或者属性添加关键字@objc optional。
@objc protocol OSProtocolOne {
//可选的
@objc optional func func1()
var age: Int { get }
}
这样我们就定义了一个可以和OC交互的协议了。
协议方法的调度
SIL分析
通过前文方法,了解到了方法的存储方式是通过V-table来存储的,这里我们用同样的方式来探索协议中的方法调度方式。 编辑如下代码,并编译成SIL语言。
//sil编译命令
swiftc -emit-sil fileName >outputFileName
protocol OSProtocolTest {
func func11()
}
class OSProtoclProduct: OSProtocolTest{
//协议的方法
func func11() {
print("11")
}
//OSProtoclProduct类的方法,在V-table中。
func func12() {
print(12)
}
}
func enter() {
let product = OSProtoclProduct()
product.func11()
product.func12()
}
在SIL中找到enter函数对应的代码。
在SIL官方文档中查找
class_method的相关资料。
可以看到,
class_method是通过VTables来调用的。而在SIL语言中也可以找到对应vtable中func11的定义,如下图:
如果我们在定义product的时候,声明product的类型为OSProtocolTest,同样的方式来查看调用。 修改代码如下:
func enter() {
let product: OSProtocolTest = OSProtoclProduct()
product.func11()
//func12不在OSProtocolTest的定义内,所以不能被调用。
// product.func12()
}
编译成SIL语言后,可以看到调用方式发生了改变,这次使用了witness_method.
在SIL官方文档中找到
witness_method的定义:
翻译比较难理解。
大致意思是,去遵循协议的类型中去找该方法的实现,会使用witness_method方式调度,如果协议使用@objc修饰,会变成objc的调用方式。
继续看SIL语言中:
这里的
witness_table中也有一个函数定义,大家都叫witness可以大胆猜测,witness_method是去witness_table中去寻找实现。而在witness_table的定义中也可以证实这一猜想。
那么我们继续看编译后的SIL语言中关于witnss_table中fun11的定义:
这个方法里面然后再通过
class_method去调用vtable中的fun11。
小结:如果实例类型是真正类型,方法通过
vtable调度,如果实例类型为协议类型,方法需要通过witness_table包装(或者说桥接)一层再去vtable调度。
注意⚠️:
witness_table和类是一一对应的。如果多个类遵循同一个协议,将会有多个witness_table。- 父类遵循协议,子类也会有对应的
witness_table。
汇编分析
除了SIL分析之外,我们还可以通过汇编代码调试来分析协议方法调度的过程。首先在函数调度的代码行下断点,然后打开汇编调试。
打开汇编调试 xcode -> Debug -> Debug Workflow -> Always Show Disassemple 。
汇编中的函数调用指令为 blr ,我们主要看blr 寄存器中的内容。汇编调试过程中可以通过Contro健+点击⬇️来控汇编代码逐行执行(Xcode13中),Xcode12可以直接下断点调试。注意:本次汇编分析的是arm架构,如果不是M1芯片的电脑,需要使用真机。
执行到上图的时候,继续Contro健+点击⬇️,就可以进入函数内部,可以看到这个函数就是
witness-table中的func11。
而汇编代码中blr只有一句,这一句也就对应来SIL中class_mathod func11,即调用vtable中的func11。
通过打印x8寄存器,可以看到这里的的确确是调用了func11函数。
分类实现协议方法
协议可以通过extention的方式去实现定义的方法,实现之后,遵循协议的类可以不再实现该方法。
protocol OSProtocolTest {
func func11()
}
extension OSProtocolTest {
func func11() {
print("11 from protocol extension")
}
}
class OSProtoclProduct: OSProtocolTest{
//协议的方法
// func func11() {
// print("11")
// }
//OSProtoclProduct类的方法,在V-table中。
func func12() {
print(12)
}
}
如果遵循协议的类,和协议的分类都实现了协议方法,会优先调用类中的方法。如果协议中没有声明方法,调用时需要看实例的具体类型。
protocol OSProtocolTest {
}
extension OSProtocolTest {
func func11() {
print("11 from protocol extension")
}
}
class OSProtoclProduct: OSProtocolTest{
//协议的方法
func func11() {
print("11 from OSProtoclProduct")
}
//OSProtoclProduct类的方法,在V-table中。
func func12() {
print(12)
}
}
执行结果:
可以大致看一下编译后的SIL语言。
作为静态语言,swift编译器在决定函数调用的时候,会根据静态类型调用函数。
协议的结构
类型还原
通过上面的例子,我们发现静态类型不同,会影响函数调用,是不是会影响实例对象的结构呢?带着疑问我们继续探索:
func func3() {
let product1:OSProtoclProduct = OSProtoclProduct()
print(MemoryLayout.size(ofValue: product1))
let product2: OSProtocolTest = OSProtoclProduct()
print(MemoryLayout.size(ofValue: product2))
}
惊呆了,老铁!静态类型不同,居然会影响内存中的大小。product1的8字节很好理解,是实例对象的地址。
那我们就再分析下这个40字节的内容了。
| 位置 | 内容 |
|---|---|
| 第0个8字节 | 实例对象的地址 |
| 第1个8字节 | 未知 |
| 第2个8字节 | 未知 |
| 第3个8字节 | 实例对象的metadate |
| 第4个8字节 | witnesstable |
得到最最粗略的结构:
struct OSProtocolBox {
var heapObject: UnsafeRawPointer
var unkonw1: UnsafeRawPointer
var unkonw2: UnsafeRawPointer
var metadata: UnsafeRawPointer
var witnessTable: UnsafeRawPointer
}
经过一系列复杂的探究(源码、IL代码、查资料等方式)这里不做赘述,终于有了这个所有的结构。
struct OSProtocolBox {
var heapObject: UnsafeRawPointer
var unkonw1: UnsafeRawPointer
var unkonw2: UnsafeRawPointer
var metadata: UnsafeRawPointer
var witnessTable: UnsafeMutablePointer<TargetWitnessTable>
}
struct TargetWitnessTable{
var protocol_conformance_descriptor: UnsafeMutablePointer<TargetProtocolConformanceDescriptor>
var witnessMethod: UnsafeRawPointer
}
struct TargetProtocolConformanceDescriptor{
var protocolDesc: TargetRelativeDirectPointer<TargetProtocolDescriptor>
var typeRef: UnsafeRawPointer
var WitnessTablePattern: UnsafeRawPointer
var flags: UInt32
}
struct TargetProtocolDescriptor
{
var flags: UInt32
var parent: TargetRelativeDirectPointer<UnsafeRawPointer>
var Name: TargetRelativeDirectPointer<CChar>
var NumRequirementsInSignature: UInt32
var NumRequirements: UInt32
var AssociatedTypeNames: TargetRelativeDirectPointer<CChar>
}
重新绑定
有了结构之后,我们通过内存重新绑定,可以动态获取到协议的更多信息了。
withUnsafePointer(to: &product2) { ptr in
ptr.withMemoryRebound(to: OSProtocolBox.self, capacity: 1) { ptr1 in
let name = ptr1.pointee.witnessTable.pointee.protocol_conformance_descriptor.pointee.protocolDesc.getmeasureRelativeOffset().pointee.Name.getmeasureRelativeOffset()
print(String(cString: name))
}
}
可以看到协议的名称,同样的方法还可以打印出遵循协议的方法地址。
可以通过
$ nm-p mach-o | grep 函数地址
$ xcrun swift-demangle <string>
来证实。 也可以通过汇编调试的打印来证实。
可以看到两个地址是一样的。
Existential Container
上面的OSProtocolBox其实是存在容器, Existential Container 是编译器生成的一种特殊的数据类型,用于管理遵守了相同协议的协议类型,因为这些类型的内存大小不一致,所以通过当前的 Existential Container 统一管理。也就是说不管是class、struct、enum,只要遵循了协议,就会由这个Existential Container来统一管理他们的内存。
valueBuffer
我们使用一个结构体来遵循协议,来看一看Existential Container的变化。
struct OSProtocolCoder: OSProtocolTest {
var age: Int = 20
var level: Int = 2
}
func func4() {
var coder: OSProtocolTest = OSProtocolCoder()
print(coder)
}
可以看到这里把结构体的属性的值存在了第0个8字节,和第1个8字节。那就想知道如果有更多个属性会有什么样的变化。
struct OSProtocolCoder: OSProtocolTest {
var age: Int = 20
var level: Int = 2
var leve11: Int = 2
var level2: Int = 2
var level3: Int = 2
}
好家伙,这是存不下了,在堆区开辟地址去存了。
总结:Existential Container的前24个字节为Value Buffer 对于小容量的数据(小于24字节),直接存储在 Value Buffer中, 对于大容量的数据,则通过堆区分配存储堆空间的地址。
更新存在容器(ExistentialContainer)数据结构:
struct OSProtocolBox {=
var valueBuffer1: UnsafeRawPointer
var valueBuffer2: UnsafeRawPointer
var valueBuffer3: UnsafeRawPointer
var metadata: UnsafeRawPointer
var witnessTable: UnsafeMutablePointer<TargetWitnessTable>
}
写时复制
可以看到,在给level复制的时候,对应堆区的对象发生了改变。这里是系统为了降低堆区内存消耗所做的优化。当valueBuffer不能存储所有属性的时候,会把结构体在堆区开辟空间存储为引用类型,但在变量发生改变的时候,会去判断应用计数是否大于1,如果大于1,将在堆区重新开辟空间来存储改变后的结构体。