Loading...
墨滴

CoderStar

2021/08/21  阅读:46  主题:橙心

Swift 派发机制

前言

对于编译型语言来看,有主要三种类型的函数派发方式,分别为:

  • Direct Dispatch: 直接派发
  • Table Dispatch: 函数表派发
  • Message Dispatch: 消息派发

分析三种派发方式主要从性能动态性两方面讨论,这两个特性相对而言是矛盾的,性能要求高,则动态性差,反之亦然,其中直接派发又被称为静态派发,函数表派发与消息派发称为动态派发,大多数语言都会支持上面派发方式的一种到多种。如

  • C 使用直接派发;
  • Java 默认使用函数表派发,可以通过 final 修饰符修改成直接派发;
  • C++ 默认使用直接派发,但可以通过加上 virtual 修饰符来改成函数表派发;
  • OC 使用直接派发、消息派发方式;(普通方法采用消息派发的方式,load 方法使用直接派发的方式)

直接派发

直接派发是三种形式里面最快速的,在编译时就确定了方法的调用地址,汇编代码中,直接跳到方法的地址执行,生成的汇编指令最少。

优点:编译器可以对这种派发方式进行更多优化,比如函数内联等。
缺点:缺乏动态性,无法实现继承等;

函数表派发

函数表是编译型语言常见的派发方式,函数表使用数组来存储类中声明的每个函数的指针。对于这个表,大部分语言叫 Virtual table(虚函数表) 。根据 Swift 编译生成的 SIL 文件分析,Swift 中存在两种函数表,其中协议使用的是 witness_table (SIL 文件中名为 sil_witness_table),类使用的是 virtual_table(SIL 文件中名为 sil_vtable)。

每一个类都会维护一个函数表,里面记录着类所有的函数,如果父类函数被 override,表里面只会保存被 override 之后的函数。 一个子类新添加的函数,都会被插入到这个数组的最后。运行时会根据这一个表去决定实际要被调用的函数;

一个函数被调用时会先去读取对象的函数表(读取第一次),再根据类的地址加上该的函数的偏移量得到函数地址(读取第二次),最后跳到那个地址上去(跳转一次)。 整个过程是两次读取一次跳转,比直接派发慢一些。

函数表派发.jpeg
函数表派发.jpeg

消息派发

消息派发是动态性最强的派发方式,也是性能最差的一种方式;方法调用包装成消息,发给运行时(相当于中间人),运行时会找到类对象,类对象会保存类的数据信息,或通过父类查找,直到命中执行,如果没找到方法,抛出异常,运行时提供了很多动态的方法用于改变消息派发的行为,相比函数表派发有很强的动态性,由于运行时支持的功能很多,方法查找的过程比较长,所以性能比较低;

OC 消息派发过程在这不展开说,后续有博文专门说这个。

Swift 中的函数派发

分析SIL文件,我们可以分析出Swift中派发方式的规律,关于SIL相关知识,可以参照该文iOS编译简析,本文只给出关键命令 swiftc main.swift -emit-sil | xcrun swift-demangle > main.sil

派发方式与 SIL 文件中关键指令对应关系

  • sil_witness_table/sil_vtable:函数表派发
  • objc_method:消息机制派发
  • 不在上述范围内的属于直接派发;

Swift 语言支持三种派发方式。采用何种方式跟以下四种因素相关:

  • 声明的位置
  • 引用类型
  • 指定行为
  • 显式地优化
直接派发 函数表派发 消息派发
NSObject @nonobjc 或者 final 修饰的方法 声明作用域中方法 扩展方法及被 dynamic 修饰的方法
Class 不被 @objc 修饰的扩展方法及被 final 修饰的方法 声明作用域中方法 dynamic 修饰的方法或者被 @objc 修饰的扩展方法
Protocol 扩展方法 声明作用域中方法 @objc 修饰的方法或者被 objc 修饰的协议中所有方法
Value Type 所有方法
其他 全局方法,staic 修饰的方法;使用 final 声明的类里面的所有方法;使用 private 声明的方法和属性会隐式 final 声明;

通过该表格你大概就可以理解一下 Swift 语言中的一些限制了:

  • extension 中定义的方法如果想 overrite,需要在方法上加上 @objc 修饰符;因为如果不加 @objc,走的是直接派发,无法重写方法。

Swift 派发优化

内联优化

Swift 编译时在直接派发方式的基础上还可以进行优化,如函数内联。

内联主要原理是:将一些函数的实现直接编译入调用函数的位置中去,减少函数指针的栈调用,提高运行效率。 当开启编译优化 (Optimization Level) 时,编译器会在直接派发方式基础上根据函数实际情况进行内联优化。下列情况编译器默认不会进行内联优化:

  • 函数体过长(无形中增加了包体积,重复代码);
  • 函数包含动态派发;
  • 函数中包含递归调用;

Swift 中显式内联优化修饰符

  • @inline(never) 声明这个函数 never 永远不被编译成 inline 的形式,即使开启了编译器优化;
  • @inline(__always) 声明这个函数总是编译成 inline 的形式, 这个修饰符只对函数体过长这种不会被内联优化的情况生效,其他情况也不生效;

内联除了可以提高运行效率这个优点之外,还有另外一个好处,将部分关键函数进行内联优化,可以增大逆向难度。

尽量直接派发

Swift 会尽可能的优化派发方式,一些函数表派发方法会优化成直接派发。编译器可以通过 whole module optimization 检查继承关系,对某些没有标记 final 的类通过计算,如果能在编译期确定执行的方法,则使用直接派发。比如一个函数没有 override,Swift 就可能会使用直接派发的方式,


有一个技术的圈子与一群同道众人非常重要,来我的技术公众号,这里只聊技术干货。

微信公众号:CoderStar

扫一扫,技术干货等着你
扫一扫,技术干货等着你

CoderStar

2021/08/21  阅读:46  主题:橙心

作者介绍

CoderStar