Swift 函数派发机制

函数派发方式

能够在编译期确定执行方法的方式叫做静态分派 Static dispatch,无法在编译期确定,只能在运行时去确定执行方法的分派方式叫做动态分派 Dynamic dispatch

静态分派(Static dispatch)

Static dispatch 更快,CPU 直接拿到函数地址并进行调用,而且静态分派可以进行内联等进一步的优化,使得执行更快速,性能更高。

使用 Static dispatch 代替 Dynamic dispatch 提升性能

我们知道Static dispatch快于Dynamic dispatch,如何在开发中去尽可能使用Static dispatch

  • inheritance constraints继承约束

    我们可以使用 final 关键字去修饰 Class,以此生成的 Final class,使用 Static dispatch

  • access control访问控制 

    private关键字修饰,使得方法或属性只对当前类可见。编译器会对方法进行 Static dispatch

编译器可以通过 whole module optimization 检查继承关系,对某些没有标记 final 的类通过计算,如果能在编译期确定执行的方法,则使用 Static dispatch。 Struct 默认使用 Static dispatch

Swift 提供了更灵活的Struct,用以在内存、引用计数、方法分派等角度去进行性能的优化,在正确的时机选择正确的数据结构,可以使我们的代码性能更快更安全。

你可能会问 Struct 如何实现多态呢?
答案是 protocol oriented programming

以上分析了影响性能的几个标准,那么不同的算法机制ClassProtocol TypesGeneric code,它们在这三方面的表现如何,Protocol TypeGeneric code 分别是怎么实现的呢?我们带着这个问题看下去。

Protocol Type

这里我们会讨论Protocol Type如何存储和拷贝变量,以及方法分派是如何实现的。不通过继承或者引用语义的多态:

protocol Drawable { 
    func draw() 
}
struct Point :Drawable {
    var x, y:Double 
    func draw() { … } 
} 
struct Line :Drawable { 
    var x1, y1, x2, y2:Double 
    func draw() { 
    … 
    } 
} 

var drawables:[Drawable] //遵守了Drawable协议的类型集合,可能是point或者line 
for d in drawables { 
    d.draw() 
}
复制代码

因为 PointLine 的尺寸不同,数组存储数据实现一致性存储,使用了Existential Container。查找正确的执行方法则使用了 Protoloc Witness Table 。

以上通过 Protocol Type 实现多态,几个类之间没有继承关系,故不能按照惯例借助 V-Table 实现动态分派。但是对于 swift 来说,class 类和 struct 结构体的实现是不同的,而属于结构体的协议Protocol,可以拥有属性和实现方法,管理Protocol Type方法分派的表就叫做Protocol Witness Table

Protocol Witness Table

V-table 一样,Protocol Witness Table(简称 PWT )内存储的是方法数组,里面包含了方法实现的指针地址,一般我们调用方法时,是通过获取对象的内存地址和方法的位移offset去查找的.

Protocol Witness Table 是用于管理 Protocol Type 的方法调用的,在我们接触 swift 性能优化时,听到另一个概念叫做 Value Witness Table (简称 VWT),这个又是做什么的呢?

Value Witness Table

value witness table.png

用于管理任意值的初始化、拷贝、销毁。即对 Protocol Type 的生命周期进行专项管理

对于每一个类型(Int或者自定义),都在metadata中存储了一个VWT(用来管理当前类型的值)

Value Witness TableProtocol Witness Table 通过分工,去管理 Protocol Type 实例的内存管理(初始化,拷贝,销毁)和方法调用。

动态分派

但是对于多态的情况,我们不能在编译期确定最终的类型,这里就用到了 Dynamic dispatch 动态分派。动态分派的实现是,每种类型都会创建一张表,表内是一个包含了方法指针的数组。动态分派更灵活,但是因为有查表和跳转的操作,并且因为很多特点对于编译器来说并不明确,所以相当于 block 了编译器的一些后期优化。所以速度慢于 Static dispatch

函数表派发(Table dispatch)

编译型语言中最常见的派发方式,既保证了动态性也兼顾了执行效率。

函数所在的类会维护一个“函数表”(虚函数表),存取了每个函数实现的指针。

每个类的 vtable 在编译时就会被构建,所以与静态派发相比多出了两个读取的工作:

  • 读取该类的 vtable
  • 读取函数的指针

优点:

  • 查表是一种简单,易实现,而且性能可预知的方式。
  • 理论上说,函数表派发也是一种高效的方式。

缺点:

  • 与静态派发相比,从字节码角度来看,多了两次读和一次跳转。
  • 与静态派发相比,编译器对某些含有副作用的函数无法优化。
  • Swift 类扩展里面的函数无法动态加入该类的函数表中,只能使用静态派发的方式。

举个例子(只是一个示例):

class A {
    func method1() {}
}
class B: A {
    func method2() {}
}
class C: B {
    override func method2() {}
    func method3() {}
}
复制代码


复制代码
offset 0xA00 A 0xB00 B 0xC00 C
0 0x121 A.method1 0x121 A.method1 0x121 A.method1
1 0x222 B.method2 0x322 C.method2
2 0x323 C.method3
let obj = C()
obj.method2()
复制代码

method2被调用时,会经历下面的几个过程:

  1. 读取对象 0xC00 的函数表
  2. 读取函数指针的索引, method2 的地址为0x322
  3. 跳转执行 0x322

消息派发(Message dispatch)

熟悉 OC 的人都知道,OC 采用了运行时机制使用 obj_msgSend 发送消息,runtime 非常的灵活,我们不仅可以对方法调用采用 swizzling,对于对象也可以通过 isa-swizzling 来扩展功能,应用场景有我们常用的 hook 和大家熟知的 KVO

大家在使用 Swift 进行开发时都会问,Swift 是否可以使用OC的运行时和消息转发机制呢?答案是可以。

Swift 可以通过关键字 dynamic 对方法进行标记,这样就会告诉编译器,此方法使用的是 OC 的运行时机制。

id returnValue = [obj messageName:param];
// 底层代码
id returnValue = objc_msgSend(obj, @selector(messageName:), param);
复制代码
复制代码

优点:

  • 动态性高
  • Method Swizzling
  • isa Swizzling
  • ...

缺点:

  • 执行效率是三种派发方式中最低的

所幸的是 objc_msgSend 会将匹配的结果缓存到一个映射表中,每个类都有这样一块缓存。若是之后发送相同的消息,执行速率会很快。

总结来说,Swift 通过 dynamic 关键字的扩展后,一共包含三种方法分派方式:Static dispatchTable dispatchMessage dispatch。下表为不同的数据结构在不同情况下采取的分派方式:

类型 静态派发 函数表派发 消息派发
值类型 所有方法 / /
协议 extension 主体创建 /
extension/final/static 主体创建 @objc + dynamic
NSObject子类 extension/final/static 主体创建 @objc + dynamic

如果在开发过程中,错误的混合了这几种分派方式,就可能出现 Bug,以下我们对这些 Bug 进行分析:

此情况是在子类的 extension 中重载父类方法时,出现和预期不同的行为。

class Base:NSObject {
    var directProperty:String { return "This is Base" }
    var indirectProperty:String { return directProperty }
}

class Sub:Base { }

extension Sub {
    override var directProperty:String { return "This is Sub" }
}
复制代码

执行以下代码,直接调用没有问题:

Base().directProperty // “This is Base”
Sub().directProperty // “This is Sub”
复制代码

间接调用结果和预期不同:

Base().indirectProperty // “This is Base”
Sub().indirectProperty // expected "this is Sub",but is “This is Base” <- Unexpected!
复制代码

Base.directProperty 前添加 dynamic 关键字就可以获得 "this is Sub" 的结果。Swiftextension 文档 中说明,不能在 extension 中重载已经存在的方法。

“Extensions can add new functionality to a type, but they cannot override existing functionality.”

会出现报错:Cannot override a non-dynamic class declaration from an extension

Swift Dispatch Method.png

出现这个问题的原因是,NSObjectextension 是使用的 Message dispatch,而 Initial Declaration 使用的是 Table dispath(查看上图)。extension 重载的方法添加在了 Message dispatch 内,没有修改虚函数表,虚函数表内还是父类的方法,故会执行父类方法。想在 extension 重载方法,需要标明dynamic来使用 Message dispatch

协议的扩展内实现的方法,无法被遵守类的子类重载:

protocol Greetable {
    func sayHi()
}
extension Greetable {
    func sayHi() {
        print("Hello")
    }
}
func greetings(greeter:Greetable) {
    greeter.sayHi()
}
复制代码

现在定义一个遵守了协议的类 Person。遵守协议类的子类 LoudPerson

class Person:Greetable {
}
class LoudPerson:Person {
    func sayHi() {
        print("sub")
    }
}
复制代码

执行下面代码结果为:

var sub:LoudPerson = LoudPerson()
sub.sayHi()  //sub
复制代码

不符合预期的代码:

var sub:Person = LoudPerson()
sub.sayHi()  //HellO  <-使用了protocol的默认实现
复制代码

注意,在子类 LoudPerson 中没有出现 override 关键字。可以理解为 LoudPerson 并没有成功注册 GreetableWitness table 的方法。所以对于声明为 Person 实际为 LoudPerson 的实例,会在编译器通过 Person 去查找,Person 没有实现协议方法,则不产生 Witness tablesayHi 方法是直接调用的。解决办法是在 base类 内实现协议方法,无需实现也要提供默认方法。或者将基类标记为 final 来避免继承。

进一步通过示例去理解:

// Defined protocol。
protocol A {
    func a() -> Int
}
extension A {
    func a() -> Int {
        return 0
    }
}

// A class doesn't have implement of the function。
class B:A {}

class C:B {
    func a() -> Int {
        return 1
    }
}

// A class has implement of the function。
class D:A {
    func a() -> Int {
        return 1
    }
}

class E:D {
    override func a() -> Int {
        return 2
    }
}

// Failure cases。
B().a() // 0
C().a() // 1
(C() as A).a() // 0 # We thought return 1。 

// Success cases。
D().a() // 1
(D() as A).a() // 1
E().a() // 2
(E() as A).a() // 2
复制代码

如果对上述代码的执行结果理解的不到位的话,还可以借助喵神 PROTOCOL EXTENSION 里面的例子理解一下:

现在我们可以对一个已有的 protocol 进行扩展,而扩展中实现的方法将作为实现扩展的类型的默认实现。也就是说,假设我们有下面的 protocol 声明,以及一个对该接口的扩展:

protocol MyProtocol {
    func method()
}

extension MyProtocol {
    func method() {
        print("Called")
    }
}
复制代码

在具体的实现这个接口的类型中,即使我们什么都不写,也可以编译通过。进行调用的话,会直接使用 extension 中的实现:

struct MyStruct: MyProtocol {

}

MyStruct().method()
// 输出:
// Called in extension
复制代码

当然,如果我们需要在类型中进行其他实现的话,可以像以前那样在具体类型中添加这个方法:

struct MyStruct: MyProtocol {
    func method() {
        print("Called in struct")
    }
}

MyStruct().method()
// 输出:
// Called in struct
复制代码

也就是说,protocol extension 为 protocol 中定义的方法提供了一个默认的实现。有了这个特性以后,之前被放在全局环境中的接受 CollectionType 的 map 方法,就可以被移动到 CollectionType 的接口扩展中去了:

extension CollectionType {
    public func map<T>(@noescape transform: (Self.Generator.Element) -> T) -> [T]
    //...
}
复制代码

在日常开发中,另一个可以用到 protocol extension 的地方是 optional 的接口方法。通过提供 protocol 的 extension,我们为 protocol 提供了默认实现,这相当于变相将 protocol 中的方法设定为了 optional。关于这个,我们在可选接口和接口扩展一节中已经讲述过,就不再重复了。

对于 protocol extension 来说,有一种会非常让人迷惑的情况,就是在接口的扩展中实现了接口里没有定义的方法时的情况。举个例子,比如我们定义了这样的一个接口和它的一个扩展:

protocol A1 {
    func method1() -> String
}

struct B1: A1 {
    func method1() -> String {
        return "hello"
    }
}
复制代码

在使用的时候,无论我们将实例的类型为 A1 还是 B1,因为实现只有一个,所以没有任何疑问,调用方法时的输出都是 “hello”:

let b1 = B1() // b1 is B1
b1.method1()
// hello

let a1: A1 = B1()
// a1 is A1
a1.method1()
// hello
复制代码

但是如果在接口里只定义了一个方法,而在接口扩展中实现了额外的方法的话,事情就变得有趣起来了。考虑下面这组接口和它的扩展:

protocol A2 {
    func method1() -> String
}

extension A2 {
    func method1() -> String {
        return "hi"
    }

    func method2() -> String {
        return "hi"
    }
}
复制代码

扩展中除了实现接口定义的 method1 之外,还定义了一个接口中不存在的方法 method2。我们尝试来实现这个接口:

struct B2: A2 {
    func method1() -> String {
        return "hello"
    }

    func method2() -> String {
        return "hello"
    }
}
复制代码

B2 中实现了 method1 和 method2。接下来,我们尝试初始化一个 B2 对象,然后对这两个方法进行调用:

let b2 = B2()

b2.method1() // hello
b2.method2() // hello
复制代码

结果在我们的意料之中,虽然在 protocol extension 中已经实现了这两个方法,但是它们只是默认的实现,我们在具体实现接口的类型中可以对默认实现进行覆盖,这非常合理。但是如果我们稍作改变,在上面的代码后面继续添加:

let a2 = b2 as A2

a2.method1() // hello
a2.method2() // hi
复制代码

a2 和 b2 是同一个对象,只不过我们通过 as 告诉编译器我们在这里需要的类型是 A2。但是这时候在这个同样的对象上调用同样的方法调用却得到了不同的结果,发生了什么?

我们可以看到,对 a2 调用 method2 实际上是接口扩展中的方法被调用了,而不是 a2 实例中的方法被调用。我们不妨这样来理解:对于 method1,因为它在 protocol 中被定义了,因此对于一个被声明为遵守接口的类型的实例 (也就是对于 a2) 来说,可以确定实例必然实现了 method1,我们可以放心大胆地用动态派发的方式使用最终的实现 (不论它是在类型中的具体实现,还是在接口扩展中的默认实现);但是对于 method2 来说,我们只是在接口扩展中进行了定义,没有任何规定说它必须在最终的类型中被实现。在使用时,因为 a2 只是一个符合 A2 接口的实例,编译器对 method2 唯一能确定的只是在接口扩展中有一个默认实现,因此在调用时,无法确定安全,也就不会去进行动态派发,而是转而编译期间就确定的默认实现。

也许在这个例子中你会觉得无所谓,因为实际中估计并不会有人将一个已知类型实例转回接口类型。但是要考虑到如果你的一些泛型 API 中有类似的直接拿到一个接口类型的结果的时候,调用它的扩展方法时就需要特别小心了:一般来说,如果有这样的需求的话,我们可以考虑将这个接口类型再转回实际的类型,然后进行调用。

整理一下相关的规则的话:

  • 如果类型推断得到的是实际的类型

    • 那么类型中的实现将被调用;如果类型中没有实现的话,那么接口扩展中的默认实现将被使用
  • 如果类型推断得到的是接口,而不是实际类型

    • 并且方法在接口中进行了定义,那么类型中的实现将被调用;如果类型中没有实现,那么接口扩展中的默认实现被使用

    • 否则 (也就是方法没有在接口中定义),扩展中的默认实现将被调用

参考:

PROTOCOL EXTENSION

【基本功】深入剖析Swift性能优化

从SIL看Swift函数派发机制

猜你喜欢

转载自juejin.im/post/7033682844581019656
今日推荐