为啥引发异常会产生副作用?
Posted
技术标签:
【中文标题】为啥引发异常会产生副作用?【英文标题】:Why is the raising of an exception a side effect?为什么引发异常会产生副作用? 【发布时间】:2012-05-29 00:21:31 【问题描述】:根据side effect 的***条目,引发异常会产生副作用。考虑这个简单的python函数:
def foo(arg):
if not arg:
raise ValueError('arg cannot be None')
else:
return 10
使用foo(None)
调用它总是会遇到异常。一样的输入,一样的输出。它是参照透明的。为什么这不是纯函数?
【问题讨论】:
异常不是简单地作为返回值返回。 重新打开这显然是一个问题,而更多理论上的性质仍然适用于本网站。 应该迁移到Software Engineering 这对 SO 来说似乎完全没问题。 【参考方案1】:只有当您观察到异常并根据它做出改变控制流的决定时,才会违反纯度。实际上抛出异常值是引用透明的——它在语义上等同于不终止或其他所谓的bottom values.
如果(纯)函数不是total,则它的计算结果为底部值。如何编码底部值取决于实现 - 它可能是一个例外;或不终止,或除以零,或其他故障。
考虑纯函数:
f :: Int -> Int
f 0 = 1
f 1 = 2
这不是为所有输入定义的。对于某些人来说,它评估为底部。实现通过抛出异常对此进行编码。它在语义上应该等同于使用 Maybe
或 Option
类型。
现在,只有当您观察底部值并根据它做出决策时,您才会破坏引用透明度——这可能会引入不确定性,因为可能会引发许多不同的异常,并且您可以'不知道是哪一个。所以出于这个原因,捕获异常是在 Haskell 中的 IO
monad 中,而生成所谓的 "imprecise" exceptions 可以纯粹地完成。
因此,引发异常本身就是一种副作用是不正确的。问题在于您是否可以基于异常值修改纯函数的行为——从而破坏引用透明度。
【讨论】:
有趣,我一直认为RT的定义是如果你可以用它的值替换一个表达式,你不能有一个“抛出异常A”的值。 @JedWesley-Smith 你是对的。当函数引发异常时,它无法返回值并破坏了引用透明度。 RT 仅在处理异常时才被破坏的错误表示法很方便,因为它允许将完全建模域的混乱细节推到引发异常的函数的调用者身上。【参考方案2】:从第一行开始:
“在计算机科学中,函数或表达式被称为有边 效果如果,除了返回一个值,它还修改了一些 状态或与调用函数或具有可观察到的交互 外面的世界”
它修改的状态是程序的终止。回答您关于为什么它不是纯函数的其他问题。该函数不是纯函数,因为抛出异常会终止程序,因此它具有副作用(您的程序结束)。
【讨论】:
终止不是效果。不是可以共享的状态。许多纯的部分函数不会终止,或者只是部分定义。他们仍然是纯洁的。例如,除以零是一个纯函数,它会失败。 Don:实际上,不终止(又名偏心)通常被视为一种效果,尤其是在人们有兴趣收获 Curry/Howard 同构并使用类型作为命题的情况下。 好的。如果我们要区分全功能和部分功能,我会买它。【参考方案3】:引用透明也可以用计算本身的结果替换计算(例如函数调用),如果你的函数引发异常,你就无法做到这一点。那是因为异常不参与计算,但需要被捕获!
【讨论】:
好吧,如果没有例外,那可能是真的。但是如果有,你可以只抛出一个异常而不是执行函数(模拟返回一个值而不是执行函数)。【参考方案4】:我知道这是一个老问题,但这里的答案并不完全正确,恕我直言。
引用透明度是指表达式具有的属性,如果它所属的程序在表达式被其结果替换时具有完全相同的含义。应该清楚的是,抛出异常违反了引用透明度,因此具有副作用。让我来说明原因...
我在这个例子中使用 Scala。考虑下面的函数,它接受一个整数参数i
,并向它添加一个整数值j
,然后将结果作为整数返回。如果两个值相加时发生异常,则返回值 0。唉,j
的值的计算导致抛出异常(为简单起见,我已将 j
的初始化表达式替换为产生的异常)。
def someCalculation(i: Int): Int =
val j: Int = throw new RuntimeException("Something went wrong...")
try
i + j
catch
case e: Exception => 0 // Return 0 if we catch any exception.
好的。这有点愚蠢,但我试图用一个非常简单的案例来证明一点。 ;-)
让我们在 Scala REPL 中定义并调用这个函数,看看我们得到了什么:
$ scala
Welcome to Scala 2.13.0 (OpenJDK 64-Bit Server VM, Java 11.0.4).
Type in expressions for evaluation. Or try :help.
scala> :paste
// Entering paste mode (ctrl-D to finish)
def someCalculation(i: Int): Int =
val j: Int = throw new RuntimeException("Something went wrong...")
try
i + j
catch
case e: Exception => 0 // Return 0 if we catch any exception.
// Exiting paste mode, now interpreting.
someCalculation: (i: Int)Int
scala> someCalculation(8)
java.lang.RuntimeException: Something went wrong...
at .someCalculation(<console>:2)
... 28 elided
好的,很明显,发生了异常。没有惊喜。
但是请记住,如果我们可以将表达式替换为它的结果以使程序具有完全相同的含义,则表达式是引用透明的。在这种情况下,我们关注的表达式是j
。让我们重构函数并将j
替换为其结果(必须将抛出异常的类型声明为整数,因为那是j
的类型):
def someCalculation(i: Int): Int =
try
i + ((throw new RuntimeException("Something went wrong...")): Int)
catch
case e: Exception => 0 // Return 0 if we catch any exception.
现在让我们在 REPL 中重新评估它:
scala> :paste
// Entering paste mode (ctrl-D to finish)
def someCalculation(i: Int): Int =
try
i + ((throw new RuntimeException("Something went wrong...")): Int)
catch
case e: Exception => 0 // Return 0 if we catch any exception.
// Exiting paste mode, now interpreting.
someCalculation: (i: Int)Int
scala> someCalculation(8)
res1: Int = 0
嗯,我猜你可能已经看到了:我们那次得到了不同的结果。
如果我们计算j
,然后尝试在try
块中使用它,那么程序会抛出异常。但是,如果我们只是将 j
替换为块中的值,我们会得到 0。因此抛出异常显然违反了引用透明性。
我们应该如何以功能的方式进行?通过不抛出异常。在 Scala 中(在其他语言中有等价物),一种解决方案是将可能失败的结果包装在 Try[T]
类型中:如果成功,结果将是 Success[T]
包装成功的结果;如果发生故障,则结果将是包含相关异常的Failure[Throwable]
;这两个表达式都是Try[T]
的子类型。
import scala.util.Failure, Try
def someCalculation(i: Int): Try[Int] =
val j: Try[Int] = Failure(new RuntimeException("Something went wrong..."))
// Honoring the initial function, if adding i and j results in an exception, the
// result is 0, wrapped in a Success. But if we get an error calculating j, then we
// pass the failure back.
j.map validJ =>
try
i + validJ
catch
case e: Exception => 0 // Result of exception when adding i and a valid j.
注意:我们仍然使用异常,只是不抛出它们。
让我们在 REPL 中试试这个:
scala> :paste
// Entering paste mode (ctrl-D to finish)
import scala.util.Failure, Try
def someCalculation(i: Int): Try[Int] =
val j: Try[Int] = Failure(new RuntimeException("Something went wrong..."))
// Honoring the initial function, if adding i and j results in an exception, the
// result is 0, wrapped in a Success. But if we get an error calculating j, then we
// pass the failure back.
j.map validJ =>
try
i + validJ
catch
case e: Exception => 0 // Result of exception when adding i and a valid j.
// Exiting paste mode, now interpreting.
import scala.util.Failure, Try
someCalculation: (i: Int)scala.util.Try[Int]
scala> someCalculation(8)
res2: scala.util.Try[Int] = Failure(java.lang.RuntimeException: Something went wrong...)
这一次,如果我们用它的值替换j
,我们会得到完全相同的结果,而且在所有情况下都是如此。
但是,对此还有另一种看法:如果在计算 j
的值时引发异常的原因是由于我们的一些错误编程(逻辑错误),那么抛出异常——这将导致程序终止——可能被认为是引起我们注意问题的好方法。但是,如果异常是由于我们无法直接控制的情况(例如整数加法溢出的结果),并且我们应该能够从这种情况中恢复,那么我们应该将这种可能性形式化为函数返回的一部分值,并使用但不抛出异常。
【讨论】:
【参考方案5】:引发异常可以是纯的也可以是非纯的,它只取决于引发的异常类型。一个好的经验法则是,如果异常是由代码引发的,它是纯的,但如果它是由硬件引发的,那么它通常必须被归类为非纯的。
这可以通过查看硬件引发异常时发生的情况来看出:首先引发中断信号,然后中断处理程序开始执行。这里的问题是中断处理程序不是您的函数的参数,也不是在您的函数中指定的,而是一个全局变量。任何时候读取或写入全局变量(也称为状态),您就不再拥有纯函数。
将其与代码中引发的异常进行比较:您从一组已知的、局部范围的参数或常量构造 Exception 值,然后“抛出”结果。没有使用全局变量。抛出异常的过程本质上是您的语言提供的语法糖,它不会引入任何非确定性或非纯行为。正如 Don 所说的“它在语义上应该等同于使用 Maybe 或 Option 类型”,这意味着它也应该具有所有相同的属性,包括纯度。
当我说引发硬件异常“通常”被归类为副作用时,并非总是如此。例如,如果运行您的代码的计算机在引发异常时没有调用中断,而是将一个特殊值压入堆栈,那么它就不能归类为非纯的。我相信 IEEE 浮点 NAN 错误是使用特殊值而不是中断引发的,因此在进行浮点数学运算时引发的任何异常都可以归类为无副作用,因为该值不是从任何全局状态读取的,而是编码到 FPU 中的常量。
查看一段代码纯的所有要求,基于代码的异常和 throw 语句语法糖勾选所有框,它们不修改任何状态,它们与调用函数或它们之外的任何东西没有任何交互调用,并且它们是引用透明的,但只有在编译器使用您的代码时才会这样做。
像所有纯粹与非纯粹的讨论一样,我排除了任何关于执行时间或内存操作的概念,并在假设可以纯粹实现的任何功能都是纯粹实现而不管其实际实现如何。我也没有 IEEE 浮点 NAN 异常声明的证据。
【讨论】:
以上是关于为啥引发异常会产生副作用?的主要内容,如果未能解决你的问题,请参考以下文章
为啥 list-tail 会引发异常?为啥我们没有关于 cdr 的闭包属性?
当 cp 没有时,为啥 shutil.copy() 会引发权限异常?
为啥在启动 REST 服务时添加注解 FormDataParam 会引发异常?
对 Apple 的 APNS 的 PushSharp 通知不起作用,不会引发任何异常