OC與Swift閉包對比總結

bestswifter發表於2017-12-13

最近在看Swift閉包截獲變數時遇到了各種問題,總結之後發現主要是還用停留在OC時代的思維來思考Swift問題導致的。藉此機會首先複習一下OC中關於block的細節,同時整理Swift中閉包的相關的問題。不管是目前使用OC還是Swift,又或者是從OC轉向Swift,都可以閱讀這篇文章並與我交流。

#OC的block

OC的block已經有很多相關的文章介紹了,主要難點在於__block修飾符的作用和原理,以及迴圈引用問題。我們首先由淺入深舉幾個例子看一看__block修飾符,最後分析迴圈引用問題。這裡的討論都是基於ARC的。

截獲基本型別

int value = 10;
void(^block)() = ^{
NSLog(@"value = %d", value);
};
value = 20;
block();

// 列印結果是:"value = 10"
複製程式碼

OC的block會截獲外部變數,對於int等基本資料型別,block的內部會拷貝一份,簡單來說,它的實現大概是這樣的:

struct block_impl {
//其它內容
int value;
};
複製程式碼

因為block內部拷貝了截獲的變數的副本,所以生成block後再修改變數,不會影響被block截獲的變數。同時block內部也不能修改這個變數。

修改基本型別

如果要想在block中修改被截獲的基本型別變數,我們需要把它標記為__block

__block int value = 10;
void(^block)() = ^{
NSLog(@"value = %d", value);
};
value = 20;
block();

// 列印結果是:"value = 20"
複製程式碼

這是因為,對於被標記了__block的變數,block在截獲它時,會儲存一個指標。簡單來說,它的實現大概是這樣的:

struct block_impl {
//其它內容
block_ref_value *value;
};

struct block_ref_value {
int value; // 這裡儲存的才是被截獲的value的值。
};
複製程式碼

由於block中一直有一個指標指向value,所以block內部對它的修改,可以影響到block外部的變數。因為block修改的就是那個外部變數而不是外部變數的副本。

上面關於block具體實現的例子只是一個簡化模型,事實上並非如此,但本質類似。總的來說,只有由__block修飾符修飾的變數,在被block截獲時才是可變的。關於這方面的詳細解釋,可以參考這三篇文章:

截獲指標

block截獲指標和截獲基本型別是相似的,不過稍稍複雜一些。先看一個最簡單的例子。

Person *p = [[Person alloc] initWithName:@"zxy"];
void(^block)() = ^{
NSLog(@"person name = %@", p.name);
};

p.name = @"new name";
block();

// 列印結果是:"person name = new name"
複製程式碼

在截獲基本型別時,block內部可能會有int capturedValue = value;這樣的程式碼,類比到指標也是一樣的,block內部也會有這樣的程式碼:Person *capturedP = p;。在ARC下,這其實是強引用(retain)了block外部的p

由於block內部的p和外部的p指向的是同一塊記憶體地址。所以在block外部修改p的屬性,依然會影響到block內部截獲的p

需要強調一點,這裡的p依然不是可變的。修改pname不是改變p,只是改變p內部的屬性:

Person *p = [[Person alloc] initWithName:@"zxy"];
void(^block)() = ^{
p.name = @"new name"; //OK,沒有改變p
p = [[Person alloc] initWithName:@"new name"]; //編譯錯誤
NSLog(@"person name = %@", p.name);
};

block();
複製程式碼

改變指標

類比__block修飾符對基本型別的作用原理,由它修飾的指標,在被block截獲時,截獲的其實是這個指標的指標。比如我們把剛剛的例子修改一下:

__block Person *p = [[Person alloc] initWithName:@"zxy"];
void(^block)() = ^{
NSLog(@"person name = %@", p.name);
};

p = nil;
block();

// 列印結果是:"person name = (null)"
複製程式碼

此時,block內部有一個指向外部的p的指標,一旦p被設為nil,這個內部的指標就指向了nil。所以列印結果就是null了。

__block與強引用

還記得以前有一次面試時被問到,__block會不會retain變數?答案是:會的。從原理上分析,__block修飾的變數被封裝在結構體中,block內部持有對這個結構體的強引用。這一點不管是對於基本型別還是指標都是通用的。從實際例子上來說:

Block block;
if (true) {
__block Person *p = [[Person alloc] initWithName:@"zxy"];
block = ^{
NSLog(@"person name = %@", p.name);
};
}
block();

// 列印結果是:"person name = zxy"
複製程式碼

如果沒有retain被標記為__block的指標p,那麼超出作用於後應該會得到nil

避免迴圈引用

不管物件是否標記為__block,一旦block截獲了它,就會強引用它。所以,判斷是否發生迴圈引用,只要判斷block截獲的物件,是否也持有block即可。如果這個物件確實需要直接或間接持有block,那麼我們需要避免block強引用這個物件。解決辦法是使用__weak修飾符。

// block是self的一個屬性

id __weak weakSelf = self;
block = ^{
//使用weakSelf代替self
};
複製程式碼

block不會強引用被標記為__weak的物件,只會對其產生弱引用。為了防止在block內的操作會釋放wself,可以先強引用它。這種做法有一個很漂亮的名字叫weak-strong dacne,具體實現方法可以參考RAC的@strongify@weakify

OC中block總結

簡單來說,除非標記為__weak,block總是會強引用任何捕獲的物件。而__block表示捕獲的就是指標本身,而非另一個指向這個物件的指標。也就是說,被__block修飾的物件在block內、外的改動會互相影響。

如果想避免迴圈引用問題,首先要確定block引用了哪些物件,然後判斷這些物件是否直接或間接持有block,如果有的話把這些物件標記為__weak避免block強引用它。

Swift的閉包

OC中的__block是一個很討厭的修飾符。它不僅不容易理解,而且在ARC和非ARC的表現截然不同。__block修飾符本質上是通過截獲變數的指標來達到在閉包內修改被截獲的變數的目的。

在Swift中,這叫做截獲變數的引用。閉包預設會擷取變數的引用,也就是說所有變數預設情況下都是加了__block修飾符的。

var x = 42
let f = {
// [x] in //如果取消註釋,結果是42
print(x)
}
x = 43
f() // 結果是43
複製程式碼

如果如果被截獲的變數是引用,和OC一樣,那麼在閉包內部有一個引用的引用:

var block2: (() -> ())?
if true {
var a: A? = A()
block2 = {
print(a?.name)
}
a = A(name: "new name")
}
block2?() //結果是:"Optional("new name")"
複製程式碼

如果把變數寫在截獲列表中,那麼block內部會有一個指向物件的強引用,這和在OC中什麼都不寫的效果是一樣的:

var block2: (() -> ())?
if true {
var a: A? = A()
block2 = {
[a] in
print(a?.name)
}
a = A(name: "new name")
}
block2?() //結果是:"Optional("old name")"
複製程式碼

Swift會自動持有被截獲的變數的引用,這樣就可以在block內部直接修改變數。不過在一些特殊情況下,Swift會做一些優化。通過之前OC中對__block的分析可以看到,持有變數的引用肯定比直接持有變數開銷更大。所以Swift會自動判斷你是否在閉包中或閉包外改變了變數。如果沒有改變,閉包會直接持有變數,即使你沒有顯式的把它解除安裝捕獲列表中。下面這句話擷取自Swift官方文件

As an optimization, Swift may instead capture and store a copy of a value if that value is not mutated by or outside a closure.

Swift迴圈引用

不管是否顯示的把變數寫進捕獲列表,閉包都會對物件有強引用。如果閉包是某個物件的屬性,而且閉包中截獲了物件本身,或物件的某個屬性,就會導致迴圈引用。這和OC中是完全一樣的。解決方法是在捕獲列表中把被截獲的變數標記為weakunowned

關於Swift的迴圈引用,有一個需要注意的例子:

class A {
var name: String = "A"
var block: (() -> ())?

//其他方法
}

var a: A? = A()
var block = {
print(a?.name)
}
a?.block = block
a = nil
block()
複製程式碼

我們先建立了可選型別的變數a,然後建立一個閉包變數,並把它賦值給ablock屬性。這個閉包內部又會截獲a,那這樣是否會導致迴圈引用呢?

答案是否定的。雖然從表面上看,物件的閉包屬性截獲了物件本身。但是如果你執行上面這段程式碼,你會發現物件的deinit方法確實被呼叫了,列印結果不是“A”而是“nil”。

這是因為我們忽略了可選型別這個因素。這裡的a不是A型別的物件,而是一個可選型別變數,其內部封裝了A的例項物件。閉包截獲的是可選型別變數a,當你執行a = nil時,並不是釋放了變數a,而是釋放了a中包含的A型別例項物件。所以A的deinit方法會執行,當你呼叫block時,由於使用了可選鏈,就會得到nil,如果使用強制解封,程式就會崩潰。

如果想要人為造成迴圈引用,程式碼要這樣寫:

var block: (() -> ())?
if true {
var a = A()
block = {
print(a.name)
}
a.name = "New Name"
}
block!()
複製程式碼

Weak-Strong Dance

為了避免weak變數在閉包中提前被釋放,我們需要在block一開始強引用它。這在OC部分已經講過如何使用了。Swift中實現Weak-Strong Dance一般有三種方法。分別是最簡單的if let可選繫結、標準庫的withExtendedLifetime方法和自定義的withExtendedLifetime方法。

總結

  1. OC中預設截獲變數,Swift預設截獲變數的引用。它們都會強引用被截獲的變數。
  2. Swift中沒有__block修飾符,但是多了截獲列表。通過把截獲的變數標記為weak避免引用迴圈
  3. 兩者都有Weak-Strong Dance,不過這一點上OC的寫法更簡單。
  4. 在使用可選型別時,要明確閉包截獲了可選型別還是例項變數。這樣才能正確判斷是否發生迴圈引用。

相關文章