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 语言设计,是一门具备高级语义信息的中间语言。

image.png

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

image.png

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

image.png

看到这里,就不得不说一下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 代码交互时才应该使用消息派发。在需要动态派发的地方,应该优先使用面向协议设计使用基于协议的派发,然后根据具体情况使用类本身的动态派发。


image.png

派发效率从高到底: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 修改对象继承关系,可以在面向对象基础上实现自定义派发缺点:速度最慢

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


image.png

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方案的功能是否将会失效,要如何兼容?
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270

推荐阅读更多精彩内容