C/C++語言中閉包的探究及比較

這裏主要討論的是C語言的擴展特性block。該特性是Apple爲C、C++、Objective-C增長的擴展,讓這些語言能夠用類Lambda表達式的語法來建立閉包。前段時間,在對CoreData存取進行封裝時(讓開發人員能夠更簡潔快速地寫相關代碼),我對block機制有了進一步瞭解,以爲能夠和C++ 11中的Lambda表達式相互印證,因此最近從新作了下整理,分享給你們。 html

0. 簡單建立匿名函數

下面兩段代碼的做用都是建立匿名函數並調用,輸出Hello, World語句。分別使用Objective-C和C++ 11: 前端

1
^{printf("Hello, World!\n"); } ();
1
[] { cout <<"Hello, World"<< endl; } ();

Lambda表達式的一個好處就是讓開發人員能夠在須要的時候臨時建立函數,便捷。 python

在建立閉包(或者說Lambda函數)的語法上,Objective-C採用的是上尖號^,而C++ 11採用的是配對的方括號[]ios

不過「匿名函數」一詞是針對程序員而言的,編譯器仍是採起了必定的命名規則。 c++

好比下面Objective-C代碼中的3個block, 程序員

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#import <Foundation/Foundation.h>
 
int(^maxBlk)(int,int) = ^(intm,intn){returnm > n ? m : n; };
 
intmain(intargc,constchar* argv[])
{
    ^{printf("Hello, World!\n"); } ();
 
    inti = 1024;
    void(^blk)(void) = ^{printf("%d\n", i); };
    blk();
 
    return0;
}

會產生對應的3個函數: express

1
2
3
__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: bash

1
2
3
inti = 1024;
void(^blk)(void) = ^{printf("%d\n", i); };
blk();

當匿名函數和non-local變量結合起來,就造成了閉包(我的見解)。
這一段代碼能夠成功輸出i的值。 閉包

咱們把同樣的邏輯搬到C++上:

1
2
3
inti = 1024;
auto func = [] {printf("%d\n", i); };
func();

GCC會輸出:錯誤:‘i’未被捕獲。可見在C++中沒法直接捕獲外圍做用域的變量。

以BNF來表示Lambda表達式的上下文無關文法,存在:

1
2
lambda-expression : lambda-introducer lambda-parameter-declarationopt compound-statement
lambda-introducer : [ lambda-captureopt ]

所以,方括號中還能夠加入一些選項:

1
2
3
4
5
6
[]        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 anythingelse
[this]    Capture thethispointer of the enclosingclass

根據文法,對代碼加以修改,使其可以成功運行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bash-3.2# vi testLambda.cpp
bash-3.2# g++-4.7 -std=c++11 testLambda.cpp -o testLambda
bash-3.2# ./testLambda
1024
bash-3.2# cat testLambda.cpp
#include <iostream>
 
using namespacestd;
 
intmain()
{
     inti = 1024;
     auto func = [=] {printf("%d\n", i); };
     func();
 
     return0;
}
bash-3.2#

2. 從語法上看如何修改外部變量

上面代碼中使用了符號=,經過拷貝方式捕獲了外部變量i。
可是若是嘗試在Lambda表達式中修改變量i:

1
auto func = [=] { i = 0;printf("%d\n", i); };

會獲得錯誤:

1
2
testLambda.cpp: 在 lambda 函數中:
testLambda.cpp:9:24: 錯誤:向只讀變量‘i’賦值

可見經過拷貝方式捕獲的外部變量是隻讀的。Python中也有一個相似的經典case,我的以爲有相通之處:

1
2
3
4
5
x=10
deffoo():
    print(x)
    x+=1
foo()

這段代碼會拋出UnboundLocalError錯誤,緣由能夠參見FAQ

在C++的閉包語法中,若是須要對外部變量的寫權限,可使用符號&,經過引用方式捕獲:

1
2
3
inti = 1024;
auto func = [&] { i = 0;printf("%d\n", i); };
func();

反過來,將修改外部變量的邏輯放到Objective-C代碼中:

1
2
3
inti = 1024;
void(^blk)(void) = ^{ i = 0;printf("%d\n", i); };
blk();

會獲得以下錯誤:

1
2
3
4
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語言。

先看以下代碼會產生怎樣的中間代碼。

1
2
3
4
5
6
7
8
intmain(intargc,constchar* argv[])
{
    inti = 1024;
    void(^blk)(void) = ^{printf("%d\n", i); };
    blk();
 
    return0;
}

首先是block結構體的實現:

1
2
3
4
5
6
7
8
9
10
11
#ifndef BLOCK_IMPL
#define BLOCK_IMPL
struct__block_impl {
    void*isa;
    intFlags;
    intReserved;
    void*FuncPtr;
};
// 省略部分代碼
 
#endif

第一個成員isa指針用來表示該結構體的類型,使其仍然處於Cocoa的對象體系中,相似Python對象系統中的PyObject。

第2、三個成員是標誌位和保留位。

第四個成員是對應的「匿名函數」,在這個例子中對應函數:

1
2
3
4
staticvoid__main_block_func_0(struct__main_block_impl_0 *__cself) {
    inti = __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的結構以下:

1
2
3
4
5
6
7
8
9
10
11
struct__main_block_impl_0 {
    struct__block_impl impl;
    struct__main_block_desc_0* Desc;
    inti;
    __main_block_impl_0(void*fp,struct__main_block_desc_0 *desc,int_i,intflags=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又引入了一個新的結構體,也是中間代碼裏最後一個結構體:

1
2
3
4
staticstruct__main_block_desc_0 {
    unsignedlongreserved;
    unsignedlongBlock_size;
} __main_block_desc_0_DATA = { 0,sizeof(struct__main_block_impl_0)};

能夠看出,這個描述性質的結構體包含的價值信息就是struct __main_block_impl_0的大小。

最後剩下main函數對應的中間代碼:

1
2
3
4
5
6
7
8
intmain(intargc,constchar* argv[])
{
    inti = 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);
 
    return0;
}

從main函數對應的中間代碼能夠看出執行block的本質就是以block結構體自身做爲__cself參數,這裏對應__main_block_impl_0,經過結構體成員FuncPtr函數指針調用對應的函數,這裏對應__main_block_func_0。

其中,局部變量i是以值傳遞的方式拷貝一份,做爲__main_block_impl_0的構造函數的參數,並以初始化列表的形式賦值給其成員變量i。因此,基於這樣的實現,不容許直接修改外部變量是合理的——由於按值傳遞根本改不到外部變量。

4. 從實現上看如何修改外部變量(__block類型指示符)

若是想要修改外部變量,則須要用__block來修飾:

1
2
3
4
5
6
7
8
intmain(intargc,constchar* argv[])
{
    __blockinti = 1024;
    void(^blk)(void) = ^{ i = 0;printf("%d\n", i); };
    blk();
 
    return0;
}

此時再看中間代碼,發現多了一個結構體:

1
2
3
4
5
6
7
struct__Block_byref_i_0 {
    void*__isa;
    __Block_byref_i_0 *__forwarding;
    int__flags;
    int__size;
    inti;
};

因而,用__block修飾的int變量i化身爲__Block_byref_i_0結構體的最後一個成員變量

代碼中blk對應的結構體也發生了變化:

1
2
3
4
5
6
7
8
9
10
11
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,intflags=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 *類型,從名稱能夠看出如今要經過引用方式來捕獲了。

對應的函數也不一樣了:

1
2
3
4
5
staticvoid__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函數也有了變更:

1
2
3
4
5
6
7
8
intmain(intargc,constchar* 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);
 
    return0;
}

前兩行代碼建立了兩個關鍵結構體,特意高亮顯示。

這裏沒有看__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做爲函數參數傳遞,以供後續回調執行。

先看一段完整的、可執行的代碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#import <Foundation/Foundation.h>
#include <pthread.h>
 
typedefvoid(^DemoBlock)(void);
 
voidtest();
void*testBlock(void*blk);
 
intmain(intargc,constchar* argv[])
{
    printf("Before test()\n");
    test();
    printf("After test()\n");
 
    sleep(5);
    return0;
}
 
voidtest()
{
    __blockinti = 1024;
    void(^blk)(void) = ^{ i = 2048;printf("%d\n", i); };
 
    pthread_tthread;
    intret = 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();
 
    returnNULL;
}

在這個示例中,位於test()函數的block類型的變量blk就做爲函數參數傳遞給testBlock。

正常狀況下,這段代碼能夠成功運行,輸出:

1
2
3
4
5
Before test()
threadreturns : 0
testBlock : Begin to exec blk.
2048
After test()

若是按照註釋,將test()函數最後一行改成休眠1s的話,正常狀況下程序會在輸出以下結果後崩潰:

1
2
3
4
Before test()
threadreturns : 0
After test()
testBlock : Begin to exec blk.

從輸出能夠看出,當要執行blk的時候,test()已經執行完畢回到main函數中,對應的函數棧也已經展開,此時棧上的變量已經不存在了,繼續訪問致使崩潰——這也是不用int *直接訪問外部變量i的緣由。

5.1 拷貝block結構體

上文提到block結構體__block_impl的第一個成員是isa指針,使其成爲NSObject的子類,因此咱們能夠經過相應的內存管理機制將其拷貝到堆上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
voidtest()
{
    __blockinti = 1024;
    void(^blk)(void) = ^{ i = 2048;printf("%d\n", i); };
 
    pthread_tthread;
    intret = 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;
}

再次執行,獲得輸出:

1
2
3
4
5
Before test()
threadreturns : 0
After 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結構體身上:

1
2
3
4
5
6
7
8
9
10
staticvoid__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*/);}
 
staticvoid__main_block_dispose_0(struct__main_block_impl_0*src) {_Block_object_dispose((void*)src->i, 8/*BLOCK_FIELD_IS_BYREF*/);}
 
staticstruct__main_block_desc_0 {
    unsignedlongreserved;
    unsignedlongBlock_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結構體都對捕獲的外部變量進行操做,會如何?

下面是一段示例代碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
voidtest()
{
    __blockinti = 1024;
    void(^blk)(void) = ^{ i++;printf("%d\n", i); };
 
    pthread_tthread;
    intret = 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;
}
  1. 在test()函數中調用pthread_create建立線程時,blk被複制了一份到堆上做爲testBlock函數的參數。
  2. test()函數中的blk結構體位於棧中,在休眠1s後被執行,對i進行自增動做。
  3. testBlock函數在休眠2s後,執行位於堆上的block結構體,這裏爲demoBlk。

上述代碼執行後輸出:

1
2
3
4
5
6
Beforetest()
thread returns : 0
1025
Aftertest()
testBlock : Begin toexecblk.
1026

可見不管是棧上的仍是堆上的block結構體,修改的都是同一個__block變量

這就是前面提到的__forwarding指針成員的做用了:

起初,棧上的__block變量的成員指針__forwarding指向__block變量自己,即棧上的__Block_byref_i_0結構體。

當__block變量被複制到堆上後,棧上的__block變量的__forwarding成員會指向堆上的那一份拷貝,從而保持一致。

參考資料:

相關文章
相關標籤/搜索