Golang語言之defer-再議
defer語句被用於預定對一個函式的呼叫。我們把這類被defer語句呼叫的函式稱為延遲函式。注意,defer語句只能出現在函式或方法的內部。
一條defer語句總是以關鍵字defer開始。在defer的右邊還必會有一條表示式語句,且它們之間要以空格" "分隔,如:
defer fmt.Println("The finishing touches.")
這裡的表示式語句必須代表一個函式或方法的呼叫。注意,既然是表示式語句,那麼一些呼叫表示式就是不被允許出現在這裡的。比如,針對各種內建函式的那些呼叫表示式。因為它們不能被稱為表示式語句。另外,在這個位置上出現的表示式語句是不能被圓括號括起來的。
defer語句的執行時機總是在直接包含它的那個函式把流程控制權交還給它的呼叫方的前一刻,無論defer語句出現在外圍函式的函式體中的哪一個位置上。具體分為下面幾種情況:
當外圍函式的函式體中的相應語句全部被正常執行完畢的時候,只有在該函式中的所有defer語句都被執行完畢之後該函式才會真正地結束執行。
當外圍函式的函式體中的return語句被執行的時候,只有在該函式中的所有defer語句都被執行完畢之後該函式才會真正地返回。
當在外圍函式中有執行時恐慌發生的時候,只有在該函式中的所有defer語句都被執行完畢之後該執行時恐慌才會真正地被擴散至該函式的呼叫方。
總之,外圍函式的執行的結束會由於其中defer語句的執行而被推遲。
正因為defer語句有著這樣的特性,所以它成為了執行釋放資源或異常處理等收尾任務的首選。使用defer語句的優勢有兩個:一、收尾任務總會被執行,我們不會再因粗心大意而造成資源的浪費;二、我們可以把它們放到外圍函式的函式體中的任何地方(一般是函式體開始處或緊跟在申請資源的語句的後面),而不是隻能放在函式體的最後。這使得程式碼邏輯變得更加清晰,並且收尾任務是否被合理的指定也變得一目瞭然。
在defer語句中,我們呼叫的函式不但可以是已宣告的命名函式,還可以是臨時編寫的匿名函式,就像這樣:
defer func() { fmt.Println("The finishing touches.") }()
注意,一個針對匿名函式的呼叫表示式是由一個函式字面量和一個代表了呼叫操作的一對圓括號組成的。
我們在這裡選擇匿名函式的好處是可以使該函式的收尾任務的內容更加直觀。不過,我們也可以把比較通用的收尾任務單獨放在一個命名函式中,然後再將其新增到需要它的defer語句中。無論在defer關鍵字右邊的是命名函式還是匿名函式,我們都可以稱之為延遲函式。因為它總是會被延遲到外圍函式執行結束前一刻才被真正的呼叫。
每當defer語句被執行的時候,傳遞給延遲函式的引數都會以通常的方式被求值。如下例:
func begin(funcName string) string {
fmt.Printf("Enter function %s.n", funcName)
return funcName
}
func end(funcName string) string {
fmt.Printf("Exit function %s.n", funcName)
return funcName
}
func record() {
defer end(begin("record"))
fmt.Println("In function record.")
}
outputs:
Enter function record.
In function record.
Exit function record.
示例中,呼叫表示式begin("record")是作為record函式的引數出現的。它會在defer語句被執行的時候被求值。也就是說,在record函式的函式體被執行之處,begin函式就被呼叫了。然而,end函式卻是在外圍函式record執行結束的前一刻被呼叫的。
這樣做除了可以避免參數值在延遲函式被真正呼叫之前再次發生改變而給該函式的執行造成影響之外,還是處於同一條defer語句可能會被多次執行的考慮。如下例:
func printNumbers() {
for i := 0; i < 5; i++ {
defer fmt.Printf("%d ", i)
}
}
outputs:
4 3 2 1 0
在for語句的每次迭代的過程中都會執行一次其中的defer語句。在第一次迭代中,針對延遲函式的呼叫表示式最終會是fmt.Printf("%d", 0)。這是由於在defer語句被執行的時候,引數i先被求值為了0,隨後這個值被代入到了原來的呼叫表示式中,並形成了最終的延遲函式呼叫表示式。顯然,這時的呼叫表示式已經與原來的表示式有所不同了。所以,Go語言會把代入引數值之後的呼叫表示式另行儲存。以此類推,後面幾次迭代所產生的延遲函式呼叫表示式依次為:
fmt.Printf("%d ", 1)
fmt.Printf("%d ", 2)
fmt.Printf("%d ", 3)
fmt.Printf("%d ", 4)
對延遲函式呼叫表示式的求值順序是與它們所在的defer語句被執行的順序完全相反的。每當Go語言把已代入引數值的延遲函式呼叫表示式另行儲存後,還會把它追加到一個專門為當前外圍函式儲存延遲函式呼叫表示式的列表中。而這個列表總是LIFO(Last In First Out,即後進先出)的。因此,這些延遲函式呼叫表示式的求值順序會是:
fmt.Printf("%d ", 4)
fmt.Printf("%d ", 3)
fmt.Printf("%d ", 2)
fmt.Printf("%d ", 1)
fmt.Printf("%d ", 0)
例:
func appendNumbers(ints []int) (result []int) {
result = append(ints, 1)
fmt.Println(result)
defer func() {
result = append(result, 2)
}()
result = append(result, 3)
fmt.Println(result) defer func() {
result = append(result, 4)
}()
result = append(result, 5)
fmt.Println(result) defer func() {
result = append(result, 6)
}()
return result
}
outputs:
[0 1 3 5 6 4 2]
例:
func printNumbers() {
for i := 0; i < 5; i++ {
defer func() {
fmt.Printf("%d ", i)
}()
}
}
outputs:
5 5 5 5 5
在defer語句被執行的時候傳遞給延遲函式的引數都會被求值,但是延遲函式呼叫表示式並不會在那時被求值。當我們把
fmt.Printf("%d ", i)
改為
defer func() {
fmt.Printf("%d ", i)
}()
之後,雖然變數i依然是有效的,但是它所代表的值卻已經完全不同了。在for語句的迭代過程中,其中defer語句被執行了5次。但是,由於我們並沒有給延遲函式傳遞任何引數,所以Go語言執行時系統也就不需要對任何作為延遲函式的引數值的表示式進行求值(因為它們根本不存在)。在for語句被執行完畢的時候,共有5個延遲函式呼叫表示式被儲存到了它們的專屬列表中。注意,被儲存在專屬列表中的是5個相同的呼叫表示式:
defer func() {
fmt.Printf("%d ", i)
}()
在printNumbers函式的執行即將結束的時候,那個專屬列表中的延遲函式呼叫表示式就會被逆序的取出並被逐個的求值。然而,這時的變數i已經被修改為了5。因此,對5個相同的呼叫表示式的求值都會使標準輸出上打印出5.
如何修正這個問題呢?
將defer語句修改為:
defer func(i int) {
fmt.Printf("%d ", i)
}(i)
我們雖然還是以匿名函式作為延遲函式,但是卻為這個匿名函式添加了一個引數宣告,並在代表呼叫操作的圓括號中加入了作為引數的變數i。這樣,在defer語句被執行的時候,傳遞給延遲函式的這個引數i就會被求值。最終的延遲函式呼叫表示式也會類似於:
defer func(i int) {
fmt.Printf("%d ", i)
}(0)
又因為延遲函式宣告中的引數i遮蔽了在for語句中宣告的變數i,所以在延遲函式被執行的時候,其中那條列印語句中所使用的i值即為傳遞給延遲函式的那個引數值。
如果延遲函式是一個匿名函式,並且在外圍函式的宣告中存在命名的結果宣告,那麼在延遲函式中的程式碼是可以對命名結果的值進行訪問和修改的。如下例:
func modify(n int) (number int) {
fmt.Println(number)
defer func() {
number += n
}()
number++
return
}
modify(2),結果為:3
雖然在延遲函式的宣告中可以包含結果宣告,但是其返回的結果值會在它被執行完畢時丟棄。因此,作為慣例,我們在編寫延遲函式的宣告的時候不會為其新增結果宣告。另一方面,推薦以傳參的方式提供延遲函式所需的外部值。如下例:
func modify(n int) (number int) {
fmt.Println(number)
defer func(plus int) (result int) {
result = n + plus
number += result
return
}(3)
number++
return
}
modify(2),結果為:6
我們可以把想要傳遞給延遲函式的引數值依照規則放入到那個代表呼叫操作的圓括號中,就像呼叫普通函式那樣。另一方面,雖然我們在延遲函式的函式體中返回了結果值,但是卻不會產生任何效果。