從 Python try except 的角度理解 Go 如何用 defer panic() recover() 處理例外/錯誤

Posted on  Mar 27, 2024  in  Go 程式設計 - 初階  by  Amo Chen  ‐ 5 min read

學習 Go 的過程一定有人跟我一樣,對於 Go 用 defer(), panic(), recover() 處理例外/錯誤的方式感到困惑,特別是已經習慣使用 Java, JavaScript, Python 等程式語言的開發者來說, Java, JavaScript, Python 所提供的 try...catch , try-except 在可讀性相對友善很多之外,在開發時也直覺很多。

但要理解 Go 的 defer(), panic(), recover() 其實可以從已知的模式出發,如此原本無法輕易理解的事物,就會變成好理解許多。

本文將從 Python 的 try-except 出發,學習 Go 如何做到相同的例外(exception)處理。

本文環境

  • Python 3
  • Go

從 Python 的 try-except 開始到 Go 的 defer, panic(), recover()

Python 的 try-except 可以捕捉 try 區塊內的程式碼所拋出的例外(exception)或錯誤,並在捕捉到例外(exception)或錯誤之後,執行 except 區塊內的程式碼,因此以下 Python 的錯誤處理模式相當常見:

try:
    # 可能發生錯誤的程式碼
    # 可能發生錯誤的程式碼
except Exception as e:
    if isinstance(e, ValueError):
        # 進行錯誤處理
    else:
        # 進行錯誤處理

例如,下列範例函式直接在 try 區塊拋出 ValueError 的錯誤,讓 except 區塊捕捉到例外之後,就印出 f'Got an exception: {e}' 字串:

def example():
    try:
        raise ValueError()
    except Exception as e:
        print(f'Got an exception: {e}')

如果轉換為 Go 的寫法將會如下所示:

package main

import "fmt"

func example() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("Got an exception:", r)
		}
	}()
	panic("value error")
}

func main() {
	example()
}

畫成圖會更容易理解 Python, Go 兩者相同作用的部分:

the-except-block.png

Go 的 panic() 其實作用等同於 Python 的 raise ,拋出 1 個例外/錯誤的意思;拋出例外/錯誤之後,就必須要有地方捕捉才有辦法進一步做處置,這個捕捉的方法所對應的就是 recover()

至於為什麼需要 recover() 一定要放在 defer 函式中,則是因為 Go 的規定:

Recover is a built-in function that regains control of a panicking goroutine. Recover is only useful inside deferred functions. During normal execution, a call to recover will return nil and have no other effect. If the current goroutine is panicking, a call to recover will capture the value given to panic and resume normal execution.

如果不是在 defer 函式中呼叫 recover() ,或者沒有 panic 發生, recover() 也只會直接回傳 nil

另外是 defer 函式的特性是在呼叫者(caller)執行 return 之前執行,也因為如此,如果 1 個函式在 return 之前發生 panic, derfer 函式內的 recover() 才能夠正確捕捉例外/錯誤:

package main

import (
	"fmt"
)

func caller() {
	defer func() {
		if r := recover(); r != nil {
			// error handling
			fmt.Println("error handling")
		}
	}()

	fmt.Println("error happened")
	panic("error happened")
}

func main() {
	caller()
}

至此,我們應能理解 defer, panic(), recover() 三者搭配的作用:

  • panic() 拋出例外/錯誤
  • defer + recover() 捕捉例外/錯誤

其他例外處理的常見模式

前文已經從 Python 的 try-except 角度理解 defer, panic(), recover() 三者搭配的作用,不過對於學習 Go 的新手來說,也許仍有常見的例外處理模式無法直覺地反應,以下將逐一破解。

無論如何都要回傳值的情況

在有些情況下,有些錯誤其實不需要理會,只需要針對錯誤作出紀錄即可,例如下列 Python 程式碼,我們僅對錯誤列印警告訊息,之後便正常回傳 1 個值:

def get_value_no_matter_what_happened():
    try:
        raise ValueError()
    except Exception as e:
        print('Warning: there is an error here')
        return 1

如果轉換為 Go 的寫法,就不像上述寫法般直覺,原因在於呼叫 panic() 之後,我們就不能再回到原本程式的流程,例如下列範例 fmt.Println("Hello") 是永遠不會被執行的:

package main

import "fmt"

func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("error handling")
        }
    }()

    panic("error")
    fmt.Println("Hello") // <- unreachable code
}

要解決前述問題的話,可以使用 named return values, 只要在函式定義時給回傳值指定 1 個命名,我們即可在 defer 函式中修改回傳值,下列範例中的 (ret int) 就是 named return value 的用法:

package main

import "fmt"

func getValueNoMatterWhatHappened() (ret int) {
	defer func() {
		if r := recover(); r != nil {
            fmt.Println("Warning: error here")
			ret = 1
		}
	}()
	panic("value error")
}

func main() {
	fmt.Println("getValueNoMatterWhatHappened:", getValueNoMatterWhatHappened())
}

上述範例執行結果如下,可以看到即使 getValueNoMatterWhatHappened() 發生 panic, 我們仍能藉由 named return values 的做法正常回傳值:

Warning: error here
getValueNoMatterWhatHappened: 1

這一切都要歸功於 defer 函式具有合法讀取、修改回傳值的能力:

Deferred functions may read and assign to the returning function’s named return values.

所以我說那個 finally 區塊呢?

A finally clause is always executed before leaving the try statement, whether an exception has occurred or not.

Python 的 try-except 還有個 finally 區塊可以使用, finally 區塊會在離開 try-except 區塊時執行,無論是否有任何例外發生。官方說明 finally 區塊相當適合作為釋放資源的用途使用,例如釋放 lock 避免死結(deadlock)產生。

以下是 1 個 Python try-except-finally 的範例:

def get_value_no_matter_what_happened():
    try:
        raise ValueError()
    except Exception as e:
        print('the exception block executed')
        return 1
    finally:
        print('the finally block executed')

上述執行結果如下,可以看到即使 return 1 已經被執行了, finally 區塊仍會運作:

>>> x = get_value_no_matter_what_happened()
the exception block executed
the finally block executed
>>> x
1

Go 也能做到類似的事,簡單來說,只要在 defer 函式內再多加 1 個 defer 函式即可:

package main

import "fmt"

func getValueNoMatterWhatHappened() (ret int) {
	defer func() {
		defer func() {
			fmt.Println("the finally block executed")
		}()

		if r := recover(); r != nil {
			fmt.Println("the exception block executed")
			ret = 1
		}
	}()
	panic("value error")
}

func main() {
	fmt.Println("getValueNoMatterWhatHappened:", getValueNoMatterWhatHappened())
}

p.s. 你也可以在最裡層的 defer 函式再次修改 ret 的值,作用跟在 Python 的 finally 區塊內 return 一樣,都對回傳值有作用

讓 flow 繼續下去

前面說到 panic() 呼叫之後,我們就無法回到原有的程式流程,如果以 Python 的 try-except 來看,它只要在 except 捕捉到錯誤,並做出處理之後,是可以繼續程式的流程的,如下列範例中的 print('do the rest of the jobs') 在錯誤發生之後仍能執行,因為我們已經使用 try-except 捕捉並且處理錯誤:

def do_something_no_matter_what_happened():
    try:
        print('do something')
        raise ValueError()
    except Exception as e:
        print('do something if an error occurred')

    print('do the rest of the jobs')

針對這種問題,其實 Go 的函式可以回傳 1 個值代表是否有錯誤發生,只要針對該錯誤進行判斷並處理即可,例如下列模式在 Go 程式碼中相當常見:

err := doX()
if err != nil {
    // error handling
}

所以前述 Python 範例等價於下列 Go 程式碼:

package main

import (
	"errors"
	"fmt"
)

func doSomething() error {
	fmt.Println("do something")
	return errors.New("value error")
}

func getValueNoMatterWhatHappened() {
	err := doSomething()
	if err != nil {
		fmt.Println("do something if an error occurred")
	}
	fmt.Println("do the rest of the jobs")
}

func main() {
	getValueNoMatterWhatHappened()
}

不過,對我們而言 doSomething() 要在函式內處理錯誤,還是呼叫 doSomething() 的地方處理錯誤,其實是可以選擇的。

	err := doSomething()
	if err != nil {
		fmt.Println("do something if an error occurred")
	}

我們也可以把會發生錯誤的上述程式碼,抽取至該函式內自行處理錯誤即可:

package main

import "fmt"

func doSomething() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("do something if an error occurred")
		}
	}()
	fmt.Println("do something")
	panic("value error")
}

func getValueNoMatterWhatHappened() {
	doSomething()
	fmt.Println("do the rest of the jobs")
}

func main() {
	getValueNoMatterWhatHappened()
}

上述範例執行結果如下,可以看到 2 種不同做法都能達到訴求:

do something
do something if an error occurred
do the rest of the jobs

總結

Go 的 defer, panic(), recover() 的設計雖然跟 Java, JavaScript, Python 等程式語言不同,但都能做到相同的事,只是對於已經慣用一套既定模式的我們而已,我們更需要從已知的範例開始延伸研究,以幫助我們更加深入地認識 Go 的 defer, panic(), recover() 的設計而已。

以上!

Enjoy!

References

Defer, Panic, and Recover - The Go Programming Language

對抗久坐職業傷害

研究指出每天增加 2 小時坐著的時間,會增加大腸癌、心臟疾病、肺癌的風險,也造成肩頸、腰背疼痛等常見問題。

然而對抗這些問題,卻只需要工作時定期休息跟伸展身體即可!

你想輕鬆改變現狀嗎?試試看我們的 PomodoRoll 番茄鐘吧! PomodoRoll 番茄鐘會根據你所設定的專注時間,定期建議你 1 項辦公族適用的伸展運動,幫助你打敗久坐所帶來的傷害!

贊助我們的創作

看完這篇文章了嗎? 休息一下,喝杯咖啡吧!

如果你覺得 MyApollo 有讓你獲得實用的資訊,希望能看到更多的技術分享,邀請你贊助我們一杯咖啡,讓我們有更多的動力與精力繼續提供高品質的文章,感謝你的支持!