lambda 演算和函数式编程中的异常处理

Otá*_*lva 4 theory haskell functional-programming exception lambda-calculus

有没有办法在 lambda 演算中模拟异常处理?
我问这个问题是因为在过程语言和派生范例中处理异常状态的多种方法是很常见的。setjmp.h即使在 C 语言中,您也可以使用,errno.h和 来非常简单地模拟这种行为signal.h
我可以在脑海中想象图灵机状态图中的一种方式,一种可以被任何其他节点访问的“异常”节点,并且我猜过程语言会做这种事情来实现它。
但在 Haskell(我最新的痴迷)和一般的函数式编程中,你看不到关于异常的所有模糊之处。我知道它们存在(Control.Exception?)并且我知道函数式编程使用 monad 来模拟副作用,但我承认我不太明白它是什么以及它是如何工作的。
但我在另一次讨论中看到,所有函数式语言都是 lambda 演算的语法糖,那么这样的东西如何工作呢?

Sil*_*olo 8

第一个注意事项:如果您开始使用 Haskell,请远离Control.Exception. 该模块模拟 monad 内传统的 Java 风格的异常处理IO。最终了解这是一件好事,但它不是处理纯函数式 Haskell 代码中(可控)错误的传统方法。

正如您已经注意到的,在像 Haskell 这样的函数式语言中处理异常情况的通常方法是使用 monad。那么我们来谈谈单子,但我们不是抽象地讨论单子,而是讨论一个具体的单子。这种类型存在于标准库中,但我用另一个更令人回味的名称来称呼它。

data Result e a = Err e | Ok a
Run Code Online (Sandbox Code Playgroud)

我们使用这种类型的方法很简单。任何时候我们有一个可以“抛出”异常的函数,该函数都会返回 a Result e a,其中a是如果一切顺利的话结果类型,并且e是我们抛出的异常类型。它有点像 Java 中的检查异常,但规模更细化。

Result作为一个可能是典型的例子,考虑一个函数,该函数将两个数字相除,但如果我们尝试除以零,则会出错。它的签名是这样的。

-- Singleton error type
data DivideByZero = DivideByZero

divide :: Double -> Double -> Result DivideByZero Double
divide _ 0 = Err DivideByZero
divide x y = Ok (x / y) -- Built-in Haskell division
Run Code Online (Sandbox Code Playgroud)

因此,如果我们发现自己处于这样的情况,即我们有一个Result e a,那就是 type 的值a,主要警告是我们可能已经失败并且该Result对象“包含” type 的异常e

现在让我们看看Monad实例会为我们提供哪些操作。单子的层次结构从 at 开始Functor,然后向下移动到Applicative,然后是finally MonadFunctor给我们

fmap :: (a -> b) -> Result e a -> Result e b
Run Code Online (Sandbox Code Playgroud)

我们可以将其理解为“如果我有一种方法可以从 到ab不会失败),那么我总是可以采取(可能失败)a并产生(可能失败)b”。这是有道理的,我们可以很容易地写出来。

fmap f (Ok a) = Ok (f a)
fmap _ (Err e) = Err e
Run Code Online (Sandbox Code Playgroud)

我们只是保留错误状态。如果计算已经错误,我们保留错误。如果没有,我们应用我们的(完全安全、纯粹的)函数f

现在出现一个自然的问题:如果我有一个包含两个参数 的函数f :: a -> b -> c,但我有x :: Result e a和,该怎么办y :: Result e b?我们可以尝试柯里化函数,但是一旦我们应用第一个参数,我们就会遇到问题。

fmap f x :: Result e (b -> c)
y :: Result e b
Run Code Online (Sandbox Code Playgroud)

现在我想将一个可能失败的函数应用到一个可能失败的值。换句话说,我有两个潜在错误的对象,我想将它们组合起来(在本例中,使用函数应用程序)。这就是Applicative发挥作用的地方。

(<*>) :: Result e (a -> b) -> Result e a -> Result e b
Run Code Online (Sandbox Code Playgroud)

Applicative运算符(<*>)只是fmap一个已经在我们的应用程序内部的函数(在本例中,一个可能秘密地是例外的函数)。

下面是它的实现方式。

Err e <*> _ = Err e
Ok _ <*> Err e = Err e
Ok f <*> Ok x = Ok (f x)
Run Code Online (Sandbox Code Playgroud)

现在我们可以将示例函数应用为

fmap f x <*> y :: Result e c
Run Code Online (Sandbox Code Playgroud)

此外,还Applicative为我们提供了pure :: a -> Result e a刚刚针对pure = Ok我们的类型实现的内容。这使我们能够将纯值视为可能失败的值,本质上“忘记”了它不会失败的事实。当您尝试使类型签名对齐时,它会派上用场,例如,如果您有一个函数预计可能会失败的计算,但您拥有的只是一个纯函数。

最后,我们到达了MonadMonad只需要实现一个函数,那就是绑定运算符(>>=)

(>>=) :: Result e a -> (a -> Result e b) -> Result e b
Run Code Online (Sandbox Code Playgroud)

好吧,看起来很不一样。让我们翻转一下论点。

fmap       ::          (a -> b) -> Result e a -> Result e b
(<*>)      :: Result e (a -> b) -> Result e a -> Result e b
flip (>>=) :: (a -> Result e b) -> Result e a -> Result e b
Run Code Online (Sandbox Code Playgroud)

我们刚刚将Result函数的一部分移动了一点。a现在,我们的函数不仅可能失败,而且我们的函数可以根据计算早期的输入值选择是否失败。

这是非常强大的,因为它允许我们用命令式语言实现我们对异常所做的最常见的事情:传播它们。

首先,我们来看看它是如何实现的。

Err e >>= _ = Err e
Ok x >>= f = f x
Run Code Online (Sandbox Code Playgroud)

现在,每当我们调用函数并希望简单地将异常传播到我们自己的调用者时,我们都会使用>>=函数的结果。

myComplicatedComputation :: Int -> Int -> Int -> Result ExceptionType Bool
myComplicatedComputation a b c =
  someMath a b >>= (\tmp ->
    someMoreMath tmp c >>= (\result ->
      pure (result > 0)))
Run Code Online (Sandbox Code Playgroud)

在 Java 中,我们可以将其写为

public static boolean myComplicatedComputation(int a, int b, int c) throws ExceptionType {
  int tmp = someMath(a, b);
  int result = someMoreMath(tmp, c);
  return (result > 0);
}
Run Code Online (Sandbox Code Playgroud)

如果我们能够以这样简洁的方式编写 Haskell 代码,让我们几乎忘记我们正在处理效果,那肯定会很好。让我稍微不同地缩进并删除一些不必要的括号。

myComplicatedComputation :: Int -> Int -> Int -> Result ExceptionType Bool
myComplicatedComputation a b c =
  someMath a b >>= \tmp ->
  someMoreMath tmp c >>= \result ->
  pure (result > 0)
Run Code Online (Sandbox Code Playgroud)

引入do符号。记住这一点非常重要,do符号是纯粹的语法糖。它绝不会为该语言添加任何新内容。它完全是根据(>>=)和其他Monad操作来实现的。这与上面完全等价。

myComplicatedComputation :: Int -> Int -> Int -> Result ExceptionType Bool
myComplicatedComputation a b c = do
  tmp <- someMath a b
  result <- someMoreMath tmp c
  pure $ result > 0
Run Code Online (Sandbox Code Playgroud)

所以都是从功能上来实现的。我们不是“应用”函数,而是使用这种有趣的类似于函数应用程序的运算符,称为“bind”并编写为(>>=). 俗话说,功能自始至终。

值得注意的是,这也正是 Rust 所做的。Rust 采用一元方法对其自己的Result<T, E>类型进行错误处理。fmap.mappureOk(>>=).and_then。(没有直接等价于(<*>),但可以很.and_then容易地实现)?Rust 中的运算符基本上意味着“将错误传播给我的调用者”,实际上只是在出现错误时短路的捷径。这是 Rust 对 Haskelldo符号的回答。

  • 我想也许您本想回到这一点,但忘记了:像这样工作的现有内置类型是“Either”。把这个留给OP和其他读者。很好的答案! (3认同)