讀古今文學網 > iOS編程基礎:Swift、Xcode和Cocoa入門指南 > 2.13 閉包 >

2.13 閉包

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