Swift奇趣漫談A

語言: CN / TW / HK

Swift奇趣漫談

一、前言

這個系列的文章,是記錄自己在學習和使用Swift的過程中,遇到的一些奇怪或者有趣的知識點,有些地方甚至和自己的直覺是相反的。在這個過程中,自己會結合彙編、SIL中間程式碼,原始碼等角度去探索。

Swift是完全開源的語言,Swift主頁地址,主要採用C++編寫,Swift編譯過程如下:

Swift編譯過程

如上圖所示,Swift編譯流程是: SwiftCode程式碼 -> Swift AST語法樹 -> Raw Swift IL特有中間程式碼 -> Canonical Swift IL特有簡潔中間程式碼 -> LLVM IR中間程式碼 -> Assembly彙編程式碼 -> Executable二進位制

所以我們瞭解和學習Swift的方式有很多,比如閱讀原始碼,分析SIL中間語言、檢視彙編程式碼等。比如新建一個main.swift,可以按照下面的指令,生成對應的中間程式碼: ```Shell

生成語法樹

swiftc -dump-ast main.swift

生成最簡介的SIL程式碼

swiftc -emit-sil main.swift

生成LLVM IR程式碼

swiftc -emit-ir main.swift -o main.ll

生成彙編程式碼

swiftc -emit-assembly main.swift -o main.s ```

二、彙編和除錯

2.1 暫存器和記憶體

通常,CPU會先將記憶體中的資料儲存到暫存器中,然後再對暫存器中的資料進行運算。 假設記憶體中有塊紅色空間的值是3,現在想把它的值加1,並將結果儲存到藍色空間,彙編過程是:

彙編過程

對應的彙編虛擬碼是: perl // 1.CPU會先將紅色記憶體空間的值,放到暫存器中: movq 紅色記憶體空間, %rax // 2.然後讓 rax 暫存器和1相加 addq $0x1, %rax // 3.最後將值賦值給記憶體空間 movq %rax, 藍色記憶體空間

2.2 彙編基礎

彙編有很多種類,常見的有 Intel彙編(Windows派系)、AT&T彙編(Unix派系)、ARM彙編等。 但是,在iOS開發中,最主要的組合語言是: - AT&T彙編 -> iOS模擬器 (M1晶片的電腦未知,猜測是ARM彙編) - ARM彙編 -> iOS真機裝置

常見的彙編指令(注意:AT&T帶有運算元長度和%)

彙編指令

這裡需要注意movqleaq的區別(類似值型別和引用型別的感覺): ```shell

將 rbp-0x18 指向記憶體地址的值,取出來賦值給 rax

movq -0x18(%rbp), %rax

將 rbp-0x18 指向記憶體地址,直接賦值給 rax

leaq -0x18(%rbp), %rax ```

有16個常用的暫存器(加粗為常見的) - rax, rbx, rcx , rdx, rsi, rdi, rbp, rsp - r8, r9, r10, r11, r12, r13, r14, r15

暫存器的具體用途規律: - rax、rdx常作為函式的返回值使用 - rdi、rsi、rdx、rcx、r8、r9 等暫存器常作為函式的引數使用 - rsp、rbp用於棧操作

rip作為指令指標: - 儲存著CPU下一條要執行的指令的地址 - 一旦CPU讀取一條指令、rip會自動指向下一條指令(儲存下一條指令的地址)

小技巧:除外上面列出來的暫存器,還有很多其他暫存器。其中r開頭的暫存器,都是64位暫存器(8位元組),e開頭的暫存器都是32位暫存器(4位元組),ax、bx、cx開頭都是16位暫存器(2位元組),ah/al/bh/bl開頭都是8位暫存器(1個位元組)

Xcode打斷點,進入彙編顯示頁面的方法: Xcode選單欄-->Debug-->Debug Workflow-->Always Show Disassembly

2.3 LLDB常用指令

LLDB是Xcode預設的偵錯程式。

LLDB

舉例子來說: Delphi register read rax // 讀取rax暫存器地址 register read // 當前可用的暫存器 x/3xw 0x00007fff816a7330 // 讀取記憶體中的值 // 3組資料 x:16進位制 w:4個位元組為一組

2.4 LLDB斷點除錯

LLDB

在真機開發過程中,有個記憶體地址規律: 記憶體地址格式為:0x4bdc(%rip),一般是全域性變數、全域性區(資料段) 記憶體地址格式為:-0x78(%rbp),一般是區域性變數、棧空間 記憶體地址格式為:0x10(%rax),一般是堆空間

三、彙編舉例

==3.1== 通過彙編,發現sizeof()的本質是編譯器關鍵字,而不是函式。 objc int main(int argc, const char * argv[]) { int c = sizeof(int); int b = 5; // 此處打斷點 return 0; } 打斷點進入彙編指令的截圖:

sizeof

==3.2== Swift裡面inout的執行彙編截圖: swift var a = 5 func test(_ number: inout Int) { number = 8 } test(&a) // 斷點處 執行,進入彙編指令頁面: inout

繼續跟進彙編指令,進入callq裡面:

inout

如果,去掉inout,將函式改造之後,再對比彙編指令: swift var a = 5 func test(_ number: Int) { } test(a) // 斷點處

inout

mov指令直接將a的值,賦值給了形參。lea指令將a的記憶體地址傳遞到了函式內部。說明了inout的工作原理。

==3.3== Swift是有預設的main函式的,只是不用自己去寫,當我們進入斷點之後,能看到彙編頁面的頂部有main方法的入口: Delphi DemoSwift`main: // 進入彙編,看到main呼叫 0x100003f00 <+0>: pushq %rbp 0x100003f01 <+1>: movq %rsp, %rbp

==3.4== 所有的函式(方法)不管有沒有返回值,編譯後,都會有return的,這也是call指令和jump指令的關鍵區別,程式碼演示:

swift func testFunc() { let a = 5 let b = 6 let c = a + b } testFunc() let a = 5 // 斷點位置

return

  • 從另外的角度,函式執行之後,需要回到呼叫函式的位置,所以必然有ret指令。
  • call指令是呼叫函式,必然會有ret指令回來繼續執行。
  • jump是直接跳到對應的指令執行,不會再回來。

四、Swift奇趣漫談

開始步入正題...

4.1 Swift的函式過載

1.返回值型別 和 函式過載無關,下面三個函式,同時存在,是可以編譯通過的。 swift func sum(v1: Int, v2: Int) -> Int { 1 } func sum(v1: Int, v2: Int) -> Double { 1.0 } func sum(v1: Int, v2: Int) { }

奇怪點來了😭:雖然編譯通過了,但是如果呼叫,就會報錯: ```swift func sum(v1: Int, v2: Int) -> Int { 1 } func sum(v1: Int, v2: Int) -> Double { 1.0 } func sum(v1: Int, v2: Int) { }

// ❌報錯:Ambiguous use of 'sum(v1:v2:)' sum(v1: 10, v2: 20) ```

4.2 函式過載和預設引數

當函式帶有預設引數值, 和函式過載一起使用時,會有奇怪的地方,程式碼: swift func sum(v1: Int, v2: Int) -> Int { v1 + v2 } func sum(v1: Int, v2: Int, v3: Int = 10) -> Int { v1 + v2 + v3 } // 會呼叫誰? 結果是什麼? print( sum(v1: 10, v2: 20) )

奇怪點:首先,上面是編譯通過的,但是從呼叫角度來說,2個函式都是對的,不過結果卻是30,呼叫了第一個函式。對於開發者而言,其實是有歧義的。

4.3 省略引數標籤和可變引數

當省略引數標籤和可變引數一起出現時,會呼叫誰呢?程式碼: swift func sum(_ v1: Int, _ v2: Int) -> Int { print("呼叫了 -- sum1") return v1 + v2 } func sum(_ numbers: Int...) -> Int { print("呼叫了 -- sum2") var total = 0 for item in numbers { total += item } return total } print( sum(10, 20) ) 上面的程式碼,從呼叫側來看,呼叫上下兩個函式都是正確的,但是結果是呼叫了 -- sum1,那麼這是為什麼呢?

4.4 函式過載總結

當我們把這幾個函式放到一起,也是能編譯通過的,但是最後呼叫了誰呢?這個讀者可以自己去試試: ```swift func sum(_ numbers: Int...) -> Int { print("sum2") var total = 0 for item in numbers { total += item } return total }

func sum(_ v1: Int, _ v2: Int) -> Int { print("sum1") return v1 + v2 }

func sum(_ v1: Int, _ v2: Int, _ v3: Int = 10) -> Int { print("sum3") return v1 + v2 }

print( sum(10, 20) ) // 你猜會呼叫哪個函式? ```

解釋:關於Swift的函式過載,Swift內部到底怎麼選擇執行哪個函式的?Swift在執行過載解析時,型別檢查器會對每個過載的函式,進行打分,找到一個分數最高的來執行,至於打分的標準,在下面的原始碼裡,規則有很多,比如:非泛型的得分高於泛型的。

原始碼地址: https://github.com/apple/swift/blob/main/lib/Sema/CSRanking.cpp

需要注意的是:Swift的這種帶有歧義的函式過載,在C++裡面是通不過編譯的。

4.5 Swift行內函數

如果開啟了編譯器優化(Release模式預設會開啟優化),編譯器會自動將某些函式變成行內函數. - Inline Function 其實就是將函式體呼叫展開成函式體

但是,即使開啟了編譯器優化,有些函式還是不會被內聯: - 函式體比較長,比如超過500行 - 包含遞迴呼叫(尾遞迴除外) - 包含動態派發

主動開啟編譯器優化:optimization optimization

關於函式內聯的2個關鍵字: swift // 永遠不會被內聯,即使開啟了編譯器優化 @inline(never) func test1() {} // 開啟編譯器優化後,即使程式碼很長,也會被內聯(遞迴呼叫、動態派發的函式除外) @inline(__always) func test2() {}

4.6 列舉的初始化

Swift的列舉是值型別,struct也是值型別,那麼二者有哪些區別呢?比如下面的程式碼: ```swift enum MyEnum { case test1(Int, Int, Int) case test2 }

struct MyStruct { let age: Int }

let e = MyEnum.test1(2, 3, 4) // 斷點處 let p = MyStruct(age: 5) ``` 執行進入斷點,在彙編指令頁面,我發現了神奇的對比:

Enum

列舉enum沒有初始化方法,而結構體struct是有初始化方法的。也就是我們建立enum變數時,系統識別到是列舉型別後,開啟記憶體,直接開始mov指令初始化記憶體。而struct是需要call指令呼叫init初始化方法的。

關於識別Metadata的相關內容,可以看看下面兩篇文章: - 初探Swift底層Metadata - Swift Hook 新思路 -- 虛擬函式表

4.7 列舉關聯值和原始值的記憶體區別

列舉關聯值(Associated Values):列舉的成員值和其他型別的值,關聯儲存在一起: ```swift enum Date { case digit(yeaer: Int, month: Int, day: Int) case desc(String) } var d1 = Date.digit(yeaer: 2021, month: 10, day: 1) d1 = .desc("2021-10-01")

// 利用case模式,可以獲得關聯值,比如當是Date.digit型別,拿到year if case let .digit(yeaer: year, month: , day: ) = d1 { print(year) } ```

列舉原始值(Raw Values):列舉成員可以使用相同型別的預設值預先對應,這個預設值叫做:原始值,注意,如果原始值型別是String、Int,Swift會自動分配原始值。 swift enum Grade: String { case perfect = "A" case great = "B" case good = "C" } print(Grade.great) print(Grade.perfect.rawValue) // rawValue獲取原始值

列舉關聯值和原始值的記憶體對比: ```swift // 1.關聯值 enum Password { case number(Int, Int, Int, Int) case other }

var pwd = Password.number(1, 2, 3, 4) pwd = .other

print( MemoryLayout.size(ofValue: pwd) ) // 33 實際用到的記憶體空間大小 print( MemoryLayout.stride(ofValue: pwd) ) // 40 系統分配佔用的空間大小,會記憶體對齊 print( MemoryLayout.alignment(ofValue: pwd) ) // 8 記憶體對齊引數

print("---------------")

// 2.原始值 enum Names: Int { case xiaoMing = 2, xiaoHong = 5, xiaoHou = 6, XiaoLi = 9 }

let name = Names.XiaoLi

print( MemoryLayout.size(ofValue: name) ) // 1 實際用到的記憶體空間大小 print( MemoryLayout.stride(ofValue: name) ) // 1 系統分配佔用的空間大小,會記憶體對齊 print( MemoryLayout.alignment(ofValue: name) ) // 1 記憶體對齊引數 `` ==結論:== 原始值 是固定死的,可以通過程式碼switch判斷返回,所以不用佔儲存記憶體。 關聯值 是要要外面傳入的,是會變化的,必須得存起來,是會佔用記憶體的。 - 1個位元組儲存成員值 - N個位元組儲存關聯值(N取佔用記憶體最大的關聯值),任何一個case`的關聯值都共用這N個位元組

我們可以檢視記憶體,來驗證我們的結論,使用Xcode的Debug->Debug Workflow->View Member工具,來檢視記憶體: ```swift /// 獲得變數的記憶體地址 func memPtr(ofValue v: inout T) -> UnsafeRawPointer { if MemoryLayout.size(ofValue: v) == 0 { return UnsafeRawPointer(bitPattern: 0x1)! } return withUnsafePointer(to: &v) { UnsafeRawPointer($0) } }

enum TestEnum { case test1, test2, test3 } var t = TestEnum.test1 print(memPtr(ofValue: &t)) // 列印記憶體地址,複製貼上到View Member中檢視記憶體 print("--------我是分割線,為了方便斷點--------")

t = .test2 print("--------我是分割線,為了方便斷點--------")

t = .test3 print("--------我是分割線,為了方便斷點--------") ```

memory

相應的,我們使用相同的辦法,驗證關聯值型別列舉的記憶體: ```swift enum TestEnum { case test1(Int, Int, Int) case test2(Int, Int) }

var t = TestEnum.test1(5, 6, 7) print(memPtr(ofValue: &t)) print("--------我是分割線,為了方便斷點--------")

t = .test2(8, 9) print("--------我是分割線,為了方便斷點--------") ```

memory

注意,因為模擬器CPU是小端模式,所以記憶體是從低到高的倒著佈局

4.8 ??和if-let配合使用

這算是小技巧,在一些第三方庫中,能看到下面程式碼的寫法,當想同時判斷2個Optional時,下面的寫法比較簡潔: ```swift let a: Int? = 1 let b: Int? = 2 let c = a ?? b ?? 3 // c是Int, 1

let a: Int? = nil let b: Int? = 2 if let c = a ?? b { // 類似於 if a != nil || b != nil print(c) } if let _ = a ?? b { } // 或者這種奇怪的程式碼 ```

==結論:== ??返回值類的型別,就看 最後一個??後面的變數型別,和後一個變數型別一致。

4.9 多重可選項

觀察程式碼如下: swift let num1: Int? = 10 let num2: Int?? = num1 // 注意,這裡的??不是空合併運算子 let num3: Int?? = 10 問題:num2和num3的區別是什麼?比如print(num2 == num3)結果是什麼? 程式碼修改之後: ```swift let num1: Int? = nil let num2: Int?? = num1 // 注意,這裡的??不是空合併運算子 let num3: Int?? = nil

// 下面分別輸出什麼? print(num2 == num3) print( (num2 ?? 1) ?? 2 ) print( (num3 ?? 1) ?? 2 ) ```

對於上面的問題,我們需要理解Optional的本質,我們都知道Optional的本質就是列舉: swift public enum Optional<Wrapped> : ExpressibleByNilLiteral { case none case some(Wrapped) }

所以,我們可以通過使用LLDB指令frame variable -R或者fr v -R檢視區別(後者只是前者的縮寫)。打上斷點,在控制檯,可以檢視指令的介紹: shell help frame help frame variable 執行打斷點的截圖:

Optional

如果使用盒子的方式去理解,就是下面的圖,注意盒子顏色的含義:

Optionoal

Optional

4.10 結構體初始化器的下面2種寫法,是否一樣?

```swift struct Point { var x: Int = 5 var y: Int = 6 }

struct Point { var x: Int var y: Int

init() { // 寫出來了init() x = 5 y = 6 } } ```

結論: 二者寫法完全等價。可以通過彙編指令檢視,二者彙編指令是完全一致的。

4.11 Swift類的初始化過程

```swift class Person { let age: Int init(age: Int) { self.age = age } }

let p = Person(age: 18) // 斷點 ```

通過進入彙編指令頁面, 一步一步跟進,發現一個Swift類初始化,必然經過下面的步驟: - 1.Class.init() - 2.Class.__allocating_init() - 3.libswiftCore.dylib: swift_allocObject - 4.libswiftCore.dylib: swift_slowAlloc - 5.libsystem_malloc.dylib: malloc

在Mac、iOS中的malloc函式分配的記憶體大小,總是16的倍數。同時,不管是Swift還是OC,初始化類,在堆空間開闢記憶體,最終都是呼叫的malloc函式。

malloc

4.12 Swift的超類

下面程式碼分別輸出什麼? ```swift class Person {} class Student: Person {}

print( class_getInstanceSize(Person.self) ) // 16 print( class_getSuperclass(Student.self)! ) // Persong print( class_getSuperclass(Person.self)! ) // _TtCs12_SwiftObject ```

結論:純Swift類,是有個共同的隱藏的基類:Swift.SwiftObject 這個Swift原始碼裡也可以得到證明: https://github.com/apple/swift/blob/master/stdlib/public/runtime/SwiftObject.h

完...等攢夠一波,再輸出第二篇...

END。
我是小侯爺。
在帝都艱苦奮鬥,白天是上班族,晚上是知識服務工作者。
如果讀完覺得有收穫的話,記得關注和點贊哦。
非要打賞的話,我也是不會拒絕的。