zl程序教程

您现在的位置是:首页 >  其他

当前栏目

Swift — 协议(Protocol)

2023-03-20 14:57:24 时间

1. 前言


协议定义了一个蓝图,规定了用来实现某一特定任务或者功能的方法、属性,以及其他需要的东西。类、结构体和枚举都可以遵循协议,并为协议定义的这些要求提供具体实现。某个类型能够满足某个协议的要求,就可以说该类型遵循这个协议。

除了遵循协议的类型必须实现的要求外,还可以对协议进行扩展,通过扩展来实现一部分要求或者实现一些附加功能,这些遵循协议的类型就能够使用这些功能。

2. 协议的基本用法


▐ 2.1 协议语法

协议的定义方式与类、结构体和枚举的定义非常相似

1、基本语法

protocol SomeProtocol {
    // 这里是协议的定义部分
}

2、如果让自定义的类型遵循某个协议,在定义类型时,需要在类型名称后面加上协议名称,中间以冒号(:)隔开,如果需要遵循多个协议时,个协议之间用逗号(,)分割:

struct SomeStructure: FirstProtocol, AnotherProtocol {
    // 这里是结构体的定义部分
}

3、如果自定义类型拥有一个父类,应该将父类名放在遵循协议名之前,以逗号分隔:

class SomeClass: SomeSuperClass, FirstProtocol, AnotherProtocol {
    // 这里是类的定义部分
}

▐ 2.2 属性要求

我们可以在协议中添加属性,但需要注意以下几点:

  1. 属性可以是实例属性和类型属性
  2. 属性需要使用 var 修饰,不能属于 let
  3. 类型属性只能使用 static 修饰,不能使用 class
  4. 我们需要声明属性必须是可读的或者可读可写的
protocol SomeProtocol {
    var propertyOne: Int { get set }
    var propertyTwo: Int { get }
    static var propertyThree: Int { get set }
}

▐ 2.3 方法要求

我们可以在协议中添加方法,但需要注意以下几点:

  1. 可以是实例方法或类方法
  2. 像普通方法一样放在协议定义中,但不需要大括号和方法体
  3. 协议中不支持为协议中的方法提供默认参数
  4. 协议中的类方法也只能使用 static 关键字作为前缀,不能使用 class
  5. 可以使用 mutating 提供异变方法,以使用该方法时修改实体的属性等
  6. 可以定义构造方法,但是使用的时候需要使用 required 关键字
protocol SomeProtocol {
    func someMethod1()
    func someMethod2() ->Int
}

构造方法

protocol SomeProtocol {
    init(param: Int)
}

class SomeClass: SomeProtocol {
    required init(param: Int) { }
}

异变方法

protocol Togglable {
    mutating func toggle()
}

enum OnOffSwitch: Togglable {
    case off, on
    mutating func toggle() {
        switch self {
        case .off:
            self = .on
        case .on:
            self = .off
        }
    }
}

▐ 2.4 协议作为类型

尽管协议本身并未实现任何功能,但是协议可以被当做一个功能完备的类型来使用。协议作为类型使用,有时被称作「存在类型」,这个名词来着存在着一个类型T,该类型遵循协议T。

协议可以像其他普通类型一样使用,使用场景如下:

  • 作为函数、方法或构造器中的参数类型或返回值类型
  • 作为常量、变量或属性的类型
  • 作为数组、字典或其他容器中的元素类型
protocol SomeProtocol { }

class SomeClass {
    required init(param: SomeProtocol) {}
}

▐ 2.5 其他

  • 协议还可以被继承
  • 可以在扩展里面遵循协议
  • 在扩展里面声明采纳协议
  • 使用合成来采纳协议
  • 可以定义由类专属协议,只需要继承自AnyObject
  • 协议可以合成
  • 协议也可以扩展

3. 协议中方法的调用


举个例子,在数学中我们会求某个图形的面积,但是不同形状求面积的公式是不一样的,如果用代码来实现可以怎么来实现呢?

首先我们可以通过继承父类的方法来实现,但是在这里我们就可以使用协议来实现:

protocol Shape {
    var area: Double {get}
}

class Circle: Shape{
    var radius: Double
   
    init(_ radius: Double) {
        self.radius = radius
    }
    
    var area: Double{
        get{
            return radius * radius * 3.14
        }
    }
}
class Rectangle: Shape{
    var width, height: Double
    init(_ width: Double, _ height: Double) {
        self.width = width
        self.height = height
    }
    
    var area: Double{
        get{
            return width * height
        }
    }
}

var circle: Shape = Circle.init(10.0)
var rectangle: Shape = Rectangle.init(10.0, 20.0)

print(circle.area)
print(rectangle.area)

<!--打印结果-->
314.0
200.0

此时的打印结果是符合我们的预期的。

我们知道协议可以扩展,此时我们把协议的代码修改成如下:

protocol Shape {
//    var area: Double {get}
}
extension Shape{
    var area: Double {
        get{return 0.0}
    }
}

<!--打印结果-->
0.0
0.0

此时并没有如我们预期的打印,如果我们声明变量的时候写成如下呢:

var circle: Circle = Circle.init(10.0)
var rectangle: Rectangle = Rectangle.init(10.0, 20.0)

<!--打印结果-->
314.0
200.0

此时的打印就符合我们的预期了。

其实我们也能够清楚的了解到为什么会打印 0.0,在 Swift 方法调度这篇文章中我们介绍了 extension 中声明的方法是静态调用的,也就是说在编译后当前代码的地址已经确定,我们无法修改,当声明为 Shap 类型后,默认调用的就是 Shape extension 中的属性的 get 方法。下面我们在通过sil代码来验证一下,关于生成 sil 代码的方法,请参考我以前的文章。

为了方便查看,我们精简并修改代码为如下:

protocol Shape {//    var area: Double {get}}extension Shape{    var area: Double {        get{return 0.0}    }}class Circle: Shape{    var radius: Double       init(_ radius: Double) {        self.radius = radius    }        var area: Double{        get{            return radius * radius * 3.14        }    }}var circle: Shape = Circle.init(10.0)var a = circle.area

生成的 sil 代码:

通过 sil 代码我们可以清晰的看到,这里直接调用的 Shape.area.getter 方法。

下面我们换一些简单的代码再次看一下:

protocol PersonProtocol {
    func eat()
}
extension PersonProtocol{
    func eat(){ print("PersonProtocol eat") }
}
class Person: PersonProtocol{
    func eat(){ print("Person eat") }
}
let p: PersonProtocol = Person()
p.eat()
let p1: Person = Person()
p1.eat()

<!--打印结果-->
Person eat
Person eat

可以看到上面这段代码的打印结果都是 Person eat,那么为什么会打印相同的结果呢?首先通过代码我们可以知道,在PersonProtocol中声明了eat方法。对于声明的协议方法,如果类中也实现了,就不会调用协议扩展中的方法。上面的属性的例子中并没有在协议中声明属性,只是在协议扩展中添加了一个属性。下面我们看看上面这段代码的sil代码:

首先我们可以看到,对于两个 eat 方法的确实存在不同,首先声明为协议类型的变量调用 eat 方法是通过 witness_method 调用,另一个则是通过 class_method调用。

  • witness_method是通过PWT(协议目击表)获取对应的函数地址
  • class_method是通过类的函数表来查找函数进行调用

在刚刚 sil 代码中我们可以找到 sil_witness_table,在里面有 PersonProtocol.eat方法,找到 PersonProtocol.eat 方法可以发现里面是调用 class_method 寻找的类中 VTable 的 Person.eat 方法。

如果我们不在协议中声明 eat 方法:

protocol PersonProtocol {
//    func eat()
}
extension PersonProtocol{
    func eat(){ print("PersonProtocol eat") }
}
class Person: PersonProtocol{
    func eat(){ print("Person eat") }
}
let p: PersonProtocol = Person()
p.eat()
let p1: Person = Person()
p1.eat()

<!--打印结果-->
PersonProtocol eat
Person eat

查看 sil 代码:

此时我们可以看到,对于不在协议中声明方法的时候,依然是直接调用(静态调用)。

所以对于协议中方法的调度:

  • 对于不在协议中声明的方法
    • 在协议扩展中有实现就是直接调用
    • 在遵循协议的实体中按照其调度方式决定
    • 两处都实现了,声明的实例是协议类型则直接调用协议扩展中的方法,反之调用遵循协议实体中的方法
  • 对于声明在协议中的方法
    • 如果遵循该协议的实体实现了该方法,则通过PWT协议目击表查找到实现的方法进行调用(与声明变量的类型无关)
    • 如果遵循协议的实体没实现,协议扩展实现了,则会调用协议扩展中的方法

4. 协议原理探索


在上面探索协议中的方法调用的时候,我们提到过 PWT 也就是 Protocol witness table,协议目击表,那么它存储在什么地方呢?我们在 Swift 方法调度这篇文章中讲过,V-Table 是存储在 metadata 中的,那么我们就探索一下 PWT 的存储位置。

▐ 4.1 内存占用

首先我们先来看看如下代码的的打印结果:

protocol Shape {
    var area: Double { get }
}
class Circle: Shape {
    var radius: Double

    init(_ radius: Double) {
        self.radius = radius
    }

    var area: Double{
        get{ return radius * radius * 3.14 }
    }
}

var circle: Shape = Circle(10.0)
print(MemoryLayout.size(ofValue: circle))
print(MemoryLayout.stride(ofValue: circle))

var circle1: Circle = Circle(10.0)
print(MemoryLayout.size(ofValue: circle1))
print(MemoryLayout.stride(ofValue: circle1))

<!--打印结果-->
40
40
8
8

▐ 4.2 lldb探索内存结构

看到这个打印结果我能第一时间想到的就是生命为协议类型会存储更多的信息。生命为类的时候,存储的是类的实例对象的指针 8 字节。下面我们通过 lldb 调试来探索一下这个 40 字节都存储了什么信息。

▐ 4.3 sil 探索内存结构

通过 lldb 我们可以看到其内部应该存储着一些信息,那么具体存了什么呢?我们在看看 sil 代码:

在sil代码中我们可以看到,在初始化 circle 这个变量的时候使用到了 init_existential_addr,查看SIL文档:

译文:用一个准备好包含类型为 $T 的存在容器部分初始化 %0 引用的内存。该指令的结果是一个地址,该地址引用了所包含值的存储空间,该存储空间仍然没有初始化。包含的值必须存储为 -d 或 copy_addr-ed,以便完全初始化存在值。如果存在容器的值未初始化时需要销毁,则必须使用 deinit_existential_addr 来完成此操作。可以像往常一样使用 destroy_addr 销毁完全初始化的存在性容器。销毁一个部分初始化存在容器的addr是未定义的行为。

文档中的意思是,使用了包含 $T 的 existential container 来初始化 %0 引用的内存。在这里就是使用包含 Circle 的 existential container 来初始化 circle 引用的内存,简单来说就是将 circle 包装到了一个 existential container 初始化的内存。

existential container 是编译器生成的一种特殊的数据类型,也用于管理遵守了相同协议的协议类型。因为这些塑化剂类型的内存空间尺寸不同,使用 existential container 进行管理可以实现存储一致性。

▐ 4.4 IR代码探索内存结构 那么这个 existential container 都包装了什么呢?目前通过sil代码是看不出来什么了,那么我们就看看 IR 代码:

; 一个结构体,占用24字节内存的数组,wift.type指针, i8*指针
%T4main5ShapeP = type { [24 x i8], %swift.type*, i8** }

define i32 @main(i32 %0, i8** %1) #0 {
entry:
  %2 = bitcast i8** %1 to i8*
  ; main.Circle 的 metadata
  %3 = call swiftcc %swift.metadata_response @"type metadata accessor for main.Circle"(i64 0) #7
  %4 = extractvalue %swift.metadata_response %3, 0
  ;init放
  %5 = call swiftcc %T4main6CircleC* @"main.Circle.__allocating_init(Swift.Double) -> main.Circle"(double 1.000000e+01, %swift.type* swiftself %4)
  ; 存%4 也就是metadata,存到T4main5ShapeP结构体中,这里存的位置是第二个位置
  store %swift.type* %4, %swift.type** getelementptr inbounds (%T4main5ShapeP, %T4main5ShapeP* @"main.circle : main.Shape", i32 0, i32 1), align 8
  ; 存pwt 也就是协议目击表,存到第三个位置
  store i8** getelementptr inbounds ([2 x i8*], [2 x i8*]* @"protocol witness table for main.Circle : main.Shape in main", i32 0, i32 0), i8*** getelementptr inbounds (%T4main5ShapeP, %T4main5ShapeP* @"main.circle : main.Shape", i32 0, i32 2), align 8
  ; 存放%5到二级指针,%5是init出来的对象,所以这里也就是个HeapObject结构,也就是T4main6CircleC结构体的第一个8字节内存空间处
  store %T4main6CircleC* %5, %T4main6CircleC** bitcast (%T4main5ShapeP* @"main.circle : main.Shape" to %T4main6CircleC**), align 8
}

从 IR 代码中我们可以知道,这里面的存储是一个结构体,结构体中主要分为三个方面:

  1. 一个连续的24字节空间
  2. 一个存放metadata的指针
  3. 存放pwt指针

▐ 4.5 仿写

下面我们就来仿写一下这个结构:

struct HeapObject {
    var type: UnsafeRawPointer
    var refCount1: UInt32
    var refCount2: UInt32
}

struct protocolData {
    //24 * i8 :因为是8字节读取,所以写成3个指针
    var value1: UnsafeRawPointer
    var value2: UnsafeRawPointer
    var value3: UnsafeRawPointer
    //type 存放metadata,目的是为了找到Value Witness Table 值目录表
    var type: UnsafeRawPointer
    // i8* 存放pwt指针
    var pwt: UnsafeRawPointer
}

4.5.1 类遵循协议重绑定

进行内存的重新绑定:

protocol Shape {
    var area: Double { get }
}
class Circle: Shape {
    var radius: Double

    init(_ radius: Double) {
        self.radius = radius
    }

    var area: Double{
        get{ return radius * radius * 3.14 }
    }
}

var circle: Shape = Circle(10.0)

// 将circle强转为protocolData结构体
withUnsafePointer(to: &circle) { ptr in
    ptr.withMemoryRebound(to: protocolData.self, capacity: 1) { pointer in
        print(pointer.pointee)
    }
}

<!--打印结果-->
protocolData(value1: 0x00000001006082b0, value2: 0x0000000000000000, value3: 0x0000000000000000, type: 0x0000000100008180, pwt: 0x0000000100004028)

lldb

通过lldb查看:

我们也可以看到对应HeapObject结构

该结构存储的是Circle的实例变量

并且在这里面的metadataprotocolData里面的存储的metadata的地址是一致的;

通过cat address命令查看pwt对应的指针,可以看到这段内存对应的就是SwiftProtocol.Circleprotocol witness table

至此我们就清楚的找到你了PWT的存储位置,PWT存在协议类型实例的内存结构中。

4.5.2 结构体遵循协议重绑定

在上面这个例子中我们使用的是类,我们知道类是引用类型,如果换成结构体呢?

protocol Shape {
    var area: Double {get}
}
struct Rectangle: Shape{
    var width, height: Double
    init(_ width: Double, _ height: Double) {
        self.width = width
        self.height = height
    }

    var area: Double{
        get{
            return width * height
        }
    }
}

var rectangle: Shape = Rectangle(10.0, 20.0)


struct HeapObject {
    var type: UnsafeRawPointer
    var refCount1: UInt32
    var refCount2: UInt32
}


struct protocolData {
    //24 * i8 :因为是8字节读取,所以写成3个指针
    var value1: UnsafeRawPointer
    var value2: UnsafeRawPointer
    var value3: UnsafeRawPointer
    //type 存放metadata,目的是为了找到Value Witness Table 值目录表
    var type: UnsafeRawPointer
    // i8* 存放pwt指针
    var pwt: UnsafeRawPointer
}

// 将circle强转为protocolData结构体
withUnsafePointer(to: &rectangle) { ptr in
    ptr.withMemoryRebound(to: protocolData.self, capacity: 1) { pointer in
        print(pointer.pointee)
    }
}

<!--打印结果-->
protocolData(value1: 0x4024000000000000, value2: 0x4034000000000000, value3: 0x0000000000000000, type: 0x0000000100004098, pwt: 0x0000000100004028)

此时我们可以看到,此时并没有存储一个HeapObject结构的指针,而是直接存储Double类型的值,metadatapwt没有变。

在看下IR代码:

define i32 @main(i32 %0, i8** %1) #0 {
entry:
  %2 = bitcast i8** %1 to i8*
  %3 = call swiftcc { double, double } @"main.Rectangle.init(Swift.Double, Swift.Double) -> main.Rectangle"(double 1.000000e+01, double 2.000000e+01)
  ; 10
  %4 = extractvalue { double, double } %3, 0
  ; 20
  %5 = extractvalue { double, double } %3, 1
  ;metadata
  store %swift.type* bitcast (i64* getelementptr inbounds (<{ i8**, i64, <{ i32, i32, i32, i32, i32, i32, i32 }>*, i32, i32 }>, <{ i8**, i64, <{ i32, i32, i32, i32, i32, i32, i32 }>*, i32, i32 }>* @"full type metadata for main.Rectangle", i32 0, i32 1) to %swift.type*), %swift.type** getelementptr inbounds (%T4main5ShapeP, %T4main5ShapeP* @"main.rectangle : main.Shape", i32 0, i32 1), align 8
  ;pwt
  store i8** getelementptr inbounds ([2 x i8*], [2 x i8*]* @"protocol witness table for main.Rectangle : main.Shape in main", i32 0, i32 0), i8*** getelementptr inbounds (%T4main5ShapeP, %T4main5ShapeP* @"main.rectangle : main.Shape", i32 0, i32 2), align 8
  ;存%4 也就是10
  store double %4, double* getelementptr inbounds (%T4main9RectangleV, %T4main9RectangleV* bitcast (%T4main5ShapeP* @"main.rectangle : main.Shape" to %T4main9RectangleV*), i32 0, i32 0, i32 0), align 8
  ; 存%5 也就是20
  store double %5, double* getelementptr inbounds (%T4main9RectangleV, %T4main9RectangleV* bitcast (%T4main5ShapeP* @"main.rectangle : main.Shape" to %T4main9RectangleV*), i32 0, i32 1, i32 0), align 8
}

通过IR代码我们可以看到:

  • 对于metadatapwt的存储依旧
  • 然后存储了两个Double值,并没有存储HeapObject类型的指针

那么如果有3个属性呢?

struct Rectangle: Shape{
    var width, width1, height: Double
    init(_ width: Double, _ width1: Double, _ height: Double) {
        self.width = width
        self.width1 = width1
        self.height = height
    }

    var area: Double{
        get{
            return width * height
        }
    }
}

<!--内存绑定后的打印结果-->
protocolData(value1: 0x4024000000000000, value2: 0x4034000000000000, value3: 0x403e000000000000, type: 0x0000000100004098, pwt: 0x0000000100004028)

这个三个Value的值分别是10,20,30

那如果是4个呢?

struct Rectangle: Shape{
    var width, width1, height, height1: Double
    init(_ width: Double, _ width1: Double, _ height: Double, _ height1: Double) {
        self.width = width
        self.width1 = width1
        self.height = height
        self.height1 = height1
    }

    var area: Double{
        get{
            return width * height
        }
    }
}

var rectangle: Shape = Rectangle(10.0, 20.0, 30.0, 40.0)

<!--内存绑定后的打印结果-->
protocolData(value1: 0x0000000100715870, value2: 0x0000000000000000, value3: 0x0000000000000000, type: 0x00000001000040c0, pwt: 0x0000000100004050)

此时并没有直接看到Double值了,查看value1的内存:

此时我们可以看到,这个内存中存储了 10,20,30,40 这四个值。

所以如果我们需要存储的数据超过了 24 x i8*,也就是 24 字节时,就会开辟内存空间进行存储。这里只存储指向新开辟内存空间的指针。

这里的顺序是,如果不够存储就直接开辟内存空间,存储值,记录指针。而不是先存储不够了在开辟内存空间。

我们都知道,结构体是值类型,如果超过这 24 字节的存储空间就会开辟内存用来存储结构体中的值,如果此时发生拷贝会是神马结构呢?下面我们就来验证一下:

结构体拷贝:

protocol Shape {
    var area: Double {get}
}
struct Rectangle: Shape{
    var width, width1, height, height1: Double
    init(_ width: Double, _ width1: Double, _ height: Double, _ height1: Double) {
        self.width = width
        self.width1 = width1
        self.height = height
        self.height1 = height1
    }

    var area: Double{
        get{
            return width * height
        }
    }
}

var rectangle: Shape = Rectangle(10.0, 20.0, 30.0, 40.0)
var rectangle1 = rectangle

struct HeapObject {
    var type: UnsafeRawPointer
    var refCount1: UInt32
    var refCount2: UInt32
}


struct protocolData {
    //24 * i8 :因为是8字节读取,所以写成3个指针
    var value1: UnsafeRawPointer
    var value2: UnsafeRawPointer
    var value3: UnsafeRawPointer
    //type 存放metadata,目的是为了找到Value Witness Table 值目录表
    var type: UnsafeRawPointer
    // i8* 存放pwt指针
    var pwt: UnsafeRawPointer
}

// 内存重绑定
withUnsafePointer(to: &rectangle) { ptr in
    ptr.withMemoryRebound(to: protocolData.self, capacity: 1) { pointer in
        print(pointer.pointee)
    }
}

withUnsafePointer(to: &rectangle1) { ptr in
    ptr.withMemoryRebound(to: protocolData.self, capacity: 1) { pointer in
        print(pointer.pointee)
    }
}

<!--打印结果-->
protocolData(value1: 0x000000010683bac0, value2: 0x0000000000000000, value3: 0x0000000000000000, type: 0x00000001000040c0, pwt: 0x0000000100004050)
protocolData(value1: 0x000000010683bac0, value2: 0x0000000000000000, value3: 0x0000000000000000, type: 0x00000001000040c0, pwt: 0x0000000100004050)

此时我们看到打印结果是一样的。

那么修改呢?

添加如下代码:

protocol Shape {
    // 为了方便修改,在这声明一下
    var width: Double {get set}
    var area: Double {get}
}

rectangle1.width = 50

通过 lldb 重新打印,我们可以看到在修改值后,内存地址已经修改了,此时就是写时复制。当复制时并没有值的修改,所以两个变量指向同一个堆区内存。当修改变量的时候,会原本的堆区内存的值拷贝到一个新的内存区域,并进行值的修改。

如果我们将 struct 修改成 class,这里并不会触发写时复制,因为在 Swift 中类是引用类型,修改类的值就是修改其引用地址中的值。这里就不验证了,感兴趣的可以自己去试试。

如果我们将 Double 换成 String 原理也是一致的,这里也就不一一验证了。

4.5.3 小结

至此我们也就清楚了,为什么协议中通过 witness_method 调用,最终能找到 V-Table 中的方法,原因就是存储了 metadata 和 pwt。这也是我们都声明为协议类型,最终能打印出不同形状的面积根本原因。

5. 总结


至此我们对Swift中协议的分析就结束了,现总结如下:

  1. Swift中类、结构体、枚举都可以遵守协议
  2. 遵守多个协议使用逗号(,)分隔
  3. 有父类的,父类写在前面,协议在后面用逗号(,)分隔
  4. 协议中可以添加属性
    1. 属性可以是实例属性和类型属性
    2. 属性需要使用var修饰,不能属于let
    3. 类型属性只能使用static修饰,不能使用class
    4. 我们需要声明属性必须是可读的或者可读可写的
  5. 协议中可以添加方法
    1. 可以是实例方法或类方法
    2. 像普通方法一样放在协议定义中,但不需要大括号和方法体
    3. 协议中不支持为协议中的方法提供默认参数
    4. 协议中的类方法也只能使用static关键字作为前缀,不能使用class
    5. 可以使用mutating提供异变方法,以使用该方法时修改实体的属性等。
    6. 可以定义构造方法,但是使用的时候需要使用required关键字
  6. 如果定义由类专属协议,则需要继承自AnyObject
  7. 协议可以作为类型
    1. 作为函数、方法或构造器中的参数类型或返回值类型
    2. 作为常量、变量或属性的类型
    3. 作为数组、字典或其他容器中的元素类型
  8. 协议的底层存储结构是:24字节的ValueBuffer+ metadata(8字节,也就是vwt) + pwt(8字节)
    1. 前24字节,官方说法是ValueBuffer,主要用于存储遵循了协议的实体的属性值
    2. 如果超过ValueBuffer最大容量就会开辟内存进行存储,此24字节拿出8字节存储指向该内存区域的指针
    3. 目前对于类,发现其存储的都是指针
    4. 存储metadata是为了查找遵守协议的实体中实现协议的方法
    5. pwt就是protocol witness table协议目击表,存储协议中的方法