Go語言核心36講(Go語言進階技術十六)–學習筆記

22 | panic函數、recover函數以及defer語句(下)

我在前一篇文章提到過這樣一個說法,panic 之中可以包含一個值,用於簡要解釋引發此 panic 的原因。

如果一個 panic 是我們在無意間引發的,那麼其中的值只能由 Go 語言運行時系統給定。但是,當我們使用panic函數有意地引發一個 panic 的時候,卻可以自行指定其包含的值。我們今天的第一個問題就是針對後一種情況提出的。

知識擴展

問題 1:怎樣讓 panic 包含一個值,以及應該讓它包含什麼樣的值?

這其實很簡單,在調用panic函數時,把某個值作為參數傳給該函數就可以了。由於panic函數的唯一一個參數是空介面(也就是interface{})類型的,所以從語法上講,它可以接受任何類型的值。

但是,我們最好傳入error類型的錯誤值,或者其他的可以被有效序列化的值。這裡的「有效序列化」指的是,可以更易讀地去表示形式轉換。

還記得嗎?對於fmt包下的各種列印函數來說,error類型值的Error方法與其他類型值的String方法是等價的,它們的唯一結果都是string類型的。

我們在通過佔位符%s列印這些值的時候,它們的字元串表示形式分別都是這兩種方法產出的。

一旦程式異常了,我們就一定要把異常的相關資訊記錄下來,這通常都是記到程式日誌里。

我們在為程式排查錯誤的時候,首先要做的就是查看和解讀程式日誌;而最常用也是最方便的日誌記錄方式,就是記下相關值的字元串表示形式。

所以,如果你覺得某個值有可能會被記到日誌里,那麼就應該為它關聯String方法。如果這個值是error類型的,那麼讓它的Error方法返回你為它訂製的字元串表示形式就可以了。

對於此,你可能會想到fmt.Sprintf,以及fmt.Fprintf這類可以格式化並輸出參數的函數。

是的,它們本身就可以被用來輸出值的某種表示形式。不過,它們在功能上,肯定遠不如我們自己定義的Error方法或者String方法。因此,為不同的數據類型分別編寫這兩種方法總是首選。

可是,這與傳給panic函數的參數值又有什麼關係呢?其實道理是相同的。至少在程式崩潰的時候,panic 包含的那個值字元串表示形式會被列印出來。

另外,我們還可以施加某種保護措施,避免程式的崩潰。這個時候,panic 包含的值會被取出,而在取出之後,它一般都會被列印出來或者記錄到日誌里。

既然說到了應對 panic 的保護措施,我們再來看下面一個問題。

問題 2:怎樣施加應對 panic 的保護措施,從而避免程式崩潰?

Go 語言的內建函數recover專用於恢復 panic,或者說平息運行時恐慌。recover函數無需任何參數,並且會返回一個空介面類型的值。

如果用法正確,這個值實際上就是即將恢復的 panic 包含的值。並且,如果這個 panic 是因我們調用panic函數而引發的,那麼該值同時也會是我們此次調用panic函數時,傳入的參數值副本。請注意,這裡強調用法的正確。我們先來看看什麼是不正確的用法。

package main

import (
 "fmt"
 "errors"
)

func main() {
 fmt.Println("Enter function main.")
 // 引發panic。
 panic(errors.New("something wrong"))
 p := recover()
 fmt.Printf("panic: %s\n", p)
 fmt.Println("Exit function main.")
}

在上面這個main函數中,我先通過調用panic函數引發了一個 panic,緊接著想通過調用recover函數恢復這個 panic。可結果呢?你一試便知,程式依然會崩潰,這個recover函數調用並不會起到任何作用,甚至都沒有機會執行。

還記得嗎?我提到過 panic 一旦發生,控制權就會訊速地沿著調用棧的反方向傳播。所以,在panic函數調用之後的程式碼,根本就沒有執行的機會。

那如果我把調用recover函數的程式碼提前呢?也就是說,先調用recover函數,再調用panic函數會怎麼樣呢?

這顯然也是不行的,因為,如果在我們調用recover函數時未發生 panic,那麼該函數就不會做任何事情,並且只會返回一個nil。

換句話說,這樣做毫無意義。那麼,到底什麼才是正確的recover函數用法呢?這就不得不提到defer語句了。

顧名思義,defer語句就是被用來延遲執行程式碼的。延遲到什麼時候呢?這要延遲到該語句所在的函數即將執行結束的那一刻,無論結束執行的原因是什麼。

這與go語句有些類似,一個defer語句總是由一個defer關鍵字和一個調用表達式組成。

這裡存在一些限制,有一些調用表達式是不能出現在這裡的,包括:針對 Go 語言內建函數的調用表達式,以及針對unsafe包中的函數的調用表達式。

順便說一下,對於go語句中的調用表達式,限制也是一樣的。另外,在這裡被調用的函數可以是有名稱的,也可以是匿名的。我們可以把這裡的函數叫做defer函數或者延遲函數。注意,被延遲執行的是defer函數,而不是defer語句。

我剛才說了,無論函數結束執行的原因是什麼,其中的defer函數調用都會在它即將結束執行的那一刻執行。即使導致它執行結束的原因是一個 panic 也會是這樣。正因為如此,我們需要聯用defer語句和recover函數調用,才能夠恢復一個已經發生的 panic。

我們來看一下經過修正的程式碼。

package main

import (
 "fmt"
 "errors"
)

func main() {
 fmt.Println("Enter function main.")
 defer func(){
  fmt.Println("Enter defer function.")
  if p := recover(); p != nil {
   fmt.Printf("panic: %s\n", p)
  }
  fmt.Println("Exit defer function.")
 }()
 // 引發panic。
 panic(errors.New("something wrong"))
 fmt.Println("Exit function main.")
}

在這個main函數中,我先編寫了一條defer語句,並在defer函數中調用了recover函數。僅當調用的結果值不為nil時,也就是說只有 panic 確實已發生時,我才會列印一行以「panic:」為前綴的內容。

緊接著,我調用了panic函數,並傳入了一個error類型值。這裡一定要注意,我們要盡量把defer語句寫在函數體的開始處,因為在引發 panic 的語句之後的所有語句,都不會有任何執行機會。

也只有這樣,defer函數中的recover函數調用才會攔截,並恢復defer語句所屬的函數,及其調用的程式碼中發生的所有 panic。

至此,我向你展示了兩個很典型的recover函數的錯誤用法,以及一個基本的正確用法。

我希望你能夠記住錯誤用法背後的緣由,同時也希望你能真正地理解聯用defer語句和recover函數調用的真諦。

在命令源碼文件 demo50.go 中,我把上述三種用法合併在了一段程式碼中。你可以運行該文件,並體會各種用法所產生的不同效果。

package main

import (
	"errors"
	"fmt"
)

func main() {
	fmt.Println("Enter function main.")

	defer func() {
		fmt.Println("Enter defer function.")

		// recover函數的正確用法。
		if p := recover(); p != nil {
			fmt.Printf("panic: %s\n", p)
		}

		fmt.Println("Exit defer function.")
	}()

	// recover函數的錯誤用法。
	fmt.Printf("no panic: %v\n", recover())

	// 引發panic。
	panic(errors.New("something wrong"))

	// recover函數的錯誤用法。
	p := recover()
	fmt.Printf("panic: %s\n", p)

	fmt.Println("Exit function main.")
}

下面我再來多說一點關於defer語句的事情。

問題 3:如果一個函數中有多條defer語句,那麼那幾個defer函數調用的執行順序是怎樣的?

如果只用一句話回答的話,那就是:在同一個函數中,defer函數調用的執行順序與它們分別所屬的defer語句的出現順序(更嚴謹地說,是執行順序)完全相反。

當一個函數即將結束執行時,其中的寫在最下邊的defer函數調用會最先執行,其次是寫在它上邊、與它的距離最近的那個defer函數調用,以此類推,最上邊的defer函數調用會最後一個執行。

如果函數中有一條for語句,並且這條for語句中包含了一條defer語句,那麼,顯然這條defer語句的執行次數,就取決於for語句的迭代次數。

並且,同一條defer語句每被執行一次,其中的defer函數調用就會產生一次,而且,這些函數調用同樣不會被立即執行。

那麼問題來了,這條for語句中產生的多個defer函數調用,會以怎樣的順序執行呢?

為了徹底搞清楚,我們需要弄明白defer語句執行時發生的事情。

其實也並不複雜,在defer語句每次執行的時候,Go 語言會把它攜帶的defer函數及其參數值另行存儲到一個鏈表中。

這個鏈表與該defer語句所屬的函數是對應的,並且,它是先進後出(FILO)的,相當於一個棧。

在需要執行某個函數中的defer函數調用的時候,Go 語言會先拿到對應的鏈表,然後從該鏈表中一個一個地取出defer函數及其參數值,並逐個執行調用。

這正是我說「defer函數調用與其所屬的defer語句的執行順序完全相反」的原因了。

下面該你出場了,我在 demo51.go 文件中編寫了一個與本問題有關的示例,其中的核心程式碼很簡單,只有幾行而已。

package main

import "fmt"

func main() {
	defer fmt.Println("first defer")
	for i := 0; i < 3; i++ {
		defer fmt.Printf("defer in for [%d]\n", i)
	}
	defer fmt.Println("last defer")
}

總結

我們這兩期的內容主要講了兩個函數和一條語句。recover函數專用於恢復 panic,並且調用即恢復。

它在被調用時會返回一個空介面類型的結果值。如果在調用它時並沒有 panic 發生,那麼這個結果值就會是nil。

而如果被恢復的 panic 是我們通過調用panic函數引發的,那麼它返回的結果值就會是我們傳給panic函數參數值的副本。

對recover函數的調用只有在defer語句中才能真正起作用。defer語句是被用來延遲執行程式碼的。

更確切地說,它會讓其攜帶的defer函數的調用延遲執行,並且會延遲到該defer語句所屬的函數即將結束執行的那一刻。

在同一個函數中,延遲執行的defer函數調用,會與它們分別所屬的defer語句的執行順序完全相反。還要注意,同一條defer語句每被執行一次,就會產生一個延遲執行的defer函數調用。

這種情況在defer語句與for語句聯用時經常出現。這時更要關注for語句中,同一條defer語句產生的多個defer函數調用的實際執行順序。

以上這些,就是關於 Go 語言中特殊的程式異常,及其處理方式的核心知識。這裡邊可以衍生出很多面試題目。

思考題

我們可以在defer函數中恢復 panic,那麼可以在其中引發 panic 嗎?

筆記源碼

//github.com/MingsonZheng/go-core-demo

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含鏈接: //www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可發布。