Swift函數就是閉包。這意味著它們可以在函數體作用域中捕獲對外部變量的引用。這是什麼意思呢?回憶一下第1章,花括號中的代碼構成了一個作用域,這些代碼能夠看到外部作用域中聲明的變量與函數:
class Dog { var whatThisDogSays = \"woof\" func bark { print(self.whatThisDogSays) } }
在上述代碼中,bark函數體引用了變量whatThisDogSays,該變量是函數體的外部變量,因為它聲明在函數體外部。它位於函數體的作用域中,因為函數體內部的代碼可以看到它。函數體內部的代碼能夠引用whatThisDogSays。
一切都很好;不過,我們現在知道函數bark可以當作值來傳遞。實際上,它可以從一個環境傳遞到另一個環境中!如果這樣做,那麼對whatThisDogSays的引用會發生什麼情況呢?下面就來看看:
func doThis(f : Void -> Void) { f } let d = Dog d.whatThisDogSays = \"arf\" let f = d.bark doThis(f) // arf
運行上述代碼,控制台會打印出\"arf\"。
也許結果不會讓你感到驚訝,不過請思考一下。我們並未直接調用bark。我們創建了一個Dog實例,然後將其bark函數作為值傳遞給函數doThis,然後被調用。現在,whatThisDogSays是某個Dog實例的一個實例屬性。在函數doThis中並沒有whatThisDogSays。實際上,在函數doThis中並沒有Dog實例!不過,調用f()依然可以使用。函數d.bark還是可以看到變量whatThisDogSays(聲明在外部),雖然它的調用環境中並沒有任何Dog實例,也沒有任何實例屬性whatThisDogSays。
bark函數在傳遞時會持有其所在的環境,甚至在傳遞到另一個全新環境中再調用時亦如此。對於「捕獲」,我的意思是當函數作為值被傳遞時,它會持有對外部變量的內部引用。這使得函數成為一個閉包。
你可能利用了函數就是閉包這一特性,但卻根本就沒有注意到過。回憶一下之前的示例,在界面上以動畫的形式移動按鈕的位置:
UIView.animateWithDuration(0.4, animations: { self.myButton.frame.origin.y += 20 }) { _ in print(\"finished!\") }
上述代碼看起來很簡單,但請注意第2行,匿名函數作為實參被傳遞給了animations:參數。真是這樣的嗎?這與Cocoa相差甚遠,這個匿名函數會在未來的某個時間被調用來啟動動畫,Cocoa會找到myButton,這個對象是self的一個屬性,代碼中早就是這樣的了?是的,Cocoa可以做到這一點,因為函數就是個閉包。對該屬性的引用會被捕獲並由匿名函數維護;這樣,當匿名函數真正被調用時,它就會執行,按鈕也會移動。
2.13.1 閉包是如何改善代碼的
如果理解了函數就是閉包這一理念,那麼你就可以利用這一點來改善代碼的語法了。閉包會讓代碼變得更加通用,實用性也更強。下面這個函數是之前提及的一個示例,它接收繪製指令,然後執行來生成一張圖片:
func imageOfSize(size:CGSize, _ whatToDraw: -> ) -> UIImage { UIGraphicsBeginImageContextWithOptions(size, false, 0) whatToDraw let result = UIGraphicsGetImageFromCurrentImageContext UIGraphicsEndImageContext return result }
我們可以通過一個尾匿名函數來調用imageOfSize:
let image = imageOfSize(CGSizeMake(45,20)) { let p = UIBezierPath( roundedRect: CGRectMake(0,0,45,20), cornerRadius: 8) p.stroke }
不過,上述代碼還有一個討厭的重複情況。這是個會根據給定大小(包含該尺寸的圓角矩形)創建圖片的調用。我們重複了這個尺寸;數值對(45,20)出現了兩次,這麼做可不好。下面將尺寸放到起始位置處的變量中來避免重複。
let sz = CGSizeMake(45,20) let image = imageOfSize(sz) { let p = UIBezierPath( roundedRect: CGRect(origin:CGPointZero, size:sz), cornerRadius: 8) p.stroke }
內部可以看到聲明在更高層的匿名函數中的變量sz。這樣,我們就可以在匿名函數中引用它了,我們也是這麼做的。匿名函數就是個函數,因此也是閉包。匿名函數會捕獲引用,將其放到對imageOfSize的調用中。當imageOfSize調用whatToDraw,而whatToDraw引用了變量sz時,這麼做是沒問題的,即便在imageOfSize中並沒有sz也可以。
下面更進一步。到目前為止,我們硬編碼了所需圓角矩形的大小。不過,假設創建各種大小的圓角矩形圖片是經常性的事情,那麼將代碼放到函數中就是更好的做法,其中sz不是固定值,而是一個參數;接下來,函數會返回創建好的圖片:
func makeRoundedRectangle(sz:CGSize) -> UIImage { let image = imageOfSize(sz) { let p = UIBezierPath( roundedRect: CGRect(origin:CGPointZero, size:sz), cornerRadius: 8) p.stroke } return image }
代碼依然可以正常使用。匿名函數中的sz會引用傳遞給外圍函數makeRounded-Rectangle的sz參數。外圍函數的參數對於外部以及匿名函數都是可見的。匿名函數是個閉包,因此在傳遞給imageOfSize時它會捕獲對該參數的引用。
代碼現在變得很緊湊了。為了調用makeRoundedRectangle,提供一個尺寸即可;創建好的圖片就會返回。這樣,就可以執行調用,獲取圖片,然後將圖片放到界面上,所有這些只需一步即可實現,如以下代碼所示:
self.myImageView.image = makeRoundedRectangle(CGSizeMake(45,20)).
2.13.2 返回函數的函數
現在再進一步!相對於返回一張圖片,函數可以返回一個函數,這個函數可以創建出指定大小的圓角矩形。如果從來沒有見過一個函數可以以值的形式從另一個函數中返回,那麼現在就是見證奇跡的時刻了。畢竟,函數可以當作值。在函數調用中,我們已經將函數作為實參傳遞給另一個函數了,現在來從一個函數中接收一個函數作為其結果:
func makeRoundedRectangleMaker(sz:CGSize) -> -> UIImage { 1 func f -> UIImage { 2 let im = imageOfSize(sz) { let p = UIBezierPath( roundedRect: CGRect(origin:CGPointZero, size:sz), cornerRadius: 8) p.stroke } return im } return f 3 }
下面來分析一下上述代碼:
1聲明是最難理解的部分。函數makeRoundedRectangleMaker的類型(簽名)到底是什麼呢?它是(CGSize)-->()-->UIImage。該表達式有兩個箭頭運算符。為了理解這一點,請記住每個箭頭運算符後面的內容就是返回值的類型。因此,makeRoundedRectangleMaker是個函數,它接收CGSize參數並返回a()--->UIImage。那()-->UIImage又是什麼意思呢?我們其實已經知道了:它是個函數,不接收參數,並且返回一個UIImage。這樣,makeRoundedRectangleMaker就是個函數,接收一個CGSize參數並返回一個函數,如果不傳遞參數,那麼該函數本身就會返回一個UIImage。
2現在來看makeRoundedRectangleMaker函數體,首先聲明一個函數(函數中的函數或是局部函數),其類型是我們期望返回的,即它不接收參數並返回一個UIImage。我們將該函數命名為f,該函數的工作方式非常簡單:調用imageOfSize,傳遞一個匿名函數(創建一個圓角矩形圖片im),然後將圖片返回。
3最後,返回創建的函數(f)。我們已經實現了契約:返回一個函數,它不接收參數並返回一個UIImage。
也許你還對makeRoundedRectangleMaker感到好奇,想知道該如何調用它,以及調用之後會得到什麼結果。下面就來試一下:
let maker = makeRoundedRectangleMaker(CGSizeMake(45,20))
代碼運行後變量maker值是什麼呢?它是個函數,不接收參數,當調用後會生成一張大小為(45,20)的圓角矩形圖片。不相信?那我就來證明一下,調用這個函數(它現在是maker的值):
let maker = makeRoundedRectangleMaker(CGSizeMake(45,20)) self.myImageView.image = maker
現在應該理解函數可以將函數作為結果了,下面來看看makeRoundedRectangleMaker的實現,再次對其進行分析,這次是以不同的方式。記住,我並沒有向你演示函數可以產生函數,我這麼寫只是為了說明閉包!來看看環境是如何被捕獲的:
func makeRoundedRectangleMaker(sz:CGSize) -> -> UIImage { func f -> UIImage { let im = imageOfSize(sz) { // * let p = UIBezierPath( roundedRect: CGRect(origin:CGPointZero, size:sz), // * cornerRadius: 8) p.stroke } return im } return f }
函數f不接收參數。不過,在f的函數體中(參見*註釋)引用了尺寸值sz兩次。f的函數體可以看到sz,它是外圍函數makeRoundedRectangleMaker的參數,因為sz位於外圍作用域中。函數f在makeRoundedRectangleMaker調用時捕獲對sz的引用,並且在將f返回並賦給maker時保持該引用:
let maker = makeRoundedRectangleMaker(CGSizeMake(45,20))
這正是maker現在是一個函數的原因所在,當調用時,它會創建並返回一個尺寸為(45,20)的圖片,雖然它本身被調用時並沒有任何參數。我們已經將所要生成的圖片尺寸傳遞給了maker。
從另一個角度再來看看,makeRoundedRectangleMaker是一個工廠,用於創建類似於maker的一系列函數,其中每個函數都會生成特定尺寸的一張圖片。這是對閉包功能的最好說明。
繼續之前,我準備以更加Swift的風格重寫該函數。在函數f中,我們無須創建im再將其返回;可以直接返回調用imageOfSize的結果:
func makeRoundedRectangleMaker(sz:CGSize) -> -> UIImage { func f -> UIImage { return imageOfSize(sz) { let p = UIBezierPath( roundedRect: CGRect(origin:CGPointZero, size:sz), cornerRadius: 8) p.stroke } } return f }
不過沒必要聲明f再將其返回;可以將其定義為匿名函數,然後直接返回:
func makeRoundedRectangleMaker(sz:CGSize) -> -> UIImage { return { return imageOfSize(sz) { let p = UIBezierPath( roundedRect: CGRect(origin:CGPointZero, size:sz), cornerRadius: 8) p.stroke } } }
不過,匿名函數只包含了一條語句,返回imageOfSize的調用結果。(imageOfSize的匿名函數參數有很多行,不過imageOfSize調用本身只有一行Swift語句。)因此,沒必要使用return:
func makeRoundedRectangleMaker(sz:CGSize) -> -> UIImage { return { imageOfSize(sz) { let p = UIBezierPath( roundedRect: CGRect(origin:CGPointZero, size:sz), cornerRadius: 8) p.stroke } } }
2.13.3 使用閉包設置捕獲變量
閉包可以捕獲其環境的能力甚至要超過之前所介紹的。如果閉包捕獲了對外部變量的引用,並且該變量的值是可以修改的,那麼閉包就可以設置該變量。
比如,我聲明了下面這個簡單的函數。它所做的是接收一個函數,該函數會接收一個Int參數,然後通過實參100調用該函數:
func pass100 (f:(Int)->) { f(100) }
仔細看看如下代碼,猜猜運行結果會是什麼:
var x = 0 print(x) func setX(newX:Int) { x = newX } pass100(setX) print(x)
第1個print(x)顯然會打印出0。第2個print(x)調用會打印出100!pass100函數進入我的代碼,並修改變量x的值!這是因為傳遞給pass100的函數包含了對x的引用;它不僅包含了對其引用,還能夠捕獲它;而且不僅能捕獲它,還會設置x,就像直接調用setX一樣。
2.13.4 使用閉包保存捕獲的環境
當閉包捕獲其環境後,即便什麼都不做,它也可以保存該環境。如下示例可能會顛覆你的三觀——這是一個可以修改函數的函數。
func countAdder(f:->) -> -> { var ct = 0 return { ct = ct + 1 print(\"count is (ct)\") f } }
函數countAdder接收一個函數作為參數,結果也返回一個函數。它所返回的函數會調用它所接收的函數;此外,它會增加變量值,然後打印出結果。現在猜猜如下代碼運行後的結果會是什麼:
func greet { print(\"howdy\") } let countedGreet = countAdder(greet) countedGreet countedGreet countedGreet
上述代碼首先定義函數greet,它會打印出\"howdy\",然後將其傳遞給函數countAdder。countAdder返回的是一個新函數,我們將其命名為countedGreet。接下來調用countedGreet 3次。下面是控制台的輸出:
count is 1 howdy count is 2 howdy count is 3 howdy
顯然,countAdder向傳遞給它的函數增加了調用次數的功能。現在來想想:維護這個數量的變量到底是什麼呢?在countAdder內部,它是個局部變量ct;不過,它並未聲明在countAdder所返回的匿名函數中。這麼做是故意的!如果聲明在匿名函數中,那麼每次調用countedGreet時都會將ct設置為0,這就達不到計數的目的了。相反,ct只會被初始化為0一次,然後會由匿名函數所捕獲。這樣,該變量就會被保存為countedGreet環境的一部分了。在某些奇怪的保留環境的情況下,它會位於countedGreet外面,這樣每次調用countedGreet時,它的值都會增加。這正是閉包的強大之處。
這個示例(可以保存環境狀態)還有助於說明函數是引用類型的。為了證明這一點,我先來個對比示例。對一個函數工廠方法的兩次單獨調用會生成兩個函數,正如你期望的那樣:
let countedGreet = countAdder(greet) let countedGreet2 = countAdder(greet) countedGreet // count is 1 countedGreet2 // count is 1
在上述代碼中,兩個函數countedGreet與countedGreet2會分別維護各自的數量。僅僅是賦值或是參數傳遞就會生成對相同函數的新引用,下面就來證明這一點:
let countedGreet = countAdder(greet) let countedGreet2 = countedGreet countedGreet // count is 1 countedGreet2 // count is 2