Swift——动态&静态分析与实战

我们知道在OC里面有一个非常牛逼的特性,那就是动态性,也就是说很多东西都被推到了运行时,包括我们所理解的消息机制和转发机制。但是据我了解OC貌似是没有动态类型的,因为他是一门静态类型的语言,他对多就是支持一些和OC动态的交互,但是Swift在通过某些手段还是可以实现对消息的动态转发的……

Swift是一门静态类型的语言,不支持类似OC中的消息机制和转发机制以及其他一些动态的特性,但是你可以通过某些手段实现这一点。

新的Swift编译器更智能,能够识别对象和方法的调用关系以及层级关系,减少对象调用方法的查找时间;同时在内存管理上也有所提高。大部分的方法调用,尤其是重复的方法调用,并没有必要次次都从头开始进行查找,消息分发、转发。Swift一定程度上减少了这种重复的劳动。

Swift是OO(面向对象)的语言,所以少不了方法和属性的重载等特性,程序只能在运行时来确定具体的方法或属性来间接调用或间接访问,这就叫做动态派发。从性能上考虑,对于动态派发的方法,会有常量时间的运行时开销。

函数的派发方式:

程序判断使用哪种途径去调用一个函数的机制.

  • 直接派发(Direct Dispatch)
  • 函数表派发(Table Dispatch)
  • 消息机制派发(Message Dispatch)

大多数语言都会支持一到两种,

  • Java 默认使用函数表派发, 但你可以通过 final 修饰符修改成直接派发.
  • C++ 默认使用直接派发, 但可以通过加上 virtual 修饰符来改成函数表派发.
  • Objective-C 则总是使用消息机制派发, 但允许开发者使用 C 直接派发来获取性能的提高.

直接派发 (Direct Dispatch)

直接派发是最快的, 不止是因为需要调用的指令集会更少, 并且编译器还能够有很大的优化空间, 例如函数内联等, 但这不在这篇博客的讨论范围. 直接派发也有人称为静态调用.因为缺乏动态性所以没办法支持继承.

函数表派发 (Table Dispatch )

函数表派发是编译型语言实现动态行为最常见的实现方式. 函数表使用了一个数组来存储类声明的每一个函数的指针. 大部分语言把这个称为 “virtual table”(虚函数表), Swift 里称为 “witness table”. 每一个类都会维护一个函数表, 里面记录着类所有的函数, 如果父类函数被 override 的话, 表里面只会保存被 override 之后的函数. 一个子类新添加的函数, 都会被插入到这个数组的最后. 运行时会根据这一个表去决定实际要被调用的函数.

这种派发方式比起直接派发还是慢一点. 从字节码角度来看, 多了两次读和一次跳转,另一个慢的原因在于编译器可能会由于函数内执行的任务导致无法优化

这种基于数组的实现, 缺陷在于函数表无法拓展. 子类会在虚数函数表的最后插入新的函数, 没有位置可以让 extension 安全地插入函数.

消息机制派发 (Message Dispatch )

消息机制是调用函数最动态的方式. 也是 Cocoa 的基石, 这样的机制催生了 KVO, UIAppearence 和 CoreData 等功能. 这种运作方式的关键在于开发者可以在运行时改变函数的行为. 不止可以通过 swizzling 来改变, 甚至可以用 isa-swizzling 修改对象的继承关系, 可以在面向对象的基础上实现自定义派发.

Swift中的派发方式

开发中遇到比较多的坑

Class中的mainMethod 会使用函数表派发,

extension中的extensionMethod 则会使用直接派发.

总结起来有这么几点:

  1. 值类型总是会使用直接派发, 简单易懂
  2. 而协议和类的 extension 都会使用直接派发
  3. NSObject 的 extension 会使用消息机制进行派发
  4. NSObject 声明作用域里的函数都会使用函数表进行派发.
  5. 协议里声明的, 并且带有默认实现的函数会使用函数表进行派发

当然上面说到的也不知道必然的,比较OC这么多年的历史,而且动态特性在某些方面还是有着实际的好处,所以苹果也考虑到了这一点,使用某些手段我们可以指定派发方式

指定派发方式 (Specifying Dispatch Behavior)

final

final 允许类里面的函数使用直接派发. 这个修饰符会让函数失去动态性.

任何函数都可以使用这个修饰符, 就算是 extension 里本来就是直接派发的函数. 这也会让 Objective-C 的运行时获取不到这个函数, 不会生成相应的 selector.

dynamic

dynamic 可以让类里面的函数使用消息机制派发.

使用 dynamic, 必须导入 Foundation 框架, 里面包括了 NSObject 和 Objective-C 的运行时. dynamic 可以让声明在 extension 里面的函数能够被 override. dynamic 可以用在所有 NSObject 的子类和 Swift 的原声类.

@objc & @nonobjc

@objc 和 @nonobjc 显式地声明了一个函数是否能被 Objective-C 的运行时捕获到.

使用 @objc 的典型例子就是给 selector 一个命名空间 @objc(abc_methodName), 让这个函数可以被 Objective-C 的运行时调用. @nonobjc 会改变派发的方式, 可以用来禁止消息机制派发这个函数, 不让这个函数注册到 Objective-C 的运行时里.

final @objc

可以在标记为 final 的同时, 也使用 @objc 来让函数可以使用消息机制派发.

这么做的结果就是, 调用函数的时候会使用直接派发, 但也会在 Objective-C 的运行时里注册响应的 selector. 函数可以响应 perform(selector:) 以及别的 Objective-C 特性, 但在直接调用时又可以有直接派发的性能.

@inline

Swift 也支持 @inline, 告诉编译器可以使用直接派发.

有趣的是, dynamic @inline(__always) func dynamicOrDirect() {} 也可以通过编译! 但这也只是告诉了编译器而已, 实际上这个函数还是会使用消息机制派发. 这样的写法看起来像是一个未定义的行为, 应该避免这么做.

在使用Swift重写之前项目中AppDelegate的时候遇到了一个这样的问题(本文也是由这个问题的产生牵引我学习Swift动态相关内容的)

Argument of ‘#selector’ refers to a method that is not exposed to Objective-C (Objective-C 无法获取 #selector 指定的函数). 你如果记得 Swift 会把这个函数优化为直接派发的话, 就能理解这件事情了. 这里修复的方式很简单: 加上@objc 或者 dynamic 就可以保证 Objective-C 的运行时可以获取到函数了. 这种类型的错误也会发生在UIAppearance 上, 依赖于 proxy 和 NSInvocation 的代码.

性能上的提升

那么在实际项目中我们应该如何使用Swift结合动态和静态方式提高项目的性能呢?

在Swift中,动态调用是通过在一个方法表中找到方法然后执行间接的调用(类似于C++的虚函数表),对于这种先查找再调用的过程,其效率是要低于方法的直接调用,而且间接调用会阻止许多编译器优化,这将加重间接调用的开销。接下来将列举一些技巧来禁用动态派发的行为,以达到提升性能的目的。

当属性、方法、或类不需要被重载时,可在其声明的地方加上final关键字

在属性,方法或类声明时加上final关键字,表示其不能被重载,这将允许编译器安全的移除动态派发。如下代码所示,point和velocity将直接从对象的存储属性中加载,updatePoint()方法将被直接调用;另外,update()依然会通过动态派发的方式来调用,这样,ParticleModel的子类就可以重载update()来自定义实现。

class ParticleModel {
    final var point = ( x: 0.0, y: 0.0 )
    final var velocity = 100.0

    final func updatePoint(newPoint: (Double, Double), newVelocity: Double) {
        point = newPoint
        velocity = newVelocity
    }

    func update(newP: (Double, Double), newV: Double) {
        updatePoint(newP, newVelocity: newV)
    }
}

除了上面所示,在属性和方法声明前加final关键字,还可以直接在类上加final,表示该类将不能作为父类被子类化,隐含的表明该类的所有的方法和属性都是final的。

final class ParticleModel {
    var point = ( x: 0.0, y: 0.0 )
    var velocity = 100.0
    // ...
}

在属性、方法、或类声明前加private关键字,将限制其只能在同一个文件中被引用

在声明前加private关键字,将限制其只能在当前文件中被引用,这将允许编译器在当前文件中找到所有潜在的重载声明,编译器会对这些private关键字的方法或属性进行优化,移除间接的方法调用以及属性访问。

假设在当前文件中没有类重载ParticleModel,那么编译器将移除所有带有private声明的动态派发调用。

class ParticleModel {
    private var point = ( x: 0.0, y: 0.0 )
    private var velocity = 100.0

    private func updatePoint(newPoint: (Double, Double), newVelocity: Double) {
        point = newPoint
        velocity = newVelocity
    }

    func update(newP: (Double, Double), newV: Double) {
        updatePoint(newP, newVelocity: newV)
    }
}

如上代码所示,point和velocity将直接访问,updatePoint()方法也将直接被调用,而update()方法由于没有加private关键字,依然是只能间接调用。
同样,private可以加在类的声明前,等同于类的所有方法和属性都将加上private关键字。

private class ParticleModel {
    var point = ( x: 0.0, y: 0.0 )
    var velocity = 100.0
    // ...
}

在使用internal的声明中通过使用Whole Module Optimization来隐式的推断出final

默认的情况下,Xcode将单独编译源文件,这会限制编译器优化的程度,Xcode 7后,增加了Whole Module Optimization选项,它能允许编译器在同一个模块(Module)中分析所有的源文件来进行优化,可以在Xcode的Building Settings中开启该选项,如下图所示。

在开启Whole Module Optimization选项,且声明为internal(默认级别)的情况下,模块的所有文件将同时被编译,这将允许编译器对整个模块一起分析,并对没有被重载且声明为internal级别的类、方法或属性添加final关键字。
如下代码所示,我们修改一下ParticleModel类,添加public关键字:

public class ParticleModel {
    var point = ( x: 0.0, y: 0.0 )
    var velocity = 100.0

    func updatePoint(newPoint: (Double, Double), newVelocity: Double) {
        point = newPoint
        velocity = newVelocity
    }

    public func update(newP: (Double, Double), newV: Double) {
        updatePoint(newP, newVelocity: newV)
    }
}

var p = ParticleModel()
for i in stride(from: 0.0, through: times, by: 1.0) {
    p.update((i * sin(i), i), newV:i*1000)
}

如上代码,当开启Whole Module Optimization选项的情况下,编译器能在属性point,velotity,以及updatePoint()方法上推断出final,既相当于在point、velocity、updatePoint()声明前加上final关键字,而update()方法由于是public级别,所以无法推断出final关键字,其仍将是间接调用。

总结:

  • 使用静态派发的话结构体是个不错的选择, 而使用消息机制派发的话则可以考虑 NSObject
  • 当你继承 NSObject 的时候, 这是一个你想要完全使用动态消息机制的表现.
  • 当使用private或final关键字,或者开启Whole Module Optimization,声明internal级别的没有被重载的方法,将直接调用,在编译时确定。
  • 运行时决定的动态派发的情形包括:
    • 继承自NSObject或者方法有@objc前缀。
    • 使用Swift的方法表的方式,除去上述情况下,将采用这种方式。
坚持原创技术分享,您的支持将鼓励我继续创作!