# 错误和异常专题 在Haskell的使用中,离不开对错误(**Error**)和异常(**Exception**)的处理,但两者应当被谨慎对待和区分。具体来说,异常应当是运行时预料之中但不规则的情况;而错误则是运行程序中的失误,这些失误只能靠调试和修复程序来解决[[1]](#ref1)。一个异常可以是“超出磁盘空间”、“读取受保护的文件”,“在读取文件时移除磁盘”等[[2]](#ref2);而一个错误则是必须由程序员来修复的问题,除了程序员外别无其他修复的方法。 例如,一个常见的异常是`divide by zero`,当我们尝试在ghci中将整除的除数设为0时,会触发这个异常: ```bash Prelude> 1 `div` 0 *** Exception: divide by zero ``` 在Haskell中,错误几乎和未定义(`undefined`)是同义词,或者说错误是`undefined`的语法糖[[1]](#ref1),因此: ```bash 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 :5:7 in interactive:Ghci1 ``` ## 错误处理 当我们需要手动处理错误时,我们可以使用`error`函数,但这只是一种语法糖函数,其本质仍然是`undefined`。 ```hs main = error "undefined" -- main = undefined ``` ```bash $ 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`: ```hs -- code'7.hs safeHead :: [a] -> Maybe a safeHead [] = Nothing safeHead (x:_) = Just x ``` 除了`Maybe`我们还可以使用表现力更强的`Either`类型,`Either`类型在抛出异常时还会添加相应的异常信息。 ```hs -- code'7.hs safeHead' :: [a] -> Either String a safeHead' [] = Left "Cannot cope with emptyList" safeHead' (x:_) = Right x ``` 在`mtl`包中`Control.Monad.Except`已经内置了基于Either的异常类型`Except`类型,其定义类似: ```hs {-# LANGUAGE GeneralizedNewtypeDeriving #-} newtype Except e a = Except (Either e a) deriving (Functor,Applicative,Monad,Show) ``` 在连续的函数操作中,我们可以使用其Monad的特性来抛出其中的异常。 ```hs -- 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`以便丢弃列表的前三个元素,当列表元素少于三个时就会抛出异常。 ```bash 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 :: * -> * > ``` 异常的抛出往往与异常的捕获联合使用,当有异常抛出时执行某个处理函数回到正常的执行中;否则保持上一个动作结束时的状态。 ```hs -- 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' ``` 例如我们希望丢弃列表前三个元素,如果失败了则反转列表。 ```hs -- code'7.hs safeDrop3OrRev :: [a] -> Except String [a] safeDrop3OrRev xs = capture (safeDrop3 xs) (\_ -> return $ reverse xs) ``` ```bash 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`类型类中,其定义如下: ```hs 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`类型类定义如下: ```hs 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`中已经内置了一些常用的异常,详情参考[文档](https://hackage.haskell.org/package/base-4.21.0.0/docs/Control-Exception.html#t:IOException)。 #### 异常抛出与捕获 **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`函数可以捕获抛出的异常,并在有异常抛出时执行一个处理函数;否则返回正常的结果。 ```bash 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`类型来区分是否抛出了异常。 ```bash 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`函数对于惰性的处理让人困惑,例如 ```bash 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`可以修正上面未探测到异常的错误。 ```bash 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](#ref3) > ```bash > 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](#ref1). **异常=>错误** 对于一个文件无法打开,这应当是异常,虽然我们仍然可以像打开文件一样操作,然而这样做的结果将导致程序崩溃并被系统终止;因此,没有处理的异常就变成了错误。 **错误=>异常** 对于大型软件来说,一部分发生了错误不一定会导致整个软件崩溃,即当跨越软件的层次后,错误就可以转化为异常;然而,发生错误的部分仍然不能依靠自身来恢复,同时也不能依靠更高层次的部分修复它(只能限制错误带来的损坏)。 ## 调用栈 `CallStack` 为了帮助程序员或用户理解问题所在,通常会提供某种`CallStack`。然而程序员用于调试的调用栈信息和用户因异常而看到的调用栈信息有明显区别[1](#ref1)。 在Haskell中我们可以使用`GHC.Stack.HasCallStack`来调用调用栈,具体可以参考[文档](https://ghc.gitlab.haskell.org/ghc/doc/users_guide/exts/callstack.html). ------------------------------------------

[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.