try/catch/finally

February 11, 2022

在〈Maybe 有無、Either 對錯〉談過,head [] 會拋出 Exception

ghci> head []
*** Exception: Prelude.head: empty list
ghci>

也談到因為惰性的關係,你不會知道何時會噴出了 Exception,這麼一來,處理 Exception 的時機是個麻煩,因為惰性的關係,head [] 不會馬上執行:

ghci> let x = head []
ghci>

try 函式

如果要處理 headException,可以使用 Control.Exceptiontry 函式:

import Control.Exception

main = do
    result <- (try $ return $ head [1, 2, 3]) :: IO (Either SomeException Int)
    case result of
        Left  e    -> putStrLn $ "發生 Exception:" ++ show e
        Right elem -> putStrLn $ "首元素:" ++ show elem

try 的型態是 Exception e => IO a -> IO (Either e a),它接受 IO a,在範例中,對 head 執行結果使用 return,再作為 try 的引數,try 傳回一個 IO (Either e a),正確執行的話結果就是 a,發生錯誤的話,會捕捉 Exceptione

這邊看到的 SomeException,具有 Exception 的行為,先用簡單的說法解釋的話,SomeException 為具有 Exception 行為的頂層型態,想瞭解階層系統如何定義,可以參考〈The Exception type〉。

如果執行上面這個程式,結果會顯示 "首元素:1",不過,試著將 [1, 2, 3] 改為 [] 並編譯執行,你會看到什麼?"發生 Exception:empty list"?不是!會看到 "Prelude.head: empty list",這是 Haskell 執行環境給你的訊息,不是你定義出來要顯示的訊息。

因為惰性的關係,head [] 執行的時機不一定在 try,Haskell 有個 evaluate 函式,可以用來要求立即執行函式,如果一個純函式可能拋出 Exception,想要透過 try 來處理,必須使用 evaluate 函式:

import Control.Exception

main = do
    result <- (try $ evaluate $ head []) :: IO (Either SomeException Int)
    case result of
        Left  e    -> putStrLn $ "發生 Exception:" ++ show e
        Right elem -> putStrLn $ "首元素:" ++ show elem

執行這個範例才會看到 "發生 Exception:Prelude.head: empty list" 的顯示結果。

從純綷的函式中拋出 Exception,並不是要讓你在執行時期嘗試處理,執行純函式時若發生了 Exception,往往表示呼叫函式的條件不足而發生的錯誤,你應該停止程式,檢視、修改程式碼,做好呼叫函式前的條件檢,別讓函式有機會拋出 Exception

就上例而言,是呼叫 head 的時機不對,實際的應用程式中,是不會直接寫 head [],上例純綷只是為了示範 try;實際的應用程式中,不該讓 [] 有機會成為 head 的引數,應該使用 null 檢查,或者透過模式比對 [] 的可能性。

要在自訂的純函式中拋出 Exception,可以使用 error 函式,在〈Data.List/Set/Map 模組〉曾經看過,自行拋出 Exception 後續的文件還會談到。

try 的使用時機,主要是那些有副作用的函式,例如,來自 System.Environment 模組的 getArgs 函式,型態是 getArgs :: IO [String],其中 list 是使用者執行程式時給定的命令列引數,readFile 函式的型態是 readFile :: FilePath -> IO StringFilePath 只是 String 的別名,你可以指定檔案路徑,它會讀取檔案的內容。

以下範例可以從命令列引數讀取純文字檔案並顯示:

import System.Environment

main = do
    (fileName:_) <- getArgs
    contents <- readFile fileName
    putStrLn contents

指定的檔案存在時,這個程式會顯示檔案內容,指定的檔案不存在時,就會發生錯誤,可以使用 System.DirectorydoesFileExist 函式,事先檢查檔案是否存在以避免錯誤,如果想使用 try 函式來處理錯誤的話,可以如下:

import Control.Exception
import System.Environment

main = do
    (fileName:_) <- getArgs
    result <- (try $ readFile fileName) :: IO (Either IOException String)
    case result of
        Left  e        -> putStrLn $ "發生 Exception:" ++ show e
        Right contents -> putStrLn $ "檔案內容:" ++ contents

catch 函式

Control.Exceptioncatch 函式,型態是 Exception e => IO a -> (e -> IO a) -> IO a,接受一個 IO 動作結果與一個可處理 Exception 的函式,最後傳回一個 IO 動作結果。

來看看如何將上面讀取檔案的例子,使用 catch 來處理錯誤:

import Data.Typeable
import Control.Exception
import System.Environment

main = 
    (do (fileName:_) <- getArgs
        contents <- readFile fileName
        putStrLn contents)
    `catch` (\(e :: SomeException) -> do putStrLn $ show $ typeOf e
                                         putStrLn $ show e)

catch 接受檔案讀取等 IO 動作,如果發生 Exception 的話,會使用另一個指定的函式來處理錯誤,在這邊使用了 Data.TypeabletypeOf 函式,以得知實際的 Exception 型態,例如指定檔案不存在時會發生 IOException

> readFile some.txt
IOException
some.txt: openFile: does not exist (No such file or directory)

將嘗試處理的函式與錯誤處理的函式分開定義,會是比較易讀的寫法,例如:

import Control.Exception
import System.Environment
                               
main = putStrLnContent `catch` putStrLnIOException

putStrLnContent :: IO ()
putStrLnContent = do
    (fileName:_) <- getArgs
    contents <- readFile fileName
    putStrLn contents

putStrLnIOException :: IOException -> IO ()
putStrLnIOException e = do
    putStrLn $ show e

方才看過的 try 函式,可基於 catch 實作,將捕捉到的 Exception 使用 Either 傳回:

import Control.Exception
import System.Environment

try' :: Exception e => IO a -> IO (Either e a)
try' a = catch toEither (return . Left)
    where toEither = do
                r <- a
                return (Right r)
                
main = do
    (fileName:_) <- getArgs
    result <- (try' $ readFile fileName) :: IO (Either IOException String)
    case result of
        Left  e        -> putStrLn $ "發生 Exception:" ++ show e
        Right contents -> putStrLn $ "檔案內容:" ++ contents

catches 函式

有時候會想捕捉多個 Exception,例如:

import Data.Typeable
import Control.Exception
import System.Environment

main =
    (do (fileName:_) <- getArgs
        contents <- readFile fileName
        putStrLn contents)
    `catch` (\(e :: IOException) -> putStrLn $ "發生 Exception:" ++ show e)
    `catch` (\(e :: SomeException) -> putStrLn $ show $ typeOf e)

這個方式乍看行得通,不過有點問題,因為這是將前一個 catch 的結果,作為第二個 catch 的第一個引數,因此,前一個 catch 若發生了 Exception 而第一個處理 Exception 的函式中又拋出了 Exception,那麼下一個 catch 處理 Exception 的函式就會捕捉到它,這顯然與其他語言中處理 Exception 的行為不太一樣。

這時可以改用 catches 函式,它的型態是 IO a -> [Handler a] -> IO a,其中 Handler 型態有個值建構式 Handler,型態為 Exception e => (e -> IO a) -> Handler a,來看看如何使用:

import Data.Typeable
import Control.Exception
import System.Environment

main =
    (do (fileName:_) <- getArgs
        contents <- readFile fileName
        putStrLn contents)
    `catches` [
        Handler (\(e::IOException) -> putStrLn $ "發生 Exception:" ++ show e),
        Handler (\(e::SomeException) -> putStrLn $ show $ typeOf e)
     ]

handle 函式

handle 函式的型態為 Exception e => (e -> IO a) -> IO a -> IO a,與 catch 函式相比,只是引數順序不同:

import Control.Exception
import System.Environment
                               
main = handle putStrLnIOException putStrLnContent

putStrLnContent :: IO ()
putStrLnContent = do
    (fileName:_) <- getArgs
    contents <- readFile fileName
    putStrLn contents

putStrLnIOException :: IOException -> IO ()
putStrLnIOException e = do
    putStrLn $ show e

可基於可讀性來選擇使用 catchhandle,通常在處理 Exception 函式簡短的情況下,可選擇使用 handle,例如:

import Control.Exception
import System.Environment

main = handle (\(e :: IOException) -> putStrLn $ "發生 Exception:" ++ show e)
        (do (fileName:_) <- getArgs
            contents <- readFile fileName
            putStrLn contents)

finally 函式

熟悉具有 Exception 處理機制的語言,像是 Java 等的開發者,都知道會有個 finally,可用來做一些資源善後工作,Haskell 也有個 finally 函式,型態是 IO a -> IO a -> IO a,如果想特意模彷 Java 的 Exception 語法,可以如下撰寫:

import Data.Typeable
import Control.Exception
import System.Environment

main =
    (do (fileName:_) <- getArgs
        contents <- readFile fileName
        putStrLn contents)
    `catches` [
        Handler (\(e::IOException) -> putStrLn $ "發生 Exception:" ++ show e),
        Handler (\(e::SomeException) -> putStrLn $ show $ typeOf e)
     ]
    `finally` (putStrLn "finally 執行最後資源善後")

額外補充說明

因為這系列文件不談論 Haskell 的檔案輸入輸出,接下來只是額外補充,略過也沒關係…XD

finally 函式只是 Haskell 中 bracket 函式的特化,對於自行開檔與關檔這類動作時,使用 bracket 會比較方便,例如可使用 bracket 自行實作 readFile 函式:

import Data.Typeable
import Prelude hiding (readFile)
import Control.Exception
import System.Environment
import System.IO hiding (readFile)

readFile :: FilePath -> IO String
readFile fileName = 
    bracket (openFile fileName ReadMode) hClose (\handle -> do
        contents <- hGetContents handle
        evaluate contents
        return contents)

main =
    (do (fileName:_) <- getArgs
        contents <- readFile fileName
        putStrLn contents)
    `catches` [
        Handler (\(e::IOException) -> putStrLn $ "發生 Exception:" ++ show e),
        Handler (\(e::SomeException) -> putStrLn $ show $ typeOf e)
     ]

分享到 LinkedIn 分享到 Facebook 分享到 Twitter