错误和异常专题

在Haskell的使用中,离不开对错误(Error)和异常(Exception)的处理,但两者应当被谨慎对待和区分。具体来说,异常应当是运行时预料之中但不规则的情况;而错误则是运行程序中的失误,这些失误只能靠调试和修复程序来解决[1]。一个异常可以是“超出磁盘空间”、“读取受保护的文件”,“在读取文件时移除磁盘”等[2];而一个错误则是必须由程序员来修复的问题,除了程序员外别无其他修复的方法。

例如,一个常见的异常是divide by zero,当我们尝试在ghci中将整除的除数设为0时,会触发这个异常:

Prelude> 1 `div` 0
*** Exception: divide by zero

在Haskell中,错误几乎和未定义(undefined)是同义词,或者说错误是undefined的语法糖[1],因此:

Prelude> undefined
*** Exception: Prelude.undefined
CallStack (from HasCallStack):
  error, called at libraries/base/GHC/Err.hs:78:14 in base:GHC.Err
  undefined, called at <interactive>:5:7 in interactive:Ghci1

错误处理

当我们需要手动处理错误时,我们可以使用error函数,但这只是一种语法糖函数,其本质仍然是undefined

main = error "undefined"
-- main = undefined 
$ runghc main.hs
main.hs: undefined
CallStack (from HasCallStack):
  error, called at test.hs:1:8 in main:Main

异常处理

“纯”异常

我们可以通过特定的数据类型来应对纯代码中的异常,使其仍然不涉及任何副作用。

一个自然的想法就是使用Maybe表示异常,在未发生异常时,使用Just返回结果;而在发生异常时用Nothing将异常抛出。

例如取列表头部操作head不能处理空列表的情形,我们可以使用Maybe数据类型构造一个“安全”的函数safeHead:

-- code'7.hs
safeHead :: [a] -> Maybe a
safeHead [] = Nothing
safeHead (x:_) = Just x

除了Maybe我们还可以使用表现力更强的Either类型,Either类型在抛出异常时还会添加相应的异常信息。

-- code'7.hs
safeHead' :: [a] -> Either String a 
safeHead' [] = Left "Cannot cope with emptyList"
safeHead' (x:_) = Right x

mtl包中Control.Monad.Except已经内置了基于Either的异常类型Except类型,其定义类似:

{-# LANGUAGE GeneralizedNewtypeDeriving #-}
newtype Except e a = Except (Either e a) 
  deriving (Functor,Applicative,Monad,Show)

在连续的函数操作中,我们可以使用其Monad的特性来抛出其中的异常。

-- code'7.hs
safeTail :: [a] -> Except String [a]
safeTail [] = Except $ Left "Cannot extract a tail from an empty list"
safeTail (_:xs) = return xs

safeDrop3 :: [a] -> Except String [a]
safeDrop3 xs = do 
  xsdrop1 <- safeTail xs
  xsdrop2 <- safeTail xsdrop1
  safeTail xsdrop2

我们定义safeTail函数作为tail的安全版本,当遇到空列表时抛出异常;safeDrop3函数连续使用了三次safeTail以便丢弃列表的前三个元素,当列表元素少于三个时就会抛出异常。

Prelude> :l "code'7.hs"
[1 of 1] Compiling Main             ( code'7.hs, interpreted )
Ok, one module loaded.
Prelude> safeDrop3 [1,2,3]
Except (Right [])
Prelude> safeDrop3 [1,2]
safeDrop3 [1,2]
Except (Left "Cannot extract a tail from an empty list")

注意: 实际上Control.Monad.Except中的Except类型仅仅是ExceptT转换器的一个实例的别名.

newtype ExceptT e (m :: * -> *) a = ExceptT (m (Either e a))
type Except e = ExceptT e Data.Functor.Identity.Identity :: * -> *

异常的抛出往往与异常的捕获联合使用,当有异常抛出时执行某个处理函数回到正常的执行中;否则保持上一个动作结束时的状态。

-- code'7.hs
capture :: Except e a -> (e -> Except e a) -> Except e a 
capture ex@(Except e) = case e of 
  Right t ->  const ex 
  Left e' -> \f -> f e'

例如我们希望丢弃列表前三个元素,如果失败了则反转列表。

-- code'7.hs
safeDrop3OrRev :: [a] -> Except String [a]
safeDrop3OrRev xs = capture (safeDrop3 xs) (\_ -> return $ reverse xs)
Prelude> safeDrop3OrRev [1,2,3]
Except (Right [])
Prelude> safeDrop3OrRev [1,2,3,4]
Except (Right [4])
Prelude> safeDrop3OrRev [1,2]
Except (Right [2,1])

Control.Monad.Except中,已经封装了异常的抛出和捕获到MonadError类型类中,其定义如下:

class Monad m => MonadError e m | m -> e where 
  throwError :: e -> m a 
  catchError :: m a -> (e -> m a) -> m a
  {-# MINIMAL throwError, catchError #-}

其中throwError为抛出异常函数;而catchError为捕获异常函数,其第一个参数中可以包含异常抛出(即throwError),而第二个参数则是用来恢复异常的函数。

注意: 最好不要使用fail来抛出异常,一方面在旧版本中,部分fail函数使用error来定义(这导致了中断);另一方面fail函数本身的意义是用来在do-notation<-左侧匹配失败后进行调用,而不是针对用户的异常抛出

IO异常

IO异常指通过封装在IO中的异常,这通常位于Control.Exception模块中。

Exception类型类

Control.Exception模块中,有关的异常的操作被封装在Exception类型类中,因此任何内置的异常均实现了该类型类的实例,同时用户自定义的异常也应当实现其实例。

Exception类型类定义如下:

class (Typeable e, Show e) => Exception e where 
  toException :: e -> SomeException
  fromException :: SomeException -> Maybe e 
  displayException :: e -> String 

其中toException将异常类型转换到SomeException类型;fromException则是与toException相反的操作,将异常从SomeException类型中转换出来;最后displayException将异常转换为字符串以便输出给用户。

提示: 定义中出现的SomeException类型是一切异常的“根”,其他异常被抛出时会被封装( encapsulated )到它内部

Control.Exception中已经内置了一些常用的异常,详情参考文档

异常抛出与捕获

throw :: forall a e. (HasCallStack, Exception e) => e -> a

throw函数将异常抛出,该函数允许在纯代码中抛出异常,但只能够在IO中捕获这些异常。一般地,我们会使用throwIO函数保持纯代码中无异常。

throwIO :: (HasCallStack, Exception e) => e -> IO a

throwIO使throw的一个变种,受到类型签名的限制,该函数只能在IO中使用。

catch :: Exception e => IO a -> (e -> IO a) -> IO a

catch函数可以捕获抛出的异常,并在有异常抛出时执行一个处理函数;否则返回正常的结果。

Prelude> f = const $ print "div by 0" :: ArithException -> IO ()
Prelude> catch (throwIO DivideByZero) f
"div by 0"
Prelude> catch (throw DivideByZero :: IO ()) f
"div by 0"

handle :: Exception e => (e -> IO a) -> IO a -> IO a

handle函数是catch函数的参数翻转版本,适合处理函数很短的情况,例如do handle (\NonTermination -> exitWith (ExitFailure 1)) $ ....

try :: Exception e => IO a -> IO (Either e a)

try函数类似catch函数,但通过返回类型的Either类型来区分是否抛出了异常。

Prelude> try (print 1) :: IO (Either ArithException Int) 
1
Right ()
Prelude> try (print (1 `div` 0)) :: IO (Either ArithException Int)
Left divide by zero

值得注意的是,try函数对于惰性的处理让人困惑,例如

Prelude> try (return 1) :: IO (Either ArithException Int)
Right 1 
Prelude> try (return (1 `div` 0)) :: IO (Either ArithException Int)
Right *** Exception: divide by zero
Prelude> try (return (1 `div` 0) >> print 1) :: IO (Either ArithException ())
1
Right ()

对于第二条语句,return没有强制1 `div` 0求值,因而结果应当是Right (1 `div` 0),因此ghci首先打印了Right,但1 `div` 0无法打印,从而导致了异常;应当注意这个异常来自ghci而非程序本身,如果我们在此之后添加一个无异常的IO动作(第三条语句),那么我们将不会看到任何错误(程序本身没有异常抛出,ghci打印也无异常抛出)。

相比之下,try (print (1 `div` 0))print强制内部参数执行,从而及时探测到异常。

evaluate :: a -> IO a

evaluate 通常用于发现延迟求值中存在的异常,使用evaluate替代上面中的return可以修正上面未探测到异常的错误。

Prelude> try (evaluate (1 `div` 0)) :: IO (Either ArithException Int)
Left divide by zero
Prelude> try (evaluate (1 `div` 0) >> print 1) :: IO (Either ArithException ())
Left divide by zero

注意: 然而,对于再深层次的求值要求,evaluate也无能为力,此时应当使用Control.DeepSeq中的force函数强制求值3

Prelude> try (evaluate (Just (Just (1 `div` 0)))) :: IO (Either ArithException (Maybe (Maybe Int)))
Right (Just (Just *** Exception: divide by zero
Prelude> try (evaluate $ force (Just (Just (1 `div` 0)))) :: IO (Either ArithException (Maybe (Maybe Int)))
Left divide by zero

小记:错误与异常的转换

值得注意的是,有时错误和异常会相互转化。

该部分来自于1.

异常=>错误

对于一个文件无法打开,这应当是异常,虽然我们仍然可以像打开文件一样操作,然而这样做的结果将导致程序崩溃并被系统终止;因此,没有处理的异常就变成了错误。

错误=>异常

对于大型软件来说,一部分发生了错误不一定会导致整个软件崩溃,即当跨越软件的层次后,错误就可以转化为异常;然而,发生错误的部分仍然不能依靠自身来恢复,同时也不能依靠更高层次的部分修复它(只能限制错误带来的损坏)。

调用栈 CallStack

为了帮助程序员或用户理解问题所在,通常会提供某种CallStack。然而程序员用于调试的调用栈信息和用户因异常而看到的调用栈信息有明显区别1

在Haskell中我们可以使用GHC.Stack.HasCallStack来调用调用栈,具体可以参考文档.


[1] Error vs. Exception. (2019, July 23). HaskellWiki. Retrieved 19:56, January 21, 2025 from https://wiki.haskell.org/index.php?title=Error_vs._Exception&oldid=62969.

[2] Exception. (2020, January 22). HaskellWiki. Retrieved 20:11, January 21, 2025 from https://wiki.haskell.org/index.php?title=Exception&oldid=63187.

[3] O’Sullivan, B., Goerzen, J., & Stewart, D. (2008). Real World Haskell (1st ed.). O’Reilly Media, Inc.