为什么提出异常会产生副作用?

can*_*dry 43 functional-programming side-effects

根据维基百科的副作用条目,提出异常构成副作用.考虑一下这个简单的python函数:

def foo(arg):
    if not arg:
        raise ValueError('arg cannot be None')
    else:
        return 10
Run Code Online (Sandbox Code Playgroud)

调用它将foo(None)始终遇到异常.相同的输入,相同的输出.它是参考透明的.为什么这不是一个纯粹的功能?

Don*_*art 33

只有在观察到异常时才会违反纯度,并根据它来更改控制流程做出决定.实际上抛出异常值是引用透明的 - 它在语义上等同于非终止或其他所谓的底部值.

如果(纯)函数不是总计,那么它将计算为最低值.如何编码底部值取决于实现 - 它可能是一个例外; 或非终止,或除以零,或其他一些失败.

考虑纯函数:

 f :: Int -> Int
 f 0 = 1
 f 1 = 2
Run Code Online (Sandbox Code Playgroud)

没有为所有输入定义.对于一些人,它评估到底部.该实现通过抛出异常对此进行编码.它应该在语义上等同于使用MaybeOption类型.

现在,只有在观察到底部值时才会破坏引用透明度,并根据它做出决策 - 这可能会引入非确定性,因为可能抛出许多不同的异常,而您无法知道哪一个.因此,由于这个原因,捕获异常是在IOHaskell 中的monad中,而生成所谓的"不精确"异常可以纯粹地完成.

因此,提出异常本身并不是一个副作用.您是否可以根据异常值修改纯函数的行为 - 从而破坏参照透明度 - 这就是问题所在.

  • 有趣的是,我一直认为RT的定义是你可以用它的值替换表达式,并且你不能有"抛出异常A"的值. (2认同)

Woo*_*Moo 14

从第一行开始:

"在计算机科学中,如果除了返回一个值之外,它还会修改某个状态或者与调用函数或外部世界进行可观察的交互,那么函数或表达式就会产生副作用"

它修改的状态是程序的终止.回答你的另一个问题,为什么它不是一个纯函数.该函数不纯,因为抛出异常会终止程序,因此它会产生副作用(程序结束).

  • 唐:实际上,非终止(也就是偏爱)往往被认为是一种效应,特别是在人们有兴趣收获库里/霍华德同构并使用类型作为命题的情况下. (5认同)

Ald*_*nio 6

引用透明性还可以用计算本身的结果替换计算(例如,函数调用),如果函数引发异常,则无法执行此操作。那是因为异常不参与计算,但是它们必须被捕获!

  • 好吧,如果没有例外,那就可能是正确的。但是,如果有的话,您可以抛出一个Exception而不是执行该函数(模拟返回一个值而不是执行该函数)。 (2认同)

Ben*_*del 5

引发异常可以是纯粹的或非纯的,它只取决于引发的异常类型.一个好的经验法则是,如果代码引发异常,它是纯粹的,但如果它是由硬件引发的,那么它通常必须被归类为非纯粹的.

通过查看硬件引发异常时会发生什么情况可以看出:首先引发中断信号,然后中断处理程序开始执行.这里的问题是中断处理程序不是函数的参数,也不是函数中指定的,而是全局变量.无论何时读取或写入全局变量(也称为状态),您都不再具有纯函数.

将其与您的代码中引发的异常进行比较:您从一组已知的,本地范围的参数或常量构造Exception值,然后"抛出"结果.没有使用全局变量.抛出异常的过程本质上是由您的语言提供的语法糖,它不会引入任何非确定性或非纯粹的行为.正如Don所说:"它应该在语义上等同于使用Maybe或Option类型",这意味着它应该具有所有相同的属性,包括纯度.

当我说提高硬件异常"通常"被归类为副作用时,并不总是必须如此.例如,如果运行代码的计算机在引发异常时不调用中断,而是将特殊值推送到堆栈上,则它不能归类为非纯类.我相信IEEE浮点NAN错误是使用特殊值而不是中断引发的,因此在执行浮点数学时引发的任何异常都可以归类为副作用,因为该值不是从任何全局状态读取的,而是常量编码到FPU中.

查看片段代码纯粹的所有要求,基于代码的异常和throw语句语法糖勾选所有框,它们不修改任何状态,它们与调用函数或调用之外的任何东西没有任何交互,并且它们是引用透明的,但只有在编译器使用您的代码时才会这样.

像所有纯粹与非纯粹的讨论一样,我已经排除了执行时间或内存操作的任何概念,并且假设任何可以纯粹实现的功能都是纯粹地实现,而不管其实际实现如何.我也没有证据表明IEEE浮点NAN异常声明.


Mik*_*len 5

我意识到这是一个老问题,但这里的答案并不完全正确,恕我直言。

引用透明性是指表达式所具有的属性,如果它所属的程序在表达式被其结果替换时具有完全相同的含义。应该清楚的是,抛出异常违反了引用透明性,因此会产生副作用。让我证明为什么...

我在这个例子中使用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.
  }
}
Run Code Online (Sandbox Code Playgroud)

好的。这有点愚蠢,但我试图用一个非常简单的案例来证明一点。;-)

让我们在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    
Run Code Online (Sandbox Code Playgroud)

好的,很明显,发生了异常。那里没有惊喜。

但是请记住,如果我们可以用它的结果替换一个表达式,使得程序具有完全相同的含义,那么它就是引用透明的。在这种情况下,我们关注的表达式是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.
  }
}
Run Code Online (Sandbox Code Playgroud)

现在让我们在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
Run Code Online (Sandbox Code Playgroud)

好吧,我想您可能已经看到了这一点:那一次我们得到了不同的结果。

如果我们计算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.
    }
  }
}
Run Code Online (Sandbox Code Playgroud)

注意:我们仍然使用异常,只是不抛出它们。

让我们在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...)
Run Code Online (Sandbox Code Playgroud)

这一次,如果我们j用它的值替换,我们会得到完全相同的结果,在所有情况下都是如此。

然而,对此还有另外一个观点:如果在计算 的值时抛出异常的原因j是由于我们的一些错误编程(逻辑错误),那么抛出异常——这将导致程序终止——可能被视为引起我们注意的极好方法。但是,如果异常归结为我们无法直接控制的情况(例如整数加法溢出的结果),并且我们应该能够从这种情况中恢复,那么我们应该将这种可能性形式化为函数返回的一部分值,并使用但不抛出异常。