Objetive-C 筆記》Block & Closure

Apple Inc. 為 C、C++、Objective-C 等語言加入了 Block 這個非標準的擴充功能。
簡單來說,Block 是一種可以當作變數傳遞的函數,而且具有 Closure(閉包)特性。

Closure 是指一個包裹了區域變數的函數物件。

1. Block Literal:

Block Literal 的語法:

^ 返回值型別 (參數列) { 表達式 }

其中的返回值型別可以省略不寫,如果該 Block 沒有使用到參數,那麼參數列也可以省略不寫。
所以,完整的 Block 寫法會長得像這樣:

^int (int a, int b) { return a + b; }

如果省略返回值型別不寫,那麼編譯器會使用表達式裡 return 的型別來當作返回值型別;如果表達式裡沒有 return 語句,那返回值型別就會使用 void。所以,上面那個 Block 的寫法可以再簡化成:

^(int a, int b) { return a + b; }

另外,沒有參數列的 Block 可以這樣寫:

^{ NSLog(@"Hello!"); }

2. Block 變數的型別宣告:

Block 變數的型別宣告與函數指標的宣告長得很像,只是把變數名稱前的 * 換成 ^ 而已,所以,Block 變數宣告的語法為:

返回值型別 (^變數名稱) (參數列)

因此,Block 變數宣告的寫法會像這樣:

void (^foo)(void)

宣告 Block 變數,並且賦值:

// 無參數、無返回值的 Block:
void (^blk1)(void) = ^{ NSLog(@"I'm blk1"); };

// 有參數、有返回值的 Block:
int (^blk2)(int, int) = ^(int a, int b) { return a + b; };

3. 使用 Block

Block 的使用方式就如同函數呼叫:

int (^add)(int, int) = ^(int a, int b) { return a + b; };
int sum = add(10, 20);  // call add()
NSLog(@"sum = %d", sum);

執行結果:

sum = 30

或者,可以直接調用一個匿名 Block:

^(int a, int b) {
    NSLog(@"=> %d", a + b);
}(11, 22);

執行結果:

=> 33

4. __block 修飾符

Block 可以捕捉外層範疇(scope)內的變數,所以可以這麼使用:

int number = 100;
void (^blk)(void) = ^{ NSLog(@"=> %d", number); };
blk();

執行結果:

=> 100

但是,如果想修改捕捉到的變數,會發生編譯錯誤,例如:

int number = 100;
void (^blk)(void) = ^{ NSLog(@"=> %d", ++number); /*把 number 加 1*/ };  // Error!
blk();

錯誤訊息如下:

Variable is not assignable (missing __block type specifier)

這時就必須在要修改的變數前面加上 __block 修飾符:

__block int number = 100;  // 前面加上 __block
void (^blk)(void) = ^{ NSLog(@"=> %d", ++number); };
blk();

執行結果:

=> 101

Q:如果是 Cocoa 物件也需要加上 __block 才能修改嗎?

請看以下程式碼:

NSMutableArray *array = [NSMutableArray array];
^{
    [array addObject:@1];
    [array addObject:@2];
}();
NSLog(@"%@", array);

執行結果:

(
  1,
  2
)

沒有編譯錯誤,輸出結果也正確。

再來看看以下程式碼:

NSMutableArray *array = [NSMutableArray array];
^{
    array = [NSMutableArray arrayWithArray:@[@1, @2, @3]];  // Error!
}();
NSLog(@"%@", array);

發生編譯錯誤,錯誤訊息如下:

Variable is not assignable (missing __block type specifier)

加上 __block 修飾符即可通過編譯:

__block NSMutableArray *array = [NSMutableArray array];
^{
    array = [NSMutableArray arrayWithArray:@[@1, @2, @3]];
}();
NSLog(@"%@", array);

執行結果:

(
  1,
  2,
  3
)

因為 Block 捕捉到的是指向物件的指標(看到物件宣告時前面那個*,就應該知道那是個指標 😛 ),所以如果想修改指標的值,就需要加上 __block 修飾符。

接著再看看以下程式碼:

int count = 0;  
void (^blk)(void) = ^{
    NSLog(@"-> %d", count);
};
count = 100;
NSLog(@"%d", count);   //印出目前 count 變數的值 
blk();                 //調用 blk

執行結果:

100
-> 0

調用 blk() 後印出的結果是 0,而不是 100。

因此,我們可以這麼認為:

在沒有使用 __block 修飾符的情況下:
對於內建型別(int, float…等),Block 捕捉到的是變數的
對於物件型別,Block 捕捉到的是物件的參考

5. 使用 typedef 定義 Block 型別

如果想把 Block 當作參數傳給函數,可以這麼寫:

void func(int(^add)(int, int)) {
    NSLog(@"sum = %d", add(10, 20));
}

int main() {
    @autoreleasepool {
        func(^(int a, int b){ return a + b; });
    }
    return 0;
}

執行結果:

sum = 30

如果想把一個 Block 當作函數返回值傳回呢?可以這麼寫:

int (^makeAdd(int c))(int, int) {
    return ^(int a, int b) { return a + b + c; };
}

int main() {
    @autoreleasepool {
        int (^add)(int, int) = makeAdd(1000);
        NSLog(@"=> %d", add(11, 22));
    }
    return 0;
}

執行結果:

=> 1033

看到這些寫法,頭都開始暈了@@…

這時可以使用 typedef 定義 Block 型別,讓程式碼比較好寫好讀:

typedef int (^AddBlock)(int a, int b);

void func(AddBlock add) {
    NSLog(@"=> %d", add(5500, 66));
}
  
AddBlock makeAdd(int c) {
    return ^(int a, int b) { return a + b + c; };
}
  
int main() {
    @autoreleasepool {
        func(^(int a, int b){ return a + b; });
        AddBlock add = makeAdd(5500);
        NSLog(@"=> %d", add(11, 55));
    }
    return 0;
}

執行結果:

=> 5566
=> 5566

(56 不能亡!)

6. Block 的範疇(scope)與記憶體管理

Block 有三種類型,分別為_NSConcreteGlobalBlock_NSConcreteStackBlock_NSConcreteMallocBlock

寫在全域空間(即函數外面)的 Block 是 _NSConcreteGlobalBlock 類別的一個實例,會儲存在 globals 區段裡。Global Block 因為是寫在全域空間,所以無法捕捉區域變數,且從程式開始執行一直到程式結束前都會存在,因此可以視為是一個單體物件(Singleton object)。

寫在函數或是程式碼區塊(即一對{})內的 Block 一般會儲存在 stack 區段,但是如果該 Block 並沒有使用到外層範疇內的變數(換句話說,就是不需要捕捉外層的區域變數),那麼該 Block 會被儲存在 globals 區段裡(_NSConcreteGlobalBlock 類別的實例)。

儲存在 stack 區段的 Block 是 _NSConcreteStackBlock 類別的實例。如果對一個 Stack Block 物件發送 copy 訊息,那麼該 Block 會被拷貝到 heap 區段上,儲存在 heap 區段上的 Block 是 _NSConcreteMallocBlock 類別的實例。

Stack Block 就跟自動變數一樣,會在離開其生存空間時被移除,如果想在生存空間外繼續使用,就必須拷貝到 heap 區段上。儲存在 heap 區段上的 Block 如果不再使用,那麼要對它發送 release 訊息釋放其所佔用的記憶體空間。

如果程式的記憶體管理方式是使用 ARC(Automatic Reference Counting),那麼大多不用擔心 Block 何時需要 copy/release,因為編譯器會在必要時自動拷貝或釋放 Block。如果記憶體管理方式是使用 MRR(Manual Retain-Release),那麼必須自行管理 Block 的 copy/release 訊息。

先來看看以下程式碼:

int main() {
    @autoreleasepool {
        void(^blk)(void) = ^{ NSLog(@"Hello!"); };  //這是一個 Global Block
        blk();
    }
    return 0;
}

blk 是一個 Global Block,因為它沒捕捉任何變數,也就是說,它不需要在執行時期設置任何狀態,所以被編譯成一個 Global Block。

那麼,以下這段程式碼裡的 blk2 會被編譯成什麼類型的Block呢?:

int main() {
    @autoreleasepool {
        int i = 100;
        void(^blk2)(void) = ^{ NSLog(@"i = %d", i); }; //這個 Block 會被編譯成什麼類型呢?
        blk2();
    }
    return 0;
}

如果是在 ARC 的情況下,那 blk2 會是 Malloc Block
如果是在 MRR 的情況下,那 blk2 會是 Stack Block

在 MRR 的情況下,函數內宣告的 Block 會是 Stack Block。問題來了,請看以下程式碼:

typedef void(^blk_t)(void);

blk_t func() {
    int i = 10;
    return ^{ NSLog(@"i = %d", i); };
}

int main() {
    @autoreleasepool {
        blk_t blk = func();
        blk();
    }
    return 0;
}

上面這段程式碼在 ARC 下,可以正常編譯且執行。但在 MRR 下,會發生編譯錯誤,錯誤訊息如下:

Returning block that lives on the local stack

這時必須把 Block 拷貝到 heap 區段上才行:

typedef void(^blk_t)(void);

blk_t func() {
    int i = 10;
    return [[^{ NSLog(@"i = %d", i); } copy] autorelease];  // copy and autorelease
}

再來看一些在 MRR 下,需要自行 copy/release 的情況:

/** 
 * 將 Block 存入 NSArray/NSDictionary 前,
 * 需要把 Block 從 stack 區段拷貝到 heap 區段上。
 *
 * 註:
 *   NSArray/NSDictionary 只能存放 Objective-C 物件,
 *   而所有的 Objective-C 物件都是配置在 heap 區段上的。
 */
NSMutableArray *array = [NSMutableArray array];
[array addObject:[^(int a){NSLog(@"%d", a);} copy]];
// Block 離開生存空間後要繼續使用,需要 copy,使用完要 release
void(^myBlock)(void);
if (isTrue) {
    //...
    myBlock = [^{ /* ... */ } copy];
} else {
    //...
    myBlock = [^{ /* ... */ } copy];
}
//...
myBlock();
[myBlock release];

總之,如果是使用 ARC 管理記憶體,那麼不用擔心 Block 拷貝和釋放的問題,一切就交給編譯器處理吧。
如果是用 MRR 管理記憶體,那麼請務必留意 Block 何時需要拷貝,何時需要釋放。

7. Block 保留循環的問題

之前有提到,Block 可以捕捉外層範疇內的變數。
如果捕捉到的變數是一個 Objective-C 物件,那麼該物件會被保留(retain),等到 Block 執行完畢後,該物件會被釋放(release)。

假設有一個 ViewController,這個 ViewController 有一個 property 是 Block 物件,程式碼看起來像這樣:

typedef void (^MyCompletionBlock)(void);

@interface MyViewController : UIViewController
@property (nonatomic, copy) MyCompletionBlock completionBlock;
//...
@end

之後,在程式當中使用了這個 ViewController:

MyViewController *myViewController = [MyViewController alloc] init];

myViewController.completionBlock = ^{
    [myViewController doSomething];
};

有看出上面這段程式碼有什麼問題嗎?這段程式碼裡發生了保留循環(或稱為強參考循環 (Strong Reference Cycles) )! 因為 myViewController 保留了 completionBlock,而 completionBlock 也保留了 myViewController,所以形成了保留循環。

產生保留循環表示這些互相保留對方的物件將無法被釋放,會一直佔用記憶體空間,即使這些物件已不再使用。這種情況稱為記憶體洩漏

ARC 下的解決方法是使用一個弱參考(Weak reference)指標指向 myViewController,並在 Block 裡使用這個弱參考指標取代原本的強參考(Strong reference)指標:

// 如果記憶體管理方式是使用 ARC,可以用這種方式解決強參考循環:
MyViewController *myViewController = [MyViewController alloc] init];
__weak MyViewController *weakMyViewController = myViewController;

myViewController.completionBlock = ^{
    [weakMyViewController doSomething];
};

MRR 下的解決方式:

// 在 MRR 下使用 __block 修飾符,表示這個物件的參考計數不要增加
__block MyViewController *myViewController = [MyViewController alloc] init];

myViewController.completionBlock = ^{
    [myViewController doSomething];
    myViewController = nil;
};

另外,附帶一提:Block 執行完畢後,如果 Block 不需要再使用,將 Block 釋放可能是一個好習慣:

if (self.completionBlock) {
    self.completionBlock();
    self.completionBlock = nil;
}

8. Closure

最常見的 Closure 範例應該就是計數器了吧。先用 JavaScript 示範計數器要怎麼寫吧:

// JavaScript 的計數器寫法


function makeCounter() {
  var n = 0;
  return function() {
    n++;
    console.log("n = " + n);
  }
}

f = makeCounter();  // 傳回一個函數物件

f();                // 調用 f() 三次...
f();
f();

執行結果:

n = 1
n = 2
n = 3

注意 makeCounter() 函數裡 n 變數的生命週期。照理說,n 變數應該在 makeCounter() 結束後就應該消失了,但是為何 n 變數可以持續累加呢?因為這是 Closure(閉包)造成的效果。

makeCounter() 會回傳一個函數物件,而這個物件會將它定義當時範疇內的變數與函數繫結在一起。換句話說,就是會將該函數物件上層範疇內的區域變數「封閉」到該函數物件裡,因此稱為 Closure(閉包)。

所以,這就是 n 變數繼續存活的原因。

接著來把這段程式碼改成 Objective-C 版本:

// Objective-C 的計數器寫法
typedef void(^CounterBlock)(void);

CounterBlock makeCounter() {
    __block int n = 0;
    return ^{ NSLog(@"n = %d", ++n); };
}
  
int main() {
    @autoreleasepool {
        CounterBlock counter = makeCounter();
        counter();
        counter();
        counter();
    }
    return 0;
}

執行結果:

n = 1
n = 2
n = 3

再來看一個 Closure 範例:

typedef NSString* (^SayBlock)(NSString *sentence);

SayBlock whoSay(NSString *name) {
    return ^(NSString *sentence) { return [NSString stringWithFormat:@"%@ say: %@", name, sentence]; };
}

int main() {
    @autoreleasepool {
        SayBlock johnSay = whoSay(@"John");
        SayBlock marySay = whoSay(@"Mary");

        NSLog(@"%@", johnSay(@"Hello!"));
        NSLog(@"%@", marySay(@"Hi!"));
        NSLog(@"\n");
        NSLog(@"%@", johnSay(@"ABCD狗咬豬!"));
        NSLog(@"%@", marySay(@"1234567..."));
    }
    return 0;
}

執行結果:

John say: Hello!
Mary say: Hi!

John say: ABCD狗咬豬!
Mary say: 1234567...

在第一個範例裡用了 JavaScript 來示範計數器的寫法,再來看看一個 JavaScript 範例吧(但是跟 Closure 無關… XD):

var op = {
  "+" : function(a,b) { return a + b; },
  "-" : function(a,b) { return a - b; },
  "*" : function(a,b) { return a * b; },
  "/" : function(a,b) { return a / b; }
};

op 是一個 JavaScript 物件,JavaScript 的物件是鍵值對的集合,也就是一般俗稱的 Hash 或是 Dictionary。
然後可以這樣使用 op 物件:

var sum = op["+"](100, 20);

sum 的值是 120。

如果想在 Objective-C 裡實現這種寫法呢?可以這麼寫:

NSDictionary *operation = @{ @"+" : ^(int a, int b){return a + b;},
                             @"-" : ^(int a, int b){return a - b;},
                             @"*" : ^(int a, int b){return a * b;},
                             @"/" : ^(int a, int b){return a / b;} };

然後來試著調用 Key 為 @”+” 的 Block:

int sum = operation[@"+"](100, 20);

結果發生編譯失敗,錯誤訊息如下:

Called object type 'id' is not a function or function pointer

因為無法對一個id型別進行函數呼叫的動作。
所以,必須先將 id 轉型為 Block,然後再調用它:

int sum = ((int(^)(int,int))operation[@"+"])(100, 20);

sum 的值為 120。
但是這個寫法真的很醜啊~~~~~

另一個寫法:

int (^add)(int, int) = operation[@"+"];
int sum = add(100, 20);

這個寫法看起來好多了,但是有點囉唆…

同場加映:C++ 的寫法:

map<string, function<int (int,int)>> operation = {
    { "+", [](int a, int b) {return a + b;} },
    { "-", [](int a, int b) {return a - b;} },
    { "*", [](int a, int b) {return a * b;} },
    { "/", [](int a, int b) {return a / b;} }
};
    
int a = 100, b = 20;
string opcodes[4] = {"+", "-", "*", "/"};
  
for (auto opcode : opcodes) {
    cout << a << " " << opcode << " " << b << " = " << operation[opcode](a, b) << endl;
}

執行結果:

100 + 20 = 120
100 - 20 = 80
100 * 20 = 2000
100 / 20 = 5

個人認為,C++ 的 Lambda 表達式寫法看起來比 Objective-C Block 好看多了…

9. 對 Block 的愛與怨

對 Block 的愛:
身為一個 Ruby 程式員(自以為,但其實沒寫過幾行 Ruby 程式啊… XD),當然會對 Block 很有愛呀(羞) >////<
(旁白哥:可是 Ruby 的 Block 跟 Objective-C 的 Block 好像不太一樣呀… )*

對 Block 的怨:
「話那欲講透更,目屎是掰袂離…」 所以,不說也罷(逃~)。

*註:Objective-C 的 Block 行為上跟 Ruby 的 lambda 比較像。