iOS 面試題·Block 的原理,Block 的屬性修飾詞為什麼用 copy,使用 Block 時有哪些要注意的?
Linux程式設計點選右側關注,免費入門到精通!
作者丨彭序猿 https://www.jianshu.com/p/4db3b4f1d522
前言
Block 在平時開發中經常使用,它是 Objective-C 對 閉包 是實現,定義如下:
Block 是一個裡面儲存了指向定義 block 時的程式碼塊的函式指標,以及block外部上下文變數資訊的結構體。
簡單來說就是:帶有自動變數的匿名函式。
本篇文章不會闡述 Block 的使用語法,有需要了解 Block 語法可以檢視文末的參考連結。本文主要通過學習 Block 原始碼來了解 Block 實現原理、記憶體相關知識、以及如何截獲外部變數,然後再通過一些常見的 Block 面試題,進一步加深對 Block 的理解。
Block 物件記憶體相關知識
iOS 記憶體分佈,一般分為:棧區、堆區、全域性區、常量區、程式碼區。其實 Block 也是一個 Objective-C 物件,常見的有以下三種 Block:
NSMallocBlock :存放在堆區的 Block
NSStackBlock : 存放在棧區的 Block
NSGlobalBlock : 存放在全域性區的 Block
通過程式碼實驗(宣告 strong、copy、weak 修飾的 Block,分別引用全域性變數、全域性靜態變數、區域性靜態變數、普通外部變數) ,得出初步的結論:
1.Block 內部沒有引用外部變數,Block 在全域性區,屬於 GlobalBlock
2.Block 內部有外部變數:
a.引用全域性變數、全域性靜態變數、區域性靜態變數:Block 在全域性區,屬於 GlobalBlock
b.引用普通外部變數,用 copy,strong 修飾的 Block 就存放在堆區,屬於 MallocBlock;用 weak 修飾的Block 存放在棧區,屬於 StackBlock
注意:Block 引用普通外部變數,都是在棧區建立的,只是用 strong、copy 修飾的 Block 會把它從棧區拷貝到堆區一份,而 weak 修飾的 Block 不會;
通過上面可以知道,在 ARC 中,用 strong、copy 修飾的 Block,會從棧區拷貝到堆區,所以在 ARC 中,用 strong 修飾和 copy 修飾的 Block 效果是一樣的;
Block 原始碼分析
利用 Clang 將 Objective-C 程式碼轉換成 C++ 程式碼
通過 clang 命令將 Objective-C 程式碼轉換成 C++ 程式碼,可以瞭解其底層機制,有助於我們更加深刻的認識其實現原理。下面是 clang 相關命令:
//1.最簡單的命令: clang -rewrite-objc mian.m //2.但是如果遇到 main.m:9:9: fatal error: 'UIKit/UIKit.h' file not found 類似的錯誤需要我們指定下框架 xcrun -sdk iphonesimulator11.4 clang -S -rewrite-objc -fobjc-arc -fobjc-runtime=ios-11.4 main.m //3.展示 SDK 版本命令 xcodebuild -showsdks
通過原始碼斷點除錯 Block
上面 clang 命令只是將 Objective-C 程式碼轉換成 C++ 程式碼,但是有時候我們想進一步瞭解 Block 整個的執行過程,我們可以通過 Block 底層原始碼一步一步斷點來研究 Block 的執行過程。
1.首先我們可以去官網上面下載 Block 原始碼:
https://opensource.apple.com/source/libclosure/libclosure-65/
2.然後將原始碼中缺少的庫新增進入工程,具體操作可以參考這篇 Blog:
https://blog.csdn.net/WOTors/article/details/54426316
通過上面兩個步驟,我們就有一個包含 Block 原始碼的工程,然後可以編寫 Block 程式碼,去斷點觀察 Block 具體的執行過程。
配置工程還是比較麻煩的,這裡我上傳了一份:BlockSourceCode
https://github.com/pengxuyuan/PXYFMWK/tree/master/BlockSourceCode
分析簡單的 Block C++ 原始碼
首先我們通過 clang 將 Block Objective-C 程式碼轉換成以下 C++ 程式碼,下面是主要程式碼:
struct __block_impl { void *isa; int Flags; int Reserved; void *FuncPtr; }; static struct __block_desc_0 { size_t reserved; size_t Block_size; } _block_desc_0_DATA = { 0, sizeof(struct __block_desc_0)}; struct _block_impl_0 { struct __block_impl impl; struct __block_desc_0* Desc; int i; // 這個是引用外部變數 i _block_impl_0(void *fp, struct __block_desc_0 *desc, int _i, int flags=0) :i(_i){ impl.Flags = flags; impl.FuncPtr = fp; Desc = desc; } };
通過分析上面原始碼,我們可以得到下面幾點結論:
1.結構體中有 isa 指標,證明 Block 也是一個物件
2.Block 底層是用結構體來實現的,結構體 _block_impl_0 包含了 __block_impl 結構體和 __block_desc_0 結構體。
3.__block_impl 結構體中的 FuncPtr 函式指標,指向的就是我們的 Block 的具體實現。真正呼叫 Block 就是利用這個函式指標去呼叫的。
4.為什麼能訪問外部變數,就是因為將外部變數複製到了結構體中(上面的 int i),即自動變數會作為成員變數追加到 Block 結構體中。
分析具有 __block 修飾符外部變數的 Block 原始碼
我們知道 Block 截獲外部變數是將外部變數作為成員變數追加到 Block 結構體中,但是匿名函式存在作用域的問題,這個就是為什麼我們不能在 Block 內部去修改普通外部變數的原因。所有就出現了 __block 修飾符來解決這個問題。
下面我們來看下 __ block 修飾的變數轉換成 C++ 程式碼是什麼樣子的。
//Objective-C 程式碼 - (void)blockDataBlockFunction { __block int a = 100;///在棧區 void (^blockDataBlock)(void) = ^{ a = 1000; NSLog(@"%d", a); };///在堆區 blockDataBlock(); } //C++ 程式碼 struct __Block_byref_a_0 { void *__isa; __Block_byref_a_0 *__forwarding; int __flags; int __size; int a; }; struct __BlockStructureViewController__blockDataBlockFunction_block_impl_0 { struct __block_impl impl; struct __BlockStructureViewController__blockDataBlockFunction_block_desc_0* Desc; __Block_byref_a_0 *a; // by ref };
具有 __block 修飾的變數,會生成一個 Block_byref_a_0 結構體來表示外部變數,然後再追加到 Block 結構體中,這裡生成 Block_byref_a_0 這個結構體大概有兩個原因:一個是抽象出一個結構體,可以讓多個 Block 同時引用這個外部變數;另外一個好管理,因為 Block_byref_a_0 中有個非常重要的成員變數 forwarding 指標,這個指標非常重要(這個指標指向 Block_byref_a_0 結構體),這裡是保證當我們將 Block 從棧拷貝到堆中,修改的變數都是同一份。
forwarding 指標存在的理由,我們可以看 Block 儲存域一節。
Block 是如何解決儲存域問題
首先我們知道 Block 底層是用結構體,Block 會轉換成 block 結構體,__block 會轉換成 __block 結構體。
然後 block 沒有截獲外部變數、截獲全域性變數的都是屬於全域性區的 Block,即 GlobalBlock;其餘的都是棧區的 Block,即 StackBlock;
對於全域性區的 Block,是不存在作用域的問題,但是棧區 Block 不同,在作用域結束後就會 pop 出棧,__block 變數也是在棧區的,同理作用域結束也會 pop 出棧。
為了解決作用域的問題,Block 提供了 Copy 函式,將 Block 從棧複製到堆上,在 MRC 環境下需要我們自己呼叫 Block_copy 函式,這裡就是為什麼 MRC 下,我們為什麼需要用 copy 來修飾 Block 的原因。
然而在 ARC 環境下,編譯器會盡可能給我們自動新增 copy 操作,這裡為什麼說盡量呢,因為有些情況編譯器無法判斷的時候,就不會給我們新增 copy 操作,這裡就需要我們自己主動呼叫 copy 方法了。
__block 變數的儲存域
Block 從棧複製到堆上,__block 修飾的變數也會從棧複製到堆上;為了結構體 __block 變數無論在棧上還是在堆上,都可以正確的訪問變數,我們需要 forwarding 指標;
在 Block 從棧複製到堆上的時候,原本棧上結構體的 forwarding 指標,會改變指向,直接指向堆上的結構體。這樣子就可以保證之後我們都是訪問同一個結構體中的變數,這裡就是為什麼 __block 修飾的變數,在 Block 內部中可以修改的原因了。
Block 截獲物件需要管理物件的生命週期
我們知道 Block 引用外部變數會將其追加到結構體中,但是編譯器是無法判斷 C 語言結構體的初始化和廢棄的,因此在 __block_desc_0 會增加成員變數 copy 和 dispose;以及 block_copy、block_dispose 函式。
用來 Block 從棧複製到堆、堆上的 Block 廢棄的時候分別呼叫。
Block 會出現迴圈引用
對於 Block 迴圈引用算是經典問題了,當 A 持有 B,B 又持有 A,這個時候就會出現迴圈引用。Block 對於外部變數都會追加到結構體中,所以在實現 Block 時候需要注意這個問題。
ARC 環境一般我們用 __weak 來打破,MRC 環境的話,我們可以使用 __block 來打破迴圈引用。
Block 面試題
1. 下面程式碼在 MRC 環境 和 ARC 環境執行的情況
void exampleA() { char a = 'A'; ^{ printf("%cn", a); }(); } //呼叫:exampleA();
答:首先這個 Block 引用了普通外部變數,所以這個 Block 是在棧上面建立的;Block 是在 exampleA() 函式內建立的,然後建立完馬上呼叫,這個時候 exampleA() 並沒有執行完,所以這個棧 Block 是存在的,不會被 pop 出棧。故在 MRC 和 ARC 上面都可以正確執行。
2. 下面程式碼在 MRC 環境 和 ARC 環境執行的情況
void exampleB_addBlockToArray(NSMutableArray *array) { char b = 'B'; [array addObject:^{ printf("%cn", b); }]; } void exampleB() { NSMutableArray *array = [NSMutableArray array]; exampleB_addBlockToArray(array); void (^block)() = [array objectAtIndex:0]; block(); } //呼叫:exampleB();
答:這個跟第一題區別就是將 Block 的建立放到一個函式中去。同理分析:exampleB_addBlockToArray 中建立的 Block 也是引用了普通外部變數,Block 建立在棧上。
MRC 環境上,呼叫 exampleB_addBlockToArray 函式,會建立一個棧 Block 存放到陣列中去,然後 exampleB_addBlockToArray 函式結束,Block 被 pop 出棧,這個時候再去呼叫 Block,Block 已經被釋放了,故出現異常,不能正確執行。
ARC 環境下,在 NSMutableArray 的 addObject 方法中,編譯器會自動執行 Copy 操作,將 Block 從棧拷貝到堆(StackBlock -> MallocBlock),故在 ARC 環境可以正確執行。
修改方案如下:
// 主動呼叫 copy 方法,將 Block 從棧拷貝到堆中,Block_copy(<#...#>) [array addObject:[^{ printf("%cn", b); } copy]];
3. 下面程式碼在 MRC 環境 和 ARC 環境執行的情況
void exampleC_addBlockToArray(NSMutableArray *array) { [array addObject:^{ printf("Cn"); }]; } void exampleC() { NSMutableArray *array = [NSMutableArray array]; exampleC_addBlockToArray(array); void (^block)() = [array objectAtIndex:0]; block(); } //呼叫:exampleC();
答:exampleC_addBlockToArray 中的 Block 並沒有引用外部變數,所以 Block 是建立在全域性區的,是一個 GlobalBlock,生命週期是跟隨著程式的,故 MRC、ARC 環境下都可以正確執行。
4. 下面程式碼在 MRC 環境 和 ARC 環境執行的情況
typedef void (^dBlock)(); dBlock exampleD_getBlock() { char d = 'D'; return ^{ printf("%cn", d); }; } void exampleD() { exampleD_getBlock()(); } //呼叫:exampleD();
答:這題跟第二題差不多,區別在於這裡是將 Block 作為函式返回值了;一樣棧區 Block 在 exampleD_getBlock 函式執行完就會釋放,MRC 環境下會呼叫異常,但是這裡編譯器能檢查到這種情況,這裡實際效果是編譯不通過。
在 ARC 環境下,Block 作為函式返回值,會自動呼叫 Copy 方法,將 Block 從棧複製到堆上(StackBlock -> MallocBlock),故 ARC 環境下可以正確執行。
5. 下面程式碼在 MRC 環境 和 ARC 環境執行的情況
typedef void (^eBlock)(); eBlock exampleE_getBlock() { char e = 'E'; void (^block)() = ^{ printf("%cn", e); }; return block; } void exampleE() { eBlock block = exampleE_getBlock(); block() } //呼叫:exampleE();
答:這題跟第四題是一樣的,這裡在 MRC 環境下,可以編譯通過,但是呼叫異常;ARC 環境下可以正確執行。
6. ARC 環境下輸入結果
__block NSString *key = @"AAA"; objc_setAssociatedObject(self, &key, @1, OBJC_ASSOCIATION_ASSIGN); id a = objc_getAssociatedObject(self, &key); void (^block)(void) = ^ { objc_setAssociatedObject(self, &key, @2, OBJC_ASSOCIATION_ASSIGN); }; id m = objc_getAssociatedObject(self, &key); block(); id n = objc_getAssociatedObject(self, &key); objc_setAssociatedObject(self, &key, @3, OBJC_ASSOCIATION_ASSIGN); id p = objc_getAssociatedObject(self, &key); NSLog(@"%@ --- %@ --- %@ --- %@",a,m,n,p);
答:輸入結果:1 — (null) — 2 — 3,程式碼執行過程如下:
1.__block 修飾的 key,建立在棧區,訪問變數 key 為:&(結構體->forwarding->key) ,key 在棧區,此時利用棧區地址作為 Key 來存值
2.變數 a 使用棧區地址取值,故 a 的值為 1
3.宣告一個 block,引用到了外部變數 key,此時將 block 從棧拷貝堆,訪問變數 key 為:&(結構體->forwarding->key) ,key 在堆區
4.變數 m 用堆區地址來取值,故為 null
5.執行 block,用堆區地址將 2 存進去
6.變數 n 用堆區地址來取值,故為 2
7.再用堆區地址將 3 存進去
8.變數 p 用堆區地址來取值,故為 3
7. 有幾種方式去呼叫 Block
void (^block)(void) = ^{ NSLog(@"block get called"); }; //1. blcok() block(); //2. 利用其它方法去執行 block [UIView animateWithDuration:0 animations:block]; //3. [[NSBlockOperation blockOperationWithBlock:block] start]; //4. NSInvocation NSMethodSignature *signature = [NSMethodSignature signatureWithObjCTypes:"v@?"]; NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; [invocation invokeWithTarget:block]; //5.DLIntrospection invoke [block invoke]; //6. 指標呼叫 void *pBlock = (__bridge void *)block; void (*invoke)(void *, ...) = *((void **)pBlock + 2); invoke(pBlock); //7. 利用 Clang __strong void(^cleaner)(void) __attribute ((cleanup(blockCleanUp),unused)) = block; //8. 內聯一個彙編 完成呼叫 asm("callq *0x10(%rax)"); static void blockCleanUp (__strong void (^*block)(void)) { (*block)(); }
8. 如何通過 Block 實現鏈式程式設計風格的程式碼
具體可看實現:Block ChainProgramming
https://github.com/pengxuyuan/PXYFMWK/blob/master/PXYFMWK/PXYFMWK/PXYFMWK/PXYFMWK/Component/PXYChainProgramming/UIView/UIView%2BPXYChainProgramming.m
Block 為什麼用 Copy 修飾
對於這個問題,得區分 MRC 環境 和 ARC 環境;首先,通過上面小節可知,Block 引用了普通外部變數,都是建立在棧區的;對於分配在棧區的物件,我們很容易會在釋放之後繼續呼叫,導致程式奔潰,所以我們使用的時候需要將棧區的物件移到堆區,來延長該物件的生命週期。
對於 MRC 環境,使用 Copy 修飾 Block,會將棧區的 Block 拷貝到堆區。
對於 ARC 環境,使用 Strong、Copy 修飾 Block,都會將棧區的 Block 拷貝到堆區。
所以,Block 不是一定要用 Copy 來修飾的,在 ARC 環境下面 Strong 和 Copy 修飾效果是一樣的。
總結
這裡我們用比較淺顯的角度分析了 Block,瞭解了 Block 也是一個物件,有對應的記憶體分佈;同時作為匿名函式,也會存在作用域的問題,也瞭解了 Block 是如何截獲外部變數的。
對於面試題,主要還是要判斷作用域的問題,棧區的 Block 是否複製到堆區中。
推薦↓↓↓
長按關注:point_right: 【 ofollow,noindex" target="_blank"> 16個技術公眾號 】都在這裡!
涵蓋:程式設計師大咖、原始碼共讀、程式設計師共讀、資料結構與演算法、黑客技術和網路安全、大資料科技、程式設計前端、Java、Python、Web程式設計開發、Android、iOS開發、Linux、資料庫研發、幽默程式設計師等。