当前位置: 首页>后端>正文

Swift数组越界引发的猜想

前言

iOS开发很多年了,之前一直主要用OC开发,今年开始渐渐主用Swift开发了。最近在开发中发现一个遇到一个数组越界的问题

Fatal error: Index out of range: 

对于数组越界相信大家都不会陌生了,在OC里面,我们Hook了数组的底层实现,所以业务上不管怎么使用都不会有问题。

但是在Swift中,如果数组越狱还是会Crash,很明显,之前实现的那一套运行时Hook方案对Swift无效;什么会这样?为了测试,我写了如下代码:

let arr1: NSMutableArray = [1,2,3,4]
arr1.removeObject(at: 5)

这段代码在运行时会被Hook,并不会产生崩溃。但如果换成下面这样就会产生崩溃

let arr1: Array = [1,2,3,4]
arr1.remove(at: 5)

二者在运行时的实现有何不同,我通过SIL来分析。这里介绍一下SIL:

Swift 和 Objective-C 使用的相同的编译架构 LLVM,LLVM 分为前端、中端和后端三部分,通过中间语言 LLVM IR 将前端和后端串联起来。swiftc 作为 Swift 语言的的编译器,负责 LLVM 前端的工作。swiftc 与其它编译器工作类似,进行词法分析、语法分析、语义分析后构建抽象语法树(AST),然后生成 LLVM IR 交由 LLVM 的中端和后端。在这个流程当中,swiftc 相比 Objective-C 使用的 clang ,swiftc 在构建完成 AST 后,生成最终的 LLVM IR 之前,加入了 SIL。

SIL (Swift Intermediate Language) 基于 SSA 形式,它针对 Swift 语言设计,是一门具备高级语义信息的中间语言。

Swift数组越界引发的猜想,第1张

当使用NSMutableArray时,我们生成SIL可以发现,函数的派发用的是objc_method

Swift数组越界引发的猜想,第2张

当使用Array时,我们生成SIL可以发现,函数的派发用的是function_ref

Swift数组越界引发的猜想,第3张

看到这里,就不得不说一下Swift的消息派发机制了

派发(dispatch)是一个比较通用的概念,一般是指为了完成某个目的把一个东西发送到某个位置的行为。在计算机科学中,这个术语在很多地方都会用到,比如派发一个调用给某个函数,派发一个事件给一个监听者,派发一个中断给中断处理程序,或者派发一个进程给 CPU。

在这篇文章中,我们主要研究 Swift 中的派发,也就是派发一个调用到某个方法上,Swift 中的方法派发包括类的方法派发和基于协议的派发。

方法派发

Swift 中类的方法的派发有以下三种方式:
● 静态派发(Static Dispatch)
● 动态派发(Dynamic Dispatch)
● 消息派发(Messaging Dispatch)

静态派发

静态派发,又叫做早期绑定,是指在编译期将方法调用绑定到方法的实现上,这种派发方式非常快。在编译期,编译器可以看到调用方和被调方的所有信息,直接生成跳转代码,这样在运行期就不会有其它额外的开销。并且编译器可以根据自己知道的信息进行优化,比如内联,可以极大提高程序运行效率。
在 Swift 中,结构体和枚举的方法调用,以及被 final 标记的类和类的方法,都会采用这种派发方式。

动态派发

动态派发是在运行时决定方法调用地址,因此需要有个查找方法地址的机制,在 Swift 中是通过虚函数表(Virtual Method Table),简称 V-Table 实现的,因此动态派发也被称为表派发(Table Dispatch)
在编译期,编译器会给每个包含动态派发方法的类型创建一个虚函数表,这个表会被放在内存的静态区,表中是方法名到方法实现地址的映射。当这个类型的方法被调用时,运行时会去这个类型的虚函数表中寻找这个方法名对应的实现地址,然后再跳转到这个地址执行代码。
动态派发主要是用来实现继承多态,继承多态是多态的一种。例如以下代码:

class Animal {
    func makeNoise() {
        fatalError("此方法必须通过子类调用")
    }
}

class Dog: Animal {
    override func makeNoise() {
        print("Wang Wang!")
    }
}

class Cat: Animal {
    override func makeNoise() {
        print("Miao!")
    }
}

这段代码在编译时,编译器会把 makeNoise 方法采用动态派发来处理,会给 Animal、Dog、Cat 这三个类分别生成一个虚函数表,每个表中包含了方法实现地址的列表和方法列表的索引。
我们可以使用一个容器来装一些列 Animal 和其子类,然后统一调用 makeNoise 方法,这样的好处是忽略每个具体类型的信息,提供高级的抽象,这种做法在很多地方都很有用。这种做法在面向对象中也被称为开放递归(Open recursion)。

let animals: [Animal] = [Dog(), Cat()]
for animal in animals {
    animal.makeNoise()
}
// 输出:
// Wang Wang!
// Miao!

相对于静态派发的直接跳转,动态派发要经过 3 个步骤,找到虚函数表、找到方法地址、跳转到方法地址,并且编译器无法对动态派发做优化,因此其性能要比静态派发慢得多。
默认情况下,如果继承了一个 Objective-C 类,子类中的方法派发是采用动态派发而不是消息派发。

消息派发

关于消息派发,这就是 Objective-C 的知识了,就是 OC 运行时通过 isa 和 super 指针查找方法实现,并包含一系列消息转发流程,在此不表。
在 Swift 类中使用 @objc dynamic 关键字可以强制方法使用消息派发。

协议的派发

类继承是一个很好用的东西,但是它也存在一些问题,比如子类只能继承一个父类,并且子类会被强制包含父类的内存布局。
Swift 提供了一个解决方案来解决上述类继承的不足,这个解决方案提供了良好的封装,支持多态,不会和某个特定的内存布局绑定,并且可以基于值类型工作,这就是利用面向协议编程(POP)。
协议定义了一个类型具备的能力,和继承不同,我们可以给让一个类型符合任意多个协议,可以让不是自己写的类型去符合一个协议,可以给协议提供默认实现。在 Swift 中,类、结构体、枚举都可以去符合协议。
用面向协议的思想来编程,我们就会摒弃类继承,而是从设计一个协议开始,比如上面的代码,我们会将 Animal 设计为一个协议:

protocol Animal {
    func makeNoise()
}

然后可以用一个协议类型的变量来保存一个对象:

let animal: Animal = ...

在类继承中,由于 Animal 是一个类,编译器知道 Animal 占用多大的内存空间,因此知道 animal 对象应该占用多大空间,但是如果 Animal 是一个协议类型,编译器怎样知道 animal 应该占用多大空间呢?

class Dog: Animal {
    let name: String
    func makeNoise() { ... }
}

class Cat: Animal {
    let age: Int
    func makeNoise() { ... }
}

协议并不限制符合协议的类型的内存布局,上面代码中,Dog 占 3 个字的大小,Cat 占 1 个字的大小。
Swift 引入了存在容器(Existential Container) 来解决这个问题。每个存在容器由以下几个部分组成:
● Value Buffer ValueBuffer 占 3 个字的长度,如果符合协议的对象是值类型且小于等于 3 个字,则直接放入 ValueBuffer 中,如果对象是引用类型或者大于 3 个字的值类型,则将对象放在堆上,在 ValueBuffer 中保存一个指向堆上对象的引用。
● 一个指向 值目击表(Value Witness Table, VWT) 的指针,用来创建、拷贝和销毁值,表中保存了创建、拷贝、销毁等函数的地址,其中创建、销毁函数的地址仅在当对象分配在堆上时才会有。
● 一个指向 协议目击表(Protocol Witness Table, PWT) 的指针,每个符合了某个协议的类型都有自己的协议目击表,保存了实现协议中方法的方法地址。
● 如果类型符合了多个协议,后面还会有第二个协议的协议目击表指针,以及第三个,第四个等。符合的协议越多,存在容器占用内存空间就越大。
这样对于某个协议类型,它的存在容器的大小总是相同的,编译器即可确定它的大小。

let animal: Animal = Dog()
animal.makeNoise()

上面的代码,animal 会被处理成一个存在容器,占用 5 个字大小的空间,由于 Dog 的大小小于等于 3 个字,它被直接放入存在容器的 ValueBuffer 中,也就是头 3 个字的空间。第 4 个字的位置是 VWT,保存了对象拷贝等函数的地址。在 PWT 中保存了 makeNoise 方法的实现地址,用存在容器第 5 个字的位置指向 PWT。
当调用 makeNoise 时,运行时会去 PWT 中寻找方法的地址,然后跳转指令,这其实和虚函数表差不多。

总结

理解了 Swift 中的方法派发方式后,可以知道,应该优先使用静态派发,可以获得最佳的性能,只有在需要和 Objective-C 代码交互时才应该使用消息派发。在需要动态派发的地方,应该优先使用面向协议设计使用基于协议的派发,然后根据具体情况使用类本身的动态派发。


Swift数组越界引发的猜想,第4张

派发效率从高到底:Static dispatch > Table dispatch > Message dispatch

1.1 static dispatch

Static dispatch 静态派发,即直接地址调用。这个函数指针在编译、链接完成后就确定了,存放在代码段。优点:派发速度最快,因为需要调用的指令集少,且编译器还有很大的优化空间(如:函数内敛 inline)。缺点:局限也是最大的,因为缺乏动态性,所以没法支持继承。

1.2 table dispatch

Table dispatch 函数表派发,是编译型语言实现动态行为最常见的实现方式。函数表使用一个数组来存储类声明的每个函数的指针。大部分语言把这个称之为 Virtual Table 虚函数表,Swift 里的协议则为 Witness Table 。每个类维护一个虚函数表,记录着类的所有函数。如果被 override 的话,表里只会保存 override 后的函数。子类新增函数会被插到这个数组的最后,没有位置可以让 extension 安全的插入函数。优点:可扩展缺点:速度慢,编译器对某些含有副作用的函数无法优化

1.3 objc_msgSend

基于 Objc RunTime 实现,沿着实例的 isa 指针进行查找,找不到最后还有3次拯救机会。详细可见:iOS_Objective-C 消息发送(消息查找 及 消息转发)过程优点:最动态的方式,可在运行时改变函数行为。不只可以通过 swizzling 来改变,甚至可以用 isa-swizzling 修改对象继承关系,可以在面向对象基础上实现自定义派发缺点:速度最慢

为了方便理解,我整理了如下表格:


Swift数组越界引发的猜想,第5张

Swift数组越界的处理

回归正题,通过对Swift消息派发机制的了解,我们可以知道对于NSMutableArray系统用的是消息派发,对于Array用的是直接(静态)派发。对于普通的class用的是函数表派发。
前面2种,上文已经介绍过了,函数表派发的SILl类似下面:

sil_vtable xxx {
  ......
}

防止Array的越界,给数组添加扩展,下面是一个安全的数组取值在扩展中的实现,其他增删改查方法类似。

import Foundation

extension Array {
  subscript (safe index: Index) -> Iterator.Element{
        return indices.contains(index) self[index] : nil
    } 
}   

展望未来

  1. 方法交换的那种hook方案,无法在函数表派发和协议派发时生效,很明显,函数没有存在isa指针联合结构体的method列表中,那么它在哪?对于虚函数表派发要怎么hook?
  2. swift的Mach-o文件和oc不一样,以前做的从data段获取无用类和无用函数的功能会失效,要如何兼容?
  3. 苹果正在计划重构Foundation框架,若以后Foundation和UIKit都用swift重构,我们之前做的全埋点方案、行为日志以及其他hook方案的功能是否将会失效,要如何兼容?

https://www.xamrdz.com/backend/3mu1933400.html

相关文章: