上篇文章简单的介绍了一下 Haskell (GHC) 提供的 user-level concurrency primitives, 这篇将介绍 Haskell 的异常与资源管理。本文默认读者对其他语言中的异常有一定了解。
众所周知,处理异常是非常 impure 的(这里将 purity 定义为 confluence modulo exceptions and non-terminations), 而且 haskell 已经有处理异常的方式了: Either
与 ExceptT
, 为什么还需要加入 exception 呢?
原因大概有三个个, 性能,mtl 的 fundep 与异步异常 (asynchronous exceptions), 我们先讨论一下性能。不像 Rust, 由于 Laziness, haskell 的 ADT 默认是 packed, 即储存的是指向 thunk 的指针而不是直接储存一个值 (虽然有 UNPACK
pragma 可以 unpack), Either
也是这样。Packed 的坏处就是每次读取数据时都有一定的开销,由于这些开销 prelude 中可能出错的函数 (比如 readFile
) 基本都没有使用 Either
。
另外一个原因是 mtl (haskell 最基础的 effect system) 不方便处理多个 Exception 同时出现的情况。
class Monad m => MonadError e m | m -> e where
throwError :: e -> m a
catchError :: m a -> (e -> m a) -> m a
这是 MonadError
的定义,注意到 m
与 e
之间的 functional dependency -- 每个 m
只能有一个对应的 e
。
异常作为一种高性能的 control structure 自然成为了 haskell 中异常处理的首选(其实如果你不太关心性能的话完全可以使用 Either
与 ExceptT
)。
不像其他语言的 try keyword,haskell 中抛出异常完全是通过函数完成的 (tbh you really shouldn't be suprised...)。抛出异常本身是 pure 的,因为异常实际上与无限循环一样糟糕,都是一种 partiality effect,在 haskell 中是无法完全避免的(由于 turing-completness) 。抛出的同步异常有两种 1. precise exceptions 2. imprecise exceptions
imprecise exceptions 是通过对 undefined
与 error
求值而产生的 exception。(这里忽略了 levity polymorphism, 忽略了 constraint, 简化了类型签名)
undefined :: ∀ a. a
error :: ∀ a. String -> a
undefined
用于临时填补未被实现的部分,error
则能立即抛出一个带有额外异常信息的异常。
注意使用这两个函数表示抛出异常是非常不推荐的。undefined
基本可被 typed holes 替代,error
也只应该在错误的原因为库的设计者时被使用。正常的异常请使用 precise excetpions。
抛出 precise exception 一般通过两个函数来实现(同样简化了签名):
throw :: ∀ e a. (Exception e) => e -> a
throwIO :: ∀ e a. (Exception e) => e -> IO a
注意到 throw
和 throwIO
都要求一个实现了 Exception
的参数,Prelude 中有一些预定义的 Exception
如 IOException
与 ArithException
. 你也可以定义自己的 Exception
:
data MyException
= Error1
| Error2
deriving (Show)
instance Exception MyException
throw
和 throwIO
的区别在于 throw
只在被 eval 的时候才会抛出异常,而 throwIO
会立即 (eagerly) 地抛出异常 (相当于 evaluate . throw
)。
同步异常的处理也是通过两个函数来实现的, try
与 catch
:
try :: ∀ e a. (Exception e) => IO a -> IO (Either e a)
catch :: ∀ e a. (Exception e) => IO a -> (e -> IO a) -> IO a
catch
会先执行 IO 操作,如果它抛出了一个类型为 e
的异常,则会把这个异常传给 handler 并执行它。如果 IO 操作抛出了不是 e
的异常,这个异常则会被传递下去。try
与 catch
类似,只不过抛出的异常会被立即抓住并放到 Either
中。
显然在释放资源的时候我们希望能抓住所有异常,这可以通过 SomeException
来实现。SomeException
的定义如下:
data SomeException = ∀ e. (Exception e) => SomeException e
注意这里的 e
是被 existentially quantified 了的。Haskell 中的异常形成了一个谱系 (hierarchy),异常之间有 subtyping 关系,只不过 subtyping 是动态的实现,这也是为什么 Exception
会有 Typeable
的要求。SomeException
是所有异常的超类型 (supertype),因此只要抓住 SomeException
就能抓到所有的异常。抓住 SomeException
并忽略它是非常不好的行为,一般会在释放资源后重新抛出这个异常。
doSomething' = doSomething `catch` \(SomeException e) -> do
logException e
releaseResource
throwIO e -- rethrow the exception
异步异常,顾名思义,就是异步的异常。异步异常来源有很多,从用户按下 Ctrl+C (UserInterrupt
) 到栈溢出 (StackOverflow
) 再到杀死线程 (ThreadKilled
)。Haskell 提供了能在任何时候把一个异常从一个线程扔到另一个线程中的 throwTo
:
throwTo :: ∀ e. (Exception e) => ThreadId -> e -> IO ()
当异常被传播到某个线程最外层时这个线程就会被杀死。killThread
就被定义为 flip throwTo ThreadKilled
。
异步异常可能在执行到任何语句时发生,如果在正在更新某些状态时发生则很有可能导致数据不一致。比如如下这个 update
函数试图读取 MVar 中储存的值并将其传给一个 operation 并把这个 operation 的结果放回 MVar 中, 若 operation 抛出了异常则将这个值原样放回 MVar 中并把这个异常传播下去, 不管怎样,这个 MVar 最终都不会空着。
update :: MVar a -> (a -> IO a) -> IO ()
update mv op = do
a <- takeMVar m -- 1
r <- op a `catch` \(SomeException e) -> do -- 2
putMVar m a
throwIO e
putMVar m r -- 3
若异常出现在执行语句1与语句2之间或语句2或语句3之间,则不会有值被填入这个 MVar 中。
因此我们需要有能暂时忽略异常的能力。
Haskell 提供了 mask
函数用于暂时忽略异常 (masking):
mask :: ∀ b. ((∀ a. IO a -> IO a) -> IO b) -> IO b
这个高阶多态的 continuation 看起来似乎很吓人,但是实际上很好理解。∀ a. IO a -> IO a
是一个通常被称为 restore
的函数,IO b
中被 restore
包住的部分会受到异常的影响,而其他部分则不会。
update :: MVar a -> (a -> IO a) -> IO ()
update mv op = mask $ \restore -> do
a <- takeMVar m
r <- restore (op a) `catch` \(SomeException e) -> do
putMVar m a
throwIO e
putMVar m r
这个是使用 mask 后的在异步异常下安全 (Asynchronous Exception Safe) 的 update
。异步异常只能在在 op a
中出现。
Haskell 中 Exception Safe 的资源管理通常使用一个叫做 bracket
的函数:
bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c
bracket before after thing =
mask $ \restore -> do
a <- before
r <- restore (thing a) `onException` after a
_ <- after a
return r
bracket
的第一个参数为一个分配资源的函数, 第二个参数为回收资源的函数,第三个函数为使用这个资源的操作。bracket
可保证无论是否有异常发生都能安全的分配与回收资源。
Haskell 灵活的异常机制给我们带来了很多好处:
- 异步异常是简单符合直觉的抽象
- 任何计算可以在任何事件被打断 (with the exception of FFI calls)
- mask 保证了线程可以在清理资源后再死去
- 高性能,在没有异常出现时不会有 overhead
当然这种错误处理机制也有它的不足之处:
- 一个函数可能抛出的异常不会表现在类型上
- 异常处理与
IO
绑定过于紧密
这些不足在最近开发出的 Effect Systems 中得到了有效的解决,不过那就是另一篇文章的话题了。
- parallel and concurrent programming in haskell - Simon Marlow
- A semantics for imprecise exceptions - S.P. Jones et al.
- An Extensible Dynamically-Typed Hierarchy of Exceptions - S. Marlow