原文連接html
最近看了關於 Swift 底層原理的一些視頻和文章,收穫頗豐,感受對於編程語言有了新的理解。所以,趁熱打鐵,記錄並總結對 Swift 底層原理的理解。因爲相關的內容很是多,這裏準備分紅多篇文章來進行闡述。git
本文主要介紹關於 Swift 性能優化的一些基本概念。編程語言的性能主要涵蓋三個指標:github
下面,以 Swift 爲例,分別對這三個指標進行介紹。編程
每個進程都有獨立的進程空間,以下圖所示。進程空間中可以用於內存分配的區域主要分爲兩種:swift
爲何會有這兩種區別呢?由於它們的設計目的不一樣。數組
棧區主要用於函數(方法)調用和局部變量管理,每調用一次函數,就會在棧區中生成一個棧幀,棧幀中包含函數運行時產生的局部變量。函數調用返回後當即執行出棧,全部局部變量就此銷燬。緩存
堆區主要用於多線程模型,每一個線程有獨立的棧區,但卻共享同一個堆區,多線程之間經過堆區進行數據訪問,對此咱們須要對堆區的數據進行鎖定和同步。安全
Swift 中的數據類型能夠分紅兩種:值類型、引用類型。二者的內存分配區域是不一樣的,值類型默認分配在棧區,引用類型默認分配在堆區。性能優化
值類型,包括:基本數據類型、結構體,默認在棧區進行分配。棧區的內存都是連續的,經過入棧和出棧進行分配和銷燬,速度很快,比堆區的分配速度更快。數據結構
下面,經過 WWDC 的一個例子來講明:
struct Point {
var x, y: Double
func draw() { ... }
}
let point1 = Point(x: 0, y: 0)
var point2 = point1
point2.x = 5
複製代碼
其內存分配及佈局以下圖所示:
上述 Struct
的內存是在棧區分配的。將 point1
賦值給 point2
會在棧區分配一塊內存區域,建立新的實例。二者相互獨立,操做互不影響。
引用類型,如:類,默認分配在堆區。堆區的內存採用徹底二叉樹的形式進行維護,屢次進行分配/銷燬以後,堆區的內存空間就能難連續。所以,在分配內存時,須要查詢可用的內存,因此比棧區的分配速度更慢。
下面,經過 WWDC 的一個例子來講明:
class Point {
var x, y: Double
func draw() { ... }
}
let point1 = Point(x: 0, y: 0)
let point2 = point1
point2.x = 5
複製代碼
其內存分配及佈局以下圖所示:
上述 Class
的內存是在堆區分配的,棧區僅僅分配了 point1
和 point2
兩個指針。值得注意的是,爲了管理對象內存,在堆區初始化時,除了分配屬性內存(本例中是 Double
類型的 x
, y
),還分配了兩個字段:type
、refCount
。其中,type
表示類型,refCount
表示引用計數。
從內存分配角度而言,Class
在堆區分配,使用了指針,經過引用計數進行管理,具備更強大的特性,可是性能較低。
所以,對於須要頻繁分配內存的需求,應儘可能使用 Struct
代替 Class
。由於棧區的內存分配速度更快,更安全。
在上述堆區分配中提到,對象在堆區初始化時會額外分配兩個字段,其中一個就是用於引用計數。Swift 經過引用計數管理堆區的對象內存,當引用計數爲 0 時,Swift 會將對應的內存釋放。一方面,引用計數的管理是一個很是高頻的操做,另外一方面,因爲對象處於堆中,還需額外考慮多線程安全,因此產生引用計數的操做會有較高的性能消耗。
對於數據結構而言,只要包含引用類型,就會出現堆區分配。一旦產生堆區分配,則必然出現引用計數。下面,以一個例子來講明:
struct Label {
var text: String
var font: UIFont
func draw() { ... }
}
let label1 = Label(text: "Hi", font: font)
let label2 = label1
複製代碼
其內存分配及佈局以下所示:
對比 Struct Label
和前文的 Class Point
,雖然屬性數量相同,可是 Struct Label
產生的引用計數要比 Class Point
多一倍!
以下圖所示,是關於複雜 Struct
和 Class
結構引用計數數量的對比。
對於 Struct
類型,再次引用時會觸發內存拷貝,由此引用計數數量會呈倍數增加;對於 Class
類型,則只會增長一次引用計數。
所以,咱們應該儘可能避免在 Struct
類型中包含引用類型,由於這可能產生大量的引用計數。
對於經常使用的引用類型 String
,咱們可使用精確類型 UUID
或者 Enum
來替代。
派發方式,也可稱爲 函數派發 或 方法派發,是程序調用一個函數的機制。編譯型語言有三種派發方式:
根據函數調用可否在編譯時或運行時肯定,能夠將派發機制分紅兩種類型:
其中,直接派發屬於靜態派發,函數表派發、消息派發屬於動態派發。
大多數編程語言都會支持一到兩種派發方式,Java 默認使用函數表派發,可是能夠經過 final
修飾符改爲直接派發。C++ 默認使用直接派發,可是能夠經過 virtual
修飾符改爲函數表派發。Objective-C 老是使用消息派發,可是容許開發者使用 C 直接派發來得到性能的提高。
下面,依次來介紹這三種派發方式。
直接派發是最快的,緣由是調用的指令少,並且還能夠經過編譯器進行優化,如:代碼內聯。其缺點是缺乏動態性,所以沒法支持繼承。
下面,以一個例子來講明:
struct Point {
var x, y: Double
func draw() { ... }
}
func pointDraw(_ point: Point) {
point.draw()
}
let point = Point(x: 0, y: 0)
pointDraw(point)
// point.draw()
複製代碼
在這個狀況下,編譯器會對代碼進行內聯優化,調用 pointDraw()
方法會變成直接調用 point.draw()
。這樣,函數調用棧會減小一層,從而可以進一步提高性能。
函數表派發是編譯型語言中爲實現動態行爲而使用的一種最多見的實現方式。函數表使用一個數組來存儲類所聲明的每個函數的指針。大部分語言將其稱爲「virtual table」(虛函數表),Swift 中也稱爲 「virtual table」。除此以外,Swift 還包含 「witness table」(見證表),主要用於實現協議類型和泛型的動態派發。
在函數表派發的實現中,每個類都會維護一個函數表,裏面記錄着全部的全部的函數指針。若是子類將父類的函數 override
,那麼子類的函數表只會保存 override
以後的函數指針。若是子類添加新的函數,則會在子類的函數表的最後插入新的函數指針。運行時會根據對應類的函數表去查找要指定的函數。
下面,以一個例子來講明:
class ParentClasss {
func method1() { ... }
func method2() { ... }
}
class ChildClass: ParentClass {
override func method2() { ... }
func method3() { ... }
}
let objc = ChildClass()
obj.method2()
複製代碼
在這個狀況下,編譯器會爲 ParentClass
和 ChildClass
各自建立一個函數表。以下圖所示,展現了 ParentClass
和 ChildClass
函數表中各個方法在內存中的佈局。
當一個函數被調用時,會經歷如下幾個步驟:
0xB00
的函數表。method2
的索引是 1(偏移量),即 0xB00+1
。method2
的位置是 0x222
。查表是一種簡單、易實現,且性能可預知的實現方式。一方面,因爲多了一次查找和跳轉,另外一方面,因爲編譯器沒法經過類型推導進一步進行優化,因此相比直接派發而言,函數表派發的性能稍差。
消息派發是一種更加動態的函數調用方式。ObjC 中的 KVO、UIAppearence、CoreData 都是對這種機制的運用。消息派發能夠在運行時改變函數的行爲,如:ObjC 中的 swizzling 技術。消息派發甚至還能夠在運行時修改對象的繼承關係,如:ObjC 中的 isa-swizzling 技術。
下面,以一個例子來講明:
class ParentClass {
dynamic func method1() { ... }
dynamic func method2() { ... }
}
class ChildClass: ParentClass {
override func method2() { ... }
dynamic func method3() { ... }
}
複製代碼
在這個狀況下,會利用 Objective-C 的運行時進行消息派發。每一個類只包含本身所定義的方法,一旦調用的方法不存在,會經過父類指針,去父類中進行查找,以此類推。以下圖所示。
當消息被派發時,運行時會順着繼承關係向上查找被調用的方法。很顯然,消息派發要比函數表派發的效率更低。爲了可以提高消息派發的性能,通常都會將查找進行緩存,從而讓效率接近函數表派發。
Swift 支持上述三種派發方式,那麼 Swift 是如何選擇派發方式呢?事實上,影響 Swift 的派發方式有如下幾個方面:
在 Swift 中,一個函數有兩個能夠聲明的位置。
// 初始聲明的做用域
class MyClass {
func mainMethod() { ... }
}
// 擴展聲明的做用域
extension MyClass {
func extensionMethod() { ... }
}
複製代碼
其中,初始聲明的做用域中的函數 mainMethod
會使用 函數表派發;擴展聲明的做用域中的函數 extensionMethod
會使用 直接派發。
上述例子是關於 Class
類型中不一樣的聲明位置對於派發方式的影響。事實上,不一樣的類型的做用域中聲明的函數,派發方式也不必定相同。下表展現了默認狀況下,類型、聲明位置與派發方式的關係圖。
Initial Declaration | Extension Declaration | |
---|---|---|
Value Type | static | static |
Protocol | table | static |
Class | table | static |
NSObject Subclass | table | message |
上表的總結有如下幾點:
Protocol
類型:初始聲明使用 函數表派發,擴展聲明使用 直接派發。即默認實現均使用Class
類型:初始聲明使用 函數表派發,擴展聲明使用 直接派發。NSObject
類型:初始聲明使用 函數表派發,擴展聲明使用 消息派發。Swift 有一些修飾符能夠指定派發方式。
final
final
修飾符容許類中的函數使用 直接派發。final
修飾符會讓函數失去動態性。任何函數均可以使用 final
修飾符,包括 extension
中本來就是直接派發的函數。
須要注意的是,Objective-C 的運行時獲取不到使用 final
修飾符的函數的 selector
。
dynamic
dynamic
修飾符容許類中的函數使用 消息派發。使用 dynamic
修飾符以前,必須導入 Foundation
框架,由於框架中包含了 NSObject
和 Objective-C 的運行時。dynamic
修飾符能夠修飾全部的 NSObject
子類和 Swift 原生類。
此外,dynamic
修飾符可讓擴展聲明(extension
)中的函數也可以被 override
。
@objc
& @nonobjc
@objc
和 @nonobjc
顯式地聲明瞭一個函數可否被 Objective-C 運行時捕獲到。
@objc
典型的用法就是給 selector
一個命名空間 @objc(xxx_methodName)
,從而容許該函數能夠被 Objective-C 的運行時捕獲到。
@nonobjc
會改變派發方式,能夠禁用消息派發,從而阻止函數註冊到 Objective-C 的運行時中。@nonobjc
的效果相似於 final
,使用的場景幾乎也是同樣,我的猜想,@nonobjc
主要是用於兼容 Objective-C,final
則是做爲原生修飾符,以用於讓 Swift 寫服務端之類的代碼。
final @objc
在使用 final
修飾符的同時,可使用 @objc
修飾符讓函數可使用消息派發。同時使用這兩個修飾符的結果是:調用函數時會使用直接派發,但也會在 Objective-C 運行時中註冊響應的 selector
。函數能夠響應 perform(seletor:)
以及別的 Objective-C 特性,但在直接調用時又能夠具備直接派發的性能。
@inline
@inline
修飾符告訴編譯器函數可使用直接派發。
Swift 會在這上面作優化,好比一個函數沒有 override,Swift 就可能會使用直接派發的方式,因此若是屬性綁定了 KVO,那麼屬性的 getter 和 setter 方法可能會被優化成直接派發而致使 KVO 的失效,因此記得加上 dynamic 的修飾來保證有效。後面 Swift 應該會在這個優化上去作更多的處理。
下表總結了引用類型、修飾符對 Swift 派發方式的影響。
Direct Dispatch | Table Dispatch | Message Dispatch | |
---|---|---|---|
NSObject | @nonobjc , final |
Initial Declaration | Extension Declaration, dynamic |
Class | Extension Declaration, final |
Initial Declaration | dynamic |
Protocol | Extension Declaration | Initial Declaration | @objc |
Value Type | All Method | n/a | n/a |
本文總結了評測 Swift 性能的幾個方面,咱們能夠經過內存分配、引用計數、派發方式等幾個方面了對 Swift 代碼進行優化。
整體而言,對於內存分配,咱們應該儘可能使用棧區內存分配;對於引用計數,咱們須要進行權衡,使用引用計數能帶來靈活性,但也會帶來性能開銷;對於派發方法,咱們應該儘可能使用更加高效的派發方式,同時也須要進行權衡,動態派發可以帶來更強大的編程特性,但也會帶來性能開銷。