第十九章: 錯(cuò)誤處理

2018-02-24 15:49 更新

第十九章: 錯(cuò)誤處理

無論使用哪門語言,錯(cuò)誤處理都是程序員最重要–也是最容易忽視–的話題之一。在Haskell中,你會(huì)發(fā)現(xiàn)有兩類主流的錯(cuò)誤處理:“純”的錯(cuò)誤處理和異常。

當(dāng)我們說“純”的錯(cuò)誤處理,我們是指算法不依賴任何IO Monad。我們通常會(huì)利用Haskell富于表現(xiàn)力的數(shù)據(jù)類型系統(tǒng)來實(shí)現(xiàn)這一類錯(cuò)誤處理。Haskell也支持異常。由于惰性求值復(fù)雜性,Haskell中任何地方都可能拋出異常,但是只會(huì)在IO monad中被捕獲。在這一章中,這兩類錯(cuò)誤處理我們都會(huì)考慮。

使用數(shù)據(jù)類型進(jìn)行錯(cuò)誤處理

讓我們從一個(gè)非常簡單的函數(shù)來開始我們關(guān)于錯(cuò)誤處理的討論。假設(shè)我們希望對一系列的數(shù)字執(zhí)行除法運(yùn)算。分子是常數(shù),但是分母是變化的。可能我們會(huì)寫出這樣一個(gè)函數(shù):

-- file: ch19/divby1.hs
divBy :: Integral a => a -> [a] -> [a]
divBy numerator = map (numerator `div`)

非常簡單,對吧?我們可以在 ghci 中執(zhí)行這些代碼:

ghci> divBy 50 [1,2,5,8,10]
[50,25,10,6,5]
ghci> take 5 (divBy 100 [1..])
[100,50,33,25,20]

這個(gè)行為跟我們預(yù)期的是一致的:50 / 1 得到50,50 / 2 得到25,等等。甚至對于無窮的鏈表 [1..] 它也是可以工作的。如果有個(gè)0溜進(jìn)去我們的鏈表中了,會(huì)發(fā)生什么事呢?

ghci> divBy 50 [1,2,0,8,10]
[50,25,*** Exception: divide by zero

是不是很有意思? ghci 開始顯示輸出,然后當(dāng)它遇到零時(shí)發(fā)生了一個(gè)異常停止了。這是惰性求值的作用–它只按需求值。

在這一章里接下來我們會(huì)看到,缺乏一個(gè)明確的異常處理時(shí),這個(gè)異常會(huì)使程序崩潰。這當(dāng)然不是我們想要的,所以讓我們思考一下更好的方式來表征這個(gè)純函數(shù)中的錯(cuò)誤。

使用Maybe

可以立刻想到的一個(gè)表示失敗的簡單的方法是使用 Maybe 。如果輸入鏈表中任何地方包含了零,相對于僅僅返回一個(gè)鏈表并在失敗的時(shí)候拋出異常,我們可以返回 Nothing ,或者如果沒有出現(xiàn)零我們可以返回結(jié)果的 Just。下面是這個(gè)算法的實(shí)現(xiàn):

-- file: ch19/divby2.hs
divBy :: Integral a => a -> [a] -> Maybe [a]
divBy _ [] = Just []
divBy _ (0:_) = Nothing
divBy numerator (denom:xs) =
    case divBy numerator xs of
      Nothing -> Nothing
      Just results -> Just ((numerator `div` denom) : results)

如果你在 ghci 中嘗試它,你會(huì)發(fā)現(xiàn)它可以工作:

ghci> divBy 50 [1,2,5,8,10]
Just [50,25,10,6,5]
ghci> divBy 50 [1,2,0,8,10]
Nothing

調(diào)用 divBy 的函數(shù)現(xiàn)在可以使用 case 語句來觀察調(diào)用成功與否,就像 divBy 調(diào)用自己時(shí)所做的那樣。

Tip

你大概注意到,上面可以使用一個(gè)monadic的實(shí)現(xiàn),像這樣子:

-- file: ch19/divby2m.hs
divBy :: Integral a => a -> [a] -> Maybe [a]
divBy numerator denominators = 
    mapM (numerator `safeDiv`) denominators
    where safeDiv _ 0 = Nothing
          safeDiv x y = x `div` y

出于簡單考慮,在這章中我們會(huì)避免使用monadic實(shí)現(xiàn),但是會(huì)指出有這種做法。

[譯注:Tip中那段代碼編譯不過]

丟失和保存惰性

使用 Maybe 很方便,但是有代價(jià)。 divBy 將不能夠再處理無限的鏈表輸入。由于結(jié)果是一個(gè) Maybe[a] ,必須要檢查整個(gè)輸入鏈表,我們才能確認(rèn)不會(huì)因?yàn)榇嬖诹愣祷?Nothing 。你可以嘗試在之前的例子中驗(yàn)證這一點(diǎn):

ghci> divBy 100 [1..]
*** Exception: stack overflow

這里觀察到,你沒有看到部分的輸出;你沒得到任何輸出。注意到在 divBy 的每一步中(除了輸入鏈表為空或者鏈表開頭是零的情況),每個(gè)子序列元素的結(jié)果必須先于當(dāng)前元素的結(jié)果得到。因此這個(gè)算法無法處理無窮鏈表,并且對于大的有限鏈表,它的空間效率也不高。

之前已經(jīng)說過, Maybe 通常是一個(gè)好的選擇。在這個(gè)特殊例子中,只有當(dāng)我們?nèi)?zhí)行整個(gè)輸入的時(shí)候我們才知道是否有問題。有時(shí)候我們可以提交發(fā)現(xiàn)問題,例如,在 ghci 中 tail[] 會(huì)生成一個(gè)異常。我們可以很容易寫一個(gè)可以處理無窮情況的 tail :

-- file: ch19/safetail.hs
safeTail :: [a] -> Maybe [a]
safeTail [] = Nothing
safeTail (_:xs) = Just xs

如果輸入為空,簡單的返回一個(gè) Nothing ,其它情況返回結(jié)果的 Just 。由于在知道是否發(fā)生錯(cuò)誤之前,我們只需要確認(rèn)鏈表非空,在這里使用 Maybe 不會(huì)破壞惰性。我們可以在 ghci 中測試并觀察跟普通的 tail 有何不同:

ghci> tail [1,2,3,4,5]
[2,3,4,5]
ghci> safeTail [1,2,3,4,5]
Just [2,3,4,5]
ghci> tail []
*** Exception: Prelude.tail: empty list
ghci> safeTail []
Nothing

這里我們可以看到,我們的 safeTail 執(zhí)行結(jié)果符合預(yù)期。但是對于無窮鏈表呢?我們不想打印無窮的結(jié)果的數(shù)字,所以我們用 take5(tail[1..]) 以及一個(gè)類似的saftTail構(gòu)建測試:

ghci> take 5 (tail [1..])
[2,3,4,5,6]
ghci> case safeTail [1..] of {Nothing -> Nothing; Just x -> Just (take 5 x)}
Just [2,3,4,5,6]
ghci> take 5 (tail [])
*** Exception: Prelude.tail: empty list
ghci> case safeTail [] of {Nothing -> Nothing; Just x -> Just (take 5 x)}
Nothing

這里你可以看到 tail 和 safeTail 都可以處理無窮鏈表。注意我們可以更好地處理空的輸入鏈表;而不是拋出異常,我們決定這種情況返回 Nothing 。我們可以獲得錯(cuò)誤處理能力卻不會(huì)失去惰性。

但是我們?nèi)绾螌⑺鼞?yīng)用到我們的 divBy 的例子中呢?讓我們思考下現(xiàn)在的情況:失敗是單個(gè)壞的輸入的屬性,而不是輸入鏈表自身。那么將失敗作為單個(gè)輸出元素的屬性,而不是整個(gè)輸出鏈表怎么樣?也就是說,不是一個(gè)類型為 a->[a]->Maybe[a] 的函數(shù),取而代之我們使用 a->[a]->[Maybea] 。這樣做的好處是可以保留惰性,并且調(diào)用者可以確定是在鏈表中的哪里出了問題–或者甚至是過濾掉有問題的結(jié)果,如果需要的話。這里是一個(gè)實(shí)現(xiàn):

-- file: ch19/divby3.hs
divBy :: Integral a => a -> [a] -> [Maybe a]
divBy numerator denominators =
    map worker denominators
    where worker 0 = Nothing
          worker x = Just (numerator `div` x)

看下這個(gè)函數(shù),我們再次回到使用 map ,這無論對簡潔和惰性都是件好事。我們可以在 ghci 中測試它,并觀察對于有限和無限鏈表它都可以正常工作:

ghci> divBy 50 [1,2,5,8,10]
[Just 50,Just 25,Just 10,Just 6,Just 5]
ghci> divBy 50 [1,2,0,8,10]
[Just 50,Just 25,Nothing,Just 6,Just 5]
ghci> take 5 (divBy 100 [1..])
[Just 100,Just 50,Just 33,Just 25,Just 20]

我們希望通過這個(gè)討論你可以明白這點(diǎn),不符合規(guī)范的(正如 safeTail 中的情況)輸入和包含壞的數(shù)據(jù)的輸入( divBy 中的情況)是有區(qū)別的。這兩種情況通常需要對結(jié)果采用不同的處理。

Maybe Monad的用法

回到 使用Maybe 這一節(jié),我們有一個(gè)叫做 divby2.hs 的示例程序。這個(gè)例子沒有保存惰性,而是返回一個(gè)類型為 Maybe[a] 的值。用monadic風(fēng)格也可以表達(dá)同樣的算法。更多信息和monad相關(guān)背景,參考 第14章Monads [http://rwh.readthedocs.org/en/latest/chp/14.html] 。這是我們新的monadic風(fēng)格的算法:

-- file: ch19/divby4.hs
divBy :: Integral a => a -> [a] -> Maybe [a]
divBy _ [] = return []
divBy _ (0:_) = fail "division by zero in divBy"
divBy numerator (denom:xs) =
    do next <- divBy numerator xs
       return ((numerator `div` denom) : next)

Maybe monad使得這個(gè)算法的表示看上去更好。對于 Maybe monad, return 就跟 Just 一樣,并且 fail_=Nothing ,因此我們看到任何的錯(cuò)誤說明的字段串。我們可以用我們在 divby2.hs 中使用過的測試來測試這個(gè)算法:

ghci> divBy 50 [1,2,5,8,10]
Just [50,25,10,6,5]
ghci> divBy 50 [1,2,0,8,10]
Nothing
ghci> divBy 100 [1..]
*** Exception: stack overflow

我們寫的代碼實(shí)際上并不限于 Maybe monad。只要簡單地改變類型,我們可以讓它對于任何monad都能工作。讓我們試一下:

-- file: ch19/divby5.hs
divBy :: Integral a => a -> [a] -> Maybe [a]
divBy = divByGeneric

divByGeneric :: (Monad m, Integral a) => a -> [a] -> m [a]
divByGeneric _ [] = return []
divByGeneric _ (0:_) = fail "division by zero in divByGeneric"
divByGeneric numerator (denom:xs) =
    do next <- divByGeneric numerator xs
       return ((numerator `div` denom) : next)

函數(shù) divByGeneric 包含的代碼 divBy 之前所做的一樣;我們只是給它一個(gè)更通用的類型。事實(shí)上,如果不給出類型,這個(gè)類型是由 ghci 自動(dòng)推導(dǎo)的。我們還為特定的類型定義了一個(gè)更方便的函數(shù) divBy 。

讓我們在 ghci 中運(yùn)行一下。

ghci> :l divby5.hs
[1 of 1] Compiling Main             ( divby5.hs, interpreted )
Ok, modules loaded: Main.
ghci> divBy 50 [1,2,5,8,10]
Just [50,25,10,6,5]
ghci> (divByGeneric 50 [1,2,5,8,10])::(Integral a => Maybe [a])
Just [50,25,10,6,5]
ghci> divByGeneric 50 [1,2,5,8,10]
[50,25,10,6,5]
ghci> divByGeneric 50 [1,2,0,8,10]
*** Exception: user error (division by zero in divByGeneric)

前兩個(gè)例子產(chǎn)生的輸出都跟我們之前看到的一樣。由于 divByGeneric 沒有指定返回的類型,我們要么指定一個(gè),要么讓解釋器從環(huán)境中推導(dǎo)得到。如果我們不指定返回類型, ghic 推薦得到 IO monad。在第三和第四個(gè)例子中你可以看出來。在第四個(gè)例子中你可以看到, IO monad將 fail 轉(zhuǎn)化成了一個(gè)異常。

mtl 包中的 Control.Monad.Error 模塊也將 EitherString 變成了一個(gè)monad。如果你使用 Either ,你可以得到保存了錯(cuò)誤信息的純的結(jié)果,像這樣子:

ghci> :m +Control.Monad.Error
ghci> (divByGeneric 50 [1,2,5,8,10])::(Integral a => Either String [a])
Loading package mtl-1.1.0.0 ... linking ... done.
Right [50,25,10,6,5]
ghci> (divByGeneric 50 [1,2,0,8,10])::(Integral a => Either String [a])
Left "division by zero in divByGeneric"

這讓我們進(jìn)入到下一個(gè)話題的討論:使用 Either 返回錯(cuò)誤信息。

使用Either

Either 類型跟 Maybe 類型類似,除了一處關(guān)鍵的不同:對于錯(cuò)誤或者成功(“ Right 類型”),它都可以攜帶數(shù)據(jù)。盡管語言沒有強(qiáng)加任何限制,按照慣例,一個(gè)返回 Either 的函數(shù)使用 Left 返回值來表示一個(gè)錯(cuò)誤, Right 來表示成功。如果你覺得這樣有助于記憶,你可以認(rèn)為 Right 表式正確結(jié)果。我們可以改一下前面小節(jié)中關(guān)于 Maybe 時(shí)使用的 divby2.hs 的例子,讓 Either 可以工作:

-- file: ch19/divby6.hs
divBy :: Integral a => a -> [a] -> Either String [a]
divBy _ [] = Right []
divBy _ (0:_) = Left "divBy: division by 0"
divBy numerator (denom:xs) =
    case divBy numerator xs of
      Left x -> Left x
      Right results -> Right ((numerator `div` denom) : results)

這份代碼跟 Maybe 的代碼幾乎是完全一樣的;我們只是把每個(gè) Just 用 Right 替換。Left 對應(yīng)于 Nothing ,但是現(xiàn)在它可以攜帶一條信息。讓我們在 ghci 里面運(yùn)行一下:

ghci> divBy 50 [1,2,5,8,10]Right [50,25,10,6,5]ghci> divBy 50 [1,2,0,8,10]Left “divBy: division by 0”

為錯(cuò)誤定制數(shù)據(jù)類型

盡管用 String 類型來表示錯(cuò)誤的原因?qū)窈蠛苡泻锰?,自定義的錯(cuò)誤類型通常會(huì)更有幫助。使用自定義的錯(cuò)誤類型我們可以知道到底是出了什么問題,并且獲知是什么動(dòng)作引發(fā)的這個(gè)問題。例如,讓我們假設(shè),由于某些原因,不僅僅是除0,我們還不想除以10或者20。我們可以像這樣子自定義一個(gè)錯(cuò)誤類型:

-- file: ch19/divby7.hs
data DivByError a = DivBy0
                 | ForbiddenDenominator a
                   deriving (Eq, Read, Show)

divBy :: Integral a => a -> [a] -> Either (DivByError a) [a]
divBy _ [] = Right []
divBy _ (0:_) = Left DivBy0
divBy _ (10:_) = Left (ForbiddenDenominator 10)
divBy _ (20:_) = Left (ForbiddenDenominator 20)
divBy numerator (denom:xs) =
    case divBy numerator xs of
      Left x -> Left x
      Right results -> Right ((numerator `div` denom) : results)

現(xiàn)在,在出現(xiàn)錯(cuò)誤時(shí),可以通過 Left 數(shù)據(jù)檢查導(dǎo)致錯(cuò)誤的準(zhǔn)確原因?;蛘撸梢院唵蔚闹皇峭ㄟ^ show 打印出來。下面是這個(gè)函數(shù)的應(yīng)用:

ghci> divBy 50 [1,2,5,8]
Right [50,25,10,6]
ghci> divBy 50 [1,2,5,8,10]
Left (ForbiddenDenominator 10)
ghci> divBy 50 [1,2,0,8,10]
Left DivBy0

Warning

所有這些 Either 的例子都跟我們之前的 Maybe 一樣,都會(huì)遇到失去惰性的問題。我們將在這一章的最后用一個(gè)練習(xí)題來解決這個(gè)問題。

Monadic地使用Either

回到 Maybe Monad的用法 這一節(jié),我們向你展示了如何在一個(gè)monad中使用 Maybe 。 Either 也可以在monad中使用,但是可能會(huì)復(fù)雜一點(diǎn)。原因是 fail 是硬編碼的只接受 String 作為失敗代碼,因此我們必須有一種方法將這樣的字符串映射成我們的 Left 使用的類型。正如你前面所見, Control.Monad.Error 為 EitherStringa 提供了內(nèi)置的支持,它沒有涉及到將參數(shù)映射到 fail 。這里我們可以將我們的例子修改為monadic風(fēng)格使得 Either 可以工作:

-- file: ch19/divby8.hs
{-# LANGUAGE FlexibleContexts #-}

import Control.Monad.Error

data Show a => 
    DivByError a = DivBy0
                  | ForbiddenDenominator a
                  | OtherDivByError String
                    deriving (Eq, Read, Show)

instance Error (DivByError a) where
    strMsg x = OtherDivByError x

divBy :: Integral a => a -> [a] -> Either (DivByError a) [a]
divBy = divByGeneric

divByGeneric :: (Integral a, MonadError (DivByError a) m) =>
                 a -> [a] -> m [a]
divByGeneric _ [] = return []
divByGeneric _ (0:_) = throwError DivBy0
divByGeneric _ (10:_) = throwError (ForbiddenDenominator 10)
divByGeneric _ (20:_) = throwError (ForbiddenDenominator 20)
divByGeneric numerator (denom:xs) =
    do next <- divByGeneric numerator xs
       return ((numerator `div` denom) : next)

這里,我們需要打開 FlexibleContexts 語言擴(kuò)展以提供 divByGeneric 的類型簽名。 divBy 函數(shù)跟之前的工作方式完全一致。對于 divByGeneric ,我們將 divByError 做為 Error 類型類的成員,通過定義調(diào)用 fail 時(shí)的行為( strMsg 函數(shù))。我們還將 Right 轉(zhuǎn)化成 return ,將 Left 轉(zhuǎn)化成 throwError 進(jìn)行泛化。

異常

許多語言中都有異常處理,包括Haskell。異常很有用,因?yàn)楫?dāng)發(fā)生故障時(shí),它提供了一種簡單的處理方法,即使故障離發(fā)生的地方沿著函數(shù)調(diào)用鏈走了幾層。有了異常,不需要檢查每個(gè)函數(shù)調(diào)用的返回值是否發(fā)生了錯(cuò)誤,不需要注意去生成表示錯(cuò)誤的返回值,像C程序員必須這么做。在Haskell中,由于有 monad以及 Either 和 Maybe 類型,你通常可以在純的代碼中達(dá)到同樣的效果而不需要使用異常和異常處理。

有些問題–尤其是涉及到IO調(diào)用–需要處理異常。在Haskell中,異常可能會(huì)在程序的任何地方拋出。然而,由于計(jì)算順序是不確定的,異常只可以在 IO monad中捕獲。Haskell異常處理不涉及像Python或者Java中那樣的特殊語法。捕獲和處理異常的技術(shù)是–真令人驚訝–函數(shù)。

異常第一步

在 Control.Exception 模塊中,定義了各種跟異常相關(guān)的函數(shù)和類型。 Exception 類型是在那里定義的;所有的異常的類型都是 Exception 。還有用于捕獲和處理異常的函數(shù)。讓我們先看一看 try ,它的類型是 IOa->IO(EitherExceptiona) 。它將異常處理包裝在 IO 中。如果有異常拋出,它會(huì)返回一個(gè) Left 值表示異常;否則,返回原始結(jié)果到 Right 值。讓我們在 ghci 中運(yùn)行一下。我們首先觸發(fā)一個(gè)未處理的異常,然后嘗試捕獲它。

ghci> :m Control.Exception
ghci> let x = 5 `div` 0
ghci> let y = 5 `div` 1
ghci> print x
*** Exception: divide by zero
ghci> print y
5
ghci> try (print x)
Left divide by zero
ghci> try (print y)
5
Right ()

注意到在 let 語句中沒有拋出異常。這是意料之中的,是因?yàn)槎栊郧笾担怀粤阒挥械酱蛴?x 的值的時(shí)候才需要計(jì)算。還有,注意 try(printy) 有兩行輸出。第一行是由 print 產(chǎn)生的,它在終端上顯示5。第二個(gè)是由 ghci 生成的,這個(gè)表示 printy 的返回值為 () 并且沒有拋出異常。

惰性和異常處理

既然你知道了 try 是如何工作的,讓我們試下另一個(gè)實(shí)驗(yàn)。讓我們假設(shè)我們想捕獲 try 的結(jié)果用于后續(xù)的計(jì)算,這樣我們可以處理除的結(jié)果。我們大概會(huì)這么做:

ghci> result <- try (return x)
Right *** Exception: divide by zero

這里發(fā)生了什么?讓我們拆成一步一步看,先試下另一個(gè)例子:

ghci> let z = undefined
ghci> try (print z)
Left Prelude.undefined
ghci> result <- try (return z)
Right *** Exception: Prelude.undefined

跟之前一樣,將 undefined 賦值給 z 沒什么問題。問題的關(guān)鍵,以及前面的迷惑,都在于惰性求值。準(zhǔn)確地說,是在于 return ,它沒有強(qiáng)制它的參數(shù)的執(zhí)行;它只是將它包裝了一下。這樣, try(returnundefined) 的結(jié)果應(yīng)該是 Rightundefined ?,F(xiàn)在, ghci 想要將這個(gè)結(jié)果顯示在終端上。它將運(yùn)行到打印”Right”,但是 undefined 無法打?。ɑ蛘哒f除以零的結(jié)果無法打?。?。因此你看到了異常信息,它是來源于 ghci 的,而不是你的程序。

這是一個(gè)關(guān)鍵點(diǎn)。讓我們想想為什么之前的例子可以工作,而這個(gè)不可以。之前,我們把 printx 放在了 try 里面。打印一些東西的值,固然是需要執(zhí)行它的,因此,異常在正確的地方被檢測到了。但是,僅僅是使用 return 并不會(huì)強(qiáng)制計(jì)算的執(zhí)行。為了解決這個(gè)問題, Control.Exception 模塊中定義了一個(gè) evaluate 函數(shù)。它的行為跟 return 類似,但是會(huì)讓參數(shù)立即執(zhí)行。讓我們試一下:

ghci> let z = undefined
ghci> result <- try (evaluate z)
Left Prelude.undefined
ghci> result <- try (evaluate x)
Left divide by zero

看,這就是我們想要的答案。無論對于 undefiined 還是除以零的例子,都可以正常工作。

Tip

記?。喝魏螘r(shí)候你想捕獲純的代碼中拋出的異常,在你的異常處理函數(shù)中使用 evaluate 而不是 return 。

使用handle

通常,你可能希望如果一塊代碼中沒有任何異常發(fā)生,就執(zhí)行某個(gè)動(dòng)作,否則執(zhí)行不同的動(dòng)作。對于像這種場合,有一個(gè)叫做 handle 的函數(shù)。這個(gè)函數(shù)的類型是 (Exception->IOa)->IOa->IOa 。即是說,它需要兩個(gè)參數(shù):前一個(gè)是一個(gè)函數(shù),當(dāng)執(zhí)行后一個(gè)動(dòng)作發(fā)生異常的時(shí)候它會(huì)被調(diào)用。下面是我們使用的一種方式:

ghci> :m Control.Exception
ghci> let x = 5 `div` 0
ghci> let y = 5 `div` 1
ghci> handle (\_ -> putStrLn "Error calculating result") (print x)
Error calculating result
ghci> handle (\_ -> putStrLn "Error calculating result") (print y)
5

像這樣,如果計(jì)算中沒有錯(cuò)誤發(fā)生,我們可以打印一條好的信息。這當(dāng)然要比除以零出錯(cuò)時(shí)程序崩潰要好。

選擇性地處理異常

上面的例子的一個(gè)問題是,對于任何異常它都是打印 “Error calculating result”??赡軙?huì)有些其它不是除零的異常。例如,顯示輸出時(shí)可能會(huì)發(fā)生錯(cuò)誤,或者純的代碼中可能拋出一些其它的異常。

handleJust 函數(shù)就是處理這種情況的。它讓你指定一個(gè)測試來決定是否對給定的異常感興趣。讓我們看一下:

-- file: ch19/hj1.hs
import Control.Exception

catchIt :: Exception -> Maybe ()
catchIt (ArithException DivideByZero) = Just ()
catchIt _ = Nothing

handler :: () -> IO ()
handler _ = putStrLn "Caught error: divide by zero"

safePrint :: Integer -> IO ()
safePrint x = handleJust catchIt handler (print x)

cacheIt 定義了一個(gè)函數(shù),這個(gè)函數(shù)會(huì)決定我們對給定的異常是否感興趣。如果是,它會(huì)返回 Just ,否則返回 Nothing 。還有, Just 中附帶的值會(huì)被傳到我們的處理函數(shù)中。現(xiàn)在我們可以很好地使用 safePrint 了:

ghci> :l hj1.hs[1 of 1] Compiling Main ( hj1.hs, interpreted )Ok, modules loaded: Main.ghci> let x = 5 div 0ghci> let y = 5 div 1ghci> safePrint xCaught error: divide by zeroghci> safePrint y5

Control.Exception 模塊還提供了一些可以在 handleJust 中使用的函數(shù),以便于我們將異常的范圍縮小到我們所關(guān)心的類別。例如,有個(gè)函數(shù) arithExceptions 類型是 Exception->MaybeArithException 可以挑選出任意的 ArithException 異常,但是會(huì)忽略掉其它。我們可以像這樣使用它:

-- file: ch19/hj2.hs
import Control.Exception

handler :: ArithException -> IO ()
handler e = putStrLn $ "Caught arithmetic error: " ++ show e

safePrint :: Integer -> IO ()
safePrint x = handleJust arithExceptions handler (print x)

用這種方式,我們可以捕獲所有 ArithException 類型的異常,但是仍然讓其它的異常通過,不捕獲也不修改。我們可以看到它是這樣工作的:

ghci> :l hj2.hs
[1 of 1] Compiling Main             ( hj2.hs, interpreted )
Ok, modules loaded: Main.
ghci> let x = 5 `div` 0
ghci> let y = 5 `div` 1
ghci> safePrint x
Caught arithmetic error: divide by zero
ghci> safePrint y
5

其中特別感興趣的是,你大概注意到了 ioErrors 測試,這是跟一大類的I/O相關(guān)的異常。

I/O異常

大概在任何程序中異常最大的來源就是I/O。在處理外部世界的時(shí)候所有事情都可能出錯(cuò):磁盤滿了,網(wǎng)絡(luò)斷了,或者你期望文件里面有數(shù)據(jù)而文件卻是空的。在Haskell中,I/O異常就跟其它的異常一樣可以用 Exception 數(shù)據(jù)類型來表示。另一方面,由于有這么多類型的I/O異常,有一個(gè)特殊的模塊– System.IO.Error 專門用于處理它們。

System.IO.Error 定義了兩個(gè)函數(shù): catch 和 try ,跟 Control.Exception 中的類似,它們都是用于處理異常的。然而,不像 Control.Exception 中的函數(shù),這些函數(shù)只會(huì)捕獲I/O錯(cuò)誤,而不處理其它類型異常。在Haskell中,所有I/O錯(cuò)誤有一個(gè)共同類型 IOError ,它的定義跟 IOException 是一樣的。

Tip

當(dāng)心你使用的哪個(gè)名字因?yàn)?System.IO.Error 和 Control.Exception 定義了同樣名字的函數(shù),如果你將它們都導(dǎo)入你的程序,你將收到一個(gè)錯(cuò)誤信息說引用的函數(shù)有歧義。你可以通過 qualified 引用其中一個(gè)或者另一個(gè),或者將其中一個(gè)或者另一個(gè)的符號隱藏。

注意 Prelude 導(dǎo)出的是 System.IO.Error 中的 catch ,而不是 ControlException 中提供的。記住,前者只捕獲I/O錯(cuò)誤,而后者捕獲所有的異常。換句話說, 你要的幾乎總是 Control.Exception 中的那個(gè) catch ,而不是默認(rèn)的那個(gè)。

讓我們看一下對我們有益的一個(gè)在I/O系統(tǒng)中使用異常的方法。在 使用文件和句柄 [http://rwh.readthedocs.org/en/latest/chp/7.html#handle] 這一節(jié)里,我們展示了一個(gè)使用命令式風(fēng)格從文件中一行一行的讀取的程序。盡管我們后面也示范過更簡潔的,更”Haskelly”的方式解決那個(gè)問題,讓我們在這里重新審視這個(gè)例子。在 mainloop 函數(shù)中,在讀一行之前,我們必須明確地測試我們的輸入文件是否結(jié)束。這次,我們可以檢查嘗試讀一行是否會(huì)導(dǎo)致一個(gè)EOF錯(cuò)誤,像這樣子:

-- file: ch19/toupper-impch20.hs
import System.IO
import System.IO.Error
import Data.Char(toUpper)

main :: IO ()
main = do 
       inh <- openFile "input.txt" ReadMode
       outh <- openFile "output.txt" WriteMode
       mainloop inh outh
       hClose inh
       hClose outh

mainloop :: Handle -> Handle -> IO ()
mainloop inh outh = 
    do input <- try (hGetLine inh)
       case input of
         Left e -> 
             if isEOFError e
                then return ()
                else ioError e
         Right inpStr ->
             do hPutStrLn outh (map toUpper inpStr)
                mainloop inh outh

這里,我們使用 System.IO.Error 中的 try 來檢測是否 hGetLine 拋出一個(gè) IOError 。如果是,我們使用 isEOFError (在 System.IO.Error 中定義)來看是否拋出異常表明我們到達(dá)了文件末尾。如果是的,我們退出循環(huán)。如果是其它的異常,我們調(diào)用 ioError 重新拋出它。

有許多的這種測試和方法可以從 System.IO.Error 中定義的 IOError 中提取信息。我們推薦你在需要的時(shí)候去查一下庫的參考頁。

拋出異常

到現(xiàn)在為止,我們已經(jīng)詳細(xì)地討論了異常處理。還有另外一個(gè)困惑:拋出異常。到目前為止這一章我們所接觸到的例子中,都是由Haskell為你拋出異常的。然后你也可以自己拋出任何異常。我們會(huì)告訴你怎么做。

你將會(huì)注意到這些函數(shù)大部分似乎返回一個(gè)類型為 a 或者 IOa 的值。這意味著這個(gè)函數(shù)似乎可以返回任意類型的值。事實(shí)上,由于這些函數(shù)會(huì)拋出異常,一般情況下它們決不“返回”任何東西。這些返回值讓你可以在各種各樣的上下文中使用這些函數(shù),不同的上下文需要不同的類型。

讓我們使用函數(shù) Control.Exception 來開始我們的拋出異常的教程。最通用的函數(shù)是 throw ,它的類型是 Exception->a 。這個(gè)函數(shù)可以拋出任何的 Exception ,并且可以用于純的上下文中。還有一個(gè)類型為 Exception->IOa 的函數(shù) throwIO 在 IO monad中拋出異常。這兩個(gè)函數(shù)都需要一個(gè) Exception 用于拋出。你可以手工制作一個(gè) Exception ,或者重用之前創(chuàng)建的 Exception 。

還有一個(gè)函數(shù) ioError ,它在 Control.Exception 和 System.IO.Error 中定義都是相同的,它的類型是 IOError->IOa 。當(dāng)你想生成任意的I/O相關(guān)的異常的時(shí)候可以使用它。

動(dòng)態(tài)異常

這需要使用兩個(gè)很不常用的Haskell模塊: Data.Dynamic 和 Data.Typeable 。我們不會(huì)講太多關(guān)于這些模塊,但是告訴你當(dāng)你需要制作自己的動(dòng)態(tài)異常類型時(shí),可以使用這些工具。

第二十一章使用數(shù)據(jù)庫http://book.realworldhaskell.org/read/using-databases.html 中,你會(huì)看到HDBC數(shù)據(jù)庫庫使用動(dòng)態(tài)異常來表示SQL數(shù)據(jù)庫返回給應(yīng)用的錯(cuò)誤。數(shù)據(jù)庫引擎返回的錯(cuò)誤通常有三個(gè)組件:一個(gè)表示錯(cuò)誤碼的整數(shù),一個(gè)狀態(tài),以及一條人類可讀的錯(cuò)誤消息。在這一章中我們會(huì)創(chuàng)建我們自己的HDBC SqlError 實(shí)現(xiàn)。讓我們從錯(cuò)誤自身的數(shù)據(jù)結(jié)構(gòu)表示開始:

-- file: ch19/dynexc.hs
{-# LANGUAGE DeriveDataTypeable #-}

import Data.Dynamic
import Control.Exception

data SqlError = SqlError {seState :: String,
                          seNativeError :: Int,
                          seErrorMsg :: String}
                deriving (Eq, Show, Read, Typeable)

通過繼承 Typeable 類型類,我們使這個(gè)類型可用于動(dòng)態(tài)的類型編程。為了讓GHC自動(dòng)生成一個(gè) Typeable 實(shí)例,我們要開啟 DeriveDataTypeable 語言擴(kuò)展。

現(xiàn)在,讓我們定義一個(gè) catchSql 和一個(gè) handleSql 用于捕獵一個(gè) SqlError 異常。注意常規(guī)的 catch 和 handle 函數(shù)無法捕獵我們的 SqlError ,因?yàn)樗皇?Exception 類型的。

-- file: ch19/dynexc.hs
{- | Execute the given IO action.

If it raises a 'SqlError', then execute the supplied 
handler and return its return value.  Otherwise, proceed
as normal. -}
catchSql :: IO a -> (SqlError -> IO a) -> IO a
catchSql = catchDyn

{- | Like 'catchSql', with the order of arguments reversed. -}
handleSql :: (SqlError -> IO a) -> IO a -> IO a
handleSql = flip catchSql

[譯注:原文中文件名是dynexc.hs,但是跟前面的沖突了,所以這里重命名為dynexc1.hs]

這些函數(shù)僅僅是在 catchDyn 外面包了很薄的一層,類型是 Typeableexception=>IOa->(exception->IOa)->IOa 。這里我們簡單地限定了它的類型使得它只捕獵SQL異常。

正常地,當(dāng)一個(gè)異常拋出,但是沒有在任何地方被捕獲,程序會(huì)崩潰并顯示異常到標(biāo)準(zhǔn)錯(cuò)誤輸出。然而,對于動(dòng)態(tài)異常,系統(tǒng)不會(huì)知道該如何顯示它,因此你將僅僅會(huì)看到一個(gè)的”unknown exception”消息,這可能沒太大幫助。我們可以提供一個(gè)輔助函數(shù),這樣應(yīng)用可以寫成,比如說 main=handleSqlError$do... ,使拋出的異??梢燥@示。下面是如何寫 handleSqlError :

-- file: ch19/dynexc.hs
{- | Catches 'SqlError's, and re-raises them as IO errors with fail.
Useful if you don't care to catch SQL errors, but want to see a sane
error message if one happens.  One would often use this as a 
high-level wrapper around SQL calls. -}
handleSqlError :: IO a -> IO a
handleSqlError action =
    catchSql action handler
    where handler e = fail ("SQL error: " ++ show e)

[譯注:原文中是dynexc.hs,這里重命名過文件]

最后,讓我們給出一個(gè)如何拋出 SqlError 異常的例子。下面的函數(shù)做的就是這件事:

-- file: ch19/dynexc.hs
throwSqlError :: String -> Int -> String -> a
throwSqlError state nativeerror errormsg =
    throwDyn (SqlError state nativeerror errormsg)

throwSqlErrorIO :: String -> Int -> String -> IO a
throwSqlErrorIO state nativeerror errormsg =
    evaluate (throwSqlError state nativeerror errormsg)

Tip

提醒一下, evaluate 跟 return 類似但是會(huì)立即計(jì)算它的參數(shù)。

這樣我們的動(dòng)態(tài)異常的支持就完成了。代碼很多,你大概不需要這么多代碼,但是我們想要給你一個(gè)動(dòng)態(tài)異常自身的例子以及和它相關(guān)的工具。事實(shí)上,這里的例子幾乎就反映在HDBC庫中。讓我們在 ghci 中試一下:

ghci> :l dynexc.hs
[1 of 1] Compiling Main             ( dynexc.hs, interpreted )
Ok, modules loaded: Main.
ghci> throwSqlErrorIO "state" 5 "error message"
*** Exception: (unknown)
ghci> handleSqlError $ throwSqlErrorIO "state" 5 "error message"
*** Exception: user error (SQL error: SqlError {seState = "state", seNativeError = 5, seErrorMsg = "error message"})
ghci> handleSqlError $ fail "other error"
*** Exception: user error (other error)

這里你可以看出, ghci 自己并不知道如何顯示SQL錯(cuò)誤。但是,你可以看到 handleSqlError 幫助做了這些,不過沒有捕獲其它的錯(cuò)誤。最后讓我們試一個(gè)自定義的handler:

ghci> handleSql (fail . seErrorMsg) (throwSqlErrorIO "state" 5 "my error")
*** Exception: user error (my error)

這里,我們自定義了一個(gè)錯(cuò)誤處理拋出一個(gè)新的異常,構(gòu)成 SqlError 中的 seErrorMsg 域。你可以看到它是按預(yù)想中那樣工作的。

練習(xí)

  1. 將 Either 修改成 Maybe 例子中的那種風(fēng)格,使它保存惰性。

monad中的錯(cuò)誤處理

因?yàn)槲覀儽仨毑东@ IO monad中的異常,如果我們在一個(gè)monad中或者在monad的轉(zhuǎn)化棧中使用它們,我們將跳出到 IO monad。這幾乎肯定不是我們想要的。

構(gòu)建以理解Monad變換器 [http://rwh.readthedocs.org/en/latest/chp/18.html#id9] 中我們定義了一個(gè) MaybeT 的變換,但是它更像是一個(gè)有助于理解的東西,而不是編程的工具。幸運(yùn)的是,已經(jīng)有一個(gè)專門的–也更有用的–monad變換: ErrorT ,它是定義在 Control.Monad.Error 模塊中的。

ErrorT 變換器使我們可以向monad中添加異常,但是它使用了特殊的方法,跟 Control.Exception 模塊中提供的不一樣。它提供給我們一些有趣的能力。

  • 如果我們繼續(xù)用 ErrorT 接口,在這個(gè)monad中我們可以拋出和捕獲異常。
  • 根據(jù)其它monad變換器的命名規(guī)范,這個(gè)執(zhí)行函數(shù)的名字是 runErrorT 。當(dāng)它遇到 runErrorT 之后,未被捕獲的 ErrorT 異常將停止向上傳遞。我們不會(huì)被踢到 IO monad中。
  • 我們可以控制我們的異常的類型。

Warning

不要把ErrorT跟普通異常混淆如果我們在 ErrorT 內(nèi)面使用 Control.Exception 中的 throw 函數(shù),我們?nèi)匀粫?huì)彈出到 IO monad。

正如其它的 mtl monad一樣, ErrorT 提供的接口是由一個(gè)類型類定義的。

-- file: ch19/MonadError.hs
class (Monad m) => MonadError e m | m -> e where
    throwError :: e             -- error to throw
               -> m a

    catchError :: m a           -- action to execute
               -> (e -> m a)    -- error handler
               -> m a

類型變量 e 代表我們想要使用的錯(cuò)誤類型。不管我們的錯(cuò)誤類型是什么,我們必須將它做成 Error 類型類的實(shí)例。

-- file: ch19/MonadError.hs
class Error a where
    -- create an exception with no message
    noMsg  :: a

    -- create an exception with a message
    strMsg :: String -> a

ErrorT 實(shí)現(xiàn) fail 時(shí)會(huì)用到 strMsg 函數(shù)。它將 strMsg 作為一個(gè)異常拋出,將自己接收到的字符串參數(shù)傳遞給這個(gè)異常。對于 noMsg ,它是用于提供 MonadPlus 類型類中的 mzero 的實(shí)現(xiàn)。

為了支持 strMsg 和 noMsg 函數(shù),我們的 ParseError 類型會(huì)有一個(gè) Chatty 構(gòu)造器。這個(gè)將用作構(gòu)造器如果,比如說,有人在我們的monad中調(diào)用 fail 。

我們需要知道的最后一塊是關(guān)于執(zhí)行函數(shù) runErrorT 的類型。

ghci> :t runErrorT
runErrorT :: ErrorT e m a -> m (Either e a)

一個(gè)小的解析構(gòu)架

為了說明 ErrorT 的使用,讓我們開發(fā)一個(gè)類似于Parsec的解析庫的基本的骨架。

-- file: ch19/ParseInt.hs
{-# LANGUAGE GeneralizedNewtypeDeriving #-}

import Control.Monad.Error
import Control.Monad.State
import qualified Data.ByteString.Char8 as B

data ParseError = NumericOverflow
            | EndOfInput
            | Chatty String
              deriving (Eq, Ord, Show)

instance Error ParseError where
    noMsg  = Chatty "oh noes!"
    strMsg = Chatty

對于我們解析器的狀態(tài),我們會(huì)創(chuàng)建一個(gè)非常小的monad變換器棧。一個(gè) State monad包含了需要解析的 ByteString ,在棧的頂部是 ErrorT 用于提供錯(cuò)誤處理。

-- file: ch19/ParseInt.hs
newtype Parser a = P {
      runP :: ErrorT ParseError (State B.ByteString) a
    } deriving (Monad, MonadError ParseError)

和平常一樣,我們將我們的monad棧包裝在一個(gè) newtype 中。這樣做沒有任意性能損耗,但是增加了類型安全。我們故意避免繼承 MonadStateB.ByteString 的實(shí)例。這意味著 Parser monad用戶將不能夠使用 get 或者 put 去查詢或者修改解析器的狀態(tài)。這樣的結(jié)果是,我們強(qiáng)制自己去做一些手動(dòng)提升的事情來獲取在我們棧中的 State monad。

-- file: ch19/ParseInt.hs
liftP :: State B.ByteString a -> Parser a
liftP m = P (lift m)

satisfy :: (Char -> Bool) -> Parser Char
satisfy p = do
  s <- liftP get
  case B.uncons s of
    Nothing         -> throwError EndOfInput
    Just (c, s')
        | p c       -> liftP (put s') >> return c
        | otherwise -> throwError (Chatty "satisfy failed")

catchError 函數(shù)對于我們的任何非常有用,遠(yuǎn)勝于簡單的錯(cuò)誤處理。例如,我們可以很輕松地解除一個(gè)異常,將它變成更友好的形式。

-- file: ch19/ParseInt.hs
optional :: Parser a -> Parser (Maybe a)
optional p = (Just `liftM` p) `catchError` \_ -> return Nothing

我們的執(zhí)行函數(shù)僅僅是將各層連接起來,將結(jié)果重新組織成更整潔的形式。

-- file: ch19/ParseInt.hs
runParser :: Parser a -> B.ByteString
          -> Either ParseError (a, B.ByteString)
runParser p bs = case runState (runErrorT (runP p)) bs of
                   (Left err, _) -> Left err
                   (Right r, bs) -> Right (r, bs)

如果我們將它加載到 ghci 中,我們可以對它進(jìn)行了一些測試。

ghci> :m +Data.Char
ghci> let p = satisfy isDigit
Loading package array-0.1.0.0 ... linking ... done.
Loading package bytestring-0.9.0.1 ... linking ... done.
Loading package mtl-1.1.0.0 ... linking ... done.
ghci> runParser p (B.pack "x")
Left (Chatty "satisfy failed")
ghci> runParser p (B.pack "9abc")
Right ('9',"abc")
ghci> runParser (optional p) (B.pack "x")
Right (Nothing,"x")
ghci> runParser (optional p) (B.pack "9a")
Right (Just '9',"a")

級習(xí)

  1. 寫一個(gè) many 解析器,類型是 Parsera->Parser[a] 。它應(yīng)該執(zhí)行解析直到失敗。
  2. 使用 many 寫一個(gè) int 解析器,類型是 ParserInt 。它應(yīng)該既能接受負(fù)數(shù)也能接受正數(shù)。
  3. 修改你們 int 解析器,如果在解析時(shí)檢測到了一個(gè)數(shù)值溢出,拋出一個(gè) NumericOverflow 異常。

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號