前言
iOS開發(fā)很多年了,之前一直主要用OC開發(fā)叠赐,今年開始漸漸主用Swift開發(fā)了偿洁。最近在開發(fā)中發(fā)現(xiàn)一個遇到一個數(shù)組越界的問題
Fatal error: Index out of range:
對于數(shù)組越界相信大家都不會陌生了,在OC里面轿钠,我們Hook了數(shù)組的底層實現(xiàn)巢钓,所以業(yè)務上不管怎么使用都不會有問題。
但是在Swift中疗垛,如果數(shù)組越獄還是會Crash症汹,很明顯,之前實現(xiàn)的那一套運行時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)
二者在運行時的實現(xiàn)有何不同芽世,我通過SIL來分析挚赊。這里介紹一下SIL:
Swift 和 Objective-C 使用的相同的編譯架構 LLVM,LLVM 分為前端济瓢、中端和后端三部分荠割,通過中間語言 LLVM IR 將前端和后端串聯(lián)起來。swiftc 作為 Swift 語言的的編譯器旺矾,負責 LLVM 前端的工作蔑鹦。swiftc 與其它編譯器工作類似,進行詞法分析箕宙、語法分析嚎朽、語義分析后構建抽象語法樹(AST),然后生成 LLVM IR 交由 LLVM 的中端和后端柬帕。在這個流程當中哟忍,swiftc 相比 Objective-C 使用的 clang ,swiftc 在構建完成 AST 后陷寝,生成最終的 LLVM IR 之前锅很,加入了 SIL。
SIL (Swift Intermediate Language) 基于 SSA 形式凤跑,它針對 Swift 語言設計爆安,是一門具備高級語義信息的中間語言。
當使用NSMutableArray時仔引,我們生成SIL可以發(fā)現(xiàn)扔仓,函數(shù)的派發(fā)用的是objc_method
當使用Array時,我們生成SIL可以發(fā)現(xiàn)咖耘,函數(shù)的派發(fā)用的是function_ref
看到這里当辐,就不得不說一下Swift的消息派發(fā)機制了
派發(fā)(dispatch)是一個比較通用的概念,一般是指為了完成某個目的把一個東西發(fā)送到某個位置的行為鲤看。在計算機科學中缘揪,這個術語在很多地方都會用到,比如派發(fā)一個調用給某個函數(shù)义桂,派發(fā)一個事件給一個監(jiān)聽者找筝,派發(fā)一個中斷給中斷處理程序,或者派發(fā)一個進程給 CPU慷吊。
在這篇文章中袖裕,我們主要研究 Swift 中的派發(fā),也就是派發(fā)一個調用到某個方法上溉瓶,Swift 中的方法派發(fā)包括類的方法派發(fā)和基于協(xié)議的派發(fā)急鳄。
方法派發(fā)
Swift 中類的方法的派發(fā)有以下三種方式:
● 靜態(tài)派發(fā)(Static Dispatch)
● 動態(tài)派發(fā)(Dynamic Dispatch)
● 消息派發(fā)(Messaging Dispatch)
靜態(tài)派發(fā)
靜態(tài)派發(fā)谤民,又叫做早期綁定,是指在編譯期將方法調用綁定到方法的實現(xiàn)上疾宏,這種派發(fā)方式非痴抛悖快。在編譯期坎藐,編譯器可以看到調用方和被調方的所有信息为牍,直接生成跳轉代碼,這樣在運行期就不會有其它額外的開銷岩馍。并且編譯器可以根據(jù)自己知道的信息進行優(yōu)化碉咆,比如內聯(lián),可以極大提高程序運行效率蛀恩。
在 Swift 中疫铜,結構體和枚舉的方法調用,以及被 final 標記的類和類的方法双谆,都會采用這種派發(fā)方式壳咕。
動態(tài)派發(fā)
動態(tài)派發(fā)是在運行時決定方法調用地址,因此需要有個查找方法地址的機制佃乘,在 Swift 中是通過虛函數(shù)表(Virtual Method Table)囱井,簡稱 V-Table 實現(xiàn)的驹尼,因此動態(tài)派發(fā)也被稱為表派發(fā)(Table Dispatch)
在編譯期趣避,編譯器會給每個包含動態(tài)派發(fā)方法的類型創(chuàng)建一個虛函數(shù)表,這個表會被放在內存的靜態(tài)區(qū)新翎,表中是方法名到方法實現(xiàn)地址的映射程帕。當這個類型的方法被調用時,運行時會去這個類型的虛函數(shù)表中尋找這個方法名對應的實現(xiàn)地址地啰,然后再跳轉到這個地址執(zhí)行代碼愁拭。
動態(tài)派發(fā)主要是用來實現(xiàn)繼承多態(tài),繼承多態(tài)是多態(tài)的一種亏吝。例如以下代碼:
class Animal {
func makeNoise() {
fatalError("此方法必須通過子類調用")
}
}
class Dog: Animal {
override func makeNoise() {
print("Wang Wang!")
}
}
class Cat: Animal {
override func makeNoise() {
print("Miao!")
}
}
這段代碼在編譯時岭埠,編譯器會把 makeNoise 方法采用動態(tài)派發(fā)來處理,會給 Animal蔚鸥、Dog惜论、Cat 這三個類分別生成一個虛函數(shù)表,每個表中包含了方法實現(xiàn)地址的列表和方法列表的索引止喷。
我們可以使用一個容器來裝一些列 Animal 和其子類馆类,然后統(tǒng)一調用 makeNoise 方法,這樣的好處是忽略每個具體類型的信息弹谁,提供高級的抽象乾巧,這種做法在很多地方都很有用句喜。這種做法在面向對象中也被稱為開放遞歸(Open recursion)。
let animals: [Animal] = [Dog(), Cat()]
for animal in animals {
animal.makeNoise()
}
// 輸出:
// Wang Wang!
// Miao!
相對于靜態(tài)派發(fā)的直接跳轉沟于,動態(tài)派發(fā)要經(jīng)過 3 個步驟咳胃,找到虛函數(shù)表、找到方法地址社裆、跳轉到方法地址拙绊,并且編譯器無法對動態(tài)派發(fā)做優(yōu)化,因此其性能要比靜態(tài)派發(fā)慢得多泳秀。
默認情況下标沪,如果繼承了一個 Objective-C 類,子類中的方法派發(fā)是采用動態(tài)派發(fā)而不是消息派發(fā)嗜傅。
消息派發(fā)
關于消息派發(fā)金句,這就是 Objective-C 的知識了,就是 OC 運行時通過 isa 和 super 指針查找方法實現(xiàn)吕嘀,并包含一系列消息轉發(fā)流程违寞,在此不表。
在 Swift 類中使用 @objc dynamic 關鍵字可以強制方法使用消息派發(fā)偶房。
協(xié)議的派發(fā)
類繼承是一個很好用的東西趁曼,但是它也存在一些問題,比如子類只能繼承一個父類棕洋,并且子類會被強制包含父類的內存布局挡闰。
Swift 提供了一個解決方案來解決上述類繼承的不足,這個解決方案提供了良好的封裝掰盘,支持多態(tài)摄悯,不會和某個特定的內存布局綁定,并且可以基于值類型工作愧捕,這就是利用面向協(xié)議編程(POP)奢驯。
協(xié)議定義了一個類型具備的能力,和繼承不同次绘,我們可以給讓一個類型符合任意多個協(xié)議瘪阁,可以讓不是自己寫的類型去符合一個協(xié)議,可以給協(xié)議提供默認實現(xiàn)邮偎。在 Swift 中管跺,類、結構體钢猛、枚舉都可以去符合協(xié)議伙菜。
用面向協(xié)議的思想來編程,我們就會摒棄類繼承命迈,而是從設計一個協(xié)議開始贩绕,比如上面的代碼火的,我們會將 Animal 設計為一個協(xié)議:
protocol Animal {
func makeNoise()
}
然后可以用一個協(xié)議類型的變量來保存一個對象:
let animal: Animal = ...
在類繼承中,由于 Animal 是一個類淑倾,編譯器知道 Animal 占用多大的內存空間馏鹤,因此知道 animal 對象應該占用多大空間,但是如果 Animal 是一個協(xié)議類型娇哆,編譯器怎樣知道 animal 應該占用多大空間呢湃累?
class Dog: Animal {
let name: String
func makeNoise() { ... }
}
class Cat: Animal {
let age: Int
func makeNoise() { ... }
}
協(xié)議并不限制符合協(xié)議的類型的內存布局,上面代碼中碍讨,Dog 占 3 個字的大小治力,Cat 占 1 個字的大小。
Swift 引入了存在容器(Existential Container) 來解決這個問題勃黍。每個存在容器由以下幾個部分組成:
● Value Buffer ValueBuffer 占 3 個字的長度宵统,如果符合協(xié)議的對象是值類型且小于等于 3 個字,則直接放入 ValueBuffer 中覆获,如果對象是引用類型或者大于 3 個字的值類型马澈,則將對象放在堆上,在 ValueBuffer 中保存一個指向堆上對象的引用弄息。
● 一個指向 值目擊表(Value Witness Table, VWT) 的指針痊班,用來創(chuàng)建、拷貝和銷毀值摹量,表中保存了創(chuàng)建涤伐、拷貝、銷毀等函數(shù)的地址荆永,其中創(chuàng)建废亭、銷毀函數(shù)的地址僅在當對象分配在堆上時才會有国章。
● 一個指向 協(xié)議目擊表(Protocol Witness Table, PWT) 的指針具钥,每個符合了某個協(xié)議的類型都有自己的協(xié)議目擊表,保存了實現(xiàn)協(xié)議中方法的方法地址液兽。
● 如果類型符合了多個協(xié)議骂删,后面還會有第二個協(xié)議的協(xié)議目擊表指針,以及第三個四啰,第四個等宁玫。符合的協(xié)議越多,存在容器占用內存空間就越大柑晒。
這樣對于某個協(xié)議類型欧瘪,它的存在容器的大小總是相同的,編譯器即可確定它的大小匙赞。
let animal: Animal = Dog()
animal.makeNoise()
上面的代碼佛掖,animal 會被處理成一個存在容器妖碉,占用 5 個字大小的空間,由于 Dog 的大小小于等于 3 個字芥被,它被直接放入存在容器的 ValueBuffer 中欧宜,也就是頭 3 個字的空間。第 4 個字的位置是 VWT拴魄,保存了對象拷貝等函數(shù)的地址冗茸。在 PWT 中保存了 makeNoise 方法的實現(xiàn)地址,用存在容器第 5 個字的位置指向 PWT匹中。
當調用 makeNoise 時夏漱,運行時會去 PWT 中尋找方法的地址,然后跳轉指令顶捷,這其實和虛函數(shù)表差不多麻蹋。
總結
理解了 Swift 中的方法派發(fā)方式后,可以知道焊切,應該優(yōu)先使用靜態(tài)派發(fā)扮授,可以獲得最佳的性能,只有在需要和 Objective-C 代碼交互時才應該使用消息派發(fā)专肪。在需要動態(tài)派發(fā)的地方刹勃,應該優(yōu)先使用面向協(xié)議設計使用基于協(xié)議的派發(fā),然后根據(jù)具體情況使用類本身的動態(tài)派發(fā)嚎尤。
派發(fā)效率從高到底:Static dispatch > Table dispatch > Message dispatch
1.1 static dispatch
Static dispatch 靜態(tài)派發(fā)荔仁,即直接地址調用。這個函數(shù)指針在編譯芽死、鏈接完成后就確定了乏梁,存放在代碼段。優(yōu)點:派發(fā)速度最快关贵,因為需要調用的指令集少遇骑,且編譯器還有很大的優(yōu)化空間(如:函數(shù)內斂 inline)。缺點:局限也是最大的揖曾,因為缺乏動態(tài)性落萎,所以沒法支持繼承。
1.2 table dispatch
Table dispatch 函數(shù)表派發(fā)炭剪,是編譯型語言實現(xiàn)動態(tài)行為最常見的實現(xiàn)方式练链。函數(shù)表使用一個數(shù)組來存儲類聲明的每個函數(shù)的指針。大部分語言把這個稱之為 Virtual Table 虛函數(shù)表奴拦,Swift 里的協(xié)議則為 Witness Table 媒鼓。每個類維護一個虛函數(shù)表,記錄著類的所有函數(shù)。如果被 override 的話绿鸣,表里只會保存 override 后的函數(shù)瓷产。子類新增函數(shù)會被插到這個數(shù)組的最后,沒有位置可以讓 extension 安全的插入函數(shù)枚驻。優(yōu)點:可擴展缺點:速度慢濒旦,編譯器對某些含有副作用的函數(shù)無法優(yōu)化
1.3 objc_msgSend
基于 Objc RunTime 實現(xiàn),沿著實例的 isa 指針進行查找再登,找不到最后還有3次拯救機會尔邓。詳細可見:iOS_Objective-C 消息發(fā)送(消息查找 及 消息轉發(fā))過程優(yōu)點:最動態(tài)的方式,可在運行時改變函數(shù)行為锉矢。不只可以通過 swizzling 來改變梯嗽,甚至可以用 isa-swizzling 修改對象繼承關系,可以在面向對象基礎上實現(xiàn)自定義派發(fā)缺點:速度最慢
為了方便理解沽损,我整理了如下表格:
Swift數(shù)組越界的處理
回歸正題灯节,通過對Swift消息派發(fā)機制的了解,我們可以知道對于NSMutableArray系統(tǒng)用的是消息派發(fā)绵估,對于Array用的是直接(靜態(tài))派發(fā)炎疆。對于普通的class用的是函數(shù)表派發(fā)。
前面2種国裳,上文已經(jīng)介紹過了形入,函數(shù)表派發(fā)的SILl類似下面:
sil_vtable xxx {
......
}
防止Array的越界,給數(shù)組添加擴展,下面是一個安全的數(shù)組取值在擴展中的實現(xiàn),其他增刪改查方法類似吸祟。
import Foundation
extension Array {
subscript (safe index: Index) -> Iterator.Element? {
return indices.contains(index) ? self[index] : nil
}
}
展望未來
- 方法交換的那種hook方案,無法在函數(shù)表派發(fā)和協(xié)議派發(fā)時生效音半,很明顯,函數(shù)沒有存在isa指針聯(lián)合結構體的method列表中,那么它在哪?對于虛函數(shù)表派發(fā)要怎么hook?
- swift的Mach-o文件和oc不一樣耳舅,以前做的從data段獲取無用類和無用函數(shù)的功能會失效,要如何兼容英妓?
- 蘋果正在計劃重構Foundation框架挽放,若以后Foundation和UIKit都用swift重構绍赛,我們之前做的全埋點方案蔓纠、行為日志以及其他hook方案的功能是否將會失效,要如何兼容吗蚌?