45

在 Haskell 中,您可以从纯函数代码中抛出异常,但您只能在 IO 代码中捕获。

  • 为什么?
  • 你能在其他情况下捕捉还是只捕捉 IO monad?
  • 其他纯函数式语言如何处理它?
4

2 回答 2

60

因为在函数内部抛出异常不会使函数的结果依赖于除参数值和函数定义之外的任何东西;函数保持纯净。OTOH在函数内部捕获异常确实(或至少可以)使该函数不再是纯函数。

我将研究两种例外情况。第一个是不确定的;此类异常在运行时出现不可预测,包括内存不足错误。这些异常的存在不包括在可能生成它们的函数的含义中。它们只是我们必须处理的一个令人不快的生活事实,因为我们在现实世界中拥有实际的物理机器,它们并不总是与我们用来帮助​​我们对其进行编程的抽象相匹配。

如果函数抛出这样的异常,则意味着对函数求值的一次特定尝试未能产生值。这并不一定意味着函数的结果是未定义的(在这次调用它的参数上),但系统无法产生结果。

如果您可以在纯调用者中捕获此类异常,则可以执行以下操作:当子计算成功完成时返回一个(非底部)值,当内存耗尽时返回另一个值。作为纯函数,这没有意义。函数调用计算的值应该由其参数的值和函数的定义唯一确定。能够根据子计算是否内存不足返回不同的东西,这使得返回值取决于其他东西(物理机器上有多少内存可用,其他程序正在运行,操作系统及其策略等.); 根据定义,可以以这种方式运行的函数不是纯粹的,并且不能(通常)存在于 Haskell 中。

由于纯粹的操作失败,我们必须允许评估一个函数可能会产生底部,而不是它“应该”产生的值。这并没有完全破坏我们对 Haskell 程序的语义解释,因为我们知道底部也会导致所有调用者产生底部(除非他们不需要应该计算的值,但在这种情况下非严格评估意味着系统永远不会尝试评估此功能并失败)。这听起来很糟糕,但是当我们将计算放在IOmonad 中时,我们可以安全地捕获此类异常。IOmonad中的值允许依赖程序“外部”的东西;事实上,它们可以根据世界上的任何事物来改变它们的值(这就是为什么对IO值的一种常见解释是它们好像被传递给了整个宇宙的表示)。因此,IO如果一个纯子计算内存不足,一个值有一个结果,如果没有,另一个结果是完全可以的。


但是确定性异常呢?在这里,我谈论的是在对特定参数集评估特定函数时总是抛出的异常。此类异常包括被零除错误,以及从纯函数显式抛出的任何异常(因为它的结果只能取决于它的参数和它的定义,如果它评估为一个 throw 一旦它总是评估为相同的 throw对于相同的论点[1])。

看起来这类异常应该可以在纯代码中捕获。毕竟,1 / 0just的值被零除的错误。如果一个函数可以有不同的结果,这取决于子计算是否通过检查它是否传入零来评估被零除错误,为什么它不能通过检查结果是否是被除数来做到这一点-零错误?

在这里,我们回到 larsmans 在评论中提出的观点。如果一个纯函数可以观察到它是从哪个异常中得到的throw ex1 + throw ex2,那么它的结果就取决于执行的顺序。但这取决于运行时系统,可以想象它甚至可以在同一系统的两个不同执行之间发生变化。也许我们有一些先进的自动并行实现,它在每次执行时尝试不同的并行化策略,以便尝试在多次运行中收敛到最佳策略。这将使异常捕获功能的结果取决于所使用的策略、机器中的 CPU 数量、机器上的负载、操作系统及其调度策略等。

同样,纯函数的定义是只有通过其参数(及其定义)进入函数的信息才会影响其结果。在非IO函数的情况下,影响抛出异常的信息不会通过其参数或定义进入函数,因此它不会对结果产生影响。但是IOmonad 中的计算隐含地被允许依赖于整个宇宙的任何细节,所以在那里捕捉这样的异常是很好的。


至于你的第二个点:不,其他单子不能用于捕获异常。所有相同的论点都适用;计算产生Maybe x[y]不应该依赖于它们的参数之外的任何东西,并且捕获任何类型的异常“泄漏”关于那些不包含在这些函数参数中的事物的各种细节。

请记住,单子并没有什么特别之处。它们的工作方式与 Haskell 的其他部分没有任何不同。monad 类型类是在普通的 Haskell 代码中定义的,几乎所有的 monad 实现都是如此。适用于普通 Haskell 代码的所有相同规则都适用于所有 monad。它IO本身是特别的,而不是它是一个单子的事实。


至于其他纯语言如何处理异常捕获,我所体验过的唯一具有强制纯洁性的其他语言是 Mercury。 [2] Mercury 的做法与 Haskell 略有不同,您可以在纯代码中捕获异常。

Mercury 是一种逻辑编程语言,因此 Mercury 程序不是基于函数构建的,而是基于谓词构建的;对谓词的调用可以有零个、一个或多个解决方案(如果您熟悉在 list monad 中进行编程以获得不确定性,这有点像整个语言都在 list monad 中)。在操作上,Mercury 执行使用回溯递归枚举谓词的所有可能解决方案,但非确定性谓词的语义是它只是具有每组输入参数的一组解决方案,而不是为每组输入参数计算单个结果值的 Haskell 函数。与 Haskell 一样,Mercury 是纯的(包括 I/O,尽管它使用了稍微不同的机制),因此对谓词的每次调用都必须唯一地确定单个解集,这仅取决于参数和谓词的定义。

Mercury 跟踪每个谓词的“确定性”。总是导致恰好一个解决方案的谓词被称为det(确定性的缩写)。产生至少一种解决方案的那些称为multi. 还有一些其他的确定性类,但它们在这里不相关。

用块捕获异常try(或通过显式调用实现它的高阶谓词)具有确定性cc_multi。cc 代表“承诺的选择”。这意味着“这个计算至少有一个解决方案,并且在操作上程序只会得到其中一个”。这是因为运行子计算并查看它是否产生异常有一个解决方案集,该解决方案集是子计算的“正常”解决方案加上它可能抛出的所有可能异常的集合。由于“所有可能的异常”包括所有可能的运行时故障,其中大部分都不会真正发生,因此无法完全实现此解决方案集。那里'try块,所以它只是给你一个解决方案(要么是一个正常的解决方案,要么是一个指示所有可能性都被探索并且没有解决方案或异常,或者碰巧出现的第一个异常)。

因为编译器会跟踪确定性,所以它不允许您try在完整解决方案集很重要的上下文中调用。您不能使用它来生成所有没有遇到异常的解决方案,例如,因为编译器会抱怨它需要cc_multi调用的所有解决方案,而这只会产生一个。但是,您也不能从det谓词中调用它,因为编译器会抱怨det谓词(应该只有一个解决方案)正在cc_multi调用,它将有多个解决方案(我们只会知道什么其中之一是)。

那么这到底有什么用呢?好吧,您可以将main(以及它调用的其他东西,如果有用的话)声明为cc_multi,并且它们可以毫无问题地调用try。这意味着整个程序理论上有多个“解决方案”,但运行它会产生一个解决方案。这允许您编写一个程序,当它碰巧在某个时候耗尽内存时,它的行为会有所不同。但它不会破坏声明性语义,因为它本应使用更多可用内存计算的“真实”结果仍在解决方案集中(就像程序实际执行时内存不足异常仍在解决方案集中一样计算一个值),只是我们最终只能得到一个任意的解决方案。

重要的是det(只有一种解决方案)与cc_multi(有多种解决方案,但您只能拥有其中一种)不同的处理方式。与在 Haskell 中捕获异常的推理类似,异常捕获不能允许在非“承诺选择”上下文中发生,或者您可以获得纯谓词,根据来自现实世界的信息产生不同的解决方案集,它们应该t 有权访问。的cc_multi确定性try允许我们编写程序,就好像它们产生了一个无限的解决方案集(大部分充满了不太可能的异常的次要变体),并阻止我们编写实际上需要多个解决方案的程序。 [3]


[1] 除非评估它首先遇到不确定性错误。现实生活很痛苦。

[2] 仅鼓励程序员使用纯度而不强制执行的语言(例如 Scala)往往只会让您在任何地方捕获异常,就像它们允许您在任何地方进行 I/O 一样。

[3] 请注意,“承诺选择”概念并不是 Mercury 处理纯 I/O 的方式。为此,Mercury 使用独特的类型,它与“承诺选择”确定性类正交。

于 2012-09-10T05:12:09.273 回答
15

delnan 在评论中提到的论文,以及对前一个问题的答案,当然为仅在IO.

但是,我也可以理解为什么诸如观察评估顺序或打破单调性之类的原因在直觉层面上可能没有说服力;很难想象这两种方法中的任何一种都会对绝大多数代码造成多大的伤害。因此,回忆一下异常处理是一种明显非局部的控制流结构可能会有所帮助,并且能够在纯代码中捕获异常将允许(错误地)将它们用于该目的。

请允许我准确说明这意味着什么恐怖。

首先,我们定义一个要使用的异常类型,以及一个catch可以在纯代码中使用的版本:

newtype Exit a = Exit { getExit :: a } deriving (Typeable)
instance Show (Exit a) where show _ = "EXIT"

instance (Typeable a) => Exception (Exit a)

unsafeCatch :: (Exception e) => a -> (e -> a) -> a
unsafeCatch x f = unsafePerformIO $ catch (seq x $ return x) (return . f)

这将让我们抛出任何Typeable值,然后从某个外部范围捕获它,而无需任何中间表达式的同意。例如,我们可以将一个Exitthrow 隐藏在我们传递给高阶函数的东西中,以便用它的求值产生的一些中间值进行转义。精明的读者现在可能已经知道这是怎么回事:

callCC :: (Typeable a) => ((a -> b) -> a) -> a
callCC f = unsafeCatch (f (throw . Exit)) (\(Exit e) -> e)

是的,这确实有效,但需要注意的是,它需要在整个表达式存在时对任何延续进行评估。如果您尝试这样做,请记住这一点,或者deepseq如果从轨道上进行核弹更适合您的速度,请使用。

看哪:

-- This will clearly never terminate, no matter what k is
foo k = fix (\f x -> if x > 100 then f (k x) else f (x + 1)) 0

但:

∀x. x ⊢ callCC foo
101

从内部逃逸map

seqs :: [a] -> [a]
seqs xs = foldr (\h t -> h `seq` t `seq` (h:t)) [] xs

bar n k = map (\x -> if x > 10 then k [x] else x) [0..n]

注意需要强制评估。

∀x. x ⊢ callCC (seqs . bar 9)
[0,1,2,3,4,5,6,7,8,9]
∀x. x ⊢ callCC (seqs . bar 11)
[11]

...啊。

现在,让我们再也不提这个了。

于 2012-09-09T06:19:44.743 回答