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

MingsonZheng發表於2021-11-09

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 嗎?

筆記原始碼

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

知識共享許可協議

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

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

相關文章