C/C++语言中闭包的探究及比较
這里主要討論的是C語言的擴展特性block。該特性是Apple為C、C++、Objective-C增加的擴展,讓這些語言可以用類Lambda表達式的語法來創建閉包。前段時間,在對CoreData存取進行封裝時(讓開發人員可以更簡潔快速地寫相關代碼),我對block機制有了進一步了解,覺得可以和C++ 11中的Lambda表達式相互印證,所以最近重新做了下整理,分享給大家。
0. 簡單創建匿名函數
下面兩段代碼的作用都是創建匿名函數并調用,輸出Hello, World語句。分別使用Objective-C和C++ 11:
| 1 | ^{ printf("Hello, World!\n"); } (); |
| 1 | [] { cout << "Hello, World" << endl; } (); |
Lambda表達式的一個好處就是讓開發人員可以在需要的時候臨時創建函數,便捷。
在創建閉包(或者說Lambda函數)的語法上,Objective-C采用的是上尖號^,而C++ 11采用的是配對的方括號[]。
不過“匿名函數”一詞是針對程序員而言的,編譯器還是采取了一定的命名規則。
比如下面Objective-C代碼中的3個block,
| 1234567891011121314 | #import <Foundation/Foundation.h>int (^maxBlk)(int , int) = ^(int m, int n){ return m > n ? m : n; };int main(int argc, const char * argv[]){????^{ printf("Hello, World!\n"); } ();????int i = 1024;????void (^blk)(void) = ^{ printf("%d\n", i); };????blk();????return 0;} |
會產生對應的3個函數:
| 123 | __maxBlk_block_func_0__main_block_func_0__main_block_func_1 |
可見函數的命名規則為:__{$Scope}_block_func_{$index}。其中{$Scope}為block所在函數,如果{$Scope}為全局就取block本身的名稱;{$index}表示該block在{$Scope}作用域內出現的順序(第幾個block)。
1. 從語法上看如何捕獲外部變量
在上面的代碼中,已經看到“匿名函數”可以直接訪問外圍作用域的變量i:
| 123 | int i = 1024;void (^blk)(void) = ^{ printf("%d\n", i); };blk(); |
當匿名函數和non-local變量結合起來,就形成了閉包(個人看法)。
這一段代碼可以成功輸出i的值。
我們把一樣的邏輯搬到C++上:
| 123 | int i = 1024;auto func = [] { printf("%d\n", i); };func(); |
GCC會輸出:錯誤:‘i’未被捕獲??梢娫贑++中無法直接捕獲外圍作用域的變量。
以BNF來表示Lambda表達式的上下文無關文法,存在:
| 12 | lambda-expression : lambda-introducer lambda-parameter-declarationopt compound-statementlambda-introducer : [ lambda-captureopt ] |
因此,方括號中還可以加入一些選項:
| 123456 | []??????? Capture nothing (or, a scorched earth strategy?)[&]?????? Capture any referenced variable by reference[=]?????? Capture any referenced variable by making a copy[=, &foo] Capture any referenced variable by making a copy, but capture variable foo by reference[bar]???? Capture bar by making a copy; don't copy anything else[this]??? Capture the this pointer of the enclosing class |
根據文法,對代碼加以修改,使其能夠成功運行:
| 123456789101112131415161718 | bash-3.2# vi testLambda.cppbash-3.2# g++-4.7 -std=c++11 testLambda.cpp -o testLambdabash-3.2# ./testLambda1024bash-3.2# cat testLambda.cpp#include <iostream>using? namespace std;int main(){?????int i = 1024;?????auto func = [=] { printf("%d\n", i); };?????func();?????return 0;}bash-3.2# |
2. 從語法上看如何修改外部變量
上面代碼中使用了符號=,通過拷貝方式捕獲了外部變量i。
但是如果嘗試在Lambda表達式中修改變量i:
| 1 | auto func = [=] { i = 0; printf("%d\n", i); }; |
會得到錯誤:
| 12 | testLambda.cpp: 在 lambda 函數中:testLambda.cpp:9:24: 錯誤:向只讀變量‘i’賦值 |
可見通過拷貝方式捕獲的外部變量是只讀的。Python中也有一個類似的經典case,個人覺得有相通之處:
| 12345 | x = 10def foo():????print(x)????x += 1foo() |
這段代碼會拋出UnboundLocalError錯誤,原因可以參見FAQ。
在C++的閉包語法中,如果需要對外部變量的寫權限,可以使用符號&,通過引用方式捕獲:
| 123 | int i = 1024;auto func = [&] { i = 0; printf("%d\n", i); };func(); |
反過來,將修改外部變量的邏輯放到Objective-C代碼中:
| 123 | int i = 1024;void (^blk)(void) = ^{ i = 0; printf("%d\n", i); };blk(); |
會得到如下錯誤:
| 1234 | main.m:14:29: error: variable is not assignable (missing __block type specifier)????void (^blk)(void) = ^{ i++; printf("%d\n", i); };???????????????????????????~^1 error generated. |
可見在block的語法中,默認捕獲的外部變量也是只讀的,如果要修改外部變量,需要使用__block類型指示符進行修飾。
為什么呢?請繼續往下看 :)
3. 從實現上看如何捕獲外部變量
閉包對于編程語言來說是一種語法糖,包括Block和Lambda,是為了方便程序員開發而引入的。因此,對Block特性的支持會落地在編譯器前端,中間代碼將會是C語言。
先看如下代碼會產生怎樣的中間代碼。
| 12345678 | int main(int argc, const char * argv[]){????int i = 1024;????void (^blk)(void) = ^{ printf("%d\n", i); };????blk();????return 0;} |
首先是block結構體的實現:
| 1234567891011 | #ifndef BLOCK_IMPL#define BLOCK_IMPLstruct __block_impl {????void *isa;????int Flags;????int Reserved;????void *FuncPtr;};// 省略部分代碼#endif |
第一個成員isa指針用來表示該結構體的類型,使其仍然處于Cocoa的對象體系中,類似Python對象系統中的PyObject。
第二、三個成員是標志位和保留位。
第四個成員是對應的“匿名函數”,在這個例子中對應函數:
| 1234 | static void __main_block_func_0(struct __main_block_impl_0 *__cself) {????int i = __cself->i; // bound by copy????printf("%d\n", i);} |
函數__main_block_func_0引入了參數__cself,為struct __main_block_impl_0 *類型,從參數名稱就可以看出它的功能類似于C++中的this指針或者Objective-C的self。
而struct __main_block_impl_0的結構如下:
| 1234567891011 | struct __main_block_impl_0 {????struct __block_impl impl;????struct __main_block_desc_0* Desc;????int i;????__main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _i, int flags=0) : i(_i) {????????impl.isa = &_NSConcreteStackBlock;????????impl.Flags = flags;????????impl.FuncPtr = fp;????????Desc = desc;????}}; |
從__main_block_impl_0這個名稱可以看出該結構體是為main函數中第零個block服務的,即示例代碼中的blk;也可以猜到不同場景下的block對應的結構體不同,但本質上第一個成員一定是struct __block_impl impl,因為這個成員是block實現的基石。
結構體__main_block_impl_0又引入了一個新的結構體,也是中間代碼里最后一個結構體:
| 1234 | static struct __main_block_desc_0 {????unsigned long reserved;????unsigned long Block_size;} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)}; |
可以看出,這個描述性質的結構體包含的價值信息就是struct __main_block_impl_0的大小。
最后剩下main函數對應的中間代碼:
| 12345678 | int main(int argc, const char * argv[]){????int i = 1024;????void (*blk)(void) = (void (*)(void))&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, i);????((void (*)(struct __block_impl *))((struct __block_impl *)blk)->FuncPtr)((struct __block_impl *)blk);????return 0;} |
從main函數對應的中間代碼可以看出執行block的本質就是以block結構體自身作為__cself參數,這里對應__main_block_impl_0,通過結構體成員FuncPtr函數指針調用對應的函數,這里對應__main_block_func_0。
其中,局部變量i是以值傳遞的方式拷貝一份,作為__main_block_impl_0的構造函數的參數,并以初始化列表的形式賦值給其成員變量i。所以,基于這樣的實現,不允許直接修改外部變量是合理的——因為按值傳遞根本改不到外部變量。
4. 從實現上看如何修改外部變量(__block類型指示符)
如果想要修改外部變量,則需要用__block來修飾:
| 12345678 | int main(int argc, const char * argv[]){????__block int i = 1024;????void (^blk)(void) = ^{ i = 0; printf("%d\n", i); };????blk();????return 0;} |
此時再看中間代碼,發現多了一個結構體:
| 1234567 | struct __Block_byref_i_0 {????void *__isa;????__Block_byref_i_0 *__forwarding;????int __flags;????int __size;????int i;}; |
于是,用__block修飾的int變量i化身為__Block_byref_i_0結構體的最后一個成員變量。
代碼中blk對應的結構體也發生了變化:
| 1234567891011 | struct __main_block_impl_0 {????struct __block_impl impl;????struct __main_block_desc_0* Desc;????__Block_byref_i_0 *i; // by ref????__main_block_impl_0(void *fp, struct__main_block_desc_0 *desc, __Block_byref_i_0 *_i, int flags=0) : i(_i->__forwarding) {????????impl.isa = &_NSConcreteStackBlock;????????impl.Flags = flags;????????impl.FuncPtr = fp;????????Desc = desc;????}}; |
__main_block_impl_0發生的變化就是int類型的成員變量i換成了__Block_byref_i_0 *類型,從名稱可以看出現在要通過引用方式來捕獲了。
對應的函數也不同了:
| 12345 | static void __main_block_func_0(struct? __main_block_impl_0 *__cself) {????__Block_byref_i_0 *i = __cself->i; // bound by ref????(i->__forwarding->i) = 0; // 看起來很厲害的樣子????printf("%d\n", (i->__forwarding->i));} |
main函數也有了變動:
| 12345678 | int main(int argc, const char * argv[]){????__block __Block_byref_i_0 i = {(void*)0,(__Block_byref_i_0 *)&i, 0, sizeof(__Block_byref_i_0), 1024};????void (*blk)(void) = (void (*)(void))&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, (struct __Block_byref_i_0 *)&i, 570425344);????((void (*)(struct __block_impl *))((struct __block_impl *)blk)->FuncPtr)((struct __block_impl *)blk);????return 0;} |
前兩行代碼創建了兩個關鍵結構體,特地高亮顯示。
這里沒有看__main_block_desc_0發生的變化,放到后面討論。
使用__block類型指示符的本質就是引入了__Block_byref_{$var_name}_{$index}結構體,而被__block關鍵字修飾的變量就被放到這個結構體中。另外,block結構體通過引入__Block_byref_{$var_name}_{$index}指針類型的成員,得以間接訪問到外部變量。
通過這樣的設計,我們就可以修改外部作用域的變量了,再一次應了那句話:
There is no problem in computer science that can’t be solved by adding another level of indirection.
指針是我們最經常使用的間接手段,而這里的本質也是通過指針來間接訪問,為什么要特地引入__Block_byref_{$var_name}_{$index}結構體,而不是直接使用int *來訪問外部變量i呢?
另外,__Block_byref_{$var_name}_{$index}結構體中的__forwarding指針成員有何作用?
請繼續往下看 :)
5. 背后的內存管理動作
在Objective-C中,block特性的引入是為了讓程序員可以更簡潔優雅地編寫并發代碼(配合看起來像敏感詞的GCD)。比較常見的就是將block作為函數參數傳遞,以供后續回調執行。
先看一段完整的、可執行的代碼:
| 12345678910111213141516171819202122232425262728293031323334353637383940 | #import <Foundation/Foundation.h>#include <pthread.h>typedef void (^DemoBlock)(void);void test();void *testBlock(void *blk);int main(int argc, const char * argv[]){????printf("Before test()\n");????test();????printf("After test()\n");????sleep(5);????return 0;}void test(){????__block int i = 1024;????void (^blk)(void) = ^{ i = 2048; printf("%d\n", i); };????pthread_t thread;????int ret = pthread_create(&thread, NULL, testBlock, (void *)blk);????printf("thread returns : %d\n", ret);????sleep(3); // 這里睡眠1s的話,程序會崩潰}void *testBlock(void *blk){????sleep(2);????printf("testBlock : Begin to exec blk.\n");????DemoBlock demoBlk = (DemoBlock)blk;????demoBlk();????return NULL;} |
在這個示例中,位于test()函數的block類型的變量blk就作為函數參數傳遞給testBlock。
正常情況下,這段代碼可以成功運行,輸出:
| 12345 | Before test()thread returns : 0testBlock : Begin to exec blk.2048After test() |
如果按照注釋,將test()函數最后一行改為休眠1s的話,正常情況下程序會在輸出如下結果后崩潰:
| 1234 | Before test()thread returns : 0After test()testBlock : Begin to exec blk. |
從輸出可以看出,當要執行blk的時候,test()已經執行完畢回到main函數中,對應的函數棧也已經展開,此時棧上的變量已經不存在了,繼續訪問導致崩潰——這也是不用int *直接訪問外部變量i的原因。
5.1 拷貝block結構體
上文提到block結構體__block_impl的第一個成員是isa指針,使其成為NSObject的子類,所以我們可以通過相應的內存管理機制將其拷貝到堆上:
| 1234567891011121314151617181920212223 | void test(){????__block int i = 1024;????void (^blk)(void) = ^{ i = 2048; printf("%d\n", i); };????pthread_t thread;????int ret = pthread_create(&thread, NULL, testBlock, (void *)[blk copy]);????printf("thread returns : %d\n", ret);????sleep(1);}void *testBlock(void *blk){????sleep(2);????printf("testBlock : Begin to exec blk.\n");????DemoBlock demoBlk = (DemoBlock)blk;????demoBlk();????[demoBlk release];????returnNULL;} |
再次執行,得到輸出:
| 12345 | Before test()thread returns : 0After test()testBlock : Begin to exec blk.2048 |
可以看出,在test()函數棧展開后,demoBlk仍然可以成功執行,這是由于blk對應的block結構體__main_block_impl_0已經在堆上了。不過這還不夠——
5.2 拷貝捕獲的變量(__block變量)
在拷貝block結構體的同時,還會將捕獲的__block變量,即結構體__Block_byref_i_0,復制到堆上。這個任務落在前面沒有討論的__main_block_desc_0結構體身上:
| 12345678910 | static void __main_block_copy_0(struct __main_block_impl_0*dst, struct __main_block_impl_0*src) {_Block_object_assign((void*)&dst->i, (void*)src->i, 8/*BLOCK_FIELD_IS_BYREF*/);}static void __main_block_dispose_0(struct __main_block_impl_0*src) {_Block_object_dispose((void*)src->i, 8/*BLOCK_FIELD_IS_BYREF*/);}static struct __main_block_desc_0 {????unsigned long reserved;????unsigned long Block_size;????void (*copy)(struct __main_block_impl_0*, struct __main_block_impl_0*);????void (*dispose)(struct __main_block_impl_0*);} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0), __main_block_copy_0, __main_block_dispose_0}; |
棧上的__main_block_impl_0結構體為src,堆上的__main_block_impl_0結構體為dst,當發生復制動作時,__main_block_copy_0函數會得到調用,將src的成員變量i,即__Block_byref_i_0結構體,也復制到堆上。
5.3 __forwarding指針的作用
當復制動作完成后,棧上和堆上都存在著__main_block_impl_0結構體。如果棧上、堆上的block結構體都對捕獲的外部變量進行操作,會如何?
下面是一段示例代碼:
| 123456789101112131415161718192021222324 | void test(){????__block int i = 1024;????void (^blk)(void) = ^{ i++; printf("%d\n", i); };????pthread_t thread;????int ret = pthread_create(&thread, NULL, testBlock, (void *)[blk copy]);????printf("thread returns : %d\n", ret);????sleep(1);????blk();}void *testBlock(void *blk){????sleep(2);????printf("testBlock : Begin to exec blk.\n");????DemoBlock demoBlk = (DemoBlock)blk;????demoBlk();????[demoBlk release];????returnNULL;} |
上述代碼執行后輸出:
| 123456 | Before test()thread returns : 01025After test()testBlock : Begin to exec blk.1026 |
可見無論是棧上的還是堆上的block結構體,修改的都是同一個__block變量。
這就是前面提到的__forwarding指針成員的作用了:
起初,棧上的__block變量的成員指針__forwarding指向__block變量本身,即棧上的__Block_byref_i_0結構體。
當__block變量被復制到堆上后,棧上的__block變量的__forwarding成員會指向堆上的那一份拷貝,從而保持一致。
參考資料:
- http://msdn.microsoft.com/en-us/library/dd293603.aspx
- http://www.cprogramming.com/c++11/c++11-lambda-closures.html
- http://developer.apple.com/library/ios/#documentation/cocoa/Conceptual/Blocks/Articles/00_Introduction.html
- http://en.wikipedia.org/wiki/Closure_(computer_science)
轉載于:https://www.cnblogs.com/alantu2018/p/8503465.html
總結
以上是生活随笔為你收集整理的C/C++语言中闭包的探究及比较的全部內容,希望文章能夠幫你解決所遇到的問題。